"""READ-ONLY, MEMORY-BOUNDED risk-managed BOOK evaluator on the Bybit liquid sleeves.

Seeds a synthetic in-memory `TimescaleFeatureStore("sqlite://", table="bybit_features")` and asserts:
  * `sleeve_returns_from_store(store, "tstrend")` yields a per-day return series; a `universe` restriction
    limits the symbols that load (the panel READ is restricted to the liquid set, not the whole table);
  * `risk_managed_book_from_store` on a multi-sleeve fixture returns SANE book metrics, and the LIVE overlay
    changes the result vs the naive equal-weight (leverage != 1 / a kill or a different curve);
  * the evaluator is READ-ONLY — it never calls a write/persist method on the store (write-tripwire proxy);
  * MEMORY-BOUND: the universe-restricted store reads via `read_panel(liquid_symbols, ...)`, NOT the
    full-universe `crypto_close_panel()` — proven by a tripwire that fails if the full-table panel is read;
  * CLI smoke (mocked store) prints the risk-managed book table.
NO network — the store is in-memory SQLite; external inputs are injected.
"""
from __future__ import annotations

import math

import pytest

from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_book_eval import (
    _DEFAULT_BYBIT_SLEEVES,
    _SLEEVE_DISPLAY_NAMES,
    risk_managed_book_from_store,
    sleeve_returns_from_store,
)

_DAY = 86_400


def _seed_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int = 260,
                   drift: float = 0.01) -> None:
    """Daily closes for `n_symbols` with a persistent up/down momentum pattern + small wobble (finite vol),
    >= 130 closes so the TSMOM min-history gate clears and the long/flat book books returns."""
    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 = 260) -> None:
    """Funding dispersion + flat perp/spot closes → a positive carry curve for the xsfunding sleeve."""
    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 = 260,
                   dollar_vol: float = 50_000_000.0) -> None:
    """A flat turnover (USD) feature so `liquid_universe` admits these symbols (median >= floor)."""
    for sym in symbols:
        store.write_features(sym, [(d * _DAY, {"turnover": dollar_vol}) for d in range(days)])


def _seed_positioning(store: TimescaleFeatureStore, *, days: int = 80) -> None:
    """Seed `long_ratio` + `close` so the CONTRARIAN positioning edge is profitable: the over-LONG coin
    (high buyRatio) FALLS next day (crowd wrong → fade pays). Two coins, oscillating positioning. The
    positioning sleeve reads `long_ratio` + `close` (memory-bounded via read_panel)."""
    px = {"PAAUSDT": 100.0, "PBBUSDT": 100.0}
    for d in range(days):
        crowd_long_a = (d % 2 == 0)
        ra = 0.70 if crowd_long_a else 0.30
        rb = 0.30 if crowd_long_a else 0.70
        store.write_features("PAAUSDT", [(d * _DAY, {"long_ratio": ra, "close": px["PAAUSDT"]})])
        store.write_features("PBBUSDT", [(d * _DAY, {"long_ratio": rb, "close": px["PBBUSDT"]})])
        px["PAAUSDT"] *= (1.0 - 0.004) if crowd_long_a else (1.0 + 0.004)
        px["PBBUSDT"] *= (1.0 + 0.004) if crowd_long_a else (1.0 - 0.004)
    d = days
    store.write_features("PAAUSDT", [(d * _DAY, {"long_ratio": 0.5, "close": px["PAAUSDT"]})])
    store.write_features("PBBUSDT", [(d * _DAY, {"long_ratio": 0.5, "close": px["PBBUSDT"]})])


class _WriteTripwireStore:
    """Read-only proxy: forwards reads, but ANY write/persist call trips. Proves the READ-ONLY guarantee."""

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


class _FullPanelTripwireStore:
    """Read-only proxy that FAILS if the full-table panel reads (`crypto_close_panel` etc.) are called, but
    forwards the symbol-restricted `read_panel`. Proves the universe path NEVER materialises the whole
    universe — the memory-bound guarantee. Records which symbol sets `read_panel` was asked for."""

    _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: evaluator read the FULL-universe panel {name!r} "
                "instead of a universe-restricted read_panel")
        return getattr(self._inner, name)


# --- sleeve_returns_from_store ------------------------------------------------------------------

