"""TDD spec for the de Prado META-LABELING experiment on the Bybit positioning edge.

The positioning edge (contrarian fade of the long/short account ratio) is the PRIMARY model — it sets the
SIDE per coin-day. The META-MODEL learns WHEN to trust each coin-day signal (filter false positives) + SIZE
by confidence P(profitable). The whole point is the SKEPTICAL, leakage-free verdict:

  * the dataset features are CAUSAL (signal-day inputs only; labels use forward returns only);
  * PurgedKFold drops training rows whose label-windows overlap the test fold (+ an embargo gap) — without
    this the CV LIES;
  * apply = take the primary position only when P>threshold, size ∝ P;
  * the SHUFFLED-LABEL control must show ~no OOS lift (a leakage tripwire — if it lifts, FAIL loudly);
  * the verdict ADOPTS meta only when it beats RAW on PURGED-OOS Sharpe by a clear margin.

All tests are READ-ONLY (a write-tripwire never trips) and use a mocked in-memory store — NO network.
"""
from __future__ import annotations

import math

from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.positioning_metalabel import (
    META_FEATURES,
    PurgedKFold,
    build_metalabel_dataset,
    metalabel_experiment,
)

_DAY = 86_400


class _WriteTripwireStore:
    """Read-only proxy: forwards reads, but ANY write/persist call trips an assertion."""

    _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: meta-labeler called write method {name!r}")
        return getattr(self._inner, name)


# --- fixtures ----------------------------------------------------------------------------------

def _seed_signal_dependent(store: TimescaleFeatureStore, *, days: int = 300) -> None:
    """Seed a panel where the PRIMARY (contrarian) is profitable ONLY when conviction is HIGH — exactly the
    regime where meta-labeling SHOULD add: when |demeaned long_ratio| is large the fade pays; when it is
    small the next-day move is noise. A meta-model keying on conviction can filter the noise rows.

    Two coins; on each day one is the (rotating) over-positioned coin. On HIGH-conviction days the fade pays
    (over-long coin falls); on LOW-conviction days the next move is essentially random (alternating tiny ±).
    """
    px = {"AAAUSDT": 100.0, "BBBUSDT": 100.0}
    start = 18628  # 2021-01-01
    for i in range(days):
        d = start + i
        high_conv = (i % 2 == 0)          # alternate high / low conviction days
        crowd_a = (i % 4 < 2)             # AAA over-long on some days, BBB on others
        if high_conv:
            ra = 0.80 if crowd_a else 0.20
            rb = 0.20 if crowd_a else 0.80
        else:
            ra = 0.54 if crowd_a else 0.46   # near-mean: low conviction
            rb = 0.46 if crowd_a else 0.54
        store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": ra, "close": px["AAAUSDT"]})])
        store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": rb, "close": px["BBBUSDT"]})])
        if high_conv:
            # over-long coin FALLS (fade pays), over-short coin RISES.
            px["AAAUSDT"] *= (1.0 - 0.010) if crowd_a else (1.0 + 0.010)
            px["BBBUSDT"] *= (1.0 + 0.010) if crowd_a else (1.0 - 0.010)
        else:
            # near-mean weights → tiny exposure; the move is small NOISE that hurts the fade slightly.
            noise = 0.006 if (i % 4 == 1) else -0.006
            px["AAAUSDT"] *= (1.0 + noise)
            px["BBBUSDT"] *= (1.0 - noise)
    d = start + days
    for c in ("AAAUSDT", "BBBUSDT"):
        store.write_features(c, [(d * _DAY, {"long_ratio": 0.5, "close": px[c]})])


def _seed_uniform_edge(store: TimescaleFeatureStore, *, days: int = 300) -> None:
    """Seed a panel where the primary pays UNIFORMLY (no feature distinguishes good from bad coin-days) — the
    regime where meta-labeling should NOT add: the side model already captures everything, the meta-model can
    only over-fit train. Every day is a clean contrarian win of the same magnitude."""
    px = {"AAAUSDT": 100.0, "BBBUSDT": 100.0}
    start = 18628
    for i in range(days):
        d = start + i
        crowd_a = (i % 2 == 0)
        ra = 0.70 if crowd_a else 0.30
        rb = 0.30 if crowd_a else 0.70
        store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": ra, "close": px["AAAUSDT"]})])
        store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": rb, "close": px["BBBUSDT"]})])
        px["AAAUSDT"] *= (1.0 - 0.004) if crowd_a else (1.0 + 0.004)
        px["BBBUSDT"] *= (1.0 + 0.004) if crowd_a else (1.0 - 0.004)
    d = start + days
    for c in ("AAAUSDT", "BBBUSDT"):
        store.write_features(c, [(d * _DAY, {"long_ratio": 0.5, "close": px[c]})])


