"""TDD spec for the de Prado META-LABELING experiment on the Bybit CARRY (xsfunding) edge.

The cross-sectional CARRY book (funding + delta-neutral basis) is the PRIMARY model — it sets the SIDE per
coin-day (LONG high-carry / SHORT low-carry). The META-MODEL learns WHEN to trust each coin-day signal (filter
false positives) + SIZE by confidence P(profitable). Carry is a DIFFERENT mechanism than retail positioning, so
this is an INDEPENDENT completeness check — and an honest "NO LIFT" is a valid result (positioning's was).

This module REUSES the positioning meta-label framework UNCHANGED (the edge-agnostic core
`metalabel_experiment_from_dataset`, `PurgedKFold`, `apply_metalabel`, the LogisticRegression meta-model, the
`_ADD_MARGIN` 0.25 verdict, the shuffled-label control). Only the per-coin-day dataset is carry-specific. These
tests verify:

  * the carry dataset is CAUSAL (signal-day inputs only; the label uses forward carry-returns only);
  * the carry experiment reuses PurgedKFold (no duplicate CV logic — asserted by identity of the core);
  * the SHUFFLED-LABEL control shows ~no OOS lift (leakage tripwire);
  * the verdict logic is the SAME as positioning (ADDS vs NO LIFT);
  * everything is READ-ONLY (a write-tripwire never trips).

All tests 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.bybit_carry_metalabel import (
    build_carry_metalabel_dataset,
    carry_metalabel_experiment,
)
from fxhnt.application.positioning_metalabel import META_FEATURES, PurgedKFold

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


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

_COINS = ("AAAUSDT", "BBBUSDT", "CCCUSDT")


def _seed_signal_dependent(store: TimescaleFeatureStore, *, days: int = 320) -> None:
    """Seed funding/close/spot where the carry primary pays ONLY when CONVICTION is HIGH — exactly the regime
    where meta-labeling SHOULD add. Three coins; one rotates as the high-funding (LONG) coin and one as the
    low-funding (SHORT) coin each day.

    On HIGH-conviction days the funding spread is LARGE and PERSISTS into the next day (long the high-funding
    coin earns positive funding, short the low-funding coin earns positive too) → the carry position is
    profitable. On LOW-conviction days the funding spread is small and the NEXT-day funding flips sign (tiny
    noise that hurts the carry slightly). A meta-model keying on |demeaned funding| can filter the noise rows.

    Prices (perp close) and spot track closely (small zero-mean basis) so the carry P&L is funding-dominated
    and the conviction→profit relation is clean."""
    px = {c: 100.0 for c in _COINS}
    sp = {c: 100.0 for c in _COINS}
    start = 18628  # 2021-01-01
    for i in range(days):
        d = start + i
        high_conv = (i % 2 == 0)
        hi = _COINS[i % 3]                       # rotating high-funding coin (LONG)
        lo = _COINS[(i + 1) % 3]                 # rotating low-funding coin (SHORT)
        if high_conv:
            fund = {c: 0.0 for c in _COINS}
            fund[hi] = 0.010                     # strong positive funding on the long coin
            fund[lo] = -0.010                    # strong negative funding on the short coin
        else:
            fund = {c: 0.0 for c in _COINS}
            # near-zero spread (low conviction); the sign will flip next low-conv day → noise.
            sgn = 1.0 if (i % 4 == 1) else -1.0
            fund[hi] = 0.0008 * sgn
            fund[lo] = -0.0008 * sgn
        for c in _COINS:
            store.write_features(c, [(d * _DAY, {
                "funding": fund[c], "close": px[c], "spot_close": sp[c]})])
        # prices drift a touch with near-zero basis (spot mirrors perp): basis ~ 0, P&L is the funding.
        for c in _COINS:
            drift = 0.001 if (i % 2 == 0) else -0.001
            px[c] *= (1.0 + drift)
            sp[c] *= (1.0 + drift)               # spot == perp move → basis change ~ 0
    # trailing buffer day so the last signal rows have a forward day available.
    d = start + days
    for c in _COINS:
        store.write_features(c, [(d * _DAY, {"funding": 0.0, "close": px[c], "spot_close": sp[c]})])


def _seed_uniform_edge(store: TimescaleFeatureStore, *, days: int = 320) -> None:
    """Seed where the carry pays UNIFORMLY (no feature separates good from bad coin-days) — the regime where
    meta-labeling should NOT add: the side model already captures everything, the meta can only over-fit train.
    Every day the same fixed funding spread persists and the carry wins by the same magnitude."""
    px = {c: 100.0 for c in _COINS}
    sp = {c: 100.0 for c in _COINS}
    start = 18628
    for i in range(days):
        d = start + i
        hi = _COINS[i % 3]
        lo = _COINS[(i + 1) % 3]
        fund = {c: 0.0 for c in _COINS}
        fund[hi] = 0.004
        fund[lo] = -0.004
        for c in _COINS:
            store.write_features(c, [(d * _DAY, {
                "funding": fund[c], "close": px[c], "spot_close": sp[c]})])
        for c in _COINS:
            px[c] *= 1.0005
            sp[c] *= 1.0005
    d = start + days
    for c in _COINS:
        store.write_features(c, [(d * _DAY, {"funding": 0.0, "close": px[c], "spot_close": sp[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_carry_metalabel_dataset(store, label_horizon=1)
    store.close()
    assert ds.n_rows > 0
    assert ds.feature_names == list(META_FEATURES)        # the SAME 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=40)
    ds = build_carry_metalabel_dataset(store, label_horizon=1)
    store.close()
    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_carry_return_only_causal() -> None:
    """The label for a coin-day must equal the SIGN of the primary carry position's realized FORWARD carry
    return — funding accrual on the NEXT day (basis ~ 0 in this fixture). We verify the recorded `primary_ret`
    equals side × next-day funding (the dominant carry term) to a tolerance that admits the tiny basis."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store, days=60)
    fund = {c: dict(store.read_panel([c], "funding")[c]) for c in _COINS}
    ds = build_carry_metalabel_dataset(store, label_horizon=1)
    store.close()
    assert ds.n_rows > 0
    for k in range(ds.n_rows):
        d, coin, side, pr = ds.days[k], ds.coins[k], ds.sides[k], ds.primary_ret[k]
        fwd_funding = fund[coin].get(d + 1)
        if fwd_funding is None:
            continue
        # carry_ret = funding + basis; basis ~ 0 here (spot mirrors perp), so side*funding dominates.
        assert math.isclose(pr, side * fwd_funding, abs_tol=2e-3), (
            f"row {k}: pr={pr} vs side*fwd_funding={side * fwd_funding}")
        assert ds.y[k] == (1 if pr > 0 else 0)


