"""OOS-disciplined SLEEVE-WEIGHTING A/B for the Bybit 4-edge book.

The 4 sleeves (crypto_tstrend, unlock, xsfunding, positioning) have very different vols, so EQUAL CAPITAL
!= EQUAL RISK. In-sample, inverse-vol weighting helps (it reins in the high-vol xsfunding sleeve which
otherwise dominates book risk). The ONLY question this harness answers is whether a CAUSAL risk-weighting
scheme beats naive equal-weight OUT-OF-SAMPLE, net of the extra turnover cost it incurs. A clean
"naive still wins" is a valid, valuable verdict — the full-sample inverse-vol Sharpe (~2.18) is
look-ahead-optimistic.

This isolates ONLY the SLEEVE-WEIGHTING question — there is NO leverage / kelly / vol-target / killswitch
overlay here (the prior `bybit_overlay_ab` already found those LOSE OOS). The three schemes:
  * naive          — static 1/n equal weight (the current book; == `_equal_weight_metrics`).
  * slow_rp        — inverse-vol from a LONG/slow causal trailing vol (90d/180d, picked on TRAIN).
  * risk_capped    — start from 1/n, cap any sleeve's RISK contribution at a ceiling (30%/40%, picked on
                     TRAIN), using causal trailing vol; iteratively shrink the over-cap sleeve.

Tests (TDD):
  * causal weights — a t-day vol spike does NOT change the t-day weight (look-ahead audit);
  * each scheme's weights sum to 1 over present sleeves; risk-cap actually bounds the max risk share;
    slow-RP down-weights the high-vol sleeve;
  * a fixture where one sleeve has huge vol → naive is risk-dominated by it, RP/cap cut its risk share;
  * OOS walk-forward picks the hyperparam on TRAIN, scores TEST; cost-monotone; verdict logic
    (RP-beats-naive fixture → PASS-switch; RP-loses fixture → STAY-NAIVE);
  * naive scheme == the established `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.sleeve_weighting_ab import (
    SCHEME_NAMES,
    _causal_realized_risk_share,
    _risk_capped_weights,
    _slow_rp_weights,
    sleeve_weighting_ab,
)

_DAY = 86_400


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

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: sleeve-weighting 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: sleeve-weighting A/B read the FULL-universe panel {name!r}")
        return getattr(self._inner, name)


# --- causal weights: a t-day vol spike must NOT move the t-day weight ---------------------------

def test_weights_are_causal_tday_vol_spike_does_not_move_tday_weight() -> None:
    """LOOK-AHEAD AUDIT. Two scenarios identical up to day t-1; on day t one sleeve has a huge spike. The
    day-t weight (computed from data <= t-1) MUST be identical in both — the spike at t cannot inform t."""
    days = 220
    # baseline: two steady sleeves.
    base_a = {d: 0.002 + 0.001 * math.sin(0.2 * d) for d in range(days)}
    base_b = {d: 0.002 + 0.003 * math.cos(0.25 * d) for d in range(days)}
    t = 200
    spiked_a = dict(base_a)
    spiked_a[t] = 5.0  # an enormous return AT day t only

    for scheme_fn in (_slow_rp_weights, _risk_capped_weights):
        # `before` for day t = returns strictly < t. Identical in both scenarios → identical weights.
        before_base = {"a": {d: v for d, v in base_a.items() if d < t},
                       "b": {d: v for d, v in base_b.items() if d < t}}
        before_spk = {"a": {d: v for d, v in spiked_a.items() if d < t},
                      "b": {d: v for d, v in base_b.items() if d < t}}
        active = ["a", "b"]
        w_base = scheme_fn(before_base, active, lookback=90, cap=0.40)
        w_spk = scheme_fn(before_spk, active, lookback=90, cap=0.40)
        for s in active:
            assert abs(w_base[s] - w_spk[s]) < 1e-12, (scheme_fn.__name__, s, w_base, w_spk)


# --- weights sum to 1 over present sleeves ------------------------------------------------------

def test_scheme_weights_sum_to_one_over_present_sleeves() -> None:
    before = {"a": {d: 0.001 * (1 + 0.3 * math.sin(d)) for d in range(120)},
              "b": {d: 0.002 * (1 + 0.5 * math.cos(d)) for d in range(120)},
              "c": {d: 0.0015 * (1 + 0.8 * math.sin(0.3 * d)) for d in range(120)}}
    active = ["a", "b", "c"]
    for w in (_slow_rp_weights(before, active, lookback=90, cap=0.40),
              _risk_capped_weights(before, active, lookback=90, cap=0.40)):
        assert abs(sum(w.values()) - 1.0) < 1e-9, w
        assert all(x >= -1e-12 for x in w.values()), w


def test_slow_rp_down_weights_the_high_vol_sleeve() -> None:
    """Inverse-vol must give the high-vol sleeve a SMALLER weight than the low-vol one."""
    before = {"lo": {d: 0.001 + 0.002 * math.sin(0.2 * d) for d in range(150)},   # low vol
              "hi": {d: 0.001 + 0.040 * math.sin(0.5 * d) for d in range(150)}}   # ~20x vol
    w = _slow_rp_weights(before, ["lo", "hi"], lookback=120, cap=1.0)
    assert w["hi"] < w["lo"], w


def test_risk_cap_bounds_the_max_realized_risk_share() -> None:
    """After risk-capping, no sleeve's CAUSAL risk share (w*vol / Σ w*vol) may exceed the cap (+tol)."""
    before = {"lo": {d: 0.001 + 0.002 * math.sin(0.2 * d) for d in range(160)},
              "hi": {d: 0.001 + 0.060 * math.sin(0.5 * d) for d in range(160)},   # huge vol
              "mid": {d: 0.001 + 0.010 * math.cos(0.3 * d) for d in range(160)}}
    active = ["lo", "hi", "mid"]
    cap = 0.40
    w = _risk_capped_weights(before, active, lookback=120, cap=cap)
    shares = _causal_realized_risk_share(w, before, active, lookback=120)
    assert max(shares.values()) <= cap + 1e-6, shares
    assert abs(sum(w.values()) - 1.0) < 1e-9


