"""Paper-forward strategy services: deterministic domain + service over fake data ports, verified by
reading the ForwardTracker's raw state JSON back (the durable on-disk contract for the standalone
`forward-track` CLI; the cockpit ETL now reads the DB record instead — see forward_ingest.py)."""
from __future__ import annotations

import json
from pathlib import Path

from fxhnt.application.forward_tracker import ForwardTracker
from fxhnt.application.paper_strategies import (
    GrowthDisciplineStrategy,
    SixtyFortyStrategy,
)


class FakeDailyBars:
    def __init__(self, data: dict[str, dict[str, float]]) -> None:
        self._data = data

    def adj_closes(self, symbol: str) -> dict[str, float]:
        return self._data[symbol]


def test_sixtyforty_daily_return_is_weighted_mean() -> None:
    from fxhnt.domain.strategies.sixtyforty import daily_returns
    closes = {
        "SPY": {"2026-01-01": 100.0, "2026-01-02": 110.0},   # +10%
        "IEF": {"2026-01-01": 100.0, "2026-01-02": 105.0},   # +5%
    }
    rows = daily_returns(closes, [("SPY", 0.6), ("IEF", 0.4)])
    assert len(rows) == 1 and rows[0][0] == "2026-01-02"
    assert abs(rows[0][1] - (0.6 * 0.10 + 0.4 * 0.05)) < 1e-12  # 0.08


def test_sixtyforty_service_round_trips_through_reader(tmp_path) -> None:
    bars = FakeDailyBars({
        "SPY": {"2026-01-01": 100.0, "2026-01-02": 110.0, "2026-01-03": 110.0},
        "IEF": {"2026-01-01": 100.0, "2026-01-02": 105.0, "2026-01-03": 105.0},
    })
    p = str(tmp_path / "sixtyforty_state.json")
    ForwardTracker(SixtyFortyStrategy(bars), p).step()       # freeze at 2026-01-03
    bars._data["SPY"]["2026-01-04"] = 121.0                  # +10%
    bars._data["IEF"]["2026-01-04"] = 105.0                  # 0%
    st = ForwardTracker(SixtyFortyStrategy(bars), p).step()
    assert st.forward_days == 1
    state = json.loads(Path(p).read_text())
    assert len(state["days"]) == 1
    assert abs(state["nav"] - (1.0 + 0.06)) < 1e-9            # 0.6*0.10 + 0.4*0


def test_multistrat_vol_targets_and_caps_leverage() -> None:
    import datetime
    import math

    import numpy as np

    from fxhnt.domain.strategies.multistrat import INSTRUMENTS, book_series
    rng = np.random.default_rng(0)
    # Faithful port keeps the original 63-day warmup (volnorm/trust windows); need >> 64 dates
    # for any day to be booked, so generate ~10 months of weekday-ish dates.
    start = datetime.date(2025, 1, 1)
    dates = [(start + datetime.timedelta(days=i)).isoformat() for i in range(300)]
    closes = {}
    for k, s in enumerate(INSTRUMENTS):
        steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates))
        px = 100.0 * np.cumprod(1 + steps)
        closes[s] = {d: float(px[i]) for i, d in enumerate(dates)}
    rows = book_series(closes)
    rets = np.array([r for _, r in rows])
    assert len(rows) >= 1 and np.isfinite(rets).all()
    vol = float(rets.std() * math.sqrt(252))
    # Faithful port is UNLEVERED (MAXLEV=1.0): on this low-cross-corr synthetic data the trust-weighted
    # combo diversifies down to ~0.047 vol, so the unlevered book legitimately realizes ~0.038 — below
    # the 0.10 target because leverage is capped at 1.0 and cannot lever the combo back up. The invariant
    # tested is that the book is vol-CONTROLLED (well under the cap, never blowing past target), not that
    # it hits 0.10 exactly. Verified bit-for-bit identical to the foxhunt original on this data.
    assert 0.02 <= vol <= 0.15            # vol-controlled (unlevered book; widened per faithful port)


