"""Unit tests for EvidenceUnit normalization and LLR conversion.

Validates: Requirements 1.1–1.8, 2.1–2.9, 3.1–3.6
"""
from __future__ import annotations

import math
from datetime import datetime, timezone

from services.aggregation.scoring import (
    EvidenceUnit,
    SourceStats,
    compute_llr,
    compute_v3_reliability,
    normalize_company_signal,
    normalize_competitive_signal,
    normalize_macro_signal,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)


def _make_company_signal(**overrides) -> dict:
    """Create a minimal valid company signal dict."""
    base = {
        "symbol": "AAPL",
        "timestamp": _NOW,
        "source_id": "doc-001",
        "event_type": "earnings",
        "source_group": "company",
        "horizon": "7d",
        "sentiment": "positive",
        "sentiment_strength": 0.8,
        "impact": 0.7,
        "extraction_conf": 0.9,
        "source_cred": 0.85,
        "novelty": 0.9,
    }
    base.update(overrides)
    return base


def _make_macro_signal(**overrides) -> dict:
    """Create a minimal valid macro signal dict."""
    base = {
        "symbol": "MSFT",
        "timestamp": _NOW,
        "source_id": "event-100",
        "event_type": "regulatory",
        "impact_direction": "positive",
        "macro_impact_score": 0.6,
        "event_confidence": 0.75,
        "estimated_duration": "medium_term",
    }
    base.update(overrides)
    return base


def _make_competitive_signal(**overrides) -> dict:
    """Create a minimal valid competitive signal dict."""
    base = {
        "symbol": "GOOG",
        "timestamp": _NOW,
        "source_id": "comp-doc-55",
        "event_type": "product_launch",
        "signal_direction": "bearish",
        "signal_strength": 0.7,
        "relationship_strength": 0.8,
        "pattern_confidence": 0.65,
        "time_horizon": "30d",
    }
    base.update(overrides)
    return base


# ===========================================================================
# TestNormalizeCompanySignal
# ===========================================================================


class TestNormalizeCompanySignal:
    """Test normalize_company_signal mapping and validation."""

    def test_full_company_signal(self):
        """A complete company signal maps all fields correctly."""
        sig = _make_company_signal()
        eu = normalize_company_signal(sig)

        assert eu is not None
        assert eu.symbol == "AAPL"
        assert eu.layer == "company"
        assert eu.event_type == "earnings"
        assert eu.source_id == "doc-001"
        assert eu.source_group == "company"
        assert eu.timestamp == _NOW
        assert eu.horizon == "7d"
        assert eu.direction == 1  # "positive" → +1
        assert eu.sentiment_strength == 0.8
        assert eu.impact == 0.7
        assert eu.extraction_conf == 0.9
        assert eu.source_cred == 0.85
        assert eu.novelty == 0.9
        assert eu.event_base_rate == 0.25  # earnings base rate
        assert len(eu.cluster_id) == 16  # sha256 hex prefix

    def test_missing_symbol_rejected(self):
        """Missing symbol → returns None with warning."""
        sig = _make_company_signal(symbol=None)
        assert normalize_company_signal(sig) is None

    def test_missing_timestamp_rejected(self):
        """Missing timestamp → returns None with warning."""
        sig = _make_company_signal(timestamp=None)
        assert normalize_company_signal(sig) is None

    def test_missing_source_id_rejected(self):
        """Missing source_id → returns None with warning."""
        sig = _make_company_signal(source_id=None)
        assert normalize_company_signal(sig) is None

    def test_empty_string_symbol_rejected(self):
        """Empty string symbol → returns None (falsy check)."""
        sig = _make_company_signal(symbol="")
        assert normalize_company_signal(sig) is None

    def test_direction_mappings(self):
        """Direction string mappings: positive→+1, negative→-1, neutral→0."""
        for sentiment, expected in [
            ("positive", 1),
            ("negative", -1),
            ("neutral", 0),
            ("bullish", 1),
            ("bearish", -1),
            ("mixed", 0),
        ]:
            eu = normalize_company_signal(_make_company_signal(sentiment=sentiment))
            assert eu is not None
            assert eu.direction == expected, f"'{sentiment}' should map to {expected}"

    def test_missing_optional_fields_default_0_5(self):
        """Missing optional numeric fields substitute 0.5."""
        sig = {
            "symbol": "TSLA",
            "timestamp": _NOW,
            "source_id": "doc-xyz",
        }
        eu = normalize_company_signal(sig)

        assert eu is not None
        assert eu.sentiment_strength == 0.5
        assert eu.impact == 0.5
        assert eu.extraction_conf == 0.5
        assert eu.source_cred == 0.5
        assert eu.novelty == 0.5

    def test_invalid_horizon_defaults_to_7d(self):
        """Invalid horizon string falls back to '7d'."""
        sig = _make_company_signal(horizon="invalid_horizon")
        eu = normalize_company_signal(sig)
        assert eu is not None
        assert eu.horizon == "7d"

    def test_timestamp_string_parsed(self):
        """ISO timestamp string is parsed to datetime."""
        sig = _make_company_signal(timestamp="2025-01-10T08:00:00+00:00")
        eu = normalize_company_signal(sig)
        assert eu is not None
        assert eu.timestamp == datetime(2025, 1, 10, 8, 0, 0, tzinfo=timezone.utc)

    def test_unknown_event_type_uses_default_base_rate(self):
        """Unknown event_type uses default base rate of 0.10."""
        sig = _make_company_signal(event_type="mysterious_event")
        eu = normalize_company_signal(sig)
        assert eu is not None
        assert eu.event_base_rate == 0.10


