"""READ-ONLY falsification study of crypto ON-CHAIN / FUNDAMENTAL signals — tests (NO network).

Covers, with fixtured HTTP + in-memory SQLite stores (pure functions for the math):
  * FETCHERS: parse fixture DefiLlama/CoinGecko responses; clock-deterministic (fixed injected clock →
    identical request, partial current day excluded); defensive on malformed rows (missing fields → skipped);
    CoinGecko symbol→id map keeps the LARGEST market cap on a duplicate ticker.
  * ANGLE ①: supply-growth z-score is strictly causal (a future day cannot change an earlier z); market
    forward returns are no-lookahead (t→t+1); the timing book books the forward return + tracks turnover; the
    verdict reacts to NET economics (a genuinely-predictive fixture → CONFIRM, noise → FALSIFY).
  * ANGLE ②: value ranks are computed from fundamentals÷mcap; coins without fundamentals are excluded; the
    book is no-lookahead; cost is charged via the measured per-coin spread; correlation is computed; a CONFIRM
    and a FALSIFY fixture prove the verdict is data-driven; a SNAPSHOT (point_in_time=False) is forced to N/A.
  * STUDY: runs both angles; READ-ONLY (a WriteTripwire never trips); the CLI prints the summaries + verdicts.
"""
from __future__ import annotations

import math

from fxhnt.adapters.data.onchain_fundamental_live import OnchainFundamentalLive
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.onchain_fundamental_eval import (
    _angle1_verdict,
    _angle2_verdict,
    _metrics,
    market_forward_returns,
    onchain_fundamental_study,
    supply_growth_zscore,
    timing_book,
    value_factor_book,
    value_factor_weights,
    value_ratio_by_coin,
)

_DAY = 86_400
_START = 18_628          # 2021-01-01 epoch day


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


# --- FIXTURE HTTP (no network) -------------------------------------------------------------------

def _fixture_get(supply_rows, protocols, fees, coins_list, markets):
    """Build a `get(url)` dispatcher returning canned DefiLlama/CoinGecko payloads by URL substring."""
    def _get(url: str):
        if "stablecoincharts" in url:
            return supply_rows
        if "/protocols" in url:
            return protocols
        if "/overview/fees" in url:
            return fees
        if "/coins/list" in url:
            return coins_list
        if "/coins/markets" in url:
            return markets
        raise AssertionError(f"unexpected URL {url}")
    return _get


# --- 1. FETCHERS --------------------------------------------------------------------------------

def test_supply_series_parses_sums_pegs_and_excludes_partial_today() -> None:
    # asof_day = 100 (clock at day 100, 12:00). Days 97,98,99 complete; day 100 is the partial current day.
    rows = [
        {"date": str(97 * _DAY), "totalCirculatingUSD": {"peggedUSD": 100.0, "peggedEUR": 5.0}},
        {"date": str(98 * _DAY), "totalCirculatingUSD": {"peggedUSD": 110.0}},
        {"date": str(99 * _DAY), "totalCirculatingUSD": {"peggedUSD": 120.0}},
        {"date": str(100 * _DAY), "totalCirculatingUSD": {"peggedUSD": 999.0}},      # partial today → excluded
        {"date": None, "totalCirculatingUSD": {"peggedUSD": 1.0}},                    # malformed → skipped
        {"date": str(96 * _DAY), "totalCirculatingUSD": "garbage"},                   # malformed → skipped
        "not-a-dict",                                                                  # malformed → skipped
    ]
    live = OnchainFundamentalLive(get=_fixture_get(rows, [], {}, [], []),
                                  clock=lambda: 100 * _DAY + 43_200)
    series = live.stablecoin_supply_series()
    assert series == {97: 105.0, 98: 110.0, 99: 120.0}
    assert 100 not in series and 96 not in series


def test_supply_series_clock_deterministic() -> None:
    rows = [{"date": str(d * _DAY), "totalCirculatingUSD": {"peggedUSD": float(d)}} for d in range(90, 105)]
    clk = lambda: 100 * _DAY + 1.0          # noqa: E731 — fixed clock
    a = OnchainFundamentalLive(get=_fixture_get(rows, [], {}, [], []), clock=clk).stablecoin_supply_series()
    b = OnchainFundamentalLive(get=_fixture_get(rows, [], {}, [], []), clock=clk).stablecoin_supply_series()
    assert a == b                                              # identical under a fixed clock
    assert max(a) == 99                                        # day 100 (asof) and beyond excluded


