"""Maker-vs-taker EXECUTION re-costing of the Bybit 4-edge book — net-Sharpe lift from passive (maker) fills.

This is NOT a signal change and NOT a fit: the GROSS book return is UNCHANGED across every scenario; only the
per-turnover COST model varies (taker crosses the spread; maker does not). So there is nothing to overfit.

The cost model (per rebalance, per coin, |Δw| split by maker fill fraction f):
  * taker leg (1−f): taker_fee(5.5) + 0.5·spread       (crosses the spread — the CURRENT live model)
  * maker leg (f):   maker_fee(2.0) + adverse_sel·spread (does NOT cross; adverse_sel ∈ [0, 0.5])
  blended = f·maker + (1−f)·taker.

Tests (TDD):
  * f=0 reproduces `per_coin_book_returns`/`_bybit_measured_net`'s current net EXACTLY (baseline tie-out);
  * f=1, adverse_sel=0 → maker-fee-only (no spread) → strictly lower cost-drag + higher net than baseline;
  * adverse_sel=0.5 at f=1 → cost-drag between maker-fee-only and taker (monotone in adverse_sel);
  * monotonicity: higher f → lower cost-drag (for adverse_sel < 0.5);
  * GROSS is IDENTICAL across all scenarios (net + cost reconstructs the same gross — execution-only);
  * the sweep reports the grid + baseline deltas + median spread + avg daily turnover;
  * READ-ONLY (WriteTripwire) + memory-bounded; empty store → unavailable; CLI smoke (mocked store).
NO network — the store is in-memory SQLite; external inputs are injected.
"""
from __future__ import annotations

import math

from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_liquidity_sweep import per_coin_book_returns
from fxhnt.application.bybit_maker_recost import (
    _ADVERSE_SELS,
    _FILL_FRACTIONS,
    _MAKER_FEE_BPS,
    blended_cost_bps,
    maker_recost_sweep,
    recost_net_series,
)

_DAY = 86_400


# --- fixtures (mirror the liquidity-sweep test seeds) --------------------------------------------

def _ohlc_from_close(close: float, spread_frac: float, vol_frac: float, phase: float) -> tuple[float, float]:
    mid = close
    vol = vol_frac * (0.5 + 0.5 * math.cos(7.0 * phase))
    half = 0.5 * spread_frac + vol
    return mid * (1.0 + half), mid * (1.0 - half)


def _seed_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int = 400,
                   drift: float = 0.01, spread_frac: float = 0.0005) -> list[str]:
    syms = [f"SYM{idx:02d}USDT" for idx in range(n_symbols)]
    for idx, sym in enumerate(syms):
        sign = 1.0 if idx % 2 == 0 else -1.0
        px = 100.0
        rows = []
        for d in range(days):
            wobble = 0.003 * math.cos(0.4 * d + idx)
            px *= (1.0 + sign * drift + wobble)
            high, low = _ohlc_from_close(px, spread_frac, vol_frac=0.0005, phase=0.4 * d + idx)
            rows.append((d * _DAY, {"close": px, "high": high, "low": low}))
        store.write_features(sym, rows)
    return syms


def _seed_carry(store: TimescaleFeatureStore, *, days: int = 400, spread_frac: float = 0.0005) -> list[str]:
    funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
               "XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
    for idx, (sym, fund) in enumerate(funding.items()):
        rows = []
        for d in range(days):
            f = fund + 0.0002 * math.cos(0.5 * d + idx)
            px = 100.0
            high, low = _ohlc_from_close(px, spread_frac, vol_frac=0.0005, phase=0.5 * d + idx)
            rows.append((d * _DAY, {"funding": f, "close": px, "spot_close": px, "high": high, "low": low}))
        store.write_features(sym, rows)
    return list(funding)


def _seed_turnover(store: TimescaleFeatureStore, dollar_vol_by_sym: dict[str, float], *,
                   days: int = 400) -> None:
    for sym, dv in dollar_vol_by_sym.items():
        store.write_features(sym, [(d * _DAY, {"turnover": dv}) for d in range(days)])


