"""Unit tests for `run_bybit_testnet` — the Bybit testnet exec leg ported off Dagster into a plain
application function (Task 5). These prove the guards AND the named past-bug fixes SURVIVED the port:
  (a) kill-switch  -> places ZERO orders, no exception (returns killed=True);
  (b) dry-run (execute=False) -> places ZERO orders and writes NO NAV row (returns dry_run=True);
  (c) a per-day NAV marker makes a re-run a no-op;
  (d) a normal reconcile records fills (decomposed slippage) + a NAV row;
  (e) FIX 1 — an unlock fill is classified at the 35bp unlock tier, not the 8bp liquid tier;
  (f) FIX 2 — mean_timing_drift excludes None-drift fills from the denominator;
  (g) FIX 3 — the recorded fill ts is the EXCHANGE fill time, not the record-step wall clock;
  (h) FIX 4 — an open order resting on a run symbol aborts the reconcile, placing nothing;
  (i) the paper-envelope refuse (account not testnet/paper) propagates as a ValueError at the run level,
      with the adapter never reaching `place_market`.

The warehouse/ccxt inputs are swapped via the module-level monkeypatchable seams + a fake adapter, so the
tests never touch Timescale or the network.
"""
from __future__ import annotations

import datetime as dt

import pytest

import fxhnt.application.bybit_testnet_run as run_mod
from fxhnt.application.bybit_testnet_run import run_bybit_testnet


class _FakeStore:
    def close(self):  # noqa: ANN202
        pass


class _FakeAdapter:
    """Mimics `BybitExecution`'s surface for `run_bybit_testnet`. By default `place_market` RAISES — a guard
    that fires means the port leaked a placement into the kill/dry-run path. Pass `allow_place=True` (the
    behavioral tests) to make it record the order and return a deterministic `Fill` instead, so slippage
    decomposition + persistence can be exercised end-to-end."""

    def __init__(self, *, equity=8000.0, positions=None, limits=None, mids=None, fill_price=None,
                 open_orders=None, fill_ts=0, allow_place=False):  # noqa: ANN001, ANN204
        from fxhnt.application.bybit_testnet_execution import InstrumentLimit

        self._equity = equity
        self._positions = positions or {}
        self._limits = limits or {
            "BTCUSDT": InstrumentLimit(min_order_qty=0.001, qty_step=0.001, min_notional=5.0),
            "ETHUSDT": InstrumentLimit(min_order_qty=0.01, qty_step=0.01, min_notional=5.0),
            "ONDOUSDT": InstrumentLimit(min_order_qty=0.1, qty_step=0.1, min_notional=5.0),
        }
        self._mids = mids or {}
        self._fill_price = fill_price or {}
        self._open_orders = open_orders or []
        self._fill_ts = fill_ts
        self._allow_place = allow_place
        self.placed: list[tuple] = []

    def equity(self):  # noqa: ANN201
        return self._equity

    def positions(self):  # noqa: ANN201
        return dict(self._positions)

    def instrument_limits(self):  # noqa: ANN201
        return dict(self._limits)

    def open_orders(self, symbol=None):  # noqa: ANN001, ANN201
        return list(self._open_orders)

    def mid(self, sym):  # noqa: ANN001, ANN201
        return self._mids.get(sym)

    def funding_since(self, cursor):  # noqa: ANN001, ANN201
        return 0.0

    def place_market(self, sym, qty, reduce_only=False):  # noqa: ANN001, ANN201
        self.placed.append((sym, qty, reduce_only))
        if not self._allow_place:
            raise AssertionError("guard breached: kill-switch / dry-run must not place an order")
        from fxhnt.adapters.broker.bybit import Fill

        px = self._fill_price.get(sym, self._mids.get(sym) or 100.0)
        side = "BUY" if qty > 0 else "SELL"
        return Fill(symbol=sym, side=side, qty=abs(qty), avg_price=px, fee=0.0,
                    ts=self._fill_ts, order_id=f"o-{sym}")