def test_fundamentals_parse_map_and_dedupe_largest_mcap() -> None:
    protocols = [
        {"name": "Aave", "symbol": "AAVE", "tvl": 1_000.0},
        {"name": "Uni", "symbol": "UNI", "tvl": 2_000.0},
        {"name": "NoSym", "symbol": "-", "tvl": 9.0},          # no usable symbol → skipped
        "not-a-dict",                                           # malformed → skipped
        {"name": "Untradeable", "symbol": "ZZZ", "tvl": 5.0},  # not tradeable → skipped
    ]
    fees = {"protocols": [
        {"symbol": "AAVE", "total24h": 50.0, "revenue24h": 20.0},
        {"symbol": "UNI", "total24h": 80.0},                   # no revenue field → revenue stays None
        {"symbol": "BADFEE"},                                  # no fees → bump skipped
    ]}
    coins_list = [
        {"id": "aave", "symbol": "aave"},
        {"id": "uniswap", "symbol": "uni"},
        {"id": "uni-impostor", "symbol": "uni"},               # duplicate ticker → keep the larger mcap
    ]
    markets = [
        {"id": "aave", "market_cap": 1_000_000.0},
        {"id": "uniswap", "market_cap": 5_000_000.0},
        {"id": "uni-impostor", "market_cap": 1.0},
        {"id": "uniswap", "market_cap": None},                 # malformed cap → skipped
    ]
    live = OnchainFundamentalLive(get=_fixture_get([], protocols, fees, coins_list, markets))
    f = live.fundamentals_by_coin({"AAVE", "UNI"})
    assert set(f) == {"AAVE", "UNI"}
    assert f["AAVE"] == {"mcap": 1_000_000.0, "tvl": 1_000.0, "fees": 50.0, "revenue": 20.0}
    assert f["UNI"]["mcap"] == 5_000_000.0                     # the larger of the two uni ids
    assert "revenue" not in f["UNI"]                           # no revenue field → omitted


def test_fundamentals_excludes_coin_without_mcap() -> None:
    protocols = [{"symbol": "AAVE", "tvl": 1.0}, {"symbol": "BBB", "tvl": 2.0}]
    coins_list = [{"id": "aave", "symbol": "aave"}, {"id": "bbb", "symbol": "bbb"}]
    markets = [{"id": "aave", "market_cap": 100.0}]            # BBB has no mcap row
    live = OnchainFundamentalLive(get=_fixture_get([], protocols, {}, coins_list, markets))
    f = live.fundamentals_by_coin({"AAVE", "BBB"})
    assert set(f) == {"AAVE"}                                  # BBB excluded (no market cap)


# --- 2. ANGLE ①: no-lookahead + causality --------------------------------------------------------

def test_supply_growth_zscore_is_causal() -> None:
    base = {0: 100.0, 1: 110.0, 2: 115.0, 3: 130.0, 4: 132.0}
    extended = dict(base)
    extended[5] = 500.0                                        # a huge FUTURE change
    z_base = supply_growth_zscore(base, lookback=10)
    z_ext = supply_growth_zscore(extended, lookback=10)
    for d in z_base:
        assert math.isclose(z_base[d], z_ext[d])               # a future day cannot alter an earlier z


def test_market_forward_returns_next_day_only() -> None:
    close = {0: 100.0, 1: 110.0, 2: 121.0}
    fwd = market_forward_returns(close)
    assert math.isclose(fwd[0], 0.10) and math.isclose(fwd[1], 0.10)
    assert 2 not in fwd                                         # last day has no forward return


def test_timing_book_books_forward_and_tracks_turnover() -> None:
    z = {0: 1.0, 1: -1.0, 2: 1.0}
    fwd = {0: 0.02, 1: -0.05, 2: 0.03}
    bd = timing_book(z, fwd, threshold=0.0, direction="long_high", cost_bps=0.0)
    assert math.isclose(bd["gross"][0], 0.02)                  # long (z>0) earns the +2% forward
    assert bd["position"][1] == 0.0                            # z<0 → flat
    assert math.isclose(bd["gross"][1], 0.0)
    assert bd["turnover"][0] == 1.0 and bd["turnover"][1] == 1.0 and bd["turnover"][2] == 1.0


