"""v1a drawdown controller: kelly_for_dd_budget, PageHinkleyDecay.health, and the edge_health overlay.

These cover the three pure pieces plus the edge_health wiring into both overlays (live==replay), and
guard that the default controller="killswitch" path is unchanged.
"""
import pytest

from fxhnt.application.paper_book import (
    IncrementalRiskOverlay,
    _DD_BUDGET,
    kelly_for_dd_budget,
    paper_risk_overlay,
)
from fxhnt.domain.edge_decay import PageHinkleyDecay


# --- A. kelly_for_dd_budget ----------------------------------------------------------------------

def test_kelly_for_dd_budget_default_budget():
    # -0.25 -> (25 - 21)/12 = 0.3333...
    assert kelly_for_dd_budget(-0.25) == pytest.approx((25.0 - 21.0) / 12.0)
    assert kelly_for_dd_budget(_DD_BUDGET) == pytest.approx(1.0 / 3.0)


def test_kelly_for_dd_budget_minus_33_is_one():
    # -0.33 -> (33 - 21)/12 = 1.0
    assert kelly_for_dd_budget(-0.33) == pytest.approx(1.0)


def test_kelly_for_dd_budget_clamps_low():
    # Tiny budget -> well below kmin -> clamps to kmin.
    assert kelly_for_dd_budget(-0.05) == 0.1
    assert kelly_for_dd_budget(0.0) == 0.1


def test_kelly_for_dd_budget_clamps_high():
    # Large budget -> above kmax -> clamps to kmax.
    assert kelly_for_dd_budget(-0.90) == 1.5


def test_kelly_for_dd_budget_sign_insensitive():
    assert kelly_for_dd_budget(-0.25) == kelly_for_dd_budget(0.25)


# --- B. PageHinkleyDecay.health ------------------------------------------------------------------

def test_health_fresh_is_one():
    ph = PageHinkleyDecay()
    assert ph.health() == 1.0          # m_max == m_t == 0


def test_health_deviation_equal_lam_is_theta_min():
    ph = PageHinkleyDecay(lam=0.20)
    ph.m_max = 0.5
    ph.m_t = 0.3                        # dev = 0.2 == lam -> 1 - 1 = 0
    assert ph.health(theta_min=0.0) == pytest.approx(0.0)
    assert ph.health(theta_min=0.1) == pytest.approx(0.1)   # clamps up to theta_min


def test_health_clamps_high_when_m_t_above_m_max():
    # m_t > m_max can't happen via update (m_max tracks the peak), but health must still clamp to 1.0.
    ph = PageHinkleyDecay(lam=0.20)
    ph.m_max = 0.1
    ph.m_t = 0.5
    assert ph.health() == 1.0


def test_health_does_not_mutate():
    ph = PageHinkleyDecay()
    for _ in range(20):
        ph.update(0.001)
    before = ph.to_dict()
    _ = ph.health()
    assert ph.to_dict() == before


def test_health_drops_on_decaying_series_holds_on_positive():
    # PH detects a downward DRIFT/shift in the mean (anti-whipsaw: a constant level does NOT trip it).
    pos = PageHinkleyDecay(lam=0.05)
    neg = PageHinkleyDecay(lam=0.05)
    for _ in range(60):
        pos.update(0.002)                          # steadily positive -> stays healthy
    for _ in range(40):
        neg.update(0.005)                          # establish a positive regime ...
    for _ in range(40):
        neg.update(-0.01)                          # ... then a sustained downward shift = edge decay
    assert pos.health() == pytest.approx(1.0)     # healthy
    assert neg.health() < 0.5                      # decayed -> de-sized hard


def test_health_partial_decay_is_between():
    ph = PageHinkleyDecay(lam=0.20)
    ph.m_max = 0.5
    ph.m_t = 0.4                        # dev 0.1 -> 1 - 0.5 = 0.5
    assert ph.health() == pytest.approx(0.5)


# --- C. edge_health overlay correctness ----------------------------------------------------------

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


def test_edge_health_desizes_decaying_sleeve_only():
    healthy = _series([0.01, 0.012, 0.008, 0.011] * 20)              # steady positive
    # A downward SHIFT (positive regime then negative) = edge decay PH detects (not a constant level).
    decaying = _series([0.01] * 40 + [-0.02] * 40)
    R = {"good": healthy, "bad": decaying}
    w, _lev, killed = paper_risk_overlay(R, ["good", "bad"], controller="edge_health")
    assert killed is False
    # Compare against the un-adjusted ISV weights: bad is cut hard by theta~0, good is ~untouched (theta~1).
    from fxhnt.application.paper_book import isv_adaptive_weights
    isv = isv_adaptive_weights(R, ["good", "bad"])
    assert w["bad"] < 0.01 * isv["bad"]               # decaying sleeve de-sized to ~zero
    assert w["good"] == pytest.approx(isv["good"], rel=0.05)   # healthy sleeve ~untouched (theta~1)


