"""Unit tests for v3 EV gate, return distribution, and eligibility.

Tests the return distribution computation, regime-specific min_edge thresholds,
mode escalation logic, posterior state projection, and divergence detection.

Requirements validated: 11.1–11.7, 12.1–12.7, 13.1–13.5
"""
from __future__ import annotations

import math

import pytest

from services.aggregation.projection import compute_v3_projection
from services.aggregation.regime import MarketRegime, V3RegimeClassification
from services.recommendation.eligibility import (
    ReturnDistribution,
    V3Eligibility,
    compute_return_distribution,
    compute_v3_eligibility,
)

# ---------------------------------------------------------------------------
# Helper: construct a V3RegimeClassification for tests
# ---------------------------------------------------------------------------

def _make_regime(regime: MarketRegime, phi: float = 0.50) -> V3RegimeClassification:
    """Create a minimal V3RegimeClassification for testing."""
    params = {
        MarketRegime.PANIC: (0.70, 0.70, 0.35, 2.5),
        MarketRegime.TREND_FOLLOWING: (1.10, 1.00, 0.80, 1.8),
        MarketRegime.MEAN_REVERSION: (0.90, 0.95, 0.55, 1.4),
        MarketRegime.UNCERTAINTY: (0.80, 0.85, 0.50, 2.0),
    }
    gamma, conf_mult, phi_val, atr_mult = params[regime]
    return V3RegimeClassification(
        regime=regime,
        trend_z=0.0,
        vol_ratio=1.0,
        evidence_multiplier=gamma,
        confidence_multiplier=conf_mult,
        phi_decay=phi_val,
        atr_multiplier=atr_mult,
    )


# ---------------------------------------------------------------------------
# EV positive → eligible (Req 12.1–12.7)
# ---------------------------------------------------------------------------


class TestEVPositiveEligible:
    """Tests that positive EV with passing quality gates → eligible=True."""

    def test_strong_signal_trend_following(self):
        """a_projected=2.0, conf=0.8, vol=0.25, h=7, costs=0.001, trend_following → eligible."""
        result = compute_return_distribution(
            a_projected=2.0,
            confidence=0.8,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.8,
            contradiction=0.1,
            n_eff_total=5.0,
            data_quality=0.8,
        )
        assert isinstance(result, ReturnDistribution)
        assert result.ev_long > 0.0
        assert result.ev_long > result.min_edge
        assert result.eligible is True
        # Verify min_edge for trend_following
        assert result.min_edge == pytest.approx(0.0035, abs=1e-9)

    def test_sigma_h_formula(self):
        """Verify sigma_h = realized_vol * sqrt(horizon / 252)."""
        result = compute_return_distribution(
            a_projected=2.0,
            confidence=0.8,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.8,
            contradiction=0.1,
            n_eff_total=5.0,
            data_quality=0.8,
        )
        expected_sigma_h = 0.25 * math.sqrt(7 / 252.0)
        assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9)

    def test_mu_h_formula(self):
        """Verify mu_h = tanh(A_projected / 3.0) * confidence * sigma_h."""
        result = compute_return_distribution(
            a_projected=2.0,
            confidence=0.8,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.8,
            contradiction=0.1,
            n_eff_total=5.0,
            data_quality=0.8,
        )
        sigma_h = 0.25 * math.sqrt(7 / 252.0)
        expected_mu_h = math.tanh(2.0 / 3.0) * 0.8 * sigma_h
        assert result.mu_h == pytest.approx(expected_mu_h, rel=1e-9)


# ---------------------------------------------------------------------------
# EV negative → ineligible (Req 12.3–12.5)
# ---------------------------------------------------------------------------


class TestEVNegativeIneligible:
    """Tests that weak signals with negative or sub-threshold EV → ineligible."""

    def test_weak_signal_high_costs(self):
        """a_projected=0.01, conf=0.3, costs=0.01 → EV < min_edge → ineligible."""
        result = compute_return_distribution(
            a_projected=0.01,
            confidence=0.3,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.01,
            regime="uncertainty",
            confidence_actual=0.3,
            contradiction=0.5,
            n_eff_total=1.0,
            data_quality=0.4,
        )
        # Weak signal: tanh(0.01/3) ≈ 0.0033, * 0.3 * sigma_h is tiny
        # Costs + CVaR should dominate → EV negative
        assert result.ev_long < result.min_edge
        assert result.eligible is False

    def test_zero_alpha_negative_ev(self):
        """a_projected=0 → mu_h=0, then costs + CVaR push EV negative."""
        result = compute_return_distribution(
            a_projected=0.0,
            confidence=0.5,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.7,
            contradiction=0.1,
            n_eff_total=5.0,
            data_quality=0.8,
        )
        # mu_h = tanh(0) * ... = 0. EV = 0 - costs - 0.10*CVaR < 0
        assert result.mu_h == pytest.approx(0.0, abs=1e-12)
        assert result.ev_long < 0.0
        assert result.eligible is False

    def test_quality_gate_fails_despite_positive_ev(self):
        """Strong EV but low n_eff → ineligible (quality gate blocks)."""
        result = compute_return_distribution(
            a_projected=3.0,
            confidence=0.9,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.9,
            contradiction=0.1,
            n_eff_total=1.0,  # Below 2.0 threshold
            data_quality=0.8,
        )
        # EV should be positive, but n_eff < 2.0 fails quality gate
        assert result.ev_long > 0.0
        assert result.eligible is False


