import math

import pytest

from fxhnt.application.paper_book import paper_book_returns, paper_risk_overlay


def _series(vals, start=1):
    return {start + i: v for i, v in enumerate(vals)}


def test_book_returns_are_causal_and_keyed_by_day():
    r = _series([0.01, -0.01] * 30)
    out = paper_book_returns({"a": r})
    assert isinstance(out, dict) and len(out) >= 1
    # first day has no prior-held weights -> not in output (no book return yet)
    assert min(r) not in out


def test_overlay_returns_weights_leverage_killed():
    r = _series([0.01, -0.01] * 30)
    w, lev, killed = paper_risk_overlay({"a": r}, ["a"])
    assert set(w) == {"a"} and w["a"] == 1.0
    assert lev > 0.0
    assert killed in (True, False)


def test_overlay_kills_after_sustained_drawdown():
    losing = _series([-0.03] * 40)
    _, _, killed = paper_risk_overlay({"a": losing}, ["a"])
    assert killed is True


def test_overlay_not_killed_on_calm_history():
    calm = _series([0.001, -0.001] * 40)
    _, _, killed = paper_risk_overlay({"a": calm}, ["a"])
    assert killed is False


def test_overlay_no_longer_gates_dead_sleeve():
    # marginal-gate is OFF in the overlay (A3g): a dead sleeve is NOT benched here; it still receives an
    # inverse-vol weight. (The gate fn keeps its own direct unit tests in test_marginal_gate_active.)
    from fxhnt.application.paper_book import paper_risk_overlay
    good = {i + 1: v for i, v in enumerate([0.02, 0.01, 0.03, 0.00] * 15)}
    dead = {i + 1: v for i, v in enumerate([-0.02, -0.01, -0.03, 0.00] * 15)}
    w, _lev, _killed = paper_risk_overlay({"good": good, "dead": dead}, ["good", "dead"])
    assert w.get("dead", 0.0) > 0.0          # not benched anymore
    assert abs(sum(w.values()) - 1.0) < 1e-9


def test_overlay_uses_adaptive_budget():
    from fxhnt.application.paper_book import (
        adaptive_target_vol,
        isv_adaptive_weights,
        paper_book_returns,
        paper_risk_overlay,
        vol_target_leverage,
    )
    R = {"a": {i + 1: (0.012 if i % 2 else 0.010) for i in range(80)}}   # strong positive Sharpe
    w, lev, _killed = paper_risk_overlay(R, ["a"])
    bret = paper_book_returns(R)
    tv = adaptive_target_vol(list(bret.values()))
    expected = vol_target_leverage(isv_adaptive_weights(R, ["a"]), R, target_vol=tv)
    assert tv == 0.35                                # strong trailing Sharpe -> rides the 35% ceiling
    assert abs(lev - expected) < 1e-9               # overlay leverage uses the adaptive budget


def test_overlay_weak_history_eases_budget():
    from fxhnt.application.paper_book import adaptive_target_vol, paper_book_returns
    R = {"a": {i + 1: (0.010 if i % 2 else -0.012) for i in range(80)}}  # weak/negative
    tv = adaptive_target_vol(list(paper_book_returns(R).values()))
    assert tv == 0.25                                # eases to the 25% floor


def test_overlay_budget_is_discriminating_when_leverage_unsaturated():
    # GUARD the wiring: with realistic vol (~0.03 daily) leverage is BELOW the 3.0 cap, so the adaptive
    # budget (0.35 here) produces a DIFFERENT leverage than the fixed base (0.30). If the overlay ignored
    # the adaptive budget, lev would equal the fixed-base leverage and this test would fail.
    from fxhnt.application.paper_book import (
        adaptive_target_vol,
        isv_adaptive_weights,
        paper_book_returns,
        paper_risk_overlay,
        vol_target_leverage,
    )
    R = {"a": {i + 1: (0.05 if i % 2 else -0.01) for i in range(80)}}   # strong Sharpe, vol ~0.03 -> unsaturated
    w, lev, _killed = paper_risk_overlay(R, ["a"])
    tv = adaptive_target_vol(list(paper_book_returns(R).values()))
    assert tv == 0.35                                       # strong trailing Sharpe -> 35% ceiling
    wts = isv_adaptive_weights(R, ["a"])
    adaptive_lev = vol_target_leverage(wts, R, target_vol=tv)
    fixed_lev = vol_target_leverage(wts, R, target_vol=0.30)
    assert lev < 3.0 - 1e-9                                 # unsaturated -> budget actually matters
    assert abs(lev - adaptive_lev) < 1e-9                   # overlay uses the ADAPTIVE budget
    assert abs(lev - fixed_lev) > 1e-6                      # ... and NOT the fixed-0.30 base


# --- combined controller ------------------------------------------------------------------------

