"""STEP 1 of the "spread-as-a-strat" research: a reusable survivorship-free LIQUIDITY PANEL + a
FALSIFICATION test of the STATIC ILLIQUIDITY PREMIUM, net of real measured costs.

This is FALSIFICATION-FIRST research. A clean "the premium dies net of cost" verdict (FALSIFY) is the
EXPECTED, VALUABLE outcome — it rules the static illiquidity premium OUT as a tradeable crypto edge. The
tests below prove the verdict tracks NET economics (the measured Corwin–Schultz spread cost on rebalance
turnover), not a hard-coded answer:

  * the PANEL — CS spread + Amihud illiquidity + close-to-close return computed correctly on a KNOWN fixture;
    survivorship-free (a delisted coin is in the panel for the days it traded, absent after); memory-bounded
    (universe-restricted reads only).
  * NO-LOOKAHEAD — the cross-sectional sort at day t uses ONLY the metric known at t; the held return is the
    t+1 return. Mutating the t+1 metric must NOT change the day-t selection; mutating the day-t return must
    NOT change the held return.
  * THE PREMIUM REACTS TO NET COST — holding the GROSS premium AND the turnover fixed, scaling the per-coin
    measured spread up flips CONFIRM -> FALSIFY (the cost trap); a tight-spread variant CONFIRMs. Proves the
    verdict tracks the MEASURED spread economics, not an assumption.
  * READ-ONLY (WriteTripwire) + memory-bounded; correlation to the live edges computed; CLI smoke (mocked
    store). NO network — the store is in-memory SQLite; external inputs injected.
"""
from __future__ import annotations

import math

from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.spread_liquidity_factor import (
    build_liquidity_panel,
    static_illiquidity_premium,
)

_DAY = 86_400


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

def _ohlc_from_close(close: float, spread_frac: float, vol_frac: float, phase: float
                     ) -> tuple[float, float]:
    """A (high, low) bracketing `close` with a KNOWN proportional half-spread plus a small DECORRELATED
    intraday vol component (a high-frequency phase makes adjacent days near-independent so Corwin–Schultz
    cancels the vol term and recovers the spread)."""
    vol = vol_frac * (0.5 + 0.5 * math.cos(7.0 * phase))
    half = 0.5 * spread_frac + vol
    return close * (1.0 + half), close * (1.0 - half)


def _seed_coin(store: TimescaleFeatureStore, sym: str, *, days: int, day_lo: int = 0,
               drift: float = 0.001, spread_frac: float = 0.0005, dollar_vol: float = 50_000_000.0,
               phase0: float = 0.0) -> None:
    """Write close/high/low/turnover for one coin over [day_lo, day_lo+days). A coin seeded over a bounded
    day-range models a coin that listed late / DELISTED early — the survivorship-free shape."""
    px = 100.0
    rows = []
    for k in range(days):
        d = day_lo + k
        px *= (1.0 + drift + 0.003 * math.cos(0.4 * d + phase0))
        high, low = _ohlc_from_close(px, spread_frac, vol_frac=0.0005, phase=0.4 * d + phase0)
        rows.append((d * _DAY, {"close": px, "high": high, "low": low, "turnover": dollar_vol}))
    store.write_features(sym, rows)


# A panel built directly (the static-premium tests drive the math without a store). Each day maps
# coin -> (spread_bps, amihud, ret). ret[t] is the close-to-close return REALISED over day t.

def _make_rotating_panel(*, spread_scale: float, n: int = 20, days: int = 120,
                         premium: float = 0.004, base: float = 0.0002,
                         noise_amp: float = 0.001) -> dict[int, dict[str, tuple]]:
    """A panel where each coin ROTATES through illiquidity ranks day to day with a COPRIME step (so quintile
    membership churns COMPLETELY -> real, large rebalance turnover), the illiquid-at-t coin earns the higher
    return at t+1 (a persistent GROSS premium with idiosyncratic noise so the long-short has finite variance),
    and the per-coin spread level is a pure SCALE knob. Sorting is invariant to the positive scale (ranks
    unchanged) so the GROSS premium + the turnover are IDENTICAL across scales; ONLY the NET cost changes ->
    this is the lever the falsification test pulls. `spread_scale` is in bps (the most-illiquid coin's CS
    spread).

    rank(i,t) = (i + 7t) mod n ; frac = rank/(n-1) ; spread = frac·scale ; amihud = frac. The return REALISED
    at day t is set by the coin's frac at the FORMATION day t-1, so forming at t (sort by frac at t) and
    holding the t+1 return gives the illiquid-at-t coin the higher held return (Q5−Q1 > 0)."""
    coins = [f"C{i:02d}USDT" for i in range(n)]

    def _frac(i: int, t: int) -> float:
        return ((i + 7 * t) % n) / (n - 1)

    panel: dict[int, dict[str, tuple]] = {}
    for t in range(days):
        row: dict[str, tuple] = {}
        for i, c in enumerate(coins):
            frac = _frac(i, t)
            spread = frac * spread_scale                       # bps; Q5 (high frac) = widest spread
            noise = noise_amp * math.sin(1.7 * i + 0.9 * t)    # idiosyncratic -> LS has finite variance
            ret = base + premium * _frac(i, t - 1) + noise     # illiquid-at-(t-1) earns more at t
            row[c] = (spread, frac, ret)
        panel[t] = row
    return panel


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