def _seed_wide_spread_book(store: TimescaleFeatureStore, *, days: int = 400) -> None:
    """A carry book whose coins carry a WIDE measured spread (so the maker leg has a lot of spread to save)."""
    funding = {"AAAUSDT": 0.0012, "BBBUSDT": 0.0012, "CCCUSDT": 0.0012,
               "XXXUSDT": -0.0012, "YYYUSDT": -0.0012, "ZZZUSDT": -0.0012}
    wide = 0.02  # 200 bps measured spread
    for idx, (sym, fund) in enumerate(funding.items()):
        rows = []
        px = 100.0
        for d in range(days):
            px *= (1.0 + 0.0005 * math.sin(0.3 * d + idx))
            high, low = _ohlc_from_close(px, wide, vol_frac=0.0005, phase=0.3 * d + idx)
            rows.append((d * _DAY, {"funding": fund + 0.0005 * math.cos(0.5 * d + idx),
                                    "close": px, "spot_close": 100.0, "high": high, "low": low}))
        store.write_features(sym, rows)
    _seed_turnover(store, {s: 50_000_000.0 for s in funding}, days=days)


class _WriteTripwireStore:
    _FORBIDDEN = frozenset({
        "write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
        "replace_positions", "upsert_nav", "upsert_sleeve_ret", "replace_shadow_positions",
        "replace_trades", "_create_schema", "_bulk_upsert",
    })

    def __init__(self, inner: TimescaleFeatureStore) -> None:
        object.__setattr__(self, "_inner", inner)

    def __getattr__(self, name: str):
        if name in _WriteTripwireStore._FORBIDDEN:
            raise AssertionError(f"READ-ONLY violation: maker-recost called write method {name!r}")
        return getattr(self._inner, name)


class _FullPanelTripwireStore:
    _FULL_PANELS = frozenset({"crypto_close_panel", "crypto_funding_panel", "crypto_spot_panel"})

    def __init__(self, inner: TimescaleFeatureStore) -> None:
        object.__setattr__(self, "_inner", inner)
        object.__setattr__(self, "read_panel_calls", [])

    def read_panel(self, symbols, feature):  # noqa: ANN001, ANN201
        self.read_panel_calls.append((tuple(symbols), feature))
        return self._inner.read_panel(symbols, feature)

    def __getattr__(self, name: str):
        if name in _FullPanelTripwireStore._FULL_PANELS:
            raise AssertionError(f"MEMORY-BOUND violation: maker-recost read the FULL panel {name!r}")
        return getattr(self._inner, name)


def _wide_book_detail() -> dict:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_wide_spread_book(store)
    pc = per_coin_book_returns(store, universe=None, sleeves=["xsfunding"], unlock_events=[],
                               return_detail=True)
    store.close()
    assert pc["available"], pc
    return pc


# --- THE COST MODEL ------------------------------------------------------------------------------

def test_blended_at_f0_equals_taker() -> None:
    """f=0 ⇒ blended == the live all-taker cost (taker_fee + half the measured spread), for any adverse_sel."""
    for adverse in (0.0, 0.25, 0.5):
        c = blended_cost_bps(spread_bps=80.0, fill_fraction=0.0, adverse_sel=adverse,
                             taker_fee_bps=5.5, maker_fee_bps=2.0)
        assert abs(c - (5.5 + 0.5 * 80.0)) < 1e-12, (adverse, c)


def test_blended_at_f1_adverse0_is_maker_fee_only() -> None:
    """Fully passive + optimistic (maker earns the spread) ⇒ cost = maker fee, NO spread term."""
    c = blended_cost_bps(spread_bps=120.0, fill_fraction=1.0, adverse_sel=0.0,
                         taker_fee_bps=5.5, maker_fee_bps=2.0)
    assert abs(c - 2.0) < 1e-12, c


def test_blended_monotone_decreasing_in_fill_fraction() -> None:
    """For adverse_sel < 0.5 the maker leg is cheaper than the taker leg, so more passive fill ⇒ lower cost."""
    prev = math.inf
    for f in (0.0, 0.3, 0.5, 0.7, 1.0):
        c = blended_cost_bps(spread_bps=100.0, fill_fraction=f, adverse_sel=0.25,
                             taker_fee_bps=5.5, maker_fee_bps=2.0)
        assert c < prev, (f, c, prev)
        prev = c


def test_blended_monotone_increasing_in_adverse_sel() -> None:
    """At f=1 the cost rises with adverse_sel; at adverse_sel=0.5 it sits between maker-fee-only and taker."""
    sp = 100.0
    maker_only = blended_cost_bps(spread_bps=sp, fill_fraction=1.0, adverse_sel=0.0)
    half = blended_cost_bps(spread_bps=sp, fill_fraction=1.0, adverse_sel=0.5)
    taker = blended_cost_bps(spread_bps=sp, fill_fraction=0.0, adverse_sel=0.0)
    assert maker_only < half < taker, (maker_only, half, taker)
    # adverse_sel=0.5 at f=1 ⇒ only the fee differs from taker (same spread term).
    assert abs(half - (_MAKER_FEE_BPS + 0.5 * sp)) < 1e-12, half