def test_edge_health_total_exposure_drops_when_a_sleeve_decays():
    healthy = _series([0.01, 0.012, 0.008, 0.011] * 20)
    decaying = _series([0.01] * 40 + [-0.02] * 40)               # downward shift = edge decay
    R_calm = {"good": healthy, "good2": _series([0.009, 0.011, 0.01, 0.012] * 20)}
    R_decay = {"good": healthy, "bad": decaying}
    w_calm, _, _ = paper_risk_overlay(R_calm, ["good", "good2"], controller="edge_health")
    w_decay, _, _ = paper_risk_overlay(R_decay, ["good", "bad"], controller="edge_health")
    # Healthy book keeps near-full exposure (theta~1 on noise); the decaying book is cut materially below it.
    assert sum(w_calm.values()) > 0.98
    assert sum(w_decay.values()) < sum(w_calm.values()) - 0.1


def test_edge_health_never_kills_even_on_deep_drawdown():
    losing = _series([-0.03] * 60)
    _, _, killed = paper_risk_overlay({"a": losing}, ["a"], controller="edge_health")
    assert killed is False


def test_edge_health_uses_dd_budget_kelly():
    # Strong-Sharpe, unsaturated leverage so the Kelly multiplier is visible: edge_health leverage uses
    # kelly_for_dd_budget(_DD_BUDGET) ~ 0.333, not the default 1.0.
    from fxhnt.application.paper_book import (
        isv_adaptive_weights, paper_book_returns, adaptive_target_vol, vol_target_leverage)
    R = {"a": {i + 1: (0.05 if i % 2 else 0.045) for i in range(80)}}   # healthy -> theta~1
    w, lev, _ = paper_risk_overlay(R, ["a"], controller="edge_health")
    bk = kelly_for_dd_budget(_DD_BUDGET)
    bret = paper_book_returns(R, kelly_fraction=bk)
    tv = adaptive_target_vol(list(bret.values()))
    expected = vol_target_leverage(w, R, target_vol=tv, kelly_fraction=bk)
    assert lev == pytest.approx(expected)
    # And it differs from the full-Kelly (killswitch) leverage when unsaturated.
    isv = isv_adaptive_weights(R, ["a"])
    bret_full = paper_book_returns(R)
    tv_full = adaptive_target_vol(list(bret_full.values()))
    full_lev = vol_target_leverage(isv, R, target_vol=tv_full, kelly_fraction=1.0)
    assert lev < full_lev - 1e-9


# --- D. live==replay golden for edge_health ------------------------------------------------------

def _golden_fixture():
    import random
    rng = random.Random(20260623)
    spec = [
        ("a", 1, 200, 0.0008, 0.004),
        ("b", 11, 190, 0.0, 0.020),
        ("c", 6, 180, -0.010, 0.020),   # negative drift -> drives theta down -> exercises health path
    ]
    full: dict[str, dict[int, float]] = {}
    for name, start, n, mu, sd in spec:
        full[name] = {start + i: rng.gauss(mu, sd) for i in range(n)}
    all_days = sorted({d for r in full.values() for d in r})
    return full, all_days


@pytest.mark.slow
def test_incremental_edge_health_matches_per_date_exact():
    full, all_days = _golden_fixture()
    inc = IncrementalRiskOverlay(controller="edge_health")
    acc: dict[str, dict[int, float]] = {}
    saw_desized = False
    for cutoff in [d + 1 for d in all_days]:
        newest = cutoff - 1
        for s, r in full.items():
            if newest in r:
                acc.setdefault(s, {})[newest] = r[newest]
        active = [s for s, r in acc.items() if r]
        w_ref, lev_ref, killed_ref = paper_risk_overlay(acc, active, controller="edge_health")
        w_inc, lev_inc, killed_inc = inc.overlay(acc, active)
        assert w_inc == w_ref, f"day {cutoff}: weights {w_inc} != {w_ref}"
        assert lev_inc == lev_ref, f"day {cutoff}: leverage {lev_inc!r} != {lev_ref!r}"
        assert killed_inc is False and killed_ref is False
        # detect that the health path actually de-sized something (theta<1 -> sum<1)
        if active and sum(w_ref.values()) < 1.0 - 1e-6:
            saw_desized = True
    assert saw_desized, "fixture never de-sized a sleeve — edge_health path not exercised"