class _FullPanelTripwireStore:
    _FULL_PANELS = frozenset({"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: spread-factor read FULL-universe panel {name!r}")
        return getattr(self._inner, name)


# --- THE PANEL -----------------------------------------------------------------------------------

def test_panel_computes_spread_amihud_and_return() -> None:
    """The panel carries, per coin per day, the trailing CS spread (bps, >= 0), the Amihud illiquidity
    (|ret| / dollar-volume) and the close-to-close return — all finite and correctly signed."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_coin(store, "AAAUSDT", days=120, drift=0.002, spread_frac=0.004, dollar_vol=10_000_000.0)
    panel = build_liquidity_panel(store, universe=None)
    store.close()

    assert panel, "panel must be non-empty"
    days = sorted(panel)
    # a mid-series day so the trailing CS window is fully populated.
    d = days[len(days) // 2]
    assert "AAAUSDT" in panel[d]
    spread, amihud, ret = panel[d]["AAAUSDT"]
    assert spread > 0.0 and math.isfinite(spread)          # a 40bp injected spread -> positive CS estimate
    assert amihud is not None and amihud >= 0.0            # |ret| / $vol
    assert math.isfinite(ret) and ret > 0.0               # positive drift -> positive close-to-close


def test_panel_amihud_is_abs_return_over_dollar_volume() -> None:
    """Amihud illiquidity equals |daily return| / daily dollar-volume — higher $-volume -> lower Amihud for
    the SAME return."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_coin(store, "LOWVUSDT", days=80, drift=0.003, spread_frac=0.001, dollar_vol=1_000_000.0)
    _seed_coin(store, "HIGHVUSDT", days=80, drift=0.003, spread_frac=0.001, dollar_vol=100_000_000.0)
    panel = build_liquidity_panel(store, universe=None)
    store.close()

    d = sorted(panel)[40]
    low_v = panel[d]["LOWVUSDT"]
    high_v = panel[d]["HIGHVUSDT"]
    # same drift/return shape, but 100x the dollar-volume -> ~100x lower Amihud illiquidity.
    assert low_v[1] > high_v[1] * 10.0, (low_v, high_v)


def test_panel_is_survivorship_free() -> None:
    """A coin that DELISTED early appears in the panel ONLY for the days it traded, and is absent afterward —
    no survivorship bias (the full universe, including the dead, for the days they were alive)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_coin(store, "ALIVEUSDT", days=120)
    _seed_coin(store, "DEADUSDT", days=40, day_lo=0)   # delists after day ~40
    panel = build_liquidity_panel(store, universe=None)
    store.close()

    early_day = sorted(panel)[20]
    late_day = sorted(panel)[100]
    assert "DEADUSDT" in panel[early_day], "delisted coin must be present while it traded"
    assert "DEADUSDT" not in panel[late_day], "delisted coin must be absent after it stopped trading"
    assert "ALIVEUSDT" in panel[late_day]


def test_panel_spread_is_clamped_non_negative() -> None:
    """A frictionless coin (high==low, zero range) yields a CS estimate clamped to 0 — never negative."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    rows = [(d * _DAY, {"close": 100.0, "high": 100.0, "low": 100.0, "turnover": 1e7})
            for d in range(60)]
    store.write_features("FLATUSDT", rows)
    panel = build_liquidity_panel(store, universe=None)
    store.close()
    for day_row in panel.values():
        if "FLATUSDT" in day_row:
            assert day_row["FLATUSDT"][0] >= 0.0