def _healthy(n, mu=0.002, amp=0.0015, freq=0.37, start=1):
    """A clearly-healthy deterministic sleeve series (positive drift t-stat well above the floors)."""
    return {start + i: mu + amp * math.sin(freq * i) for i in range(n)}


def _decaying(n_pos, n_neg, mu_pos=0.002, mu_neg=-0.02, amp=0.0015, freq=0.37, start=1):
    """Healthy for n_pos days then a sustained structural negative-mean regime for n_neg days."""
    out = {}
    for i in range(n_pos):
        out[start + i] = mu_pos + amp * math.sin(freq * i)
    for i in range(n_neg):
        out[start + n_pos + i] = mu_neg + amp * math.sin(freq * (n_pos + i))
    return out


@pytest.mark.slow
def test_combined_inert_when_all_sleeves_healthy():
    # With two HEALTHY sleeves, combined must equal killswitch EXACTLY at every date (no retirements →
    # same active set → identical weights/leverage/killed). This proves it cannot touch the current curve.
    A = _healthy(180, mu=0.0018, freq=0.37)
    B = _healthy(180, mu=0.0022, freq=0.51)
    R = {"a": A, "b": B}
    active = ["a", "b"]
    all_days = sorted({d for r in R.values() for d in r})
    for cutoff in all_days:                       # strictly-prior window = keys < cutoff
        acc = {s: {d: v for d, v in r.items() if d < cutoff} for s, r in R.items()}
        ks = paper_risk_overlay(acc, active, controller="killswitch")
        cb = paper_risk_overlay(acc, active, controller="combined")
        assert cb == ks, f"day {cutoff}: combined {cb} != killswitch {ks}"


@pytest.mark.slow
def test_combined_retires_dead_sleeve_and_beats_killswitch():
    # Decisive test: A healthy throughout; B healthy then a sustained structural decay. combined retires
    # B (weight → 0) within ~retire_window of the decay onset; killswitch keeps B in the book; combined
    # compounds to a HIGHER NAV over the dying-sleeve scenario. The decay is a genuine STRUCTURAL bleed
    # (not so violent it pins the book in the killswitch forever — that would leave no non-killed days
    # on which the retirement gate's relief can compound).
    n_pos, n_neg = 120, 160
    A = _healthy(n_pos + n_neg, mu=0.0020, freq=0.37)
    B = _decaying(n_pos, n_neg, mu_pos=0.0018, mu_neg=-0.008, freq=0.51)
    R = {"a": A, "b": B}
    active = ["a", "b"]
    all_days = sorted({d for r in R.values() for d in r})

    def _nav(controller):
        nav = 1.0
        b_weights_after_onset = []
        for cutoff in all_days:
            acc = {s: {d: v for d, v in r.items() if d < cutoff} for s, r in R.items()}
            w, lev, killed = paper_risk_overlay(acc, active, controller=controller)
            if not killed:
                bret = lev * sum(w.get(s, 0.0) * R[s].get(cutoff, 0.0) for s in w)
                nav *= (1.0 + bret)
            # B's weight on dates well after the decay onset (onset at index n_pos, day = n_pos+1).
            if cutoff > n_pos + 40:
                b_weights_after_onset.append(w.get("b", 0.0))
        return nav, b_weights_after_onset

    nav_cb, b_w_cb = _nav("combined")
    nav_ks, b_w_ks = _nav("killswitch")
    # combined drops B after the decay is confirmed; killswitch keeps allocating to it.
    assert max(b_w_cb) == 0.0, "combined never retired the dead sleeve B"
    assert max(b_w_ks) > 0.0, "killswitch should keep allocating to B (no retirement gate)"
    # The gate earns its keep: combined compounds higher by not feeding the dying sleeve.
    assert nav_cb > nav_ks, f"combined NAV {nav_cb} !> killswitch NAV {nav_ks}"


def test_combined_readmits_recovered_sleeve():
    # B: healthy → sustained decay (retired) → sustained recovery. combined re-admits B (weight > 0
    # again) within ~readmit_window of the recovery onset (no permanent exile).
    n_pos, n_neg, n_rec = 120, 120, 160
    A = _healthy(n_pos + n_neg + n_rec, mu=0.0018, freq=0.37)
    B = dict(_decaying(n_pos, n_neg, mu_pos=0.0018, mu_neg=-0.02, freq=0.51))
    for i in range(n_rec):                         # recovery tail
        B[n_pos + n_neg + 1 + i] = 0.0025 + 0.0015 * math.sin(0.51 * (n_pos + n_neg + i))
    R = {"a": A, "b": B}
    active = ["a", "b"]
    full_acc = {s: dict(r) for s, r in R.items()}
    # At the very end (full history) B has recovered for ~n_rec days → must be re-admitted.
    w, _lev, _killed = paper_risk_overlay(full_acc, active, controller="combined")
    assert w.get("b", 0.0) > 0.0, "combined left B permanently exiled after recovery"
