"""Unit tests for the regime-monitor pure functions and aggregation."""

from __future__ import annotations

from datetime import date, timedelta

from app.services.regime_monitor_service import (
    DEFAULT_CONFIG,
    _attach_early_warning,
    band_for,
    compute_regime_score,
    f2_credit_spreads,
    p1_trend_break,
    p2_death_cross,
    p3_drawdown,
    p4_relative_strength,
    p5_volatility,
    p6_canary,
    _compute_index,
)


def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
    n = len(values)
    return [(end - timedelta(days=(n - 1 - i)), v) for i, v in enumerate(values)]


# ---------------------------------------------------------------------------
# Bands
# ---------------------------------------------------------------------------

def test_band_for():
    assert band_for(10) == "stable"
    assert band_for(45) == "watch"
    assert band_for(70) == "elevated"
    assert band_for(90) == "breaking"


def test_attach_early_warning_blends():
    result = {"total_score": 80.0}
    _attach_early_warning(result, 40.0, {"coincident": 0.6, "early_warning": 0.4})
    assert result["early_warning"]["score"] == 40.0
    assert result["early_warning"]["band"] == "watch"
    # combined = (80*0.6 + 40*0.4) / 1.0 = 64
    assert result["combined"]["score"] == 64.0
    assert result["combined"]["band"] == "elevated"


def test_attach_early_warning_none_falls_back_to_index():
    result = {"total_score": 80.0}
    _attach_early_warning(result, None, {"coincident": 0.6, "early_warning": 0.4})
    assert result["early_warning"]["score"] is None
    assert result["combined"]["score"] == 80.0  # no early warning -> just the index


def test_divergence_asof_tolerates_small_lag():
    from app.services.regime_monitor_service import _divergence_asof
    items = [(date(2026, 6, 1), 55.0), (date(2026, 6, 3), 60.0)]
    assert _divergence_asof(items, date(2026, 6, 3)) == 60.0   # exact date
    assert _divergence_asof(items, date(2026, 6, 4)) == 60.0   # 1-day lag -> newest
    assert _divergence_asof(items, date(2026, 6, 20)) is None  # too stale
    assert _divergence_asof([], date(2026, 6, 3)) is None


# ---------------------------------------------------------------------------
# Price sub-scores
# ---------------------------------------------------------------------------

def test_p1_blends_leader_double():
    smh_under = [100.0] * 199 + [50.0]   # last below its 200-DMA
    qqq_above = [100.0] * 200            # last at/above its 200-DMA -> healthy
    score = p1_trend_break(smh_under, qqq_above, leader_weight=2.0)
    # leader(100) weighted 2, confirm(0) weighted 1 -> 66.7
    assert round(score, 1) == 66.7


def test_p1_none_without_history():
    assert p1_trend_break([100.0] * 50, [100.0] * 50, 2.0) is None


def test_p2_death_cross_bearish_vs_healthy():
    bearish = [300.0 - i for i in range(260)]      # falling: 50 < 200, slope down
    healthy = [100.0 + i * 0.5 for i in range(260)]  # rising: 50 > 200
    assert p2_death_cross(bearish, bearish, 2.0) > 0
    assert p2_death_cross(healthy, healthy, 2.0) == 0


def test_p3_drawdown_linear():
    closes = [100.0] * 252 + [80.0]   # 20% below the 52w high -> 100
    assert p3_drawdown(closes, [100.0] * 253) == 100.0


def test_p4_relative_strength_direction():
    falling = [100.0 - i * 0.5 for i in range(70)]   # SMH underperforms flat SPY
    rising = [100.0 + i * 0.5 for i in range(70)]
    spy = [100.0] * 70
    assert p4_relative_strength(falling, spy, 60) > 50
    assert p4_relative_strength(rising, spy, 60) < 50


def test_p5_volatility_linear():
    assert p5_volatility(15) == 0
    assert p5_volatility(30) == 100
    assert p5_volatility(22.5) == 50
    assert p5_volatility(None) is None


def test_f2_credit_percentile():
    rising = [float(i) for i in range(1, 31)]   # latest is the max -> ~100th pct
    assert f2_credit_spreads(rising) == 100.0
    falling = [float(i) for i in range(30, 0, -1)]  # latest is the min
    assert f2_credit_spreads(falling) < 10
    assert f2_credit_spreads([1.0] * 5) is None  # too short


def test_p6_canary_divergence():
    nvda_weak = [100.0] * 49 + [80.0]            # below its 50-DMA
    smh_intact = [100.0] * 199 + [120.0]         # above its 200-DMA
    assert p6_canary(nvda_weak, smh_intact) == 100.0
    assert p6_canary([100.0] * 50, smh_intact) == 0.0


# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------

def test_compute_regime_score_excludes_na_and_zero_weight():
    weights = {"P1": 10, "P2": 0, "F2": 5}
    subs = {"P1": 80.0, "P2": 50.0, "F2": None}
    result = compute_regime_score(subs, weights)
    # Only P1 counts: P2 weight 0, F2 unavailable.
    assert result["total_score"] == 80.0
    ids = {row["id"]: row for row in result["breakdown"]}
    assert "P2" not in ids            # zero-weight signals are hidden
    assert ids["F2"]["available"] is False
    assert ids["P1"]["contribution"] == 80.0


def test_compute_regime_score_contributions_sum_to_total():
    weights = {"P1": 10, "F2": 10}
    subs = {"P1": 80.0, "F2": 40.0}
    result = compute_regime_score(subs, weights)
    assert result["total_score"] == 60.0
    total = sum(row["contribution"] for row in result["breakdown"])
    assert round(total, 1) == 60.0


# ---------------------------------------------------------------------------
# As-of index replay (backfill mechanics)
# ---------------------------------------------------------------------------

def test_compute_index_as_of_truncates_history():
    rising = [100.0 + i * 0.2 for i in range(260)]
    prices = {sym: _dated(rising) for sym in ("SMH", "QQQ", "SPY", "RSP", "NVDA")}
    overrides = {"f1_score": 50.0, "f3_score": 50.0}

    full = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26))
    by_id = {r["id"]: r for r in full["breakdown"]}
    assert by_id["P1"]["available"] is True          # 200-DMA computable on full history
    assert 0 <= full["total_score"] <= 100
    assert full["band"] in {"stable", "watch", "elevated", "breaking"}

    # As-of 250 days earlier: only ~10 bars are in scope -> long-lookback signals n/a.
    early = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26) - timedelta(days=250))
    early_by_id = {r["id"]: r for r in early["breakdown"]}
    assert early_by_id["P1"]["available"] is False