def _build_timing_store(returns: list[float], *, start=_START) -> TimescaleFeatureStore:
    """Seed a store with a BTCUSDT close series produced by `returns` (close[t+1]=close[t]·(1+r))."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    px = 100.0
    for i, r in enumerate(returns):
        d = start + i
        store.write_features("BTCUSDT", [(d * _DAY, {"close": px, "high": px * 1.0005, "low": px * 0.9995})])
        px *= (1.0 + r)
    # one final close so the last return is realizable as a forward return
    store.write_features("BTCUSDT", [((start + len(returns)) * _DAY,
                                      {"close": px, "high": px * 1.0005, "low": px * 0.9995})])
    return store


def _block_signal(n: int, block: int = 10):
    """Alternating-block sign s[t] ∈ {+1,-1}: high blocks (z>0) then low blocks (z<0)."""
    return [1.0 if (t // block) % 2 == 0 else -1.0 for t in range(n)]


def test_angle1_CONFIRM_when_supply_growth_predicts_market() -> None:
    n = 440
    s = _block_signal(n)
    # Supply change oscillates with the block sign → z>0 in high blocks; market goes UP next day exactly then.
    supply = {_START: 1.0e9}
    for t in range(n):
        supply[_START + t + 1] = supply[_START + t] + 1.0e7 * s[t]
    returns = [0.01 if s[t] > 0 else -0.01 for t in range(n)]   # long-only earns +1% in high blocks
    store = _build_timing_store(returns)
    rep = _angle1_verdict(store, supply, market="btc", universe=None, edges={}, cost_bps=0.0,
                          lookback=30, threshold_grid=(-0.5, 0.0, 0.5, 1.0), train_frac=0.6)
    store.close()
    assert rep["available"] is True
    assert rep["verdict"] == "CONFIRM", rep["reason"]
    assert rep["net_test_oos"]["sharpe"] >= 0.5
    assert abs(rep["tstat_full"]) >= 2.0


def test_angle1_FALSIFY_when_market_is_noise() -> None:
    n = 440
    s = _block_signal(n)
    supply = {_START: 1.0e9}
    for t in range(n):
        supply[_START + t + 1] = supply[_START + t] + 1.0e7 * s[t]
    # Market returns are unrelated zero-mean noise (seeded RNG → deterministic but uncorrelated with the
    # block signal) → no timing edge.
    import random
    rng = random.Random(7)
    returns = [rng.uniform(-0.01, 0.01) for _ in range(n)]
    store = _build_timing_store(returns)
    rep = _angle1_verdict(store, supply, market="btc", universe=None, edges={}, cost_bps=0.0,
                          lookback=30, threshold_grid=(-0.5, 0.0, 0.5, 1.0), train_frac=0.6)
    store.close()
    assert rep["available"] is True
    assert rep["verdict"] == "FALSIFY", rep["reason"]


def test_angle1_NA_when_series_too_thin() -> None:
    supply = {_START + i: 1.0e9 + i for i in range(30)}        # far fewer than _MIN_TIMING_DAYS
    store = _build_timing_store([0.001] * 30)
    rep = _angle1_verdict(store, supply, market="btc", universe=None, edges={}, cost_bps=0.0,
                          lookback=10, threshold_grid=(0.0,), train_frac=0.6)
    store.close()
    assert rep["available"] is False
    assert "thin" in rep["reason"]


# --- 3. ANGLE ②: value factor --------------------------------------------------------------------

def test_value_ratio_excludes_missing_fundamentals_or_mcap() -> None:
    fundamentals = {
        "AAA": {"fees": 10.0, "mcap": 100.0},
        "BBB": {"fees": 5.0, "mcap": 0.0},        # mcap 0 → excluded
        "CCC": {"mcap": 100.0},                   # no fees → excluded for fees_mcap
        "DDD": {"fees": 20.0, "mcap": 200.0},
    }
    r = value_ratio_by_coin(fundamentals, ratio_kind="fees_mcap")
    assert set(r) == {"AAA", "DDD"}
    assert math.isclose(r["AAA"], 0.1) and math.isclose(r["DDD"], 0.1)


def test_value_factor_weights_long_cheap_neutral_unit_gross() -> None:
    w = value_factor_weights({"CHEAP": 0.10, "MID": 0.05, "RICH": 0.01})
    assert w["CHEAP"] > 0.0 > w["RICH"]                        # long high ratio (cheap) / short low (rich)
    assert abs(sum(w.values())) < 1e-9
    assert math.isclose(sum(abs(v) for v in w.values()), 1.0, abs_tol=1e-9)


def test_value_factor_book_no_lookahead_books_forward() -> None:
    value_panel = {0: {"AUSDT": 0.1, "BUSDT": 0.0}, 1: {"AUSDT": 0.1, "BUSDT": 0.0}}
    fwd = {0: {"AUSDT": 0.05, "BUSDT": -0.05}, 1: {"AUSDT": 0.05, "BUSDT": -0.05}}
    bd = value_factor_book(value_panel, fwd, cost_bps=0.0)
    assert bd["gross"][0] > 0.0                                # long cheap A (rises) / short rich B (falls)


def _seed_value_store(ratios: dict[str, float], returns_fn, *, n=400, start=_START) -> TimescaleFeatureStore:
    """Seed a store: each coin's close produced by `returns_fn(coin, t)`; tight high/low (small spread)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    px = {c: 100.0 for c in ratios}
    for t in range(n):
        d = start + t
        for c in ratios:
            p = px[c]
            store.write_features(c, [(d * _DAY, {"close": p, "high": p * 1.0005, "low": p * 0.9995})])
            px[c] *= (1.0 + returns_fn(c, t))
    for c in ratios:
        store.write_features(c, [((start + n) * _DAY,
                                  {"close": px[c], "high": px[c] * 1.0005, "low": px[c] * 0.9995})])
    return store


