"""Unit tests for event and relation macro/micro F1 metrics.

Validates: Requirements 16.3, 16.4
"""
from __future__ import annotations

from services.intelligence_pipeline_v3.evaluation.event_metrics import (
    EventMetricsResult,
    EventRelationEvaluationReport,
    GoldEvent,
    GoldRelation,
    PredictedEvent,
    PredictedRelation,
    RelationMetricsResult,
    compute_event_metrics,
    compute_relation_metrics,
    evaluate_events_and_relations,
)
from services.intelligence_pipeline_v3.schemas.annotations import (
    EventClass,
    RelationType,
)

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


def _pred_event(
    event_class: EventClass,
    evidence_ids: list[str] | None = None,
    primary_company_ids: list[str] | None = None,
    confidence: float = 1.0,
) -> PredictedEvent:
    return PredictedEvent(
        event_class=event_class,
        evidence_ids=evidence_ids or [],
        primary_company_ids=primary_company_ids or [],
        confidence=confidence,
    )


def _gold_event(
    event_class: EventClass,
    evidence_ids: list[str] | None = None,
    primary_company_ids: list[str] | None = None,
) -> GoldEvent:
    return GoldEvent(
        event_class=event_class,
        evidence_ids=evidence_ids or [],
        primary_company_ids=primary_company_ids or [],
    )


def _pred_relation(
    relation_type: RelationType,
    source_id: str,
    target_id: str,
    confidence: float = 1.0,
) -> PredictedRelation:
    return PredictedRelation(
        relation_type=relation_type,
        source_id=source_id,
        target_id=target_id,
        confidence=confidence,
    )


def _gold_relation(
    relation_type: RelationType,
    source_id: str,
    target_id: str,
) -> GoldRelation:
    return GoldRelation(
        relation_type=relation_type,
        source_id=source_id,
        target_id=target_id,
    )


# ---------------------------------------------------------------------------
# Event Metrics — Basic
# ---------------------------------------------------------------------------


class TestEventMetricsBasic:
    def test_both_empty(self) -> None:
        result = compute_event_metrics([], [])
        # All per-class are vacuously 1.0 (no predictions, no gold)
        assert result.micro.precision == 1.0
        assert result.micro.recall == 1.0
        assert result.micro.f1 == 1.0
        assert result.macro_f1 == 1.0

    def test_perfect_match_evidence(self) -> None:
        """Events with same class and overlapping evidence match."""
        pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1", "e2"])]
        gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e2", "e3"])]
        result = compute_event_metrics(pred, gold)
        assert result.micro.precision == 1.0
        assert result.micro.recall == 1.0
        assert result.micro.f1 == 1.0

    def test_perfect_match_company(self) -> None:
        """Events with same class and overlapping primary company match."""
        pred = [_pred_event(EventClass.MA_ANNOUNCEMENT, primary_company_ids=["c1"])]
        gold = [_gold_event(EventClass.MA_ANNOUNCEMENT, primary_company_ids=["c1", "c2"])]
        result = compute_event_metrics(pred, gold)
        assert result.micro.precision == 1.0
        assert result.micro.recall == 1.0

    def test_no_predictions(self) -> None:
        gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        result = compute_event_metrics([], gold)
        assert result.micro.recall == 0.0

    def test_no_gold(self) -> None:
        pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        result = compute_event_metrics(pred, [])
        assert result.micro.precision == 0.0

    def test_wrong_class_no_match(self) -> None:
        """Different event_class means no match regardless of evidence."""
        pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        gold = [_gold_event(EventClass.EARNINGS_MISS, evidence_ids=["e1"])]
        result = compute_event_metrics(pred, gold)
        assert result.micro.precision == 0.0
        assert result.micro.recall == 0.0

    def test_no_overlap_no_match(self) -> None:
        """Same class but no overlapping evidence or companies means no match."""
        pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"], primary_company_ids=["c1"])]
        gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e2"], primary_company_ids=["c2"])]
        result = compute_event_metrics(pred, gold)
        assert result.micro.precision == 0.0
        assert result.micro.recall == 0.0