def test_blended_clamps_negative_spread() -> None:
    assert blended_cost_bps(spread_bps=-50.0, fill_fraction=1.0, adverse_sel=0.0) == _MAKER_FEE_BPS


# --- BASELINE TIE-OUT (f=0 == current deployed number) -------------------------------------------

def test_f0_reproduces_per_coin_book_net_exactly() -> None:
    """The CORE sanity tie-out: re-costing at f=0 reproduces the live all-taker `per_coin_book_returns` net
    series BYTE-IDENTICALLY — proves the re-costing introduces no drift over the deployed number."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
    pc = per_coin_book_returns(store, universe=None, unlock_events=[], return_detail=True)
    store.close()

    r = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0)
    assert r["net_series"] == pc["net_series"]


def test_f0_ties_to_bybit_measured_net() -> None:
    """The baseline equity-curve metrics (f=0) match the deployed `_bybit_measured_net` — same metric space."""
    from fxhnt.application.compare_measured_cost import _bybit_measured_net

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
    deployed = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
                                   universe=None, unlock_events=[])
    rep = maker_recost_sweep(store, universe=None, capital=100_000.0, unlock_events=[])
    store.close()

    assert rep["available"] is True
    assert abs(rep["baseline"]["sharpe"] - deployed["metrics"]["sharpe"]) < 1e-9
    assert abs(rep["baseline"]["total_return"] - deployed["metrics"]["total_return"]) < 1e-9
    assert abs(rep["baseline"]["cost_drag_bps"] - deployed["cost_drag_bps"]) < 1e-9


# --- THE LIFT ------------------------------------------------------------------------------------

def test_f1_adverse0_strictly_lower_drag_and_higher_net_than_baseline() -> None:
    """On a WIDE-spread book, fully passive + optimistic fills slash the cost drag (to ~maker fee) and lift
    the net cumulative return above the all-taker baseline (the spread crossing is the dominant cost)."""
    pc = _wide_book_detail()
    base = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0)
    maker = recost_net_series(pc, fill_fraction=1.0, adverse_sel=0.0)

    assert maker["cost_drag_bps"] < base["cost_drag_bps"], (maker["cost_drag_bps"], base["cost_drag_bps"])
    assert abs(maker["cost_drag_bps"] - _MAKER_FEE_BPS) < 1e-9, maker["cost_drag_bps"]
    assert sum(maker["net_series"].values()) > sum(base["net_series"].values())


def test_adverse_sel_half_drag_between_maker_only_and_taker() -> None:
    """At f=1, adverse_sel=0.5's cost drag sits strictly between maker-fee-only (adverse 0) and the all-taker
    baseline — monotone in adverse_sel, as the model prescribes."""
    pc = _wide_book_detail()
    maker_only = recost_net_series(pc, fill_fraction=1.0, adverse_sel=0.0)["cost_drag_bps"]
    half = recost_net_series(pc, fill_fraction=1.0, adverse_sel=0.5)["cost_drag_bps"]
    taker = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0)["cost_drag_bps"]
    assert maker_only < half < taker, (maker_only, half, taker)


def test_cost_drag_monotone_decreasing_in_fill_fraction() -> None:
    """Higher maker fill fraction ⇒ strictly lower turnover-weighted cost drag (for adverse_sel < 0.5)."""
    pc = _wide_book_detail()
    prev = math.inf
    for f in (0.0, 0.3, 0.5, 0.7, 1.0):
        drag = recost_net_series(pc, fill_fraction=f, adverse_sel=0.25)["cost_drag_bps"]
        assert drag < prev, (f, drag, prev)
        prev = drag


def test_gross_is_identical_across_all_scenarios() -> None:
    """Execution-only proof: for EVERY (f, adverse_sel), net[d] + day_cost[d] reconstructs the SAME gross[d]
    — the GROSS book return is untouched (no signal change, nothing to overfit)."""
    pc = _wide_book_detail()
    gross = pc["gross_series"]
    for adverse in (0.0, 0.25, 0.5):
        for f in (0.0, 0.5, 1.0):
            r = recost_net_series(pc, fill_fraction=f, adverse_sel=adverse)
            for d in gross:
                assert abs((r["net_series"][d] + r["day_cost"][d]) - gross[d]) < 1e-12, (f, adverse, d)
    # the input gross dict is never mutated by re-costing.
    assert pc["gross_series"] == gross


def test_return_detail_does_not_change_existing_keys() -> None:
    """Adding return_detail=True must not alter the existing all-taker net (so the tie-out cannot drift)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
    plain = per_coin_book_returns(store, universe=None, unlock_events=[])
    detail = per_coin_book_returns(store, universe=None, unlock_events=[], return_detail=True)
    store.close()
    assert plain["net_series"] == detail["net_series"]
    assert plain["gross_series"] == detail["gross_series"]
    assert "spread_by_coin" in detail and "daily_turnover" in detail
    assert "spread_by_coin" not in plain