# ---------------------------------------------------------------------------
# Regime-specific min_edge thresholds (Req 12.4)
# ---------------------------------------------------------------------------


class TestRegimeMinEdge:
    """Tests that regime-specific min_edge values are correct."""

    def test_panic_min_edge_strictest(self):
        """Panic regime has min_edge = 0.0100 (strictest)."""
        result = compute_return_distribution(
            a_projected=1.0,
            confidence=0.5,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="panic",
            confidence_actual=0.7,
            contradiction=0.2,
            n_eff_total=3.0,
            data_quality=0.8,
        )
        assert result.min_edge == pytest.approx(0.0100, abs=1e-9)

    def test_trend_following_min_edge_most_lenient(self):
        """Trend following has min_edge = 0.0035 (most lenient)."""
        result = compute_return_distribution(
            a_projected=1.0,
            confidence=0.5,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.7,
            contradiction=0.2,
            n_eff_total=3.0,
            data_quality=0.8,
        )
        assert result.min_edge == pytest.approx(0.0035, abs=1e-9)

    def test_mean_reversion_min_edge(self):
        """Mean reversion has min_edge = 0.0050."""
        result = compute_return_distribution(
            a_projected=1.0,
            confidence=0.5,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="mean_reversion",
            confidence_actual=0.7,
            contradiction=0.2,
            n_eff_total=3.0,
            data_quality=0.8,
        )
        assert result.min_edge == pytest.approx(0.0050, abs=1e-9)

    def test_uncertainty_min_edge(self):
        """Uncertainty has min_edge = 0.0075."""
        result = compute_return_distribution(
            a_projected=1.0,
            confidence=0.5,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="uncertainty",
            confidence_actual=0.7,
            contradiction=0.2,
            n_eff_total=3.0,
            data_quality=0.8,
        )
        assert result.min_edge == pytest.approx(0.0075, abs=1e-9)

    def test_unknown_regime_defaults_to_uncertainty(self):
        """Unknown regime string → falls back to uncertainty min_edge."""
        result = compute_return_distribution(
            a_projected=1.0,
            confidence=0.5,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="nonexistent_regime",
            confidence_actual=0.7,
            contradiction=0.2,
            n_eff_total=3.0,
            data_quality=0.8,
        )
        assert result.min_edge == pytest.approx(0.0075, abs=1e-9)


# ---------------------------------------------------------------------------
# Mode escalation: live vs paper vs informational (Req 13.1–13.5)
# ---------------------------------------------------------------------------