# ===========================================================================
# TestNormalizeMacroSignal
# ===========================================================================


class TestNormalizeMacroSignal:
    """Test normalize_macro_signal mapping and validation."""

    def test_full_macro_signal(self):
        """A complete macro signal maps all fields correctly."""
        sig = _make_macro_signal()
        eu = normalize_macro_signal(sig)

        assert eu is not None
        assert eu.symbol == "MSFT"
        assert eu.layer == "macro"
        assert eu.source_group == "macro"
        assert eu.direction == 1  # "positive" → +1
        assert eu.impact == 0.6  # macro_impact_score
        assert eu.source_cred == 0.75  # event_confidence
        assert eu.extraction_conf == 0.75  # event_confidence
        assert eu.novelty == 1.0  # default for new events

    def test_horizon_short_term(self):
        """short_term → 7d."""
        sig = _make_macro_signal(estimated_duration="short_term")
        eu = normalize_macro_signal(sig)
        assert eu is not None
        assert eu.horizon == "7d"

    def test_horizon_medium_term(self):
        """medium_term → 30d."""
        sig = _make_macro_signal(estimated_duration="medium_term")
        eu = normalize_macro_signal(sig)
        assert eu is not None
        assert eu.horizon == "30d"

    def test_horizon_long_term(self):
        """long_term → 90d."""
        sig = _make_macro_signal(estimated_duration="long_term")
        eu = normalize_macro_signal(sig)
        assert eu is not None
        assert eu.horizon == "90d"

    def test_missing_symbol_rejected(self):
        """Missing symbol in macro signal → None."""
        sig = _make_macro_signal()
        del sig["symbol"]
        assert normalize_macro_signal(sig) is None

    def test_ticker_alias_accepted(self):
        """'ticker' key is accepted as alias for 'symbol'."""
        sig = _make_macro_signal()
        del sig["symbol"]
        sig["ticker"] = "AMZN"
        eu = normalize_macro_signal(sig)
        assert eu is not None
        assert eu.symbol == "AMZN"

    def test_event_id_alias_accepted(self):
        """'event_id' key is accepted as alias for 'source_id'."""
        sig = _make_macro_signal()
        del sig["source_id"]
        sig["event_id"] = "global-evt-42"
        eu = normalize_macro_signal(sig)
        assert eu is not None
        assert eu.source_id == "global-evt-42"

    def test_direction_mapping_negative(self):
        """Negative impact_direction → direction = -1."""
        sig = _make_macro_signal(impact_direction="negative")
        eu = normalize_macro_signal(sig)
        assert eu is not None
        assert eu.direction == -1

    def test_direction_mapping_neutral(self):
        """Neutral impact_direction → direction = 0."""
        sig = _make_macro_signal(impact_direction="neutral")
        eu = normalize_macro_signal(sig)
        assert eu is not None
        assert eu.direction == 0