def test_sleeve_returns_tstrend_is_a_per_day_series() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store)
    series = sleeve_returns_from_store(store, "tstrend", cost_bps=0.0)
    store.close()

    assert isinstance(series, dict)
    assert len(series) > 0
    # keys are epoch-days (ints), values finite floats.
    for d, r in series.items():
        assert isinstance(d, int)
        assert math.isfinite(r)


def test_tstrend_sleeve_is_net_of_perp_funding() -> None:
    """The crypto_tstrend sleeve charges the perp funding a LONG book pays. Seeding positive funding on the
    (long-held) trending symbols lowers the sleeve's cumulative return vs. the identical panel with none —
    the honest deploy accounting: a long-biased perp trend book bleeds funding, it isn't free price-trend."""
    def _series(with_funding: bool) -> dict[int, float]:
        store = TimescaleFeatureStore("sqlite://", table="bybit_features")
        _seed_momentum(store)
        if with_funding:
            for idx in range(8):   # +10 bp/day funding on every seeded symbol (longs pay it)
                store.write_features(f"SYM{idx:02d}USDT",
                                     [(d * _DAY, {"funding": 0.001}) for d in range(260)])
        s = sleeve_returns_from_store(store, "tstrend", cost_bps=0.0)
        store.close()
        return s

    plain, funded = _series(False), _series(True)
    assert sum(plain.values()) > sum(funded.values())   # funding is a strict net drag on the long book


def test_sleeve_returns_accepts_sleeve_name_alias() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store)
    by_edge = sleeve_returns_from_store(store, "tstrend")
    by_sleeve = sleeve_returns_from_store(store, "crypto_tstrend")   # paper_sleeves vocabulary
    store.close()
    assert by_edge == by_sleeve


def test_sleeve_returns_universe_restriction_limits_symbols_loaded() -> None:
    """The universe path reads via read_panel(liquid_symbols, ...) and NEVER the full-table panel."""
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(inner, n_symbols=8)
    guarded = _FullPanelTripwireStore(inner)

    universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT"}
    series = sleeve_returns_from_store(guarded, "tstrend", universe=universe)
    inner.close()

    # The series computed (universe non-empty) AND the only reads were symbol-restricted read_panel calls.
    assert isinstance(series, dict)
    assert guarded.read_panel_calls, "expected a universe-restricted read_panel, none happened"
    for symbols, _feature in guarded.read_panel_calls:
        # Every read_panel was asked for a SUBSET of the liquid universe — never the full 8-symbol table.
        assert set(symbols) <= universe
        assert set(symbols), "read_panel must be given the liquid symbol set, not empty/all"


def test_sleeve_returns_xsfunding_series() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_carry(store)
    series = sleeve_returns_from_store(store, "xsfunding", cost_bps=5.5)
    store.close()
    assert len(series) > 0
    for d, r in series.items():
        assert isinstance(d, int)
        assert math.isfinite(r)


def test_sleeve_returns_positioning_is_a_per_day_series() -> None:
    """positioning is a known edge: a `long_ratio`+`close` fixture yields a sane per-day return series."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_positioning(store)
    series = sleeve_returns_from_store(store, "positioning", cost_bps=0.0)
    store.close()
    assert isinstance(series, dict)
    assert len(series) > 0
    for d, r in series.items():
        assert isinstance(d, int)
        assert math.isfinite(r)


def test_sleeve_returns_positioning_universe_restricted_reads_only() -> None:
    """The positioning sleeve reads via read_panel(liquid_symbols, ...) — NEVER the full-table panel."""
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_positioning(inner)
    guarded = _FullPanelTripwireStore(inner)
    universe = {"PAAUSDT", "PBBUSDT"}
    series = sleeve_returns_from_store(guarded, "positioning", universe=universe, cost_bps=0.0)
    inner.close()
    assert isinstance(series, dict)
    assert len(series) > 0
    assert guarded.read_panel_calls, "expected a universe-restricted read_panel, none happened"
    for symbols, _feature in guarded.read_panel_calls:
        assert set(symbols) <= universe
        assert set(symbols)


def test_sleeve_returns_positioning_empty_when_no_ratio_data() -> None:
    """No `long_ratio` (close-only fixture) → positioning is uncomputable → empty series, never fabricated."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store, n_symbols=4)
    series = sleeve_returns_from_store(store, "positioning")
    store.close()
    assert series == {}


def test_sleeve_returns_unknown_edge_raises() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    with pytest.raises(ValueError):
        sleeve_returns_from_store(store, "not_an_edge")
    store.close()


