"""OOS-disciplined overlay A/B harness for the Bybit 4-edge book.

The live (Binance-tuned) risk overlay HURTS the Bybit book (naive eq-wt Sharpe ~1.81 → overlay ~0.98 — it
over-levers ~2.79×). This harness sweeps a SMALL, PRINCIPLED config grid through the SAME live overlay
primitives (reused, not reimplemented — see `bybit_overlay_ab` docstring), scoring each on a TRAIN split and a
held-out TEST split, and only recommends a re-tuned overlay if the TRAIN-chosen config beats naive eq-wt
OUT-OF-SAMPLE. The KEY discipline test is `test_overfit_config_wins_train_loses_test_is_flagged`.

Tests:
  * the grid runs each config through the real overlay and returns train+test metrics per config;
  * a fixture where a calibrated-vol-target config beats naive on BOTH train and test → recommended;
  * a fixture where a config wins train but loses test → flagged overfit / NOT adopted (the discipline test);
  * the naive_eqwt config == the existing `risk_managed_book_from_store` naive number (consistency);
  * READ-ONLY (WriteTripwire) + memory-bounded (universe-restricted reads);
  * 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_book_eval import risk_managed_book_from_store
from fxhnt.application.bybit_overlay_ab import CONFIG_NAMES, overlay_ab

_DAY = 86_400


# --- fixtures (re-used shapes from test_bybit_book_eval) ----------------------------------------

def _seed_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int = 400,
                   drift: float = 0.01) -> None:
    for idx in range(n_symbols):
        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)
            rows.append((d * _DAY, {"close": px}))
        store.write_features(f"SYM{idx:02d}USDT", rows)


def _seed_carry(store: TimescaleFeatureStore, *, days: int = 400) -> None:
    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)
            rows.append((d * _DAY, {"funding": f, "close": 100.0, "spot_close": 100.0}))
        store.write_features(sym, rows)


def _seed_turnover(store: TimescaleFeatureStore, symbols: list[str], *, days: int = 400,
                   dollar_vol: float = 50_000_000.0) -> None:
    for sym in symbols:
        store.write_features(sym, [(d * _DAY, {"turnover": dollar_vol}) for d in range(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: overlay A/B 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: overlay A/B read the FULL-universe panel {name!r}")
        return getattr(self._inner, name)


# --- the grid runs each config through the real overlay -> train+test metrics -------------------

def test_grid_runs_every_config_with_train_and_test_metrics() -> None:
    """Each config in the grid is scored on TRAIN and on the held-out TEST, with finite Sharpe/CAGR/maxDD."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store)
    _seed_carry(store)
    rep = overlay_ab(store, universe=None, cost_bps=5.5, train_frac=0.6)
    store.close()

    assert rep["available"] is True
    table = rep["table"]
    # every named config is present in the table.
    assert set(table) == set(CONFIG_NAMES)
    for name, row in table.items():
        for split in ("train", "test"):
            m = row[split]
            assert math.isfinite(m["sharpe"]), (name, split)
            assert math.isfinite(m["cagr"]), (name, split)
            assert m["max_dd"] <= 1e-9, (name, split)
            assert m["days"] >= 0
    # train-winner + its OOS + best-test reported.
    assert rep["train_winner"] in CONFIG_NAMES
    assert rep["best_test"] in CONFIG_NAMES
    assert "verdict" in rep and "recommendation" in rep["verdict"]


def test_grid_accepts_prebuilt_returns_by_sleeve() -> None:
    """overlay_ab works directly on a returns_by_sleeve dict (no store needed) — the pure path."""
    # two uncorrelated positive sleeves over 200 days.
    a = {d: 0.002 + 0.01 * math.sin(0.3 * d) for d in range(200)}
    b = {d: 0.0015 + 0.01 * math.cos(0.27 * d) for d in range(200)}
    rep = overlay_ab({"crypto_tstrend": a, "xsfunding": b}, universe=None, cost_bps=0.0)
    assert rep["available"] is True
    assert set(rep["table"]) == set(CONFIG_NAMES)


# --- naive_eqwt config == the existing risk_managed_book naive number (consistency) -------------