# ---------------------------------------------------------------------------
# Event Metrics — Per-Class
# ---------------------------------------------------------------------------


class TestEventMetricsPerClass:
    def test_per_class_breakdown_all_13_classes(self) -> None:
        """Result always contains all 13 event classes."""
        result = compute_event_metrics([], [])
        assert len(result.per_class) == 13
        for ec in EventClass:
            assert ec.value in result.per_class

    def test_per_class_single_class(self) -> None:
        pred = [
            _pred_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e1"]),
            _pred_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e2"]),
        ]
        gold = [
            _gold_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e1"]),
            _gold_event(EventClass.PRODUCT_LAUNCH, evidence_ids=["e3"]),
        ]
        result = compute_event_metrics(pred, gold)
        pl = result.per_class["product_launch"]
        # 1 TP (e1 match), 1 FP, 1 FN
        assert pl.precision == 0.5
        assert pl.recall == 0.5
        assert abs(pl.f1 - 0.5) < 1e-9

    def test_per_class_mixed(self) -> None:
        pred = [
            _pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
            _pred_event(EventClass.LEGAL_REGULATORY, primary_company_ids=["c1"]),
        ]
        gold = [
            _gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
            _gold_event(EventClass.LEGAL_REGULATORY, primary_company_ids=["c1"]),
            _gold_event(EventClass.MACRO_EVENT, evidence_ids=["e5"]),
        ]
        result = compute_event_metrics(pred, gold)
        assert result.per_class["earnings_beat"].f1 == 1.0
        assert result.per_class["legal_regulatory"].f1 == 1.0
        assert result.per_class["macro_event"].recall == 0.0


# ---------------------------------------------------------------------------
# Event Metrics — Macro vs Micro
# ---------------------------------------------------------------------------


class TestEventMetricsMacroMicro:
    def test_macro_averages_across_classes(self) -> None:
        """Macro-F1 averages per-class F1, including classes with no data."""
        pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        result = compute_event_metrics(pred, gold)
        # earnings_beat has F1=1.0, all other 12 classes have F1=1.0 (empty/empty)
        assert result.macro_f1 == 1.0

    def test_macro_penalizes_missing_class(self) -> None:
        """A class with only gold items drags macro-F1 down."""
        pred = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        gold = [
            _gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
            _gold_event(EventClass.EARNINGS_MISS, evidence_ids=["e2"]),
        ]
        result = compute_event_metrics(pred, gold)
        # earnings_beat: F1=1.0, earnings_miss: recall=0 -> F1=0, rest: F1=1.0
        # macro = (1.0 + 0.0 + 11*1.0) / 13 = 12/13
        assert abs(result.macro_f1 - 12 / 13) < 1e-9

    def test_micro_aggregates_tp_fp_fn(self) -> None:
        """Micro-F1 sums TP/FP/FN across all classes."""
        pred = [
            _pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),  # TP
            _pred_event(EventClass.RATING_CHANGE, evidence_ids=["e99"]),  # FP
        ]
        gold = [
            _gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
            _gold_event(EventClass.MACRO_EVENT, evidence_ids=["e5"]),  # FN
        ]
        result = compute_event_metrics(pred, gold)
        # TP=1, FP=1, FN=1 -> P=1/2, R=1/2, F1=1/2
        assert result.micro.support_predicted == 2
        assert result.micro.support_gold == 2
        assert result.micro.precision == 0.5
        assert result.micro.recall == 0.5
        assert abs(result.micro.f1 - 0.5) < 1e-9


# ---------------------------------------------------------------------------
# Relation Metrics — Basic
# ---------------------------------------------------------------------------