# --- risk_managed_book_from_store ---------------------------------------------------------------

def test_risk_managed_book_has_sane_metrics() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store, n_symbols=8, days=260)
    _seed_carry(store, days=260)
    book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=5.5)
    store.close()

    assert book["days"] > 0
    assert set(book["sleeves"]) <= {"crypto_tstrend", "unlock", "xsfunding"}
    assert "crypto_tstrend" in book["sleeves"]
    assert "xsfunding" in book["sleeves"]
    assert math.isfinite(book["sharpe"])
    assert math.isfinite(book["cagr"])
    assert book["max_dd"] <= 0.0
    # leverage stats present + finite; killed_days a count.
    for k in ("avg", "min", "max"):
        assert math.isfinite(book["leverage_stats"][k])
    assert book["killed_days"] >= 0
    # standalone Sharpe reported per active sleeve.
    assert set(book["per_sleeve_standalone_sharpe"]) == set(book["sleeves"])
    # final-day post-overlay weights present for the active sleeves.
    assert book["per_sleeve_weights"]


def test_overlay_changes_result_vs_naive_equal_weight() -> None:
    """The LIVE overlay (vol-target leverage + ISV weights + killswitch) must move the book away from the
    naive equal-weight baseline — same returns, different curve (leverage != 1 and/or different total)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store, n_symbols=8, days=260)
    _seed_carry(store, days=260)
    book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=0.0)
    store.close()

    naive = book["naive_equal_weight"]
    assert naive["days"] > 0
    # The overlay applies leverage (vol-target) so the average leverage is not pinned at exactly 1.0, OR
    # the risk-managed total return differs from the equal-weight baseline. Either proves the overlay ran.
    overlay_moved = (abs(book["leverage_stats"]["avg"] - 1.0) > 1e-6
                     or abs(book["total_return"] - naive["total_return"]) > 1e-9)
    assert overlay_moved, (book["leverage_stats"], book["total_return"], naive["total_return"])


def test_risk_managed_book_includes_positioning_as_fourth_sleeve() -> None:
    """With tstrend + xsfunding + positioning data present the book carries all three (the default sleeve set
    now includes positioning), and the correlation matrix is reported for the active set."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store, n_symbols=8, days=260)
    _seed_carry(store, days=260)
    _seed_positioning(store, days=260)
    book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=5.5)
    store.close()

    assert "positioning" in book["sleeves"]
    assert {"crypto_tstrend", "xsfunding", "positioning"} <= set(book["sleeves"])
    # correlation matrix is square over the active sleeves, diagonal 1.0.
    cm = book["correlation_matrix"]
    assert set(cm) == set(book["sleeves"])
    for si in book["sleeves"]:
        assert set(cm[si]) == set(book["sleeves"])
        assert cm[si][si] == 1.0
        for sj in book["sleeves"]:
            v = cm[si][sj]
            assert v is None or (-1.0 - 1e-9 <= v <= 1.0 + 1e-9)
    # standalone Sharpe + final weights reported for every active sleeve incl. positioning.
    assert set(book["per_sleeve_standalone_sharpe"]) == set(book["sleeves"])


def test_naive_eqwt_sharpe_improves_with_uncorrelated_positive_fourth_sleeve() -> None:
    """Diversification: adding an UNCORRELATED, positive 4th sleeve must NOT lower the naive equal-weight
    Sharpe (it should help). Compare the 3-sleeve book vs the 4-sleeve book on the SAME fixture."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store, n_symbols=8, days=260)
    _seed_carry(store, days=260)
    _seed_positioning(store, days=260)

    three = risk_managed_book_from_store(
        store, sleeves=["crypto_tstrend", "xsfunding"], capital=100_000.0, cost_bps=0.0)
    four = risk_managed_book_from_store(
        store, sleeves=["crypto_tstrend", "xsfunding", "positioning"], capital=100_000.0, cost_bps=0.0)
    store.close()

    # positioning is genuinely the added sleeve.
    assert "positioning" in four["sleeves"] and "positioning" not in three["sleeves"]
    # positioning is uncorrelated with the others (|corr| small) — the diversification premise.
    cm = four["correlation_matrix"]
    for other in ("crypto_tstrend", "xsfunding"):
        c = cm["positioning"][other]
        assert c is None or abs(c) < 0.5, (other, c)
    # And positioning is positive standalone (a real positive sleeve, not noise).
    assert four["per_sleeve_standalone_sharpe"]["positioning"] > 0.0
    # The naive equal-weight Sharpe does not DROP when the uncorrelated positive sleeve joins.
    assert four["naive_equal_weight"]["sharpe"] >= three["naive_equal_weight"]["sharpe"] - 1e-9


def test_risk_managed_book_skips_positioning_gracefully_when_absent() -> None:
    """No `long_ratio` data → positioning is omitted, the book is the 3 (or fewer) available sleeves."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store, n_symbols=8, days=260)
    _seed_carry(store, days=260)
    book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=5.5)
    store.close()
    assert "positioning" not in book["sleeves"]
    assert book["days"] > 0   # the rest of the book still builds