class TestModeEscalation:
    """Tests for v3 mode escalation logic."""

    def test_live_eligible(self):
        """BUY with high conf/low contra/high n_eff/EV >> min_edge → live."""
        result = compute_v3_eligibility(
            p_up=0.75,
            ev_long=0.020,  # >> 2 * 0.0035 = 0.0070
            min_edge=0.0035,
            confidence=0.80,
            contradiction=0.10,
            strength=0.50,
            n_eff_total=6.0,
            data_quality=0.85,
            regime="trend_following",
            has_existing_position=False,
            risk_engine_passed=True,
        )
        assert isinstance(result, V3Eligibility)
        assert result.action == "BUY"
        assert result.mode == "live"
        assert result.eligible is True

    def test_paper_eligible(self):
        """BUY with moderate confidence → paper."""
        result = compute_v3_eligibility(
            p_up=0.70,
            ev_long=0.010,  # > min_edge but < 2 * min_edge for live
            min_edge=0.0035,
            confidence=0.65,  # >= 0.60 for paper but < 0.75 for live
            contradiction=0.15,
            strength=0.40,
            n_eff_total=4.0,
            data_quality=0.80,
            regime="trend_following",
            has_existing_position=False,
            risk_engine_passed=True,
        )
        assert result.action == "BUY"
        assert result.mode == "paper"
        assert result.eligible is True

    def test_informational_low_confidence(self):
        """BUY with low confidence → informational."""
        result = compute_v3_eligibility(
            p_up=0.70,
            ev_long=0.010,
            min_edge=0.0035,
            confidence=0.55,  # >= regime min but < 0.60 for paper
            contradiction=0.15,
            strength=0.40,
            n_eff_total=4.0,
            data_quality=0.80,
            regime="trend_following",
            has_existing_position=False,
            risk_engine_passed=True,
        )
        assert result.action == "BUY"
        assert result.mode == "informational"

    def test_hold_always_informational(self):
        """HOLD action is always informational regardless of quality."""
        result = compute_v3_eligibility(
            p_up=0.55,  # Below bullish threshold for trend_following (0.60)
            ev_long=0.020,
            min_edge=0.0035,
            confidence=0.90,
            contradiction=0.05,
            strength=0.50,
            n_eff_total=10.0,
            data_quality=0.95,
            regime="trend_following",
            has_existing_position=True,
            risk_engine_passed=True,
        )
        assert result.action == "HOLD"
        assert result.mode == "informational"

    def test_watch_when_ineligible(self):
        """Low confidence below regime min → WATCH."""
        result = compute_v3_eligibility(
            p_up=0.80,
            ev_long=0.020,
            min_edge=0.0035,
            confidence=0.40,  # Below trend_following min of 0.55
            contradiction=0.10,
            strength=0.60,
            n_eff_total=5.0,
            data_quality=0.80,
            regime="trend_following",
            has_existing_position=False,
            risk_engine_passed=True,
        )
        assert result.action == "WATCH"
        assert result.eligible is False

    def test_risk_engine_blocks_live(self):
        """Risk engine failure blocks live but allows paper."""
        result = compute_v3_eligibility(
            p_up=0.75,
            ev_long=0.020,
            min_edge=0.0035,
            confidence=0.80,
            contradiction=0.10,
            strength=0.50,
            n_eff_total=6.0,
            data_quality=0.85,
            regime="trend_following",
            has_existing_position=False,
            risk_engine_passed=False,
        )
        # Both live and paper require risk_engine_passed
        assert result.action == "BUY"
        assert result.mode == "informational"

    def test_sell_on_negative_ev_with_position(self):
        """Existing position with negative EV → SELL."""
        result = compute_v3_eligibility(
            p_up=0.35,  # bearish
            ev_long=-0.005,
            min_edge=0.0035,
            confidence=0.70,
            contradiction=0.15,
            strength=0.30,
            n_eff_total=5.0,
            data_quality=0.80,
            regime="trend_following",
            has_existing_position=True,
            risk_engine_passed=True,
        )
        assert result.action == "SELL"


# ---------------------------------------------------------------------------
# Projection decay convergence (Req 11.1–11.4)
# ---------------------------------------------------------------------------