def _settings(tmp_path, *, kill_switch=False, testnet=True):  # noqa: ANN001, ANN202
    """Real sqlite operational_dsn (the run function builds its OWN repos from it — not monkeypatchable) with a
    seeded `bybit_4edge` B2a allocation so the envelope is non-zero and `plan_orders` yields a REAL intent —
    otherwise the guard-under-test would have nothing to place and the assertion would be vacuous."""
    from fxhnt.adapters.persistence.cockpit_models import StrategyAllocationRow
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.config import BybitExecutionConfig, Settings

    dsn = f"sqlite:///{tmp_path / 'op.db'}"
    s = Settings(operational_dsn=dsn,
                 bybit_exec=BybitExecutionConfig(api_key="k", api_secret="s", testnet=testnet,
                                                 kill_switch=kill_switch))
    at = dt.datetime.now(dt.UTC).replace(tzinfo=None)
    repo = ForwardNavRepo(dsn)
    repo.migrate()
    repo.replace_allocations([StrategyAllocationRow(
        strategy_id="bybit_4edge", target_weight=1.0, target_dollars=1_000_000.0,
        policy_version=1, policy_hash="h", as_of=at.date().isoformat(), computed_at=at)], at)
    return s, dsn


def _patch_seams(monkeypatch, adapter, *, weights=None, marks=None, unlock_syms=None,
                 as_of_day="2026-07-11"):  # noqa: ANN001, ANN202
    monkeypatch.setattr(run_mod, "_testnet_adapter", lambda s: adapter)
    monkeypatch.setattr(run_mod, "_testnet_store", lambda s: _FakeStore())
    monkeypatch.setattr(run_mod, "_testnet_universe", lambda store: None)
    monkeypatch.setattr(run_mod, "_testnet_unlock_events", lambda s:
                        [{"sym": x, "cliff_day": 0, "n": 0, "cat": "c"} for x in (unlock_syms or [])])
    monkeypatch.setattr(run_mod, "_testnet_marks", lambda store, universe=None: marks or {"BTCUSDT": 100.0})
    # non-empty raw weights so plan_orders emits an intent (envelope is non-zero from the seed).
    monkeypatch.setattr(run_mod, "latest_raw_sleeve_weights", lambda *a, **k: dict(weights or {"BTCUSDT": 0.5}))
    monkeypatch.setattr(run_mod, "combined_symbol_weights", lambda *a, **k: ({}, as_of_day))


def test_kill_switch_places_nothing(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    s, _dsn = _settings(tmp_path, kill_switch=True)
    adapter = _FakeAdapter()
    _patch_seams(monkeypatch, adapter)
    logs: list[str] = []
    out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=logs.append)
    assert out.get("killed") is True
    assert out["placed"] == 0
    assert adapter.placed == []


