"""The Bybit LIVE paper-book service: combine the 4 sleeves naive eq-wt → per-symbol target positions on the
latest Bybit prices → diff → trades → mark-to-market → persist. NO network (in-memory
TimescaleFeatureStore + sqlite PaperRepo). Proves: naive eq-wt 1/n combination, target positions from
prices, trades from the diff, the 5.5bp cost is charged, and the book renders via the SHARED read model."""
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_book import (
    combined_symbol_weights,
    derive_and_persist_bybit_paper_book,
    latest_prices,
    latest_raw_sleeve_weights,
)
from fxhnt.application.paper_book import PaperBookService
from fxhnt.ports.live_price import LivePrice

_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]:
    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)
            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": 0.0005, "spot_close": px[s] * 1.0002, "long_ratio": ratio})])
    return [{"sym": "AAA", "cliff_day": days - 5, "n": 5_000_000.0, "cat": "insiders"}]


class _DictLive(LivePrice):
    """A LivePrice that returns a fixed {symbol: price} map — no network."""

    def __init__(self, prices: dict[str, float]) -> None:
        self._p = prices

    def get_prices(self, symbols: list[str]) -> dict[str, float]:
        return {s: self._p[s] for s in symbols if s in self._p}


def test_combined_weights_are_naive_eq_weight() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    combined, day = combined_symbol_weights(store, unlock_events=events)
    store.close()
    assert day is not None
    # All 4 sleeves contribute (the seed gives each data).
    assert set(combined) == {"crypto_tstrend", "unlock", "xsfunding", "positioning"}
    # The 1/n share is baked in: a unit-gross sleeve (positioning, Σ|w|=1) sums to 1/4 in gross terms.
    pos_gross = sum(abs(w) for w in combined["positioning"].values())
    assert math.isclose(pos_gross, 0.25, rel_tol=1e-9)


def test_latest_raw_sleeve_weights_is_preoverlay_per_symbol_book() -> None:
    """The reconcile read-model: RAW per-symbol target weights of the 4-edge book at the latest as_of, BEFORE
    any risk overlay — exactly the per-symbol sum of the naive-eq-wt `combined_symbol_weights` (no sim book
    state, no overlay leverage/killswitch baked in)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    raw = latest_raw_sleeve_weights(store, unlock_events=events)
    combined, _day = combined_symbol_weights(store, unlock_events=events)
    store.close()

    assert raw, "expected a non-empty raw book at the latest as_of"
    # The wrapper is exactly the per-symbol sum of the pre-overlay combined sleeve weights.
    expected: dict[str, float] = {}
    for by_symbol in combined.values():
        for sym, w in by_symbol.items():
            expected[sym] = expected.get(sym, 0.0) + w
    expected = {s: w for s, w in expected.items() if w != 0.0}
    assert set(raw) == set(expected)
    for sym, w in expected.items():
        assert math.isclose(raw[sym], w, rel_tol=1e-12, abs_tol=1e-15)
    # Pre-overlay gross is the naive 4·(1/4)=1.0 book scale (no vol-target leverage applied), modulo
    # per-symbol netting across sleeves — so gross is bounded by 1.0 and strictly positive.
    gross = sum(abs(w) for w in raw.values())
    assert 0.0 < gross <= 1.0 + 1e-9


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

    n = derive_and_persist_bybit_paper_book(repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    assert n > 0

    positions = repo.latest_positions()
    assert positions, "expected open bybit positions"
    # qty == weight·capital/price for each lot (the target_positions contract).
    prices = latest_prices(store)
    combined, _ = combined_symbol_weights(store, unlock_events=events)
    for p in positions:
        w = combined[p.sleeve][p.symbol]
        assert math.isclose(p.qty, w * _CAPITAL / prices[p.symbol], rel_tol=1e-6)

    # First rebalance = a BUY/SELL for every opened lot (diff vs empty prior book).
    trades = repo.recent_trades(500)
    assert len(trades) == len(positions)

    # The read model marks to LIVE prices (here: bump every price +1% → positive unrealized).
    live = _DictLive({s: px * 1.01 for s, px in prices.items()})
    view = PaperBookService(repo, _CAPITAL).view(live)
    assert view.positions, "view should expose the live positions"
    # combined equity = nav-through-last-close + intraday MTM since that close.
    assert view.equity != view.capital
    store.close()


def test_cost_is_charged_at_bybit_rate_on_rebalance() -> None:
    # The book books the day's TRUE return + charges the 5.5bp turnover cost. Compare GROSS (cost_rate=0)
    # vs NET (the default Bybit 5.5bp): NET < GROSS by the (bounded) turnover haircut. (The old assertion
    # `equity < capital` assumed the day-0 book booked NO return — the funding-dropping bug; the book now
    # books the day's return like the reconstruction, so equity can exceed capital on a positive day.)
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    gross_repo = PaperRepo("sqlite://")
    gross_repo.migrate()
    derive_and_persist_bybit_paper_book(gross_repo, store, capital=_CAPITAL, at=_AT,
                                        unlock_events=events, cost_rate=0.0)
    net_repo = PaperRepo("sqlite://")
    net_repo.migrate()
    derive_and_persist_bybit_paper_book(net_repo, store, capital=_CAPITAL, at=_AT, unlock_events=events)
    store.close()
    gross_eq = gross_repo.nav_history()[-1].equity
    net_eq = net_repo.nav_history()[-1].equity
    # The 5.5bp turnover cost is charged: NET < GROSS, by a bounded amount (≤ ~1% of capital on $100k turnover).
    assert net_eq < gross_eq
    assert 0.0 < (gross_eq - net_eq) < 0.01 * _CAPITAL


def test_disabled_is_noop() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    events = _seed(store)
    repo = PaperRepo("sqlite://")
    repo.migrate()
    n = derive_and_persist_bybit_paper_book(repo, store, capital=_CAPITAL, at=_AT,
                                            unlock_events=events, enabled=False)
    store.close()
    assert n == 0
    assert repo.latest_positions() == []