def test_naive_is_risk_dominated_by_high_vol_sleeve_and_schemes_cut_it() -> None:
    """A fixture where one sleeve has huge vol → under NAIVE 1/n it dominates book RISK; both slow-RP and
    risk-cap must REDUCE that sleeve's realized risk share vs naive."""
    before = {"calm": {d: 0.001 + 0.002 * math.sin(0.2 * d) for d in range(180)},
              "wild": {d: 0.001 + 0.080 * math.sin(0.5 * d) for d in range(180)}}  # ~40x vol
    active = ["calm", "wild"]
    naive_w = {"calm": 0.5, "wild": 0.5}
    naive_share = _causal_realized_risk_share(naive_w, before, active, lookback=120)
    assert naive_share["wild"] > 0.8, naive_share  # naive is risk-dominated by the wild sleeve

    rp_share = _causal_realized_risk_share(
        _slow_rp_weights(before, active, lookback=120, cap=1.0), before, active, lookback=120)
    cap_share = _causal_realized_risk_share(
        _risk_capped_weights(before, active, lookback=120, cap=0.40), before, active, lookback=120)
    assert rp_share["wild"] < naive_share["wild"], (rp_share, naive_share)
    assert cap_share["wild"] < naive_share["wild"], (cap_share, naive_share)


# --- OOS harness: train+test metrics per scheme ------------------------------------------------

def test_harness_runs_every_scheme_with_train_and_test_metrics() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store)
    _seed_carry(store)
    rep = sleeve_weighting_ab(store, universe=None, cost_bps=11.0, train_frac=0.6)
    store.close()

    assert rep["available"] is True
    table = rep["table"]
    assert set(table) == set(SCHEME_NAMES)
    for name, row in table.items():
        for split in ("train", "test", "full"):
            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 "turnover" in row, name
        assert "risk_composition" in row, name
        assert math.isfinite(row["turnover"])
    assert "verdict" in rep
    v = rep["verdict"]
    assert v["recommendation"] in SCHEME_NAMES
    assert v["switch"] in (True, False)
    # STAY-NAIVE is the default-safe outcome.
    if not v["switch"]:
        assert v["recommendation"] == "naive"


def test_harness_accepts_prebuilt_returns_by_sleeve() -> None:
    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 = sleeve_weighting_ab({"crypto_tstrend": a, "xsfunding": b}, universe=None, cost_bps=11.0)
    assert rep["available"] is True
    assert set(rep["table"]) == set(SCHEME_NAMES)


# --- naive scheme == established risk_managed_book naive (consistency) --------------------------

def test_naive_scheme_matches_risk_managed_book_naive() -> None:
    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=11.0)
    rep = sleeve_weighting_ab(store, universe=None, cost_bps=11.0, train_frac=0.6)
    store.close()

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


# --- cost monotonicity: higher cost never RAISES a scheme's net Sharpe (more turnover hurts) ----

def test_higher_cost_does_not_raise_full_total_return() -> None:
    """Cost is a per-turnover DRAG, so higher cost can only LOWER (never raise) total return / CAGR — the
    economically monotone invariant (Sharpe is NOT monotone in cost: a per-day drag can dampen volatile days
    and nudge Sharpe up, so we assert total return, which strictly decreases with cost)."""
    a = {d: 0.0025 + 0.004 * math.sin(0.3 * d) for d in range(300)}
    b = {d: 0.0025 + 0.030 * math.sin(0.5 * d + 1.0) for d in range(300)}
    lo = sleeve_weighting_ab({"crypto_tstrend": a, "xsfunding": b}, universe=None, cost_bps=0.0)
    hi = sleeve_weighting_ab({"crypto_tstrend": a, "xsfunding": b}, universe=None, cost_bps=22.0)
    for name in SCHEME_NAMES:
        assert (hi["table"][name]["full"]["total_return"]
                <= lo["table"][name]["full"]["total_return"] + 1e-9), name
        # the turnover-bearing risk schemes should actually be cheaper at lower cost (strict drag).
        if name != "naive":
            assert (hi["table"][name]["full"]["total_return"]
                    <= lo["table"][name]["full"]["total_return"] + 1e-9)


