"""Bybit testnet execution leg as a plain application function (ported off the Dagster reconcile/record
assets — same guards, now CLI-driven). `run_bybit_testnet` reconciles the bybit_4edge book on TESTNET and
records fills/NAV + the observed bybit_4edge_exec return in ONE process. Dry-run (execute=False) plans only.

Faithful relocation of `bybit_testnet_reconcile` + `bybit_testnet_record` (adapters/orchestration/assets.py):
EVERY safety guard is preserved verbatim — kill-switch (zero orders), per-day NAV-marker idempotency no-op,
open-orders resting-guard abort, per-order BrokerError -> structured gap row (never a crash), low-confidence
slippage exclusion, and funding-cursor advance semantics. The only edits are the mechanical transforms the
Task-5 brief enumerates (context.log -> log, get_settings() -> settings param, paper-envelope, +venue,
execute-gated placement/writes)."""
from __future__ import annotations

import datetime as dt
import os
from collections.abc import Callable
from typing import Any

# `latest_raw_sleeve_weights` / `combined_symbol_weights` are imported at MODULE level (not locally like the
# other body deps) so tests can `monkeypatch.setattr(run_mod, "latest_raw_sleeve_weights", ...)`.
from fxhnt.application.bybit_forward_track import POSITIONING_COIN_GROSS_CAP
from fxhnt.application.bybit_paper_book import combined_symbol_weights, latest_raw_sleeve_weights


def _data_dir() -> str:
    return os.environ.get("FXHNT_SURFER_DATA_DIR", "/data/surfer")


# ---- Bybit testnet real-paper execution leg — monkeypatchable seams (moved verbatim from assets.py) ----
# Small, monkeypatchable seams so the reconcile/record logic is testable WITHOUT touching Timescale or ccxt:
# each builds one concrete dependency from settings, named at module level so a test can swap it.
def _testnet_store(s):  # type: ignore[no-untyped-def]
    """The Bybit warehouse feature store the raw sleeve weights + marks read from (READ-ONLY)."""
    from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore

    return TimescaleFeatureStore(s.operational_dsn, table="bybit_features")


def _testnet_universe(store):  # type: ignore[no-untyped-def]
    """The liquid universe over the Bybit warehouse (None → full universe), mirroring `bybit_paper_book`."""
    from fxhnt.application.bybit_liquidity import liquid_universe

    return liquid_universe(store) or None


def _testnet_unlock_events(s):  # type: ignore[no-untyped-def]
    """The unlock cliff calendar (DB-first, JSON fallback) — the SAME source the live sleeve uses; also tells
    the reconcile which symbols are unlock-shorts (the 35bp assumed tier)."""
    from fxhnt.application.unlock_calendar_loader import load_unlock_events, operational_unlock_repo

    return load_unlock_events(operational_unlock_repo(s), f"{_data_dir()}/unlock_calendar.json")


def _testnet_marks(store, universe=None):  # type: ignore[no-untyped-def]
    """`{symbol: latest_close}` from the Bybit close panel = the book_mark the targets were sized off (the
    same `latest_prices` read the bybit paper book uses)."""
    from fxhnt.application.bybit_paper_book import latest_prices

    return latest_prices(store, universe=universe)


def _testnet_adapter(s):  # type: ignore[no-untyped-def]
    """The ccxt-backed Bybit execution adapter built from the `bybit_exec` config (testnet by default)."""
    from fxhnt.adapters.broker.bybit import BybitExecution

    return BybitExecution(s.bybit_exec.api_key, s.bybit_exec.api_secret, testnet=s.bybit_exec.testnet)


