"""Bybit LIVE paper-book BACKFILL: walk the full Bybit history and persist the live eq-wt 4-edge book daily,
so `/paper?venue=bybit` shows a multi-year record. NO network (in-memory TimescaleFeatureStore + sqlite
PaperRepo).

Proves:
  * the backfill walks ALL weight days and persists a nav point per day (N days → N nav rows, ascending);
  * CONTINUITY/RECONCILIATION — each backfilled day's book is the eq-wt 4-edge book (reuses the reconciled
    weight seam), and the LAST backfilled day == what the single-day live derive produces;
  * IDEMPOTENT — running twice yields the same rows (per-date replace);
  * READ-ONLY against the warehouse (WriteTripwire) + memory-bounded (universe-restricted reads).
"""
from __future__ import annotations

import datetime as dt
import math

from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_paper_backfill import (
    backfill_bybit_paper_book,
    closes_by_day,
    combined_symbol_weights_by_day,
)
from fxhnt.application.bybit_paper_book import (
    combined_symbol_weights,
    derive_and_persist_bybit_paper_book,
)

_DAY = 86_400
_AT = dt.datetime(2026, 6, 24, 23, 30, tzinfo=dt.UTC)
_CAPITAL = 100_000.0


def _seed(store: TimescaleFeatureStore, *, days: int = 200) -> list[dict]:
    """Seed BTC + 3 alts with close/funding/spot/long_ratio over `days` — enough history for tstrend, funding/
    spot for xsfunding, oscillating long_ratio for positioning, and one unlock cliff. Mirrors the live-book +
    reconciliation seeds so the backfill reconciles to the same engines."""
    import math as _m
    syms = ["BTCUSDT", "AAAUSDT", "BBBUSDT", "CCCUSDT"]
    px = {s: 100.0 for s in syms}
    px["BTCUSDT"] = 50_000.0
    for d in range(days):
        crowd = (d % 3 == 0)
        for s in syms:
            drift = {"BTCUSDT": 0.001, "AAAUSDT": 0.002, "BBBUSDT": -0.001, "CCCUSDT": 0.0015}[s]
            wobble = 0.003 * _m.sin(d / 5.0 + hash(s) % 7)
            px[s] *= (1.0 + drift + wobble)
            funding = 0.0005 if s != "BTCUSDT" else 0.0001
            spot = px[s] * (1.0 + 0.0002)
            ratio = (0.70 if crowd else 0.30) if s == "AAAUSDT" else \
                    (0.30 if crowd else 0.70) if s == "BBBUSDT" else 0.50
            store.write_features(s, [(d * _DAY, {
                "close": px[s], "funding": funding, "spot_close": spot, "long_ratio": ratio})])
    return [{"sym": "AAA", "cliff_day": days - 5, "n": 5_000_000.0, "cat": "insiders"}]