# --- THE SWEEP -----------------------------------------------------------------------------------

def test_sweep_runs_full_grid_with_baseline_deltas() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
    rep = maker_recost_sweep(store, universe=None, capital=100_000.0, unlock_events=[])
    store.close()

    assert rep["available"] is True
    assert rep["taker_fee_bps"] == 5.5 and rep["maker_fee_bps"] == 2.0
    assert len(rep["table"]) == len(_FILL_FRACTIONS) * len(_ADVERSE_SELS)
    for row in rep["table"]:
        assert row["fill_fraction"] in _FILL_FRACTIONS
        assert row["adverse_sel"] in _ADVERSE_SELS
        assert math.isfinite(row["sharpe"])
        assert math.isfinite(row["delta_sharpe"])
        assert math.isfinite(row["cost_drag_bps"])
    # the f=0 rows ARE the baseline → zero delta.
    for row in rep["table"]:
        if row["fill_fraction"] == 0.0:
            assert abs(row["delta_sharpe"]) < 1e-9
            assert abs(row["delta_cost_drag_bps"]) < 1e-9
    assert "median_spread_bps" in rep and "avg_daily_turnover" in rep
    assert "operating_point" in rep and "gross_metrics" in rep


def test_sweep_lift_is_capital_robust() -> None:
    """The cost is capital-independent per-bp, so the per-cell Sharpe (and the lift) is identical across very
    different capitals."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
    small = maker_recost_sweep(store, universe=None, capital=100_000.0, unlock_events=[])
    large = maker_recost_sweep(store, universe=None, capital=50_000_000.0, unlock_events=[])
    store.close()
    sa = {(r["fill_fraction"], r["adverse_sel"]): r["sharpe"] for r in small["table"]}
    la = {(r["fill_fraction"], r["adverse_sel"]): r["sharpe"] for r in large["table"]}
    assert sa == la


def test_sweep_empty_store_reports_unavailable() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    rep = maker_recost_sweep(store, universe=None, unlock_events=[])
    store.close()
    assert rep["available"] is False
    assert "reason" in rep


# --- READ-ONLY + memory-bound --------------------------------------------------------------------

def test_sweep_is_read_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(inner)
    carry = _seed_carry(inner)
    _seed_turnover(inner, {s: 50_000_000.0 for s in syms + carry})
    guarded = _WriteTripwireStore(inner)
    rep = maker_recost_sweep(guarded, universe=None, capital=100_000.0, unlock_events=[])
    inner.close()
    assert rep["available"] is True


def test_sweep_universe_restricted_reads_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(inner, n_symbols=8)
    _seed_turnover(inner, {s: 50_000_000.0 for s in syms})
    guarded = _FullPanelTripwireStore(inner)
    universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT", "SYM06USDT"}
    maker_recost_sweep(guarded, universe=universe, capital=100_000.0, unlock_events=[])
    inner.close()
    assert guarded.read_panel_calls
    for symbols, _feature in guarded.read_panel_calls:
        assert set(symbols) <= universe


# --- CLI smoke (mocked store) --------------------------------------------------------------------

def test_cli_maker_recost_prints_table(monkeypatch) -> None:
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(seeded, n_symbols=8, days=400)
    carry = _seed_carry(seeded, days=400)
    _seed_turnover(seeded, {s: 50_000_000.0 for s in syms + carry}, days=400)

    monkeypatch.setattr(
        "fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
        lambda *a, **k: seeded, raising=False)

    result = CliRunner().invoke(cli.app, ["maker-recost", "--capital", "100000", "--min-dollar-vol", "0"])
    seeded.close()

    assert result.exit_code == 0, result.output
    out_lower = result.output.lower()
    assert "maker" in out_lower and "taker" in out_lower
    assert "baseline" in out_lower
    assert "operating point" in out_lower
    assert "gross book return is unchanged" in out_lower