def test_naive_eqwt_config_matches_risk_managed_book_naive() -> None:
    """The harness's `naive_eqwt` config Sharpe over the FULL sample must equal the established
    `risk_managed_book_from_store` naive equal-weight Sharpe (same engine, same number)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store)
    _seed_carry(store)
    book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=5.5)
    # full-sample (train_frac=1.0 → TRAIN spans everything; naive is the same regardless of split anyway,
    # but the FULL metric is what the established book reports).
    rep = overlay_ab(store, universe=None, cost_bps=5.5, train_frac=0.6)
    store.close()

    naive_full = rep["table"]["naive_eqwt"]["full"]["sharpe"]
    assert abs(naive_full - book["naive_equal_weight"]["sharpe"]) < 1e-9, (
        naive_full, book["naive_equal_weight"]["sharpe"])


# --- THE KEY DISCIPLINE TEST: train-wins / test-loses config is flagged overfit, not adopted ----

def test_overfit_config_wins_train_loses_test_is_flagged() -> None:
    """A config that beats naive on TRAIN but LOSES to naive on TEST must be flagged overfit and NOT adopted;
    the recommendation must fall back to a config that survives OOS (or to naive eq-wt itself).

    The fixture: two sleeves whose RELATIVE behaviour FLIPS at the train/test boundary so an in-sample
    leverage/weighting fit (the overlay configs) is rewarded on TRAIN but punished on TEST, while naive
    eq-wt (no fitting) is robust. We assert the harness never recommends a config whose TEST Sharpe is below
    naive's TEST Sharpe."""
    days = 300
    split = int(days * 0.6)
    a: dict[int, float] = {}
    b: dict[int, float] = {}
    for d in range(days):
        if d < split:
            # TRAIN: 'a' is the calm low-vol star (small steady gains), 'b' is choppy. An overlay that
            # weights/levers toward the calm low-vol 'a' looks great here.
            a[d] = 0.004 + 0.001 * math.sin(0.5 * d)
            b[d] = 0.002 + 0.02 * math.sin(0.9 * d)
        else:
            # TEST: regime FLIPS — 'a' (the in-sample favourite) turns into a steady bleeder, 'b' recovers.
            # A book that over-committed to 'a' on TRAIN now bleeds OOS; naive 50/50 is cushioned by 'b'.
            a[d] = -0.004 + 0.001 * math.sin(0.5 * d)
            b[d] = 0.004 + 0.01 * math.cos(0.4 * d)
    rep = overlay_ab({"crypto_tstrend": a, "xsfunding": b}, universe=None, cost_bps=0.0, train_frac=0.6)

    assert rep["available"] is True
    table = rep["table"]
    naive_test = table["naive_eqwt"]["test"]["sharpe"]
    rec = rep["verdict"]["recommendation"]

    # The recommended config must NOT underperform naive eq-wt OUT-OF-SAMPLE.
    rec_test = table[rec]["test"]["sharpe"]
    assert rec_test >= naive_test - 1e-9, (rec, rec_test, naive_test)

    # If the TRAIN winner is an overlay config that loses OOS, it must be flagged overfit (haircut surfaced)
    # and must NOT be the recommendation.
    tw = rep["train_winner"]
    if table[tw]["test"]["sharpe"] < naive_test - 1e-9:
        assert rep["verdict"]["train_winner_overfit"] is True
        assert rec != tw or rec == "naive_eqwt"
    # the train->test haircut is reported for the train winner.
    assert "train_to_test_haircut" in rep["verdict"]
    assert math.isfinite(rep["verdict"]["train_to_test_haircut"])


def test_calibrated_voltarget_can_beat_naive_oos_and_is_recommended() -> None:
    """A fixture where a calibrated vol-target config genuinely ADDS value OOS → it (or another OOS-winning
    config) is recommended over naive eq-wt, and the verdict says a re-tuned overlay is adopted.

    The fixture: two sleeves with very DIFFERENT vols (one ~3× the other), STABLE across train/test. Naive
    50/50 lets the high-vol sleeve dominate book risk; an inverse-vol / calibrated-vol-target config that
    risk-weights them is better Sharpe on BOTH train and test (this is fitting risk to vol, not returns — the
    robust fix), so an overlay config should win OOS."""
    days = 360
    a: dict[int, float] = {}
    b: dict[int, float] = {}
    for d in range(days):
        # 'a' low-vol steady; 'b' high-vol but also positive. Both stable across the whole sample.
        a[d] = 0.0025 + 0.004 * math.sin(0.3 * d)
        b[d] = 0.0025 + 0.030 * math.sin(0.5 * d + 1.0)
    rep = overlay_ab({"crypto_tstrend": a, "xsfunding": b}, universe=None, cost_bps=0.0, train_frac=0.6)

    assert rep["available"] is True
    table = rep["table"]
    naive_test = table["naive_eqwt"]["test"]["sharpe"]
    # at least one overlay config beats naive OOS (the risk-weighting helps on this vol-dispersed book).
    beats = [n for n in CONFIG_NAMES if n != "naive_eqwt"
             and table[n]["test"]["sharpe"] > naive_test + 1e-9]
    assert beats, {n: table[n]["test"]["sharpe"] for n in CONFIG_NAMES}
    # the recommendation is at least as good as naive OOS (never worse).
    rec = rep["verdict"]["recommendation"]
    assert table[rec]["test"]["sharpe"] >= naive_test - 1e-9


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

def test_overlay_ab_is_read_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(inner)
    _seed_carry(inner)
    guarded = _WriteTripwireStore(inner)
    rep = overlay_ab(guarded, universe=None, cost_bps=5.5)
    inner.close()
    assert rep["available"] is True   # ran without tripping the write tripwire


def test_overlay_ab_universe_restricted_reads_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(inner, n_symbols=8)
    guarded = _FullPanelTripwireStore(inner)
    universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT", "SYM06USDT"}
    overlay_ab(guarded, universe=universe, cost_bps=0.0)
    inner.close()
    assert guarded.read_panel_calls
    for symbols, _feature in guarded.read_panel_calls:
        assert set(symbols) <= universe


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


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

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

    import fxhnt.cli as cli

    seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(seeded, n_symbols=8, days=400)
    _seed_carry(seeded, days=400)
    syms = ([f"SYM{i:02d}USDT" for i in range(8)]
            + ["AAAUSDT", "BBBUSDT", "CCCUSDT", "XXXUSDT", "YYYUSDT", "ZZZUSDT"])
    _seed_turnover(seeded, syms, days=400)

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

    result = CliRunner().invoke(
        cli.app, ["bybit-overlay-ab", "--liquid", "--min-dollar-vol", "10000000"])
    seeded.close()

    assert result.exit_code == 0, result.output
    out_lower = result.output.lower()
    assert "overlay a/b" in out_lower
    assert "train" in out_lower and "test" in out_lower
    assert "naive_eqwt" in result.output
    # a verdict / recommendation line is printed.
    assert "recommend" in out_lower or "verdict" in out_lower