class _WriteTripwireStore:
    """Read-only proxy: any warehouse write attempt is a hard failure. The backfill must only READ the
    warehouse (it writes ONLY the paper store, which is a separate object)."""

    _FORBIDDEN = frozenset({
        "write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
        "_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: backfill called warehouse write method {name!r}")
        return getattr(self._inner, name)


def test_backfill_persists_a_nav_point_per_weight_day_ascending() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    repo = PaperRepo("sqlite://")
    repo.migrate()

    n = backfill_bybit_paper_book(repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    store.close()

    # Every day the eq-wt weights exist gets a nav row.
    hist = repo.nav_history()
    assert n == len(hist) > 1, "backfill should persist a multi-day nav series"
    # Ascending, one point per day, no dupes.
    dates = [p.run_date for p in hist]
    assert dates == sorted(dates)
    assert len(dates) == len(set(dates))


def test_backfill_day_count_matches_the_combined_weight_days() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    weights_by_day = combined_symbol_weights_by_day(store, unlock_events=events)
    repo = PaperRepo("sqlite://")
    repo.migrate()
    n = backfill_bybit_paper_book(repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    store.close()
    assert n == len(weights_by_day)
    assert {p.run_date for p in repo.nav_history()} == {
        (dt.date(1970, 1, 1) + dt.timedelta(days=d)).isoformat() for d in weights_by_day}


def test_last_backfilled_day_matches_the_single_day_live_derive_weights() -> None:
    """CONTINUITY: the backfill and the single-day live derive share the SAME weight seam, so the LAST
    backfilled day reproduces the live derive's per-symbol WEIGHTS for the sleeves that fall on the global
    latest day. (Both call `combined_symbol_weights*` → `persist_paper_book`. The qty differs only by the
    equity base — the backfill has compounded the full history while the live derive seeds at `capital` — so
    the load-bearing continuity invariant is the per-symbol WEIGHT, which is what makes backfill + forward
    ONE book. NOTE: the live snapshot BUNDLES each sleeve's OWN latest day onto the max run_date, while the
    backfill places each sleeve on its TRUE day; so the comparison is over the sleeves whose own latest day
    IS the global latest day — for those the two agree exactly.)"""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)

    # The live derive's combined weights for the latest (bundled) run_date.
    live_combined, latest_day = combined_symbol_weights(store, unlock_events=events)
    assert latest_day is not None
    latest_iso = (dt.date(1970, 1, 1) + dt.timedelta(days=latest_day)).isoformat()

    # The backfill's combined weights for that SAME day (true per-day, no bundling).
    bf_by_day = combined_symbol_weights_by_day(store, unlock_events=events)
    bf_combined = bf_by_day[latest_day]

    # The sleeves that genuinely fall on the global latest day appear in BOTH with identical per-symbol
    # weights (the shared reconciled seam). Bundled-stale sleeves (own latest day < global) are in the live
    # snapshot only — by design.
    common = set(live_combined) & set(bf_combined)
    assert common, "no sleeve on the global latest day (seed too short?)"
    for sleeve in common:
        assert bf_combined[sleeve].keys() == live_combined[sleeve].keys()
        for sym, w in live_combined[sleeve].items():
            assert math.isclose(bf_combined[sleeve][sym], w, rel_tol=1e-12, abs_tol=1e-15)

    # And the backfill actually persists a book on that latest day (the forward track continues from here).
    live_repo = PaperRepo("sqlite://")
    live_repo.migrate()
    derive_and_persist_bybit_paper_book(live_repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    bf_repo = PaperRepo("sqlite://")
    bf_repo.migrate()
    backfill_bybit_paper_book(bf_repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    store.close()
    assert bf_repo.positions_at(latest_iso), "backfill must persist a book on the latest day"
    # The backfill's latest persisted nav date IS the live derive's run_date — the forward track resumes here.
    assert bf_repo.latest_nav_run_date() == live_repo.latest_nav_run_date() == latest_iso


def test_backfill_per_day_book_reconciles_to_eqwt_edge_return() -> None:
    """RECONCILIATION: the backfilled book's per-symbol weights on each day ARE the reconciled eq-wt edge
    weights (Σ_symbol weight·return == the GROSS edge return / n, summed over sleeves). We assert the shadow-
    free composition seam: each day's combined weight == (1/n)·the edge's extracted per-symbol weight."""
    from fxhnt.application.bybit_paper_weights import sleeve_weights_and_contributions

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    combined_by_day = combined_symbol_weights_by_day(store, unlock_events=events)
    # Re-derive each sleeve's per-day per-symbol weights and confirm the combined book is exactly (1/n)·them.
    per_sleeve = {sl: sleeve_weights_and_contributions(store, sl, unlock_events=events)
                  for sl in ("crypto_tstrend", "unlock", "xsfunding", "positioning")}
    store.close()
    n_sleeves = 4
    checked = 0
    for day, by_sleeve in combined_by_day.items():
        for sleeve, sym_w in by_sleeve.items():
            src = per_sleeve[sleeve].get(day, {})
            for sym, w in sym_w.items():
                assert math.isclose(w, (1.0 / n_sleeves) * src[sym][0], rel_tol=1e-12, abs_tol=1e-15)
                checked += 1
    assert checked > 0, "reconciliation asserted nothing (seed too short?)"


def test_backfill_is_idempotent_per_date() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    repo = PaperRepo("sqlite://")
    repo.migrate()

    backfill_bybit_paper_book(repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    first = [(p.run_date, p.equity, p.realized, p.unrealized) for p in repo.nav_history()]
    pos_first = {p.run_date: [(x.sleeve, x.symbol, x.qty) for x in repo.positions_at(p.run_date)]
                 for p in repo.nav_history()}

    # Run again — per-date replace must reproduce the SAME rows (no dups, no drift).
    backfill_bybit_paper_book(repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    store.close()
    second = [(p.run_date, p.equity, p.realized, p.unrealized) for p in repo.nav_history()]
    pos_second = {p.run_date: [(x.sleeve, x.symbol, x.qty) for x in repo.positions_at(p.run_date)]
                  for p in repo.nav_history()}
    assert second == first, "re-running the backfill changed the nav curve (not idempotent)"
    assert pos_second == pos_first, "re-running the backfill changed the positions (not idempotent)"


def test_backfill_incremental_resume_appends_new_days_only() -> None:
    """`since_day` (the CLI's incremental resume) fills only days strictly after it, and the resumed curve
    equals a from-scratch full backfill (the per-date compute is strictly-prior, so resuming is exact)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)

    # Full backfill (the reference).
    full_repo = PaperRepo("sqlite://")
    full_repo.migrate()
    backfill_bybit_paper_book(full_repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    full = [(p.run_date, p.equity) for p in full_repo.nav_history()]
    assert len(full) > 2

    # Resumed: a full first pass, then a pure resume STRICTLY AFTER the last persisted day (the CLI's
    # incremental path) must be a no-op — and the resulting curve must equal the from-scratch full backfill.
    inc_repo = PaperRepo("sqlite://")
    inc_repo.migrate()
    backfill_bybit_paper_book(inc_repo, store, capital=_CAPITAL, at=_AT, unlock_events=events,
                              since_day=None)  # full first pass
    last_done = inc_repo.latest_nav_run_date()
    since = (dt.date.fromisoformat(last_done) - dt.date(1970, 1, 1)).days
    # A pure resume from the last persisted day must be a no-op (nothing strictly after).
    added = backfill_bybit_paper_book(inc_repo, store, capital=_CAPITAL, at=_AT, unlock_events=events,
                                      since_day=since)
    store.close()
    assert added == 0, "resume after the last persisted day should add nothing"
    inc = [(p.run_date, p.equity) for p in inc_repo.nav_history()]
    assert inc == full, "incremental resume curve diverged from full backfill"


def test_backfill_is_read_only_against_the_warehouse() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    guarded = _WriteTripwireStore(store)
    repo = PaperRepo("sqlite://")
    repo.migrate()
    n = backfill_bybit_paper_book(repo, guarded, capital=_CAPITAL, at=_AT, unlock_events=events)
    store.close()
    assert n > 1
    assert repo.nav_history()


def test_backfill_is_memory_bounded_to_the_universe() -> None:
    """The close read goes through `_bounded_store`, so only the universe symbols load (the same memory bound
    the live derive uses). With CCCUSDT excluded from the universe, `closes_by_day` never surfaces it and no
    persisted position is for an out-of-universe symbol."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    universe = {"BTCUSDT", "AAAUSDT", "BBBUSDT"}   # deliberately EXCLUDE CCCUSDT

    # The close read is universe-bounded: the excluded symbol never appears.
    by_day = closes_by_day(store, universe=universe)
    assert by_day
    for day_closes in by_day.values():
        assert set(day_closes).issubset(universe)
    assert all("CCCUSDT" not in c for c in by_day.values())

    repo = PaperRepo("sqlite://")
    repo.migrate()
    n = backfill_bybit_paper_book(repo, store, capital=_CAPITAL, at=_AT,
                                  unlock_events=events, universe=universe)
    store.close()
    assert n > 1
    # No persisted position is for an out-of-universe symbol.
    for p in repo.nav_history():
        for x in repo.positions_at(p.run_date):
            assert x.symbol in universe


def test_closes_by_day_inverts_the_panel() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed(store, days=10)
    by_day = closes_by_day(store)
    panel = store.crypto_close_panel()
    store.close()
    # Round-trip: every (symbol, day, close) in the panel appears in the inverted map.
    for sym, series in panel.items():
        for day, close in series.items():
            assert math.isclose(by_day[int(day)][sym], float(close), rel_tol=1e-12)
