"""Unit tests for signal scoring upgrades and pipeline-wide behaviors.

Tests information gain, adaptive decay, macro exposure, macro integration,
graph distance, momentum, EV gate, and feature flag behaviors.

Requirements: 3.1, 3.4, 5.5, 5.6, 10.3, 10.4, 11.3, 13.3, 14.3, 14.4, 16.4, 16.5
"""
from __future__ import annotations

import math
from datetime import datetime, timezone

import pytest

from services.aggregation.interpolation import (
    _compute_multiplicative_exposure,
    integrate_macro_signals,
)
from services.aggregation.projection import (
    compute_ew_momentum,
    compute_trend_momentum,
)
from services.aggregation.scoring import (
    DEFAULT_BASE_RATE,
    ScoringConfig,
    SignalWeight,
    WeightedSignal,
    compute_adaptive_half_life,
    compute_info_gain,
    compute_regime_multiplier,
    compute_signal_weight,
)
from services.aggregation.signal_propagation import (
    compute_graph_distance_attenuation,
)
from services.recommendation.eligibility import (
    compute_expected_value,
    evaluate_eligibility,
)
from services.shared.schemas import (
    RecommendationMode,
    TrendDirection,
    TrendSummary,
    TrendWindow,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_trend_summary(**overrides) -> TrendSummary:
    """Create a minimal TrendSummary for testing."""
    defaults = {
        "entity_id": "test-company",
        "ticker": "TEST",
        "window": TrendWindow.SEVEN_DAY,
        "trend_direction": TrendDirection.BULLISH,
        "trend_strength": 0.5,
        "confidence": 0.6,
        "contradiction_score": 0.1,
        "signal_count": 5,
        "unique_source_count": 3,
        "weighted_sentiment_avg": 0.4,
        "top_supporting_evidence": ["doc-1", "doc-2"],
        "top_opposing_evidence": ["doc-3"],
        "material_risks": [],
    }
    defaults.update(overrides)
    return TrendSummary(**defaults)


def _make_signal(
    sentiment: float,
    combined_weight: float = 1.0,
    impact: float = 1.0,
) -> WeightedSignal:
    """Create a minimal WeightedSignal for testing."""
    weight = SignalWeight(
        recency=1.0,
        credibility=1.0,
        novelty_bonus=0.0,
        confidence_gate=1.0,
        market_ctx_multiplier=1.0,
        combined=combined_weight,
    )
    return WeightedSignal(
        document_id="test-doc",
        weight=weight,
        sentiment_value=sentiment,
        impact_score=impact,
    )


# ---------------------------------------------------------------------------
# Information gain clamp (Req 3.4)
# ---------------------------------------------------------------------------


class TestInfoGainClamp:
    """Test info gain clamp: very rare event → factor ≤ 3.0."""

    def test_very_rare_event_clamped(self):
        """An event with extremely low base rate is clamped to 3.0."""
        # base_rate = 0.001 → -log₂(0.001) ≈ 9.97 → r = 1 + 0.3*9.97 ≈ 3.99
        # Should be clamped to 3.0
        result = compute_info_gain("unknown_type", lambda_param=0.3, max_gain=3.0, default_base_rate=0.001)
        assert result <= 3.0

    def test_m_and_a_high_gain(self):
        """M&A (base_rate=0.03) produces high but clamped gain."""
        result = compute_info_gain("m_and_a")
        assert result > 1.0
        assert result <= 3.0

    def test_earnings_low_gain(self):
        """Earnings (base_rate=0.25) produces modest gain."""
        result = compute_info_gain("earnings")
        assert result >= 1.0
        assert result < 2.0

    def test_none_event_type_returns_one(self):
        """None event type returns neutral factor 1.0."""
        assert compute_info_gain(None) == 1.0


# ---------------------------------------------------------------------------
# Default base rate (Req 3.2)
# ---------------------------------------------------------------------------


class TestDefaultBaseRate:
    """Test default base rate: unknown event type → 0.1."""

    def test_unknown_event_uses_default(self):
        """Unknown event type uses DEFAULT_BASE_RATE = 0.1."""
        result = compute_info_gain("completely_unknown_event")
        expected = 1.0 + 0.3 * (-math.log2(DEFAULT_BASE_RATE))
        assert result == pytest.approx(min(expected, 3.0), abs=0.01)

    def test_default_base_rate_value(self):
        """DEFAULT_BASE_RATE is 0.1."""
        assert DEFAULT_BASE_RATE == 0.1


# ---------------------------------------------------------------------------
# Adaptive decay edge cases (Req 5.5, 5.6)
# ---------------------------------------------------------------------------


class TestAdaptiveDecayEdgeCases:
    """Test adaptive decay: all zeros → τ_base, all max → 6×τ_base."""

    def test_all_zeros_gives_base(self):
        """All β factors zero → τ_i = τ_base (Req 5.6)."""
        config = ScoringConfig(probabilistic=True)
        result = compute_adaptive_half_life(
            base_half_life=72.0,
            impact_score=0.0,
            info_gain_factor=1.0,  # r=1 → β_surprise=0
            market_multiplier=1.0,  # M=1 → β_market=0
            config=config,
        )
        assert result == pytest.approx(72.0)

    def test_all_max_gives_six_times_base(self):
        """All β factors at max → τ_i ≈ 6×τ_base (Req 5.5).

        β_impact = 1.0 * 1.0 = 1.0
        β_surprise = ((3.0 - 1.0) / 2.0) * 1.0 = 1.0
        β_market = ((1.45 - 1.0) / 0.45) * 0.5 = 0.5
        τ = 72 * (1+1) * (1+1) * (1+0.5) = 72 * 2 * 2 * 1.5 = 432 = 6 * 72
        """
        config = ScoringConfig(
            probabilistic=True,
            adaptive_decay_impact_scale=1.0,
            adaptive_decay_surprise_scale=1.0,
            adaptive_decay_market_scale=0.5,
        )
        result = compute_adaptive_half_life(
            base_half_life=72.0,
            impact_score=1.0,
            info_gain_factor=3.0,
            market_multiplier=1.45,
            config=config,
        )
        assert result == pytest.approx(72.0 * 6.0, rel=0.01)

    def test_adaptive_never_below_base(self):
        """Adaptive half-life is always >= base (Property 5)."""
        config = ScoringConfig(probabilistic=True)
        result = compute_adaptive_half_life(
            base_half_life=72.0,
            impact_score=0.5,
            info_gain_factor=2.0,
            market_multiplier=1.2,
            config=config,
        )
        assert result >= 72.0


# ---------------------------------------------------------------------------
# Zero overlap → zero macro impact (Req 10.3)
# ---------------------------------------------------------------------------


class TestZeroOverlapMacro:
    """Test zero overlap → zero macro impact."""

    def test_all_zero_overlaps(self):
        """All overlaps zero → exposure = 0.0."""
        exposure = _compute_multiplicative_exposure(0.0, 0.0, 0.0, 0.0)
        assert exposure == 0.0


# ---------------------------------------------------------------------------
# Max overlap → ≈severity×0.724 (Req 10.4)
# ---------------------------------------------------------------------------


class TestMaxOverlapMacro:
    """Test max overlap → ≈severity×0.724."""

    def test_all_max_overlaps(self):
        """All overlaps 1.0 → exposure ≈ 0.689.

        1 - (1-0.35)*(1-0.25)*(1-0.25)*(1-0.15) = 1 - 0.65*0.75*0.75*0.85 ≈ 0.689
        """
        exposure = _compute_multiplicative_exposure(1.0, 1.0, 1.0, 1.0)
        expected = 1.0 - (0.65 * 0.75 * 0.75 * 0.85)
        assert exposure == pytest.approx(expected, abs=0.001)
        assert exposure > 0.5  # significantly above zero


# ---------------------------------------------------------------------------
# Macro fallback behaviors (Req 11.3, 11.4)
# ---------------------------------------------------------------------------


class TestMacroFallbackBehaviors:
    """Test macro fallback: only macro → additive, only company → no modifier."""

    def test_only_macro_additive_fallback(self):
        """Only macro signals → additive merge (Req 11.3)."""
        macro_signals = [_make_signal(sentiment=-1.0)]
        merged, modifier = integrate_macro_signals(
            company_signals=[],
            macro_signals=macro_signals,
            company_direction="neutral",
            macro_impacts=[],
            probabilistic=True,
        )
        # Macro-only: returns macro signals, modifier = 1.0
        assert len(merged) == 1
        assert modifier == 1.0

    def test_only_company_no_modifier(self):
        """Only company signals → modifier = 1.0 (Req 11.4)."""
        company_signals = [_make_signal(sentiment=1.0)]
        merged, modifier = integrate_macro_signals(
            company_signals=company_signals,
            macro_signals=[],
            company_direction="bullish",
            macro_impacts=[],
            probabilistic=True,
        )
        assert len(merged) == 1
        assert modifier == 1.0

    def test_heuristic_mode_additive_merge(self):
        """Heuristic mode: simple concatenation of all signals."""
        company = [_make_signal(sentiment=1.0)]
        macro = [_make_signal(sentiment=-1.0)]
        merged, modifier = integrate_macro_signals(
            company_signals=company,
            macro_signals=macro,
            company_direction="bullish",
            macro_impacts=[],
            probabilistic=False,
        )
        assert len(merged) == 2
        assert modifier == 1.0


# ---------------------------------------------------------------------------
# Graph distance cutoff (Req 12.3)
# ---------------------------------------------------------------------------


class TestGraphDistanceCutoff:
    """Test graph distance cutoff: d>3 → no propagation."""

    def test_distance_4_no_propagation(self):
        """Distance 4 → transfer strength = 0.0."""
        result = compute_graph_distance_attenuation(
            source_strength=1.0, correlation=1.0, distance=4,
        )
        assert result == 0.0

    def test_distance_3_propagates(self):
        """Distance 3 → still propagates (e^(-3) ≈ 0.05)."""
        result = compute_graph_distance_attenuation(
            source_strength=1.0, correlation=1.0, distance=3,
        )
        assert result > 0.0
        assert result == pytest.approx(math.exp(-3), abs=0.001)

    def test_distance_1_strongest(self):
        """Distance 1 → strongest propagation."""
        d1 = compute_graph_distance_attenuation(1.0, 1.0, 1)
        d2 = compute_graph_distance_attenuation(1.0, 1.0, 2)
        d3 = compute_graph_distance_attenuation(1.0, 1.0, 3)
        assert d1 > d2 > d3 > 0.0

    def test_distance_0_no_propagation(self):
        """Distance 0 → no propagation (self-loop)."""
        result = compute_graph_distance_attenuation(1.0, 1.0, 0)
        assert result == 0.0


# ---------------------------------------------------------------------------
# Momentum fallback (Req 13.3)
# ---------------------------------------------------------------------------


class TestMomentumFallback:
    """Test momentum fallback: <2 cycles → heuristic."""

    def test_empty_changes_returns_zero(self):
        """Empty list → 0.0 (fallback)."""
        assert compute_ew_momentum([]) == 0.0

    def test_single_change_returns_zero(self):
        """Single change → 0.0 (fewer than 2 cycles)."""
        assert compute_ew_momentum([0.5]) == 0.0

    def test_two_changes_computes(self):
        """Two changes → computes EW momentum."""
        result = compute_ew_momentum([0.3, 0.2])
        assert result != 0.0

    def test_heuristic_fallback_for_trend_momentum(self):
        """compute_trend_momentum with no previous data uses heuristic."""
        result = compute_trend_momentum(
            current_strength=0.6,
            current_direction="bullish",
            previous_strength=None,
            previous_direction=None,
        )
        # Heuristic: dir_sign * strength * 0.5 = 1.0 * 0.6 * 0.5 = 0.3
        assert result == pytest.approx(0.3, abs=0.01)


# ---------------------------------------------------------------------------
# EV threshold behavior (Req 14.3, 14.4)
# ---------------------------------------------------------------------------


class TestEVThresholdBehavior:
    """Test EV threshold: EV>0.005→proceed, EV≤0.005→informational."""

    def test_positive_ev_proceeds(self):
        """EV > 0.005 → recommendation proceeds normally."""
        summary = _make_trend_summary(
            trend_direction=TrendDirection.BULLISH,
            trend_strength=0.5,
            confidence=0.7,
        )
        result = evaluate_eligibility(
            summary,
            probabilistic=True,
            p_bull=0.8,
            sigma_20=0.02,
        )
        # With p_bull=0.8, strength=0.5, sigma_20=0.02, horizon=7d:
        # R_up = 0.5 * 0.02 * sqrt(7) ≈ 0.0265
        # R_down = 0.5 * 0.02 * sqrt(7) ≈ 0.0265
        # EV = 0.8 * 0.0265 - 0.2 * 0.0265 ≈ 0.0159
        assert result.ev_value is not None
        assert result.ev_value > 0.005
        assert result.pipeline_mode == "probabilistic"

    def test_low_ev_forces_informational(self):
        """EV ≤ 0.005 → forced to informational mode (Req 14.4)."""
        summary = _make_trend_summary(
            trend_direction=TrendDirection.BULLISH,
            trend_strength=0.5,
            confidence=0.7,
        )
        # p_bull near 0.5 → EV near 0
        result = evaluate_eligibility(
            summary,
            probabilistic=True,
            p_bull=0.5,
            sigma_20=0.001,  # very low vol → tiny EV
        )
        assert result.ev_value is not None
        assert result.ev_value <= 0.005
        assert result.mode == RecommendationMode.INFORMATIONAL

    def test_ev_computation_values(self):
        """Verify EV computation formula directly."""
        ev = compute_expected_value(
            p_bull=0.7,
            strength=0.5,
            sigma_20=0.02,
            horizon_days=7.0,
        )
        # R_up = 0.5 * 0.02 * sqrt(7) ≈ 0.02646
        # R_down = 0.5 * 0.02 * sqrt(7) ≈ 0.02646
        # EV = 0.7 * 0.02646 - 0.3 * 0.02646 ≈ 0.01058
        assert ev > 0.005
        assert ev == pytest.approx(0.7 * 0.5 * 0.02 * math.sqrt(7) - 0.3 * 0.5 * 0.02 * math.sqrt(7), abs=0.001)


# ---------------------------------------------------------------------------
# Feature flag behaviors (Req 16.4, 16.5)
# ---------------------------------------------------------------------------


class TestFeatureFlagBehaviors:
    """Test flag=false→heuristic, flag=true→probabilistic."""

    def test_heuristic_mode_binary_gate(self):
        """flag=false → uses binary confidence gate."""
        config = ScoringConfig(probabilistic=False)
        now = datetime.now(timezone.utc)

        # Below confidence floor → gate = 0
        result = compute_signal_weight(
            published_at=now,
            reference_time=now,
            window="7d",
            source_credibility=0.8,
            extraction_confidence=0.1,  # below floor of 0.2
            config=config,
        )
        assert result.confidence_gate == 0.0
        assert result.combined == 0.0
        assert result.sigmoid_gate is None

    def test_probabilistic_mode_sigmoid_gate(self):
        """flag=true → uses sigmoid confidence gate."""
        config = ScoringConfig(probabilistic=True)
        now = datetime.now(timezone.utc)

        result = compute_signal_weight(
            published_at=now,
            reference_time=now,
            window="7d",
            source_credibility=0.8,
            extraction_confidence=0.5,
            config=config,
        )
        assert result.sigmoid_gate is not None
        assert result.sigmoid_gate == pytest.approx(0.5, abs=0.01)
        assert result.combined > 0.0

    def test_heuristic_mode_no_info_gain(self):
        """flag=false → info_gain_factor stays at default 1.0."""
        config = ScoringConfig(probabilistic=False)
        now = datetime.now(timezone.utc)

        result = compute_signal_weight(
            published_at=now,
            reference_time=now,
            window="7d",
            source_credibility=0.8,
            extraction_confidence=0.8,
            event_type="m_and_a",
            config=config,
        )
        assert result.info_gain_factor == 1.0

    def test_probabilistic_mode_has_info_gain(self):
        """flag=true → info_gain_factor computed from event type."""
        config = ScoringConfig(probabilistic=True)
        now = datetime.now(timezone.utc)

        result = compute_signal_weight(
            published_at=now,
            reference_time=now,
            window="7d",
            source_credibility=0.8,
            extraction_confidence=0.8,
            event_type="m_and_a",
            config=config,
        )
        assert result.info_gain_factor > 1.0

    def test_heuristic_eligibility_skips_ev(self):
        """flag=false → EV gate is skipped entirely."""
        summary = _make_trend_summary()
        result = evaluate_eligibility(summary, probabilistic=False)
        assert result.ev_value is None
        assert result.pipeline_mode == "heuristic"

    def test_probabilistic_eligibility_computes_ev(self):
        """flag=true → EV is computed."""
        summary = _make_trend_summary()
        result = evaluate_eligibility(
            summary, probabilistic=True, p_bull=0.7, sigma_20=0.02,
        )
        assert result.ev_value is not None
        assert result.pipeline_mode == "probabilistic"


# ---------------------------------------------------------------------------
# Regime multiplier edge cases
# ---------------------------------------------------------------------------


class TestRegimeMultiplierEdgeCases:
    """Test regime multiplier with edge case inputs."""

    def test_no_returns_gives_one(self):
        """No returns → M_regime = 1.0."""
        assert compute_regime_multiplier(None, None) == 1.0

    def test_single_return_gives_one(self):
        """Single return → M_regime = 1.0 (need at least 2)."""
        assert compute_regime_multiplier([0.01], None) == 1.0

    def test_constant_returns_gives_one(self):
        """Constant returns (σ=0) → z_r=0 → M_regime = 1.0."""
        returns = [0.01] * 20
        result = compute_regime_multiplier(returns, None)
        assert result == pytest.approx(1.0)

    def test_clamped_to_max(self):
        """Extreme z-scores → clamped to 2.5."""
        # Create returns with extreme outlier
        returns = [0.001] * 19 + [10.0]
        result = compute_regime_multiplier(returns, None)
        assert result <= 2.5