# --- 1. dataset builder (causal features, forward-only labels) ---------------------------------

def test_dataset_has_documented_features_and_binary_labels() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    ds = build_metalabel_dataset(store, label_horizon=1)
    store.close()
    assert ds.n_rows > 0
    assert ds.feature_names == list(META_FEATURES)        # the few documented features, in order
    assert 4 <= len(ds.feature_names) <= 6                # anti-overfit: few features
    assert ds.X.shape == (ds.n_rows, len(ds.feature_names))
    assert set(ds.y.tolist()) <= {0, 1}                   # binary profitable / not


def test_dataset_one_row_per_coin_day_with_a_position() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store, days=20)
    ds = build_metalabel_dataset(store, label_horizon=1)
    store.close()
    # rows align: day/coin/side/primary-weight arrays all match X rows.
    assert len(ds.days) == ds.n_rows
    assert len(ds.coins) == ds.n_rows
    assert len(ds.sides) == ds.n_rows
    assert len(ds.primary_ret) == ds.n_rows
    assert all(s in (-1.0, 1.0) for s in ds.sides)        # every row has a real primary side


def test_dataset_label_uses_forward_returns_only_causal() -> None:
    """The label for a coin-day must equal the SIGN of the primary position's realized return over the NEXT
    `label_horizon` days — never the same-day or a past move. We verify the recorded `primary_ret` equals the
    side × the forward close-to-close return computed independently here."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store, days=40)
    closes = {c: dict(store.read_panel([c], "close")[c]) for c in ("AAAUSDT", "BBBUSDT")}
    ds = build_metalabel_dataset(store, label_horizon=1)
    store.close()
    for k in range(ds.n_rows):
        d, coin, side, pr = ds.days[k], ds.coins[k], ds.sides[k], ds.primary_ret[k]
        p0, p1 = closes[coin][d], closes[coin][d + 1]
        fwd = p1 / p0 - 1.0
        assert math.isclose(pr, side * fwd, rel_tol=1e-9, abs_tol=1e-12)
        assert ds.y[k] == (1 if pr > 0 else 0)


# --- 2. PurgedKFold + embargo (the core leakage-free CV) ---------------------------------------

def test_purged_kfold_excludes_overlapping_and_embargoed_train_rows() -> None:
    # 20 samples on consecutive days 0..19, each label spans `label_horizon` forward days.
    days = list(range(20))
    pk = PurgedKFold(n_splits=4, label_horizon=2, embargo_days=1)
    for train_idx, test_idx in pk.split(days):
        test_days = {days[i] for i in test_idx}
        t_lo, t_hi = min(test_days), max(test_days)
        for i in train_idx:
            d = days[i]
            # a train row's label window is [d, d+horizon]; with embargo the forbidden zone around the test
            # block is [t_lo - horizon - embargo, t_hi + horizon + embargo]. No train row may fall inside.
            assert not (t_lo - 2 - 1 <= d <= t_hi + 2 + 1), (
                f"train day {d} leaks into test block [{t_lo},{t_hi}]")
        # train and test never share an index
        assert set(train_idx).isdisjoint(test_idx)


def test_purged_kfold_covers_all_indices_as_test_once() -> None:
    days = list(range(24))
    pk = PurgedKFold(n_splits=4, label_horizon=1, embargo_days=0)
    seen: list[int] = []
    for _train, test in pk.split(days):
        seen.extend(test)
    assert sorted(seen) == list(range(24))     # every sample is tested exactly once


# --- 3. meta apply: filter by P>threshold, size ∝ P --------------------------------------------

def test_apply_filters_below_threshold_and_sizes_by_p() -> None:
    from fxhnt.application.positioning_metalabel import apply_metalabel

    # primary returns for 4 coin-days; meta P per row; threshold 0.5.
    primary_ret = [0.02, -0.01, 0.03, 0.04]
    p = [0.9, 0.8, 0.4, 0.55]
    sized = apply_metalabel(primary_ret, p, threshold=0.5)
    assert sized[2] == 0.0                       # P=0.4 < 0.5 → filtered out (no position)
    assert sized[0] == 0.02 * 0.9               # kept, sized ∝ P
    assert sized[3] == 0.04 * 0.55
    assert sized[1] == -0.01 * 0.8              # a kept LOSER still counts (no look-ahead on outcome)


# --- 4. SHUFFLED-LABEL leakage control ---------------------------------------------------------

def test_shuffled_label_control_shows_no_oos_lift() -> None:
    """With labels SHUFFLED, the meta-model has nothing real to learn → on PURGED-OOS it must NOT beat raw by
    a meaningful margin. If it does, the pipeline leaks (the test FAILS loudly)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    rep = metalabel_experiment(store, n_splits=5, label_horizon=1, shuffle_label=True, seed=0)
    store.close()
    assert rep["available"] is True
    # lift is meta_oos_sharpe - raw_oos_sharpe; shuffled labels must not produce real lift.
    assert rep["oos"]["sharpe_lift"] <= 0.20, (
        f"shuffled-label control LEAKS: lift={rep['oos']['sharpe_lift']:.3f} (should be ~0)")
    assert rep["verdict"]["adds"] is False