class TestRelationMetricsBasic:
    def test_both_empty(self) -> None:
        result = compute_relation_metrics([], [])
        assert result.micro.f1 == 1.0
        assert result.macro_f1 == 1.0

    def test_perfect_match(self) -> None:
        pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "comp1")]
        gold = [_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "comp1")]
        result = compute_relation_metrics(pred, gold)
        assert result.micro.f1 == 1.0

    def test_wrong_type_no_match(self) -> None:
        pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "comp1")]
        gold = [_gold_relation(RelationType.INFERRED_EXPOSURE, "ev1", "comp1")]
        result = compute_relation_metrics(pred, gold)
        assert result.micro.precision == 0.0
        assert result.micro.recall == 0.0

    def test_wrong_source_no_match(self) -> None:
        pred = [_pred_relation(RelationType.COMPETES_WITH, "c1", "c2")]
        gold = [_gold_relation(RelationType.COMPETES_WITH, "c3", "c2")]
        result = compute_relation_metrics(pred, gold)
        assert result.micro.precision == 0.0

    def test_wrong_target_no_match(self) -> None:
        pred = [_pred_relation(RelationType.SUPPLIES, "c1", "c2")]
        gold = [_gold_relation(RelationType.SUPPLIES, "c1", "c3")]
        result = compute_relation_metrics(pred, gold)
        assert result.micro.precision == 0.0

    def test_no_predictions(self) -> None:
        gold = [_gold_relation(RelationType.COMPETES_WITH, "c1", "c2")]
        result = compute_relation_metrics([], gold)
        assert result.micro.recall == 0.0

    def test_no_gold(self) -> None:
        pred = [_pred_relation(RelationType.COMPETES_WITH, "c1", "c2")]
        result = compute_relation_metrics(pred, [])
        assert result.micro.precision == 0.0


# ---------------------------------------------------------------------------
# Relation Metrics — Per-Type
# ---------------------------------------------------------------------------


class TestRelationMetricsPerType:
    def test_per_type_breakdown_all_4_types(self) -> None:
        """Result always contains all 4 relation types."""
        result = compute_relation_metrics([], [])
        assert len(result.per_type) == 4
        for rt in RelationType:
            assert rt.value in result.per_type

    def test_per_type_mixed(self) -> None:
        pred = [
            _pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
            _pred_relation(RelationType.COMPETES_WITH, "c1", "c2"),
            _pred_relation(RelationType.COMPETES_WITH, "c3", "c4"),  # FP
        ]
        gold = [
            _gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
            _gold_relation(RelationType.COMPETES_WITH, "c1", "c2"),
            _gold_relation(RelationType.SUPPLIES, "c5", "c6"),  # FN
        ]
        result = compute_relation_metrics(pred, gold)
        assert result.per_type["directly_affects"].f1 == 1.0
        assert result.per_type["competes_with"].precision == 0.5
        assert result.per_type["competes_with"].recall == 1.0
        assert result.per_type["supplies"].recall == 0.0


# ---------------------------------------------------------------------------
# Relation Metrics — Macro vs Micro
# ---------------------------------------------------------------------------


class TestRelationMetricsMacroMicro:
    def test_macro_averages_across_types(self) -> None:
        pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
        gold = [_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
        result = compute_relation_metrics(pred, gold)
        # directly_affects: F1=1.0, other 3: F1=1.0 (empty)
        assert result.macro_f1 == 1.0

    def test_macro_penalizes_missing_type(self) -> None:
        pred = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
        gold = [
            _gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
            _gold_relation(RelationType.SUPPLIES, "c5", "c6"),
        ]
        result = compute_relation_metrics(pred, gold)
        # directly_affects: F1=1.0, supplies: recall=0 -> F1=0, other 2: F1=1.0
        # macro = (1.0 + 0.0 + 1.0 + 1.0) / 4 = 3/4
        assert abs(result.macro_f1 - 0.75) < 1e-9

    def test_micro_aggregates(self) -> None:
        pred = [
            _pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),  # TP
            _pred_relation(RelationType.COMPETES_WITH, "c1", "c99"),  # FP
        ]
        gold = [
            _gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1"),
            _gold_relation(RelationType.INFERRED_EXPOSURE, "ev2", "c3"),  # FN
        ]
        result = compute_relation_metrics(pred, gold)
        # TP=1, FP=1, FN=1 -> P=1/2, R=1/2, F1=1/2
        assert result.micro.precision == 0.5
        assert result.micro.recall == 0.5
        assert abs(result.micro.f1 - 0.5) < 1e-9