def test_target_weights_are_the_decomposition_of_book_series_last_return() -> None:
    # target_weights must be the EXACT per-instrument weights book_series applies: book[last] =
    # Σ_j (L·tw·scale)[j]·R[last,j] = Σ_j w[j]·R[last,j]. So Σ w·R[last] reconstructs the last booked return.
    import datetime

    import numpy as np

    from fxhnt.domain.strategies.multistrat import INSTRUMENTS, _build, book_series, target_weights
    rng = np.random.default_rng(0)        # SAME seed/generation as the book_series test → same closes
    start = datetime.date(2025, 1, 1)
    dates = [(start + datetime.timedelta(days=i)).isoformat() for i in range(300)]
    closes = {}
    for k, s in enumerate(INSTRUMENTS):
        steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates))
        px = 100.0 * np.cumprod(1 + steps)
        closes[s] = {d: float(px[i]) for i, d in enumerate(dates)}

    w = target_weights(closes)
    assert set(w) == set(INSTRUMENTS)
    _, R = _build(closes)
    reconstructed = sum(w[INSTRUMENTS[j]] * float(R[-1, j]) for j in range(len(INSTRUMENTS)))
    last_return = book_series(closes)[-1][1]
    assert abs(reconstructed - last_return) < 1e-8      # the weights ARE the book's booked-return decomposition
    gross = sum(abs(v) for v in w.values())
    assert 0.2 < gross < 3.0                            # risk-parity gross near/modestly above 1 (unlevered)


def test_target_weights_empty_on_insufficient_history() -> None:
    from fxhnt.domain.strategies.multistrat import INSTRUMENTS, target_weights
    closes = {s: {f"2025-01-{i + 1:02d}": 100.0 + i for i in range(10)} for s in INSTRUMENTS}
    assert target_weights(closes) == {}                 # < warmup+1 booked day → no weights (no fabrication)


def test_fund_instruments_drops_btc_and_reconciles() -> None:
    # The fxhnt fund book uses FUND_INSTRUMENTS (5 ETFs, no BTC-USD). target_weights over that list returns
    # exactly those 5 keys, and Σ w·R[last] still reconstructs book_series's last return on the SAME list.
    import datetime

    import numpy as np

    from fxhnt.domain.strategies.multistrat import (
        FUND_INSTRUMENTS, INSTRUMENTS, _build, book_series, target_weights)
    assert "BTC-USD" in INSTRUMENTS and "BTC-USD" not in FUND_INSTRUMENTS
    rng = np.random.default_rng(0)
    start = datetime.date(2025, 1, 1)
    dates = [(start + datetime.timedelta(days=i)).isoformat() for i in range(300)]
    closes = {}
    for k, s in enumerate(INSTRUMENTS):                   # generate all 6; the fund path uses only the 5
        steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates))
        closes[s] = {d: float(px) for d, px in zip(dates, 100.0 * np.cumprod(1 + steps))}

    w = target_weights(closes, FUND_INSTRUMENTS)
    assert set(w) == set(FUND_INSTRUMENTS)               # 5 equity keys, BTC-USD absent
    _, R = _build(closes, FUND_INSTRUMENTS)
    reconstructed = sum(w[FUND_INSTRUMENTS[j]] * float(R[-1, j]) for j in range(len(FUND_INSTRUMENTS)))
    assert abs(reconstructed - book_series(closes, FUND_INSTRUMENTS)[-1][1]) < 1e-8