# ===========================================================================
# TestNormalizeCompetitiveSignal
# ===========================================================================


class TestNormalizeCompetitiveSignal:
    """Test normalize_competitive_signal mapping and validation."""

    def test_full_competitive_signal(self):
        """A complete competitive signal maps all fields correctly."""
        sig = _make_competitive_signal()
        eu = normalize_competitive_signal(sig)

        assert eu is not None
        assert eu.symbol == "GOOG"
        assert eu.layer == "competitive"
        assert eu.source_group == "competitive"
        assert eu.horizon == "30d"
        assert eu.direction == -1  # "bearish" → -1

    def test_impact_is_product_of_strengths(self):
        """Impact = signal_strength × relationship_strength."""
        sig = _make_competitive_signal(signal_strength=0.7, relationship_strength=0.8)
        eu = normalize_competitive_signal(sig)
        assert eu is not None
        assert abs(eu.impact - 0.56) < 1e-9  # 0.7 × 0.8

    def test_source_cred_from_pattern_confidence(self):
        """source_cred mapped from pattern_confidence."""
        sig = _make_competitive_signal(pattern_confidence=0.65)
        eu = normalize_competitive_signal(sig)
        assert eu is not None
        assert eu.source_cred == 0.65
        assert eu.extraction_conf == 0.65

    def test_direction_bullish(self):
        """signal_direction='bullish' → direction = +1."""
        sig = _make_competitive_signal(signal_direction="bullish")
        eu = normalize_competitive_signal(sig)
        assert eu is not None
        assert eu.direction == 1

    def test_direction_neutral(self):
        """signal_direction='neutral' → direction = 0."""
        sig = _make_competitive_signal(signal_direction="neutral")
        eu = normalize_competitive_signal(sig)
        assert eu is not None
        assert eu.direction == 0

    def test_novelty_defaults_to_1(self):
        """Novelty defaults to 1.0 for competitive signals."""
        sig = _make_competitive_signal()
        # Ensure no explicit novelty key
        sig.pop("novelty", None)
        eu = normalize_competitive_signal(sig)
        assert eu is not None
        assert eu.novelty == 1.0

    def test_missing_required_source_id_rejected(self):
        """Missing source_id in competitive signal → None."""
        sig = _make_competitive_signal(source_id=None)
        # Also ensure alias key is absent
        sig.pop("source_document_id", None)
        assert normalize_competitive_signal(sig) is None

    def test_target_ticker_alias(self):
        """'target_ticker' key accepted as alias for 'symbol'."""
        sig = _make_competitive_signal()
        del sig["symbol"]
        sig["target_ticker"] = "META"
        eu = normalize_competitive_signal(sig)
        assert eu is not None
        assert eu.symbol == "META"

    def test_time_horizon_short_term_maps_to_7d(self):
        """Competitive time_horizon='short_term' maps to '7d' via macro map."""
        sig = _make_competitive_signal(time_horizon="short_term")
        eu = normalize_competitive_signal(sig)
        assert eu is not None
        assert eu.horizon == "7d"


# ===========================================================================
# TestReliabilityPipeline
# ===========================================================================