# ---------------------------------------------------------------------------
# Combined Evaluation Report
# ---------------------------------------------------------------------------


class TestEvaluateEventsAndRelations:
    def test_full_report(self) -> None:
        pred_events = [_pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        gold_events = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        pred_relations = [_pred_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]
        gold_relations = [_gold_relation(RelationType.DIRECTLY_AFFECTS, "ev1", "c1")]

        report = evaluate_events_and_relations(
            pred_events, gold_events, pred_relations, gold_relations,
            document_count=5,
        )

        assert isinstance(report, EventRelationEvaluationReport)
        assert isinstance(report.event_metrics, EventMetricsResult)
        assert isinstance(report.relation_metrics, RelationMetricsResult)
        assert report.document_count == 5
        assert report.event_metrics.micro.f1 == 1.0
        assert report.relation_metrics.micro.f1 == 1.0

    def test_report_with_failures(self) -> None:
        pred_events = [
            _pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
            _pred_event(EventClass.SUPPLY_CHAIN, evidence_ids=["e99"]),
        ]
        gold_events = [
            _gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
            _gold_event(EventClass.MACRO_EVENT, primary_company_ids=["c7"]),
        ]
        pred_relations = []
        gold_relations = [_gold_relation(RelationType.SUPPLIES, "c1", "c2")]

        report = evaluate_events_and_relations(
            pred_events, gold_events, pred_relations, gold_relations,
            document_count=2,
        )

        assert report.event_metrics.micro.precision == 0.5
        assert report.event_metrics.micro.recall == 0.5
        assert report.relation_metrics.micro.recall == 0.0
        assert report.document_count == 2


# ---------------------------------------------------------------------------
# Edge Cases
# ---------------------------------------------------------------------------


class TestEdgeCases:
    def test_event_match_requires_both_class_and_overlap(self) -> None:
        """Same class but completely empty evidence and companies — no match."""
        pred = [_pred_event(EventClass.BUYBACK)]
        gold = [_gold_event(EventClass.BUYBACK)]
        result = compute_event_metrics(pred, gold)
        # No evidence or companies to overlap -> no match
        assert result.per_class["buyback"].precision == 0.0

    def test_multiple_events_greedy_matching(self) -> None:
        """Greedy matching: first match consumes the gold item."""
        pred = [
            _pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
            _pred_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"]),
        ]
        gold = [_gold_event(EventClass.EARNINGS_BEAT, evidence_ids=["e1"])]
        result = compute_event_metrics(pred, gold)
        # 1 TP, 1 FP -> precision = 0.5, recall = 1.0
        assert result.per_class["earnings_beat"].precision == 0.5
        assert result.per_class["earnings_beat"].recall == 1.0

    def test_relation_duplicates(self) -> None:
        """Duplicate predictions can only match once."""
        pred = [
            _pred_relation(RelationType.COMPETES_WITH, "c1", "c2"),
            _pred_relation(RelationType.COMPETES_WITH, "c1", "c2"),
        ]
        gold = [_gold_relation(RelationType.COMPETES_WITH, "c1", "c2")]
        result = compute_relation_metrics(pred, gold)
        assert result.per_type["competes_with"].precision == 0.5
        assert result.per_type["competes_with"].recall == 1.0

    def test_event_confidence_does_not_affect_matching(self) -> None:
        """Confidence is stored but doesn't affect match logic."""
        pred = [_pred_event(EventClass.DIVIDEND_CHANGE, evidence_ids=["e1"], confidence=0.1)]
        gold = [_gold_event(EventClass.DIVIDEND_CHANGE, evidence_ids=["e1"])]
        result = compute_event_metrics(pred, gold)
        assert result.per_class["dividend_change"].f1 == 1.0