def test_panel_is_memory_bounded() -> None:
    """The panel reads via `read_panel(universe, feature)` (a WHERE symbol IN (...) read) — never the
    full-universe funding/spot panels."""
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    for i in range(8):
        _seed_coin(inner, f"SYM{i:02d}USDT", days=60)
    guarded = _FullPanelTripwireStore(inner)
    universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT"}
    panel = build_liquidity_panel(guarded, universe=universe)
    inner.close()
    assert guarded.read_panel_calls
    for symbols, _feature in guarded.read_panel_calls:
        assert set(symbols) <= universe
    # only the universe coins appear in the panel.
    seen = {c for row in panel.values() for c in row}
    assert seen <= universe, seen


# --- NO-LOOKAHEAD --------------------------------------------------------------------------------

def test_premium_is_deterministic() -> None:
    """Identical panel in -> identical long-short out (no hidden look-ahead state)."""
    panel = _make_rotating_panel(spread_scale=5.0)
    a = static_illiquidity_premium(panel, train_frac=0.6)
    b = static_illiquidity_premium({t: dict(row) for t, row in panel.items()}, train_frac=0.6)
    assert a["spread"]["gross_series"] == b["spread"]["gross_series"]
    assert a["spread"]["net_series"] == b["spread"]["net_series"]


def test_sort_never_peeks_at_t_plus_1_metric() -> None:
    """NO-LOOKAHEAD (selection): the LAST panel day is never a formation day (it has no t+1) and only its
    RETURN is ever consumed (as the held return of the prior formation). Scaling its METRICS (spread+amihud)
    by 100x must therefore leave the entire gross series UNCHANGED — proof the sort never reads a future
    day's metric."""
    base = _make_rotating_panel(spread_scale=5.0)
    days = sorted(base)
    last = days[-1]
    mutated = {t: dict(row) for t, row in base.items()}
    mutated[last] = {c: (sp * 100.0, am * 100.0, ret) for c, (sp, am, ret) in base[last].items()}
    g0 = static_illiquidity_premium(base, train_frac=0.6)["spread"]["gross_series"]
    gm = static_illiquidity_premium(mutated, train_frac=0.6)["spread"]["gross_series"]
    assert g0 == gm, (g0, gm)


def test_held_return_is_t_plus_1_not_formation_day() -> None:
    """NO-LOOKAHEAD (return): the FIRST panel day is a formation day whose OWN return is never held (the first
    held return is day 1's). Mutating day-0's RETURN must therefore leave the gross series UNCHANGED — proof
    the held return is the t+1 return, not the formation-day return."""
    base = _make_rotating_panel(spread_scale=5.0)
    days = sorted(base)
    first = days[0]
    mutated = {t: dict(row) for t, row in base.items()}
    mutated[first] = {c: (sp, am, ret + 9.9) for c, (sp, am, ret) in base[first].items()}
    g0 = static_illiquidity_premium(base, train_frac=0.6)["spread"]["gross_series"]
    gm = static_illiquidity_premium(mutated, train_frac=0.6)["spread"]["gross_series"]
    assert g0 == gm, (g0, gm)


# --- THE PREMIUM REACTS TO NET COST (the falsification proof) ------------------------------------

def test_gross_premium_exists_in_the_fixture() -> None:
    """Sanity: the rotating fixture HAS a positive gross illiquid-minus-liquid premium (so the NET test is
    meaningful — there is something for cost to destroy)."""
    panel = _make_rotating_panel(spread_scale=1.0)   # tiny spread -> net ~ gross
    rep = static_illiquidity_premium(panel, train_frac=0.6)
    sp = rep["spread"]
    assert sp["gross_full_sharpe"] > 0.0, sp
    # the data-driven direction is illiquid-rich (Q5-Q1 positive).
    assert sp["direction_sign"] == 1, sp


def test_wide_spread_falsifies_the_premium_net_of_cost() -> None:
    """THE CORE PROOF. Holding the GROSS premium AND the turnover IDENTICAL (pure spread SCALE), a WIDE
    measured spread makes the rebalance cost exceed the harvested premium -> NET <= 0 -> FALSIFY. This is the
    expected, valuable research outcome: the static illiquidity premium is a COST TRAP."""
    wide = _make_rotating_panel(spread_scale=400.0)   # 400 bps top-quintile CS spread
    rep = static_illiquidity_premium(wide, train_frac=0.6)
    sp = rep["spread"]
    assert sp["net_test_sharpe"] <= 0.0 or sp["verdict"] == "FALSIFY", sp
    assert sp["verdict"] == "FALSIFY", sp
    assert sp["cost_drag_bps"] > 0.0