class TestReliabilityPipeline:
    """Test compute_v3_reliability with known inputs."""

    def test_known_inputs_perfect_signal(self):
        """Perfect inputs (source_cred=1, extraction_conf=1, novelty=1, fresh, no duplicates) → q_i close to 1."""
        unit = EvidenceUnit(
            symbol="AAPL",
            layer="company",
            event_type="earnings",
            source_id="doc-perfect",
            source_group="company",
            timestamp=_NOW,
            horizon="7d",
            direction=1,
            sentiment_strength=1.0,
            impact=1.0,
            extraction_conf=1.0,
            source_cred=1.0,
            novelty=1.0,
            event_base_rate=0.25,
            cluster_id="test-cluster",
        )
        # Source with strong track record
        stats = SourceStats(source_id="doc-perfect", hits=50, misses=0)
        # Fresh signal (0 age)
        rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)

        # q_ext: sigmoid(8.0 * (1.0 - 0.55)) = sigmoid(3.6) ≈ 0.9734
        assert rel.q_ext > 0.95

        # q_source: E[theta] = (3+50)/(3+3+50+0) = 53/56 ≈ 0.946
        # clamp((0.946 - 0.50) / 0.35, 0, 1) = clamp(1.274, 0, 1) = 1.0
        assert rel.q_source == 1.0

        # q_recency: fresh signal → 2^0 = 1.0
        assert rel.q_recency == 1.0

        # q_uniqueness: clamp(0.5 + 0.5*1.0, 0.5, 1.0) * 1/sqrt(1) = 1.0
        assert rel.q_uniqueness == 1.0

        # q_i should be close to 1 (bounded by q_ext ≈ 0.97)
        assert rel.q_i > 0.90

    def test_zero_history_source_yields_zero_q_source(self):
        """A source with zero history (hits=0, misses=0) → q_source = 0.0 (Req 2.3)."""
        unit = EvidenceUnit(
            symbol="AAPL",
            layer="company",
            event_type="earnings",
            source_id="new-source",
            source_group="company",
            timestamp=_NOW,
            horizon="7d",
            direction=1,
            sentiment_strength=0.8,
            impact=0.7,
            extraction_conf=0.9,
            source_cred=0.85,
            novelty=0.9,
            event_base_rate=0.25,
            cluster_id="test-cluster",
        )
        stats = SourceStats(source_id="new-source", hits=0, misses=0)
        rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)

        # E[theta] = 3/(3+3) = 0.5; clamp((0.5-0.5)/0.35, 0, 1) = 0.0
        assert rel.q_source == 0.0
        # Therefore q_i = 0.0 (multiplied by zero)
        assert rel.q_i == 0.0

    def test_duplicate_signal_penalized(self):
        """Signals later in a cluster (high cluster_position) get lower q_uniqueness."""
        unit = EvidenceUnit(
            symbol="AAPL",
            layer="company",
            event_type="earnings",
            source_id="doc-dup",
            source_group="company",
            timestamp=_NOW,
            horizon="7d",
            direction=1,
            sentiment_strength=0.8,
            impact=0.7,
            extraction_conf=0.9,
            source_cred=0.85,
            novelty=0.9,
            event_base_rate=0.25,
            cluster_id="test-cluster",
        )
        stats = SourceStats(source_id="doc-dup", hits=20, misses=5)

        rel_first = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)
        rel_third = compute_v3_reliability(unit, stats, cluster_position=3, reference_time=_NOW)

        # Third signal has lower q_uniqueness due to 1/sqrt(1+3) = 0.5
        assert rel_third.q_uniqueness < rel_first.q_uniqueness
        assert rel_third.q_i < rel_first.q_i

    def test_stale_signal_low_recency(self):
        """A signal that is very old gets low q_recency."""
        old_timestamp = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
        unit = EvidenceUnit(
            symbol="AAPL",
            layer="company",
            event_type="earnings",
            source_id="doc-old",
            source_group="company",
            timestamp=old_timestamp,
            horizon="7d",
            direction=1,
            sentiment_strength=0.8,
            impact=0.7,
            extraction_conf=0.9,
            source_cred=0.85,
            novelty=0.9,
            event_base_rate=0.25,
            cluster_id="test-cluster",
        )
        stats = SourceStats(source_id="doc-old", hits=20, misses=5)
        rel = compute_v3_reliability(unit, stats, cluster_position=0, reference_time=_NOW)

        # Over a year old with 7d horizon (tau_base=72h) → q_recency very low
        # Display floor is 0.01
        assert rel.q_recency == 0.01


# ===========================================================================
# TestLLRConversion
# ===========================================================================