# --- 2. reuses PurgedKFold (no duplicate CV logic) ---------------------------------------------

def test_carry_reuses_purged_kfold_no_duplicate_cv() -> None:
    """The carry module must NOT reimplement the CV/verdict — it imports the edge-agnostic core. We assert the
    core function the carry experiment delegates to is the SAME object positioning uses."""
    from fxhnt.application import bybit_carry_metalabel as carry
    from fxhnt.application import positioning_metalabel as pos

    assert carry.metalabel_experiment_from_dataset is pos.metalabel_experiment_from_dataset
    assert carry.MetalabelDataset is pos.MetalabelDataset
    # PurgedKFold purge+embargo still holds (the shared CV).
    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:
            assert not (t_lo - 2 - 1 <= days[i] <= t_hi + 2 + 1)
        assert set(train_idx).isdisjoint(test_idx)


# --- 3. SHUFFLED-LABEL leakage control ---------------------------------------------------------

def test_shuffled_label_control_shows_no_oos_lift() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    rep = carry_metalabel_experiment(store, n_splits=5, label_horizon=1, shuffle_label=True, seed=0)
    store.close()
    assert rep["available"] is True
    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


# --- 4. verdict: SAME logic as positioning (ADDS vs NO LIFT) -----------------------------------

def test_verdict_adds_when_meta_genuinely_helps_oos() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    rep = carry_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"]
    assert rep["edge"] == "carry"


def test_verdict_no_lift_on_uniform_edge() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_uniform_edge(store)
    rep = carry_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_and_oos() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_signal_dependent(store)
    rep = carry_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())
    oos = rep["oos"]
    assert 0.0 <= oos["meta_precision"] <= 1.0
    assert 0.0 <= oos["frac_trades_kept"] <= 1.0
    for k in ("raw_sharpe", "meta_sharpe", "raw_max_dd", "meta_max_dd"):
        assert math.isfinite(oos[k])


# --- 5. read-only + empty-data -----------------------------------------------------------------

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


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


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

def test_cli_carry_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, ["carry-metalabel", "--all"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "carry" in out
    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_carry_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, ["carry-metalabel", "--all", "--shuffle-label"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "shuffle" in out
    assert "no lift" in out