# --- 5. verdict: ADDS when it genuinely helps OOS; NO LIFT when only train ----------------------

def test_verdict_adds_when_meta_genuinely_helps_oos() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    rep = metalabel_experiment(store, n_splits=5, label_horizon=1, seed=0)
    store.close()
    assert rep["available"] is True
    assert rep["oos"]["sharpe_lift"] > 0.0
    assert rep["verdict"]["adds"] is True
    assert "ADDS" in rep["verdict"]["label"]


def test_verdict_no_lift_on_uniform_edge() -> None:
    """When the primary pays uniformly (no feature separates good from bad coin-days) the meta-model can only
    overfit TRAIN; on PURGED-OOS there is no clear lift → STAY RAW."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_uniform_edge(store)
    rep = metalabel_experiment(store, n_splits=5, label_horizon=1, seed=0)
    store.close()
    assert rep["available"] is True
    assert rep["verdict"]["adds"] is False
    assert "NO LIFT" in rep["verdict"]["label"]


def test_experiment_reports_feature_importances() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    rep = metalabel_experiment(store, n_splits=5, label_horizon=1, seed=0)
    store.close()
    imps = rep["feature_importances"]
    assert set(imps) == set(META_FEATURES)
    assert all(math.isfinite(v) for v in imps.values())


def test_experiment_reports_oos_precision_and_trades_kept() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    rep = metalabel_experiment(store, n_splits=5, label_horizon=1, seed=0)
    store.close()
    oos = rep["oos"]
    assert 0.0 <= oos["meta_precision"] <= 1.0
    assert 0.0 <= oos["frac_trades_kept"] <= 1.0
    # raw + meta both expose Sharpe/CAGR/maxDD on the OOS (purged) concatenated series.
    for k in ("raw_sharpe", "meta_sharpe", "raw_max_dd", "meta_max_dd"):
        assert math.isfinite(oos[k])


# --- 6. read-only ------------------------------------------------------------------------------

def test_experiment_is_read_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(inner)
    store = _WriteTripwireStore(inner)
    rep = metalabel_experiment(store, n_splits=5, label_horizon=1, seed=0)
    inner.close()
    assert rep["available"] is True


def test_dataset_na_when_no_data() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    rep = metalabel_experiment(store, n_splits=5, label_horizon=1)
    store.close()
    assert rep["available"] is False
    assert "reason" in rep and rep["reason"]


# --- 7. CLI smoke (mocked in-memory store, NO network) -----------------------------------------

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

    import fxhnt.cli as cli

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    monkeypatch.setattr(
        "fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
        lambda *_a, **_k: store, raising=False)

    result = CliRunner().invoke(cli.app, ["positioning-metalabel", "--all"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "meta-label" in out
    assert "purged" in out
    assert "adds" in out or "no lift" in out
    assert "feature importance" in out


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

    import fxhnt.cli as cli

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    monkeypatch.setattr(
        "fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
        lambda *_a, **_k: store, raising=False)

    result = CliRunner().invoke(cli.app, ["positioning-metalabel", "--all", "--shuffle-label"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "shuffle" in out
    # the shuffled control must report NO LIFT (the leakage tripwire passing).
    assert "no lift" in out