def run_bybit_testnet(settings: Any, *, paper_envelope: float, execute: bool,
                      log: Callable[[str], None]) -> dict[str, Any]:
    """Reconcile the `bybit_4edge` book on Bybit TESTNET and record its fills/NAV + observed exec return in ONE
    process (the ported reconcile+record bodies, concatenated). Read the RAW pre-overlay sleeve weights, diff
    vs the live testnet positions, place MARKET orders, capture each fill with decomposed slippage, then
    persist fills + a post-rebalance NAV row + the bybit_4edge_exec forward return.

    Guards (preserved from the assets): `kill_switch` → place ZERO orders (log intent only); a per-day NAV
    marker → no-op re-run; open orders resting on a run symbol → abort placing nothing (visible gap); a per
    order `BrokerError` → a structured error row, never a crash. Dry-run (`execute=False`) plans only: place
    NO orders, write NO fills/NAV, DON'T advance the funding cursor."""
    from fxhnt.adapters.broker.bybit import BrokerError
    from fxhnt.adapters.persistence.bybit_testnet_repo import (
        BybitTestnetRepo,
        TestnetFill,
        TestnetNav,
    )
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.application.bybit_exec_record import (
        apply_fills,
        compute_crypto_exec_return,
        paper_or_b2a_crypto_envelope,
        record_bybit_exec_track,
    )
    from fxhnt.application.bybit_testnet_execution import plan_orders
    from fxhnt.application.paper_book import assumed_tier_bps
    from fxhnt.application.slippage import (
        execution_slippage_bps,
        slippage_vs_assumed_bps,
        timing_drift_bps,
    )

    s = settings
    run_date = dt.datetime.now(dt.UTC).date().isoformat()

    # idempotency: a NAV row for today ⇒ already executed ⇒ no-op (re-run safety).
    repo = BybitTestnetRepo(s.operational_dsn)
    repo.migrate()
    if repo.testnet_executed_on(run_date):
        log(f"bybit_testnet_reconcile: already executed on {run_date}, no-op")
        return {"run_date": run_date, "placed": 0, "noop": True, "fills": []}

    adapter = _testnet_adapter(s)

    store = _testnet_store(s)
    try:
        universe = _testnet_universe(store)
        unlock_events = _testnet_unlock_events(s)
        # Cap the executed testnet positions the SAME way the modeled/forward/paper-book paths do, so a single
        # over-short coin can't take an outsized real position (the reconciled return is capped; the executed
        # position must match). POSITIONING_COIN_GROSS_CAP is the ex-ante per-coin weight cap (0.07).
        weights = latest_raw_sleeve_weights(store, universe=universe, unlock_events=unlock_events,
                                            positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP)
        _combined, as_of_day = combined_symbol_weights(
            store, universe=universe, unlock_events=unlock_events,
            positioning_coin_gross_cap=POSITIONING_COIN_GROSS_CAP)
        marks = _testnet_marks(store, universe=universe)
    finally:
        store.close()

    # `sym` is the BASE coin WITHOUT the USDT suffix (UnlockEventRow); `it.symbol` is USDT-suffixed, so
    # normalize here (mirroring unlock_runner._events_norm) or every unlock fill mis-classifies as 8bp.
    unlock_syms = {(e["sym"] + "USDT" if not e["sym"].endswith("USDT") else e["sym"])
                   for e in (unlock_events or []) if e.get("sym")}

    # equity/current/limits are read once and shared by both the kill-switch (dry-run) and live branches, so
    # the envelope (B2a/B3-driven, not full equity) sizes BOTH `plan_orders` calls identically.
    equity = adapter.equity()
    current = adapter.positions()
    limits = adapter.instrument_limits()

    fwd_repo = ForwardNavRepo(s.operational_dsn)
    fwd_repo.migrate()
    envelope = paper_or_b2a_crypto_envelope(fwd_repo, equity, paper_envelope, is_paper=s.bybit_exec.testnet)
    prior_state = fwd_repo.get_book_state("bybit_4edge_exec_pos") or {}
    funding_cursor_prior = int(prior_state.get("funding_cursor_ts", 0))

    # kill switch: log the INTENDED orders, place nothing.
    if s.bybit_exec.kill_switch:
        intents = plan_orders(weights, current, envelope, marks,   # size to the B2a/B3 envelope, not full equity
                              limits, min_order_usd=s.bybit_exec.min_order_usd, max_gross=s.bybit_exec.max_gross)
        for it in intents:
            log(f"bybit_testnet KILL_SWITCH: WOULD place {it.symbol} qty={it.qty} "
                f"reduce_only={it.reduce_only} (no order sent)")
        return {"run_date": run_date, "placed": 0, "killed": True,
                "intended": len(intents), "fills": []}

    intents = plan_orders(weights, current, envelope, marks,   # size to the B2a/B3 envelope, not full equity
                          limits, min_order_usd=s.bybit_exec.min_order_usd, max_gross=s.bybit_exec.max_gross)

    # Dry-run (execute=False): plan only — mirror `execute-multistrat`'s dry-run. Place NO orders, write NO
    # fills/NAV, DON'T advance the funding cursor (and don't pay for the open-orders / funding network calls).
    if not execute:
        gross = sum(abs(w) for w in weights.values())
        log(f"bybit_testnet_reconcile: DRY-RUN, planned {len(intents)} orders on {run_date}, placing nothing "
            f"(equity={equity:.2f}, gross={gross:.4f})")
        return {"run_date": run_date, "placed": 0, "dry_run": True, "intended": len(intents),
                "equity": equity, "envelope": envelope, "gross": gross,
                "realized_funding": 0.0, "realized_fees": 0.0,
                "mean_exec_slippage_bps": 0.0, "mean_timing_drift_bps": 0.0,
                "exec_return": None, "venue": "bybit-testnet", "fills": []}

    # Open-orders idempotency guard (design §4.3, in addition to the NAV-row marker): if any order is
    # already resting on a run symbol, a prior reconcile placed but the record step failed — re-placing
    # would double-execute. Abort placing NOTHING and surface a structured error (no NAV row → visible gap).
    run_symbols = {it.symbol for it in intents}
    open_orders = adapter.open_orders()
    open_syms = {(o.get("info") or {}).get("symbol") or o.get("symbol") for o in open_orders}
    resting = sorted(sym for sym in (open_syms & run_symbols) if sym)
    if resting:
        log(f"bybit_testnet_reconcile: open orders resting on {resting} — aborting "
            f"(prior place succeeded, record failed?); placed nothing")
        return {"run_date": run_date, "placed": 0, "aborted": True,
                "error": "open_orders_present", "open_symbols": resting, "fills": []}

    fills: list[dict] = []
    placed = 0
    for it in intents:
        book_mark = marks.get(it.symbol)
        tier = assumed_tier_bps(it.symbol, it.symbol in unlock_syms)
        try:
            mid = adapter.mid(it.symbol)
            fill = adapter.place_market(it.symbol, it.qty, reduce_only=it.reduce_only)
        except BrokerError as e:  # best-effort: a broker hiccup becomes a visible gap row, never a crash
            log(f"bybit_testnet order failed for {it.symbol}: {e}")
            fills.append({"symbol": it.symbol, "error": True, "assumed_tier_bps": tier})
            continue
        placed += 1
        low_confidence = mid is None
        exec_bps = None if mid is None else execution_slippage_bps(fill.avg_price, mid, fill.side)
        drift_bps = (None if (mid is None or book_mark is None)
                     else timing_drift_bps(mid, book_mark, fill.side))
        delta_bps = None if exec_bps is None else slippage_vs_assumed_bps(exec_bps, tier)
        fills.append({
            "symbol": fill.symbol, "side": fill.side, "qty": fill.qty, "fill_price": fill.avg_price,
            "mid_at_order": mid, "book_mark": book_mark, "exec_slippage_bps": exec_bps,
            "timing_drift_bps": drift_bps, "assumed_tier_bps": tier, "slippage_delta_bps": delta_bps,
            "low_confidence": low_confidence, "fee": fill.fee, "order_id": fill.order_id,
            "ts": fill.ts,
        })

    gross = sum(abs(w) for w in weights.values())
    real = [f for f in fills if not f.get("error") and not f.get("low_confidence")]
    mean_exec = (sum(f["exec_slippage_bps"] for f in real) / len(real)) if real else 0.0
    # Divide by the count of NON-None drift values, not len(real): a fill with no book_mark has
    # timing_drift_bps=None and must not understate the mean by inflating the denominator.
    drift_vals = [f["timing_drift_bps"] for f in real if f["timing_drift_bps"] is not None]
    mean_drift = sum(drift_vals) / len(drift_vals) if drift_vals else 0.0
    realized_fees = sum(float(f.get("fee") or 0.0) for f in fills if not f.get("error"))
    # only the LIVE path pays for a network call — the kill-switch (dry-run) branch returns before this.
    # `funding_query_ms` timestamps the QUERY window's end so the next period's `funding_since` cursor starts
    # exactly where this one's query ended (no gap, no overlap) — captured BEFORE the funding_since call.
    funding_query_ms = int(dt.datetime.now(dt.UTC).timestamp() * 1000)
    realized_funding = adapter.funding_since(funding_cursor_prior) if funding_cursor_prior else 0.0
    positions_after = apply_fills(current, fills)   # the real placed book (tested helper), next-run basis
    realized_pnl = realized_funding - realized_fees
    log(f"bybit_testnet_reconcile: placed {placed}/{len(intents)} orders on {run_date} "
        f"(equity={equity:.2f}, gross={gross:.4f})")

    # ---- record: persist fills (with decomposed slippage) + a post-rebalance NAV row, then book the
    # observed bybit_4edge_exec forward return (the reconcile locals feed this directly — no dict handoff).
    now = dt.datetime.now(dt.UTC)

    def _fill_ts(f: dict) -> dt.datetime:  # type: ignore[type-arg]
        # Stamp the row with the EXCHANGE fill time (ms epoch) when present, not the record wall clock; fall
        # back to now() for a fill that carried no timestamp.
        ms = f.get("ts")
        return dt.datetime.fromtimestamp(ms / 1000.0, dt.UTC) if ms else now

    rows = [
        TestnetFill(
            run_date=run_date, symbol=f["symbol"], side=f["side"], qty=f["qty"],
            fill_price=f["fill_price"], mid_at_order=f["mid_at_order"], book_mark=f["book_mark"],
            exec_slippage_bps=f["exec_slippage_bps"], timing_drift_bps=f["timing_drift_bps"],
            assumed_tier_bps=f["assumed_tier_bps"], slippage_delta_bps=f["slippage_delta_bps"],
            low_confidence=f["low_confidence"], fee=f["fee"], order_id=f["order_id"], ts=_fill_ts(f))
        for f in fills if not f.get("error")
    ]
    if rows:
        repo.insert_testnet_fills(rows)

    nav = TestnetNav(
        run_date=run_date, equity=equity, realized_pnl=realized_pnl,
        gross=gross, n_orders=placed,
        mean_exec_slippage_bps=mean_exec,
        mean_timing_drift_bps=mean_drift)
    repo.upsert_testnet_nav(
        nav.run_date, equity=nav.equity, realized_pnl=nav.realized_pnl, gross=nav.gross,
        n_orders=nav.n_orders, mean_exec_slippage_bps=nav.mean_exec_slippage_bps,
        mean_timing_drift_bps=nav.mean_timing_drift_bps, ts=now)
    log(f"bybit_testnet_record: wrote {len(rows)} fills + NAV for {run_date}")

    pos_prior = fwd_repo.get_book_state("bybit_4edge_exec_pos")
    exec_return = compute_crypto_exec_return(
        pos_prior or {}, marks, realized_funding, realized_fees)
    # advance the funding cursor to the reconcile's funding-QUERY timestamp (not this record step's wall
    # clock), so the NEXT period's `funding_since` starts exactly where this period's query ended (no gap).
    record_bybit_exec_track(
        fwd_repo, exec_return, positions_after, marks, envelope,
        funding_query_ms, run_date, dt.datetime.now(dt.UTC).replace(tzinfo=None),
        venue="bybit-testnet")
    log(f"bybit_testnet_record: bybit_4edge_exec return="
        f"{'inception' if exec_return is None else f'{exec_return*100:.3f}%'} "
        f"(envelope=${envelope:.0f}, funding=${realized_funding:.2f}, fees=${realized_fees:.2f})")

    return {
        "run_date": run_date, "placed": placed, "killed": False, "noop": False, "dry_run": False,
        "equity": equity, "envelope": envelope,
        "realized_funding": realized_funding, "realized_fees": realized_fees,
        "realized_pnl": realized_pnl,
        "gross": gross, "mean_exec_slippage_bps": mean_exec, "mean_timing_drift_bps": mean_drift,
        "as_of_day": as_of_day, "fills": fills, "n_fills_recorded": len(rows), "nav_written": True,
        "positions_after": positions_after, "marks": marks,
        "funding_cursor_prior": funding_cursor_prior, "funding_query_ms": funding_query_ms,
        "exec_return": exec_return, "venue": "bybit-testnet",
    }