def _value_universe(ratios: dict[str, float]) -> dict[int, dict[str, float]]:
    return ratios


def test_angle2_CONFIRM_when_value_predicts_cross_section() -> None:
    coins = [f"C{i}USDT" for i in range(10)]
    ratios = {c: float(i) for i, c in enumerate(coins)}        # ratio rises with i; median split at 4.5
    med = 4.5

    def ret(c, t):
        i = int(c[1:-4])
        base = 0.01 * (1.0 if i > med else -1.0)               # cheap (high i) rises, rich (low i) falls
        noise = 0.002 * (((t + i) % 5) - 2)
        return base + noise

    store = _seed_value_store(ratios, ret)
    days = sorted({d for series in store.crypto_close_panel().values() for d in series})
    value_panel = {d: dict(ratios) for d in days}
    rep = _angle2_verdict(store, value_panel, point_in_time=True, value_universe=set(coins),
                          edges={}, cost_bps=5.5, train_frac=0.6)
    store.close()
    assert rep["available"] is True and rep["point_in_time"] is True
    assert rep["verdict"] == "CONFIRM", rep["reason"]
    assert rep["net_test_oos"]["sharpe"] >= 0.5
    assert abs(rep["tstat_full"]) >= 2.0


def test_angle2_FALSIFY_when_value_unrelated_to_returns() -> None:
    coins = [f"C{i}USDT" for i in range(10)]
    ratios = {c: float(i) for i, c in enumerate(coins)}

    def ret(c, t):
        i = int(c[1:-4])
        return 0.002 * (((t * 3 + i * 11) % 7) - 3)            # zero-mean noise, unrelated to the ratio

    store = _seed_value_store(ratios, ret)
    days = sorted({d for series in store.crypto_close_panel().values() for d in series})
    value_panel = {d: dict(ratios) for d in days}
    rep = _angle2_verdict(store, value_panel, point_in_time=True, value_universe=set(coins),
                          edges={}, cost_bps=5.5, train_frac=0.6)
    store.close()
    assert rep["available"] is True
    assert rep["verdict"] == "FALSIFY", rep["reason"]


def test_angle2_NA_when_snapshot_not_point_in_time() -> None:
    coins = [f"C{i}USDT" for i in range(10)]
    ratios = {c: float(i) for i, c in enumerate(coins)}

    def ret(c, t):                                              # genuinely-predictive, but SNAPSHOT
        i = int(c[1:-4])
        return 0.01 * (1.0 if i > 4.5 else -1.0) + 0.002 * (((t + i) % 5) - 2)

    store = _seed_value_store(ratios, ret)
    days = sorted({d for series in store.crypto_close_panel().values() for d in series})
    value_panel = {d: dict(ratios) for d in days}
    rep = _angle2_verdict(store, value_panel, point_in_time=False, value_universe=set(coins),
                          edges={}, cost_bps=5.5, train_frac=0.6)
    store.close()
    assert rep["verdict"] == "N/A"                              # snapshot → never CONFIRM, regardless of Sharpe
    assert "snapshot" in rep["reason"].lower()


def test_angle2_NA_when_universe_too_thin() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    rep = _angle2_verdict(store, {}, point_in_time=True, value_universe={"AUSDT", "BUSDT"},
                          edges={}, cost_bps=5.5, train_frac=0.6)
    store.close()
    assert rep["available"] is False and "thin" in rep["reason"]