def test_tight_spread_confirms_when_premium_survives_net() -> None:
    """INVERSE fixture: with a TIGHT measured spread the same gross premium SURVIVES net of cost -> the
    verdict CONFIRMs. Proves the verdict tracks NET economics, not a hard-coded FALSIFY."""
    tight = _make_rotating_panel(spread_scale=0.2, premium=0.01)   # near-frictionless, strong premium
    rep = static_illiquidity_premium(tight, train_frac=0.6, edge_series=None)
    sp = rep["spread"]
    assert sp["net_test_sharpe"] > 0.0, sp
    assert sp["verdict"] == "CONFIRM", sp


def test_verdict_flips_with_spread_scale_only() -> None:
    """The decisive lever: GROSS + turnover are identical across scales; only NET cost changes. The verdict
    must flip CONFIRM -> FALSIFY as the spread scale grows — nothing else differs."""
    tight = static_illiquidity_premium(_make_rotating_panel(spread_scale=0.2, premium=0.01),
                                       train_frac=0.6)["spread"]
    wide = static_illiquidity_premium(_make_rotating_panel(spread_scale=400.0, premium=0.01),
                                      train_frac=0.6)["spread"]
    # GROSS is identical (scale-invariant ranks + same returns) ...
    assert abs(tight["gross_full_sharpe"] - wide["gross_full_sharpe"]) < 1e-6, (tight, wide)
    # ... but the verdict flips on NET cost alone.
    assert tight["verdict"] == "CONFIRM" and wide["verdict"] == "FALSIFY", (tight, wide)


def test_amihud_sort_path_also_produces_a_verdict() -> None:
    """The Amihud-sorted long-short produces its own gross/net/verdict (the second illiquidity lens)."""
    panel = _make_rotating_panel(spread_scale=400.0, premium=0.01)
    rep = static_illiquidity_premium(panel, train_frac=0.6)
    assert "amihud" in rep
    am = rep["amihud"]
    assert am["verdict"] in ("CONFIRM", "FALSIFY")
    assert math.isfinite(am["net_test_sharpe"])
    assert math.isfinite(am["t_stat"])


def test_correlation_to_live_edges_is_computed() -> None:
    """The NET daily series is correlated against each provided live-edge series; a NET series that IS one of
    the edges shows corr ~1 (the rebrand check), an unrelated edge shows low corr."""
    panel = _make_rotating_panel(spread_scale=0.2, premium=0.01)
    rep0 = static_illiquidity_premium(panel, train_frac=0.6)
    net = rep0["spread"]["net_series"]
    # one edge IS the net series (perfect corr); one edge is a constant-ish unrelated series (low corr).
    import math as _m
    unrelated = {d: _m.sin(0.7 * d) for d in net}
    edges = {"tstrend": dict(net), "unlock": unrelated, "xsfunding": unrelated, "positioning": unrelated}
    rep = static_illiquidity_premium(panel, train_frac=0.6, edge_series=edges)
    corr = rep["spread"]["corr_to_edges"]
    assert abs(corr["tstrend"] - 1.0) < 1e-6, corr
    assert abs(corr["unlock"]) < 0.5, corr


def test_empty_panel_reports_unavailable() -> None:
    rep = static_illiquidity_premium({}, train_frac=0.6)
    assert rep["available"] is False


# --- READ-ONLY -----------------------------------------------------------------------------------

def test_panel_build_is_read_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    for i in range(6):
        _seed_coin(inner, f"SYM{i:02d}USDT", days=80)
    guarded = _WriteTripwireStore(inner)
    panel = build_liquidity_panel(guarded, universe=None)
    inner.close()
    assert panel


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

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

    import fxhnt.cli as cli

    seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
    # a spread of momentum + a wide-spread thin tail so the verdict path exercises real cost.
    for i in range(10):
        _seed_coin(seeded, f"SYM{i:02d}USDT", days=200, drift=0.001 * (1 if i % 2 == 0 else -1),
                   spread_frac=0.0005 + 0.01 * (i / 10.0), dollar_vol=5_000_000.0 + i * 1e7)

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

    result = CliRunner().invoke(cli.app, ["spread-factor-test", "--min-dollar-vol", "0"])
    seeded.close()

    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "spread" in out and "amihud" in out
    assert "gross" in out and "net" in out
    assert "confirm" in out or "falsify" in out