def test_dry_run_places_nothing_and_writes_no_nav(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo

    s, dsn = _settings(tmp_path, kill_switch=False)
    adapter = _FakeAdapter()
    _patch_seams(monkeypatch, adapter)
    logs: list[str] = []
    out = run_bybit_testnet(s, paper_envelope=0.0, execute=False, log=logs.append)
    assert out["placed"] == 0
    assert out.get("dry_run") is True
    assert adapter.placed == []
    # no NAV row written -> a subsequent run would NOT see today as executed.
    repo = BybitTestnetRepo(dsn)
    repo.migrate()
    run_date = dt.datetime.now(dt.UTC).date().isoformat()
    assert repo.testnet_executed_on(run_date) is False


def test_executed_marker_makes_reconcile_a_noop(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo

    s, dsn = _settings(tmp_path)
    adapter = _FakeAdapter(allow_place=True)
    _patch_seams(monkeypatch, adapter)

    # Pre-mark today as executed → the run must short-circuit before placing anything.
    run_date = dt.datetime.now(dt.UTC).date().isoformat()
    repo = BybitTestnetRepo(dsn)
    repo.migrate()
    repo.upsert_testnet_nav(run_date, equity=10_000.0, realized_pnl=0.0, gross=0.0, n_orders=0,
                            mean_exec_slippage_bps=0.0, mean_timing_drift_bps=0.0, ts=dt.datetime.now(dt.UTC))

    out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
    assert out.get("noop") is True
    assert adapter.placed == []


def test_normal_reconcile_records_fills_and_nav(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo

    s, dsn = _settings(tmp_path)
    # Target a long BTC; book_mark=100, live mid=101, fill=102 → exec_slippage = +bps, timing_drift = +bps.
    adapter = _FakeAdapter(equity=10_000.0, mids={"BTCUSDT": 101.0}, fill_price={"BTCUSDT": 102.0},
                           allow_place=True)
    _patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})

    out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
    assert out["placed"] == 1
    assert adapter.placed and adapter.placed[0][0] == "BTCUSDT"
    fill0 = out["fills"][0]
    assert fill0["symbol"] == "BTCUSDT"
    assert fill0["exec_slippage_bps"] is not None and fill0["exec_slippage_bps"] > 0
    assert fill0["timing_drift_bps"] is not None and fill0["timing_drift_bps"] > 0
    assert fill0["low_confidence"] is False
    assert out["nav_written"] is True

    run_date = dt.datetime.now(dt.UTC).date().isoformat()
    repo = BybitTestnetRepo(dsn)
    nav = repo.read_testnet_nav()
    assert len(nav) == 1 and nav[0].run_date == run_date and nav[0].n_orders == 1
    fills = repo.read_testnet_fills(run_date)
    assert len(fills) == 1 and fills[0].symbol == "BTCUSDT"
    assert fills[0].exec_slippage_bps is not None


def test_unlock_fill_classified_at_unlock_tier(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    """FIX 1: an unlock event carries the BARE base coin ("ONDO"); the order is on the USDT-suffixed
    "ONDOUSDT". The run must normalize the unlock set to the USDT suffix so the fill is charged the 35bp
    unlock tier — not the 8bp liquid tier (the misclassification this test would have caught)."""
    from fxhnt.application.paper_book import assumed_tier_bps

    s, _dsn = _settings(tmp_path)
    adapter = _FakeAdapter(mids={"ONDOUSDT": 1.0}, fill_price={"ONDOUSDT": 1.0}, allow_place=True)
    _patch_seams(monkeypatch, adapter, weights={"ONDOUSDT": 0.5}, marks={"ONDOUSDT": 1.0},
                unlock_syms=["ONDO"])

    out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
    assert out["placed"] == 1
    fill0 = out["fills"][0]
    assert fill0["symbol"] == "ONDOUSDT"
    assert fill0["assumed_tier_bps"] == assumed_tier_bps("ONDOUSDT", True)      # 35bp unlock tier
    assert fill0["assumed_tier_bps"] != assumed_tier_bps("ONDOUSDT", False)     # NOT the 8bp liquid tier


class _MarksDropBookMark(dict):
    """Returns the real mark to `plan_orders` (1st `.get`) but None to the later `book_mark` read (2nd
    `.get`) for `drop` symbols — so a PLACED fill carries timing_drift_bps=None, the case FIX 2 guards
    (the mean must divide by the count of non-None drifts, not len(real))."""

    def __init__(self, data, drop):  # noqa: ANN001, ANN204
        super().__init__(data)
        self._drop = set(drop)
        self._seen: dict = {}

    def get(self, key, default=None):  # noqa: ANN001, ANN201
        val = super().get(key, default)
        if key in self._drop:
            n = self._seen.get(key, 0)
            self._seen[key] = n + 1
            if n >= 1:
                return None
        return val


def test_mean_drift_excludes_none_drift_from_denominator(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    """FIX 2: BTC has a real book_mark → drift present. ETH's book_mark read returns None on the SECOND
    lookup (the fill-time read) even though the fill itself is real (mid not None ⇒ not low_confidence). The
    mean must be over the single non-None drift (BTC), not BTC/2 — the buggy len(real) denominator gave half."""
    s, _dsn = _settings(tmp_path)
    marks = _MarksDropBookMark({"BTCUSDT": 100.0, "ETHUSDT": 200.0}, drop=["ETHUSDT"])
    adapter = _FakeAdapter(mids={"BTCUSDT": 101.0, "ETHUSDT": 202.0},
                           fill_price={"BTCUSDT": 102.0, "ETHUSDT": 202.0}, allow_place=True)
    _patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.3, "ETHUSDT": 0.3}, marks=marks)

    out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
    assert out["placed"] == 2
    drifts = {f["symbol"]: f["timing_drift_bps"] for f in out["fills"]}
    assert drifts["ETHUSDT"] is None and drifts["BTCUSDT"] is not None
    assert out["mean_timing_drift_bps"] == drifts["BTCUSDT"]


def test_recorded_fill_ts_is_exchange_fill_time(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    """FIX 3: the recorded fill ts is the EXCHANGE fill time, not the record-step wall clock."""
    from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo

    s, dsn = _settings(tmp_path)
    fill_ms = 1_700_000_000_000   # 2023-11-14T22:13:20Z — far from today's wall clock
    adapter = _FakeAdapter(mids={"BTCUSDT": 101.0}, fill_price={"BTCUSDT": 102.0}, fill_ts=fill_ms,
                           allow_place=True)
    _patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})

    out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
    assert out["fills"][0]["ts"] == fill_ms

    run_date = dt.datetime.now(dt.UTC).date().isoformat()
    fills = BybitTestnetRepo(dsn).read_testnet_fills(run_date)
    expected = dt.datetime.fromtimestamp(fill_ms / 1000.0, dt.UTC)
    got = fills[0].ts
    assert (got.year, got.month, got.day) == (expected.year, expected.month, expected.day)
    assert got.year == 2023   # the exchange fill time, NOT now()