def test_value_factor_book_charges_measured_cost() -> None:
    # A flipping value panel (turnover every day) loses to a high per-coin cost → net < gross.
    value_panel = {t: ({"AUSDT": 0.1, "BUSDT": 0.0} if t % 2 == 0 else {"AUSDT": 0.0, "BUSDT": 0.1})
                   for t in range(10)}
    fwd = {t: {"AUSDT": 0.0, "BUSDT": 0.0} for t in range(10)}
    cheap = value_factor_book(value_panel, fwd, cost_by_coin={"AUSDT": 0.0, "BUSDT": 0.0})
    pricey = value_factor_book(value_panel, fwd, cost_by_coin={"AUSDT": 1000.0, "BUSDT": 1000.0})
    assert _metrics(cheap["net"])["total_return"] == 0.0       # no cost, flat returns → exactly 0
    assert _metrics(pricey["net"])["total_return"] < 0.0       # measured cost drags net negative


# --- 4. THE FULL STUDY: read-only + both verdicts -----------------------------------------------

def _seed_study_store() -> TimescaleFeatureStore:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    px = 100.0
    for t in range(420):
        d = _START + t
        store.write_features("BTCUSDT", [(d * _DAY, {"close": px, "high": px * 1.001, "low": px * 0.999})])
        px *= (1.0 + 0.001 * (((t * 7) % 5) - 2))
    # a handful of value coins
    for i in range(10):
        c = f"C{i}USDT"
        p = 50.0 + i
        for t in range(420):
            d = _START + t
            store.write_features(c, [(d * _DAY, {"close": p, "high": p * 1.001, "low": p * 0.999})])
            p *= (1.0 + 0.001 * (((t + i) % 5) - 2))
    return store


def _study_inputs():
    supply = {_START + i: 1.0e9 + 1.0e7 * (((i * 3) % 5) - 2) * i for i in range(420)}
    fundamentals = {f"C{i}": {"fees": float(i + 1), "mcap": 1.0e6} for i in range(10)}
    return supply, fundamentals


def test_study_runs_both_angles_and_is_read_only() -> None:
    inner = _seed_study_store()
    supply, fundamentals = _study_inputs()
    store = _WriteTripwireStore(inner)
    rep = onchain_fundamental_study(store, supply_series=supply, fundamentals_by_coin=fundamentals,
                                    fundamentals_point_in_time=False, universe=None, market="btc")
    inner.close()
    assert rep["supply_series_days"] == 420
    assert rep["n_value_universe"] == 10
    assert rep["angle1"].get("verdict") in ("CONFIRM", "FALSIFY") or rep["angle1"].get("available") is False
    assert rep["angle2"]["verdict"] == "N/A"                   # snapshot fundamentals → N/A


def test_study_angle2_confirm_with_point_in_time_panel() -> None:
    # Proves the study is not hard-wired to N/A: a point-in-time panel + predictive fundamentals can CONFIRM.
    coins = [f"C{i}USDT" for i in range(10)]
    ratios = {c: float(i) for i, c in enumerate(coins)}

    def ret(c, t):
        i = int(c[1:-4])
        return 0.01 * (1.0 if i > 4.5 else -1.0) + 0.002 * (((t + i) % 5) - 2)

    store = _seed_value_store(ratios, ret)
    days = sorted({d for series in store.crypto_close_panel().values() for d in series})
    value_panel = {d: dict(ratios) for d in days}
    rep = _angle2_verdict(store, value_panel, point_in_time=True, value_universe=set(coins),
                          edges={}, cost_bps=5.5, train_frac=0.6)
    store.close()
    assert rep["verdict"] == "CONFIRM", rep["reason"]


# --- 5. CLI smoke -------------------------------------------------------------------------------

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

    import fxhnt.cli as cli

    inner = _seed_study_store()
    supply, fundamentals = _study_inputs()

    def _fake_store(*_a, **_k):  # noqa: ANN001, ANN202
        return inner

    class _FakeLive:
        def __init__(self, *a, **k) -> None:  # noqa: ANN001
            pass

        def stablecoin_supply_series(self):
            return supply

        def fundamentals_by_coin(self, _bases):  # noqa: ANN001
            return fundamentals

    monkeypatch.setattr(
        "fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore", _fake_store, raising=False)
    monkeypatch.setattr(
        "fxhnt.adapters.data.onchain_fundamental_live.OnchainFundamentalLive", _FakeLive, raising=False)

    result = CliRunner().invoke(cli.app, ["onchain-probe", "--all"])
    inner.close()

    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "angle ①" in out and "angle ②" in out
    assert "stablecoin-supply" in out
    assert "confirm" in out or "falsify" in out or "n/a" in out
