"""Pure unit tests for the per-sleeve structural-retirement hysteresis state machine.

The gate wraps a WindowedEdgeHealth + two confirmation counters (below_run / above_run) and a
status ∈ {active, retired}. It is the slow, binary, per-sleeve RETIREMENT gate from the combined
risk-stack design: drop a sleeve whose edge is STRUCTURALLY dead (a month below the retire floor),
re-admit one that STRUCTURALLY recovers (a month above the readmit floor). Hysteresis
(readmit_floor > retire_floor) + long windows make it un-whipsawable.
"""
import json

from fxhnt.domain.edge_decay import RetirementGate

# --- (e) bootstrap: a young sleeve is never retired ----------------------------------------------

def test_bootstrap_young_sleeve_never_retired():
    # WEH.health() == 1.0 until min_obs observations; even a strongly-negative young series must NOT
    # retire (no structural evidence yet).
    g = RetirementGate()
    for _ in range(g.weh.min_obs):           # exactly bootstrap length
        g.update(-0.05)
    assert g.status == "active"
    assert g.below_run == 0                   # health stayed 1.0 → no sub-floor days counted


# --- (a) retire after EXACTLY retire_window consecutive sub-retire_floor days --------------------

def test_retire_after_exactly_retire_window_consecutive_bad_days():
    g = RetirementGate(retire_floor=0.15, retire_window=30, weh_window=63)
    # Warm the WEH into a clearly-healthy regime first (so it starts active with health well above floor).
    for _ in range(80):
        g.update(0.003)
    assert g.status == "active"
    assert g.weh.health() >= 0.15
    # Now feed a sustained strongly-negative regime; health falls below retire_floor and STAYS there.
    # Find the day health first drops below the floor, then assert retirement fires retire_window days later.
    retired_at = None
    for step in range(400):
        g.update(-0.02)
        if g.status == "retired":
            retired_at = step
            break
    assert retired_at is not None, "sustained decay never retired the sleeve"
    # Retirement requires retire_window consecutive sub-floor days; below_run resets to 0 on retire.
    assert g.below_run == 0
    assert g.above_run == 0


def test_retire_fires_precisely_at_window_count_on_synthetic_health():
    # Deterministic counter test: bypass WEH calibration noise by checking the COUNTER logic directly via
    # a gate whose WEH we drive into a stable sub-floor reading, then count the exact day it flips.
    g = RetirementGate(retire_floor=0.15, retire_window=30)
    for _ in range(80):
        g.update(0.003)                       # healthy warmup → active, health high
    # Step into sustained decay and record below_run progression until the flip.
    below_runs = []
    for _ in range(200):
        g.update(-0.02)
        below_runs.append(g.below_run)
        if g.status == "retired":
            break
    assert g.status == "retired"
    # below_run is recorded AFTER each update but BEFORE the flip's reset zeroes it. The flip fires on
    # the day below_run would reach retire_window; on that day the machine sets status=retired and resets
    # below_run to 0. So the recorded sequence climbs 1,2,...,retire_window-1 and the final entry is 0
    # (the reset). max == retire_window - 1 proves the run was exactly retire_window long at the flip.
    assert max(below_runs) == g.retire_window - 1
    assert below_runs[-1] == 0  # the flip's reset


# --- (b) a dip < retire_window does NOT retire (structural vs tactical) --------------------------

def test_dip_below_floor_shorter_than_window_does_not_retire():
    g = RetirementGate(retire_floor=0.15, retire_window=30, weh_window=63)
    for _ in range(80):
        g.update(0.003)                        # healthy
    assert g.status == "active"
    # A short tactical drawdown: a handful of bad days (fewer than retire_window), then recovery.
    for _ in range(10):                        # 10 < 30 → must not retire
        g.update(-0.02)
    assert g.status == "active", "a tactical dip shorter than retire_window must NOT retire"
    # Recovery resets the below_run back toward 0.
    for _ in range(20):
        g.update(0.003)
    assert g.status == "active"
    assert g.below_run == 0


# --- (c) re-admit after readmit_window consecutive ≥ readmit_floor days --------------------------

def test_readmit_after_recovery_window():
    g = RetirementGate(retire_floor=0.15, retire_window=30,
                       readmit_floor=0.50, readmit_window=30, weh_window=63)
    for _ in range(80):
        g.update(0.003)                        # healthy → active
    for _ in range(400):                        # sustained decay → retired
        g.update(-0.02)
        if g.status == "retired":
            break
    assert g.status == "retired"
    # Now a sustained recovery: health climbs back above readmit_floor and stays there for readmit_window.
    readmitted = False
    for _ in range(400):
        g.update(0.01)
        if g.status == "active":
            readmitted = True
            break
    assert readmitted, "sustained recovery never re-admitted the sleeve"
    assert g.below_run == 0 and g.above_run == 0


# --- (d) hysteresis: health between the two floors neither retires nor re-admits -----------------

def test_hysteresis_band_is_inert():
    # Drive an ACTIVE gate to a health reading in (retire_floor, readmit_floor): below retire? no.
    # below_run must stay 0 → never retired. And drive a RETIRED gate to the same band: ≥ readmit? no.
    # above_run stays 0 → never re-admitted. We use the gate's own health() to confirm the band.
    g = RetirementGate(retire_floor=0.15, readmit_floor=0.50)
    # Build a mildly-positive series whose t-stat lands health between the floors.
    # mu small relative to noise → modest positive t-stat → health in the mid band.
    import math
    for i in range(200):
        g.update(0.0005 + 0.004 * math.sin(0.7 * i))
    h = g.weh.health()
    # Only assert the inertness property when we actually landed in the band (robust to calibration):
    if 0.15 <= h < 0.50:
        assert g.status == "active"
        assert g.below_run == 0   # h >= retire_floor → below_run never advances
    # Force into retired, then verify the same mid-band health does not re-admit.
    g.status = "retired"
    g.below_run = g.above_run = 0
    g.update(0.0005)
    if 0.15 <= g.weh.health() < 0.50:
        assert g.status == "retired"
        assert g.above_run == 0   # h < readmit_floor → above_run never advances


# --- (f) to_dict / from_dict round-trip ----------------------------------------------------------

def test_to_from_dict_roundtrip():
    g = RetirementGate(retire_floor=0.2, retire_window=12, readmit_floor=0.6,
                       readmit_window=8, weh_window=40)
    import math
    for i in range(60):
        g.update(0.001 + 0.003 * math.sin(0.3 * i))
    d = json.loads(json.dumps(g.to_dict()))
    g2 = RetirementGate.from_dict(d)
    assert g2.to_dict() == g.to_dict()
    assert g2.status == g.status
    assert g2.below_run == g.below_run and g2.above_run == g.above_run
    assert g2.weh.health() == g.weh.health()
    # Continuing both gates with the same input keeps them in lockstep (state truly restored).
    g.update(-0.02)
    g2.update(-0.02)
    assert g2.to_dict() == g.to_dict()


def test_all_retired_is_allowed_empty_set():
    # A single-sleeve gate that retires leaves the active set empty — acceptable (don't trade dead edges).
    g = RetirementGate(retire_window=30)
    for _ in range(80):
        g.update(0.003)
    for _ in range(400):
        g.update(-0.02)
        if g.status == "retired":
            break
    assert g.status == "retired"