class TestLLRConversion:
    """Test compute_llr boundary cases and sign behavior."""

    def _make_unit(self, direction: int, impact: float = 0.7, sentiment_strength: float = 0.8) -> EvidenceUnit:
        """Helper to create an EvidenceUnit with specified direction."""
        return EvidenceUnit(
            symbol="TEST",
            layer="company",
            event_type="earnings",
            source_id="llr-test",
            source_group="company",
            timestamp=_NOW,
            horizon="7d",
            direction=direction,
            sentiment_strength=sentiment_strength,
            impact=impact,
            extraction_conf=0.9,
            source_cred=0.85,
            novelty=0.9,
            event_base_rate=0.25,
            cluster_id="test-cluster",
        )

    def test_neutral_signal_zero_llr(self):
        """Neutral signal (direction=0) → LLR = 0.0 exactly (Req 3.3)."""
        unit = self._make_unit(direction=0)
        llr = compute_llr(unit, q_i=0.9)
        assert llr == 0.0

    def test_bullish_positive_llr(self):
        """Bullish signal (direction=+1) → positive LLR (Req 3.6)."""
        unit = self._make_unit(direction=1)
        llr = compute_llr(unit, q_i=0.9)
        assert llr > 0.0

    def test_bearish_negative_llr(self):
        """Bearish signal (direction=-1) → negative LLR (Req 3.6)."""
        unit = self._make_unit(direction=-1)
        llr = compute_llr(unit, q_i=0.9)
        assert llr < 0.0

    def test_p_correct_max_clamp(self):
        """Maximum p_correct = 0.85 → LLR ≈ ln(0.85/0.15) ≈ 1.735 (Req 3.5)."""
        # With direction=+1, q_i=1.0, impact=1.0, sentiment_strength=1.0:
        # p_correct = clamp(0.50 + 0.35*1*1*1, 0.501, 0.85) = 0.85
        unit = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0)
        llr = compute_llr(unit, q_i=1.0)
        expected = math.log(0.85 / 0.15)  # ≈ 1.7346
        assert abs(llr - expected) < 0.001

    def test_p_correct_min_clamp(self):
        """Minimum p_correct = 0.501 → |LLR| ≈ ln(0.501/0.499) ≈ 0.004 (Req 3.4)."""
        # With direction=-1, q_i very small → p_correct clamps to 0.501
        # q_i=0 → 0.50 + 0.35*0*anything = 0.50 → clamped to 0.501
        unit = self._make_unit(direction=-1, impact=0.0, sentiment_strength=0.0)
        llr = compute_llr(unit, q_i=0.0)
        expected = -math.log(0.501 / 0.499)  # ≈ -0.004
        assert abs(llr - expected) < 0.001

    def test_llr_sign_always_matches_direction(self):
        """For directional signals, LLR sign must match direction (Req 3.6)."""
        for direction in [1, -1]:
            for q_i in [0.0, 0.1, 0.5, 0.9, 1.0]:
                unit = self._make_unit(direction=direction)
                llr = compute_llr(unit, q_i=q_i)
                if direction == 1:
                    assert llr > 0.0, f"direction=+1, q_i={q_i} should give positive LLR"
                else:
                    assert llr < 0.0, f"direction=-1, q_i={q_i} should give negative LLR"

    def test_llr_magnitude_bounded(self):
        """LLR magnitude is bounded by [≈0.004, ≈1.735] for directional signals."""
        min_mag = math.log(0.501 / 0.499)  # ≈ 0.004
        max_mag = math.log(0.85 / 0.15)  # ≈ 1.735

        # Test at both extremes
        unit_max = self._make_unit(direction=1, impact=1.0, sentiment_strength=1.0)
        llr_max = compute_llr(unit_max, q_i=1.0)
        assert abs(llr_max) <= max_mag + 0.001

        unit_min = self._make_unit(direction=1, impact=0.0, sentiment_strength=0.0)
        llr_min = compute_llr(unit_min, q_i=0.0)
        assert abs(llr_min) >= min_mag - 0.001