def test_risk_managed_book_is_read_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(inner, n_symbols=8, days=260)
    _seed_carry(inner, days=260)
    guarded = _WriteTripwireStore(inner)
    book = risk_managed_book_from_store(guarded, capital=100_000.0, cost_bps=5.5)
    inner.close()
    assert book["days"] > 0   # ran without tripping the write tripwire


def test_risk_managed_book_empty_store_reports_reason() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    book = risk_managed_book_from_store(store)
    store.close()
    assert book["days"] == 0
    assert book["sleeves"] == []
    assert "reason" in book


def test_risk_managed_book_universe_restricted_reads_only() -> None:
    """End-to-end memory bound: with a universe, the WHOLE book build never reads a full-table panel."""
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(inner, n_symbols=8, days=260)
    guarded = _FullPanelTripwireStore(inner)
    universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT", "SYM06USDT"}
    # Only tstrend (close-only fixture); must not raise the full-panel tripwire.
    risk_managed_book_from_store(guarded, universe=universe, sleeves=["crypto_tstrend"])
    inner.close()
    assert guarded.read_panel_calls
    for symbols, _feature in guarded.read_panel_calls:
        assert set(symbols) <= universe


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

def test_cli_bybit_book_eval_prints_table(monkeypatch) -> None:
    """CLI smoke: a seeded in-memory store stands in for the live Bybit warehouse (no network); the command
    prints the standalone-Sharpe rows + the RISK-MANAGED book table + the naive comparison."""
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(seeded, n_symbols=8, days=260)
    _seed_carry(seeded, days=260)
    _seed_positioning(seeded, days=260)   # the 4th sleeve (long_ratio + close)
    # turnover so --liquid admits the seeded symbols (median >= floor).
    syms = ([f"SYM{i:02d}USDT" for i in range(8)] + ["AAAUSDT", "BBBUSDT", "CCCUSDT",
                                                     "XXXUSDT", "YYYUSDT", "ZZZUSDT"]
            + ["PAAUSDT", "PBBUSDT"])
    _seed_turnover(seeded, syms, days=260)

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

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

    assert result.exit_code == 0, result.output
    out_lower = result.output.lower()
    assert "risk-managed book" in out_lower
    assert "Sharpe" in result.output
    assert "naive" in out_lower
    # the positioning sleeve appears + the correlation matrix is printed (the diversification view).
    # unlock has no calendar in this smoke, so the active book is tstrend + xsfunding + positioning (3).
    assert "positioning" in out_lower
    assert "correlation" in out_lower
    assert "sleeves=3" in result.output


def test_sleeve_display_names_cover_every_book_sleeve() -> None:
    # `_SLEEVE_DISPLAY_NAMES` is the book breakdown's OWN display-name source, independent of
    # STRATEGY_REGISTRY (see app.py `_deploy_individual_edges`) — every book sleeve must have an entry so a
    # standalone forward-track retirement (e.g. crypto_tstrend, Phase 0b Task 4) can never blank one.
    assert set(_SLEEVE_DISPLAY_NAMES) == set(_DEFAULT_BYBIT_SLEEVES)


def test_crypto_tstrend_display_name_is_human_not_the_raw_sleeve_id() -> None:
    # The bug: crypto_tstrend's STRATEGY_REGISTRY entry was retired, so its book-breakdown row fell back to
    # the raw sleeve id. The sleeve's own display name must never be the bare id.
    assert _SLEEVE_DISPLAY_NAMES["crypto_tstrend"] != "crypto_tstrend"
    assert _SLEEVE_DISPLAY_NAMES["crypto_tstrend"]