def test_growth_discipline_blend_matches_w_qqq_plus_multistrat() -> None:
    import datetime

    import numpy as np

    from fxhnt.domain.strategies.growth_discipline import daily_returns
    from fxhnt.domain.strategies.multistrat import INSTRUMENTS, book_series
    rng = np.random.default_rng(1)
    # Enough dates that multistrat's 63-day warmup still leaves booked days (mirror multistrat test).
    start = datetime.date(2025, 1, 1)
    dates = [(start + datetime.timedelta(days=i)).isoformat() for i in range(300)]
    closes = {}
    for k, s in enumerate(INSTRUMENTS):
        steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates))
        px = 100.0 * np.cumprod(1 + steps)
        closes[s] = {d: float(px[i]) for i, d in enumerate(dates)}
    # QQQ shares the same date grid so its consecutive-day return aligns with the book's dates exactly.
    qsteps = rng.normal(0.0004, 0.011, len(dates))
    qpx = 100.0 * np.cumprod(1 + qsteps)
    qqq = {d: float(qpx[i]) for i, d in enumerate(dates)}

    w = 0.70
    rows = daily_returns(qqq, closes, w)
    gd = dict(rows)

    # Reconstruct the expectation: r_multistrat from book_series, r_QQQ as consecutive-day return.
    ms = dict(book_series(closes))
    qdates = sorted(qqq)
    qret = {qdates[i]: qqq[qdates[i]] / qqq[qdates[i - 1]] - 1.0 for i in range(1, len(qdates))}
    expected = {d: w * qret[d] + (1.0 - w) * ms[d] for d in (set(ms) & set(qret))}

    common = set(gd) & set(expected)
    assert len(common) >= 1
    for d in common:
        assert abs(gd[d] - expected[d]) < 1e-9


def test_growth_discipline_service_round_trips_through_reader(tmp_path) -> None:
    import datetime

    import numpy as np

    from fxhnt.domain.strategies.multistrat import INSTRUMENTS
    rng = np.random.default_rng(2)
    start = datetime.date(2025, 1, 1)
    dates = [(start + datetime.timedelta(days=i)).isoformat() for i in range(300)]
    data = {}
    for k, s in enumerate(INSTRUMENTS):
        steps = rng.normal(0.0003 * (1 if k % 2 else -1), 0.01, len(dates))
        px = 100.0 * np.cumprod(1 + steps)
        data[s] = {d: float(px[i]) for i, d in enumerate(dates)}
    qsteps = rng.normal(0.0004, 0.011, len(dates))
    qpx = 100.0 * np.cumprod(1 + qsteps)
    data["QQQ"] = {d: float(qpx[i]) for i, d in enumerate(dates)}
    bars = FakeDailyBars(data)

    p = str(tmp_path / "gd_state.json")
    ForwardTracker(GrowthDisciplineStrategy(bars), p).step()       # freeze at inception
    # Append one new trading day across every series so the second step books >= 1 day.
    nd = (start + datetime.timedelta(days=len(dates))).isoformat()
    for s in list(INSTRUMENTS) + ["QQQ"]:
        last = data[s][dates[-1]]
        data[s][nd] = last * 1.001
    ForwardTracker(GrowthDisciplineStrategy(bars), p).step()
    state = json.loads(Path(p).read_text())
    assert len(state["days"]) >= 1


# ------------------------------------------------------------------------ vendored surfer signal utility
def test_xs_weights_market_neutral_unit_gross() -> None:
    """Real signal_sweep.xs_weights on a small array → cross-sectionally market-neutral, unit gross.

    (MomentumVrpStrategy — the dormant Binance-fed surfer PoC this utility once backed via surfer_poc.py —
    was retired Phase 0b Task 7c along with surfer_poc.py; signal_sweep.py itself is kept (still imported by
    the vendored pit_sweep.py), so this direct utility test is kept with it.)"""
    import numpy as np

    from fxhnt.vendor.surfer.signal_sweep import xs_weights

    sig = np.array(
        [
            [1.0, 2.0, 3.0, 4.0, 5.0],
            [-2.0, 0.0, 1.0, 3.0, -1.0],
            [10.0, -5.0, 2.0, 7.0, -3.0],
        ]
    )
    w = xs_weights(sig)
    assert w.shape == sig.shape
    # market-neutral: each row sums ~0; unit gross: sum|w| ~1 per row.
    for t in range(w.shape[0]):
        assert abs(float(w[t].sum())) < 1e-9
        assert abs(float(np.abs(w[t]).sum()) - 1.0) < 1e-9