def test_open_orders_present_aborts_without_placing(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    """FIX 4: a resting order on a run symbol means a prior run placed but the record step failed; re-placing
    would double-execute. The run must ABORT placing nothing (and write no NAV row → visible gap)."""
    from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo

    s, dsn = _settings(tmp_path)
    adapter = _FakeAdapter(mids={"BTCUSDT": 100.0}, allow_place=True,
                           open_orders=[{"symbol": "BTC/USDT:USDT", "info": {"symbol": "BTCUSDT"}}])
    _patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})

    out = run_bybit_testnet(s, paper_envelope=0.0, execute=True, log=lambda *a: None)
    assert out.get("aborted") is True
    assert out["error"] == "open_orders_present"
    assert out["placed"] == 0
    assert adapter.placed == []

    run_date = dt.datetime.now(dt.UTC).date().isoformat()
    assert not BybitTestnetRepo(dsn).testnet_executed_on(run_date)   # no NAV row → visible gap


def test_paper_envelope_refused_when_account_not_paper(monkeypatch, tmp_path):  # noqa: ANN001, ANN201
    """A live (non-testnet) account with a non-zero --paper-envelope must be refused AT THE run level — this
    is a real-capital exposure guard, not a connectivity error. The adapter must never reach `place_market`."""
    s, _dsn = _settings(tmp_path, testnet=False)
    adapter = _FakeAdapter(mids={"BTCUSDT": 100.0}, allow_place=True)
    _patch_seams(monkeypatch, adapter, weights={"BTCUSDT": 0.5}, marks={"BTCUSDT": 100.0})

    with pytest.raises(ValueError, match="paper-envelope refused"):
        run_bybit_testnet(s, paper_envelope=1_000_000.0, execute=True, log=lambda *a: None)
    assert adapter.placed == []