class TestProjectionDecay:
    """Tests for compute_v3_projection decay behavior."""

    def test_evidence_accumulates(self):
        """cluster_llrs=[1.0, 0.5] → A_t = phi*A_prev + 1.5."""
        regime = _make_regime(MarketRegime.TREND_FOLLOWING)
        result = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[1.0, 0.5],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
        )
        # A_t = 0.80 * 0.0 + 1.5 = 1.5
        assert result.a_t == pytest.approx(1.5, abs=1e-9)
        # P_up_projected should be > 0.5 (bullish evidence)
        assert result.p_up_projected > 0.5

    def test_projection_horizon_decays(self):
        """Higher projection_horizon → stronger decay → closer to prior."""
        regime = _make_regime(MarketRegime.TREND_FOLLOWING)
        # phi=0.80, horizon=5 → phi^5 = 0.32768
        result_h1 = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[2.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
        )
        result_h5 = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[2.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=5,
        )
        # Longer horizon → more decay → projected_strength should be lower
        assert result_h5.projected_strength < result_h1.projected_strength
        # Both still bullish
        assert result_h1.p_up_projected > 0.5
        assert result_h5.p_up_projected > 0.5

    def test_panic_decays_faster_than_trend(self):
        """Panic (phi=0.35) decays much faster than trend_following (phi=0.80)."""
        regime_panic = _make_regime(MarketRegime.PANIC)
        regime_trend = _make_regime(MarketRegime.TREND_FOLLOWING)

        result_panic = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[2.0],
            regime=regime_panic,
            p_prior=0.50,
            projection_horizon=3,
        )
        result_trend = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[2.0],
            regime=regime_trend,
            p_prior=0.50,
            projection_horizon=3,
        )
        # Trend should retain more signal after projection
        assert result_trend.projected_strength > result_panic.projected_strength

    def test_phi_regime_stored(self):
        """Result stores the correct phi_regime value."""
        regime = _make_regime(MarketRegime.MEAN_REVERSION)
        result = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[1.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
        )
        assert result.phi_regime == pytest.approx(0.55, abs=1e-9)

    def test_a_prev_contributes(self):
        """Non-zero a_prev gets decayed and added to new evidence."""
        regime = _make_regime(MarketRegime.UNCERTAINTY)  # phi=0.50
        result = compute_v3_projection(
            a_prev=2.0,
            cluster_llrs=[1.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
        )
        # A_t = 0.50 * 2.0 + 1.0 = 2.0
        assert result.a_t == pytest.approx(2.0, abs=1e-9)


# ---------------------------------------------------------------------------
# Divergence flag behavior (Req 11.6)
# ---------------------------------------------------------------------------


class TestDivergenceFlag:
    """Tests for divergence detection between current and projected P_up."""

    def test_no_divergence_same_direction(self):
        """Bullish current and projected → diverges=False."""
        regime = _make_regime(MarketRegime.TREND_FOLLOWING)
        result = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[2.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
        )
        # Both current P_up_t and projected should be > 0.5 (bullish)
        assert result.diverges is False

    def test_divergence_strong_decay(self):
        """Bullish current but projected decay crosses 0.5 boundary → diverges=True."""
        # Use panic regime (phi=0.35) with small evidence and large projection horizon
        regime = _make_regime(MarketRegime.PANIC)
        # A_t = 0.35 * 0 + 0.1 = 0.1 → P_up_t > 0.5 (bullish)
        # A_projected = 0.35^20 * 0.1 ≈ 0 → P_up_projected ≈ 0.5
        # Need negative catalyst to push projected below 0.5
        result = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[0.5],  # Mild bullish evidence
            regime=regime,
            p_prior=0.50,
            projection_horizon=20,
            known_catalyst_llr=-1.0,  # Bearish catalyst flips projected direction
        )
        # Current: A_t = 0.5, P_up_t = sigmoid(0.5) > 0.5 (bullish)
        # Projected: phi^20 * 0.5 - 1.0 = practically -1.0 → P_up_projected < 0.5
        assert result.diverges is True

    def test_no_divergence_both_bearish(self):
        """Bearish current and projected → diverges=False."""
        regime = _make_regime(MarketRegime.TREND_FOLLOWING)
        result = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[-2.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
        )
        # Both should be < 0.5 (bearish)
        assert result.p_up_projected < 0.5
        assert result.diverges is False

    def test_known_catalyst_shifts_projection(self):
        """known_catalyst_llr adds to projected alpha."""
        regime = _make_regime(MarketRegime.TREND_FOLLOWING)
        result_no_catalyst = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[1.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
            known_catalyst_llr=0.0,
        )
        result_with_catalyst = compute_v3_projection(
            a_prev=0.0,
            cluster_llrs=[1.0],
            regime=regime,
            p_prior=0.50,
            projection_horizon=1,
            known_catalyst_llr=1.0,
        )
        # Catalyst boosts projected P_up
        assert result_with_catalyst.p_up_projected > result_no_catalyst.p_up_projected


# ---------------------------------------------------------------------------
# Default vol (Req 12.7)
# ---------------------------------------------------------------------------


class TestDefaultVolatility:
    """Tests that realized_vol_20d=0 defaults to 0.25."""

    def test_zero_vol_uses_default(self):
        """realized_vol_20d=0 → uses 0.25 default."""
        result_zero = compute_return_distribution(
            a_projected=2.0,
            confidence=0.8,
            realized_vol_20d=0,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.8,
            contradiction=0.1,
            n_eff_total=5.0,
            data_quality=0.8,
        )
        result_default = compute_return_distribution(
            a_projected=2.0,
            confidence=0.8,
            realized_vol_20d=0.25,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.8,
            contradiction=0.1,
            n_eff_total=5.0,
            data_quality=0.8,
        )
        assert result_zero.sigma_h == pytest.approx(result_default.sigma_h, abs=1e-12)
        assert result_zero.ev_long == pytest.approx(result_default.ev_long, abs=1e-12)

    def test_negative_vol_uses_default(self):
        """realized_vol_20d=-0.1 → uses 0.25 default."""
        result = compute_return_distribution(
            a_projected=2.0,
            confidence=0.8,
            realized_vol_20d=-0.1,
            horizon_days=7,
            costs=0.001,
            regime="trend_following",
            confidence_actual=0.8,
            contradiction=0.1,
            n_eff_total=5.0,
            data_quality=0.8,
        )
        expected_sigma_h = 0.25 * math.sqrt(7 / 252.0)
        assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9)