# --- VERDICT: a fixture where risk-weighting genuinely beats naive OOS -> PASS-switch -----------

def test_riskweighting_beats_naive_oos_yields_switch() -> None:
    """Two sleeves with very DIFFERENT vols, STABLE across train/test. Naive 50/50 lets the high-vol sleeve
    dominate book risk; the slow-RP / risk-cap schemes risk-weight them = better OOS Sharpe (fitting risk to
    vol, not returns). At least one risk scheme must beat naive OOS and the verdict must say switch."""
    days = 360
    a: dict[int, float] = {}
    b: dict[int, float] = {}
    for d in range(days):
        a[d] = 0.0025 + 0.004 * math.sin(0.3 * d)            # low vol, positive
        b[d] = 0.0025 + 0.030 * math.sin(0.5 * d + 1.0)      # high vol, positive
    rep = sleeve_weighting_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"]["test"]["sharpe"]
    beats = [n for n in SCHEME_NAMES if n != "naive" and table[n]["test"]["sharpe"] > naive_test + 1e-9]
    assert beats, {n: table[n]["test"]["sharpe"] for n in SCHEME_NAMES}
    v = rep["verdict"]
    assert v["switch"] is True, v
    assert v["recommendation"] != "naive"
    assert table[v["recommendation"]]["test"]["sharpe"] > naive_test + 1e-9


# --- VERDICT: a fixture where risk-weighting does NOT survive OOS -> STAY-NAIVE -----------------

def test_riskweighting_loses_oos_yields_stay_naive() -> None:
    """Regime FLIPS at the train/test boundary so the in-sample-chosen risk-weights are wrong OOS, while
    naive (no fitting) is robust. The verdict MUST be STAY-NAIVE (switch=False, recommendation=naive)."""
    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' calm low-vol star, 'b' choppy → RP/cap tilt toward 'a'.
            a[d] = 0.004 + 0.001 * math.sin(0.5 * d)
            b[d] = 0.002 + 0.020 * math.sin(0.9 * d)
        else:
            # TEST: 'a' becomes a bleeder, 'b' recovers → the in-sample tilt to 'a' bleeds OOS.
            a[d] = -0.006 + 0.001 * math.sin(0.5 * d)
            b[d] = 0.004 + 0.010 * math.cos(0.4 * d)
    rep = sleeve_weighting_ab({"crypto_tstrend": a, "xsfunding": b},
                              universe=None, cost_bps=11.0, train_frac=0.6)
    assert rep["available"] is True
    table = rep["table"]
    naive_test = table["naive"]["test"]["sharpe"]
    rec = rep["verdict"]["recommendation"]
    # the recommendation never underperforms naive OOS.
    assert table[rec]["test"]["sharpe"] >= naive_test - 1e-9, (rec, table[rec]["test"]["sharpe"], naive_test)
    # in this flipped-regime fixture no risk scheme should beat naive OOS → STAY-NAIVE.
    if not any(table[n]["test"]["sharpe"] > naive_test + 1e-9 for n in SCHEME_NAMES if n != "naive"):
        assert rep["verdict"]["switch"] is False
        assert rec == "naive"


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

def test_sleeve_weighting_ab_is_read_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(inner)
    _seed_carry(inner)
    guarded = _WriteTripwireStore(inner)
    rep = sleeve_weighting_ab(guarded, universe=None, cost_bps=11.0)
    inner.close()
    assert rep["available"] is True


def test_sleeve_weighting_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"}
    sleeve_weighting_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_sleeve_weighting_ab_empty_store_reports_unavailable() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    rep = sleeve_weighting_ab(store, universe=None)
    store.close()
    assert rep["available"] is False
    assert "reason" in rep


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

def test_cli_bybit_sleeve_weighting_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, ["sleeve-weighting-ab", "--cost-bps", "11"])
    seeded.close()

    assert result.exit_code == 0, result.output
    out_lower = result.output.lower()
    assert "sleeve" in out_lower and "weight" in out_lower
    assert "train" in out_lower and "test" in out_lower
    assert "naive" in out_lower
    assert "verdict" in out_lower or "recommend" in out_lower
    # the verdict prints PASS(switch) or STAY-NAIVE.
    assert "stay" in out_lower or "switch" in out_lower
