"""Unit tests for sentiment macro-F1, micro-F1, direction accuracy, and calibration metrics.

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

from services.intelligence_pipeline_v3.evaluation.sentiment_metrics import (
    CalibrationResult,
    DirectionAccuracyResult,
    SentimentEvaluationReport,
    SentimentF1Result,
    SentimentLabel,
    SentimentPrediction,
    compute_calibration,
    compute_direction_accuracy,
    compute_sentiment_f1,
    evaluate_sentiment,
)

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


def _pred(
    company_entity_id: str,
    label: str,
    pos: float = 0.0,
    neg: float = 0.0,
    neu: float = 0.0,
    mix: float = 0.0,
) -> SentimentPrediction:
    return SentimentPrediction(
        company_entity_id=company_entity_id,
        label=SentimentLabel(label),
        positive_prob=pos,
        negative_prob=neg,
        neutral_prob=neu,
        mixed_prob=mix,
    )


# ---------------------------------------------------------------------------
# Sentiment F1 Metrics
# ---------------------------------------------------------------------------


class TestSentimentF1:
    def test_perfect_match(self) -> None:
        gold = [
            _pred("c1", "positive", pos=0.9, neg=0.05, neu=0.05),
            _pred("c2", "negative", pos=0.1, neg=0.8, neu=0.1),
            _pred("c3", "neutral", pos=0.1, neg=0.1, neu=0.8),
        ]
        pred = [
            _pred("c1", "positive", pos=0.85, neg=0.1, neu=0.05),
            _pred("c2", "negative", pos=0.05, neg=0.9, neu=0.05),
            _pred("c3", "neutral", pos=0.05, neg=0.05, neu=0.9),
        ]
        result = compute_sentiment_f1(pred, gold)
        assert result.macro_f1 == 1.0
        assert result.micro_f1 == 1.0
        assert result.support == 3

    def test_all_wrong(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
            _pred("c3", "neutral"),
        ]
        pred = [
            _pred("c1", "negative"),
            _pred("c2", "neutral"),
            _pred("c3", "positive"),
        ]
        result = compute_sentiment_f1(pred, gold)
        assert result.macro_f1 == 0.0
        assert result.micro_f1 == 0.0

    def test_partial_match(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "positive"),
            _pred("c3", "negative"),
            _pred("c4", "neutral"),
        ]
        pred = [
            _pred("c1", "positive"),  # correct
            _pred("c2", "negative"),  # wrong
            _pred("c3", "negative"),  # correct
            _pred("c4", "neutral"),   # correct
        ]
        result = compute_sentiment_f1(pred, gold)
        # micro: overall accuracy across all label comparisons
        # TP: c1=pos correct, c3=neg correct, c4=neu correct = 3
        # Total predictions that match across all labels = 3
        assert result.micro_f1 == 0.75
        assert result.support == 4

    def test_unmatched_predictions_ignored(self) -> None:
        gold = [_pred("c1", "positive")]
        pred = [
            _pred("c1", "positive"),
            _pred("c_unknown", "negative"),  # no match in gold
        ]
        result = compute_sentiment_f1(pred, gold)
        assert result.macro_f1 == 1.0
        assert result.support == 1

    def test_empty_inputs(self) -> None:
        result = compute_sentiment_f1([], [])
        assert result.macro_f1 == 1.0
        assert result.micro_f1 == 1.0
        assert result.support == 0

    def test_per_label_breakdown(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "positive"),
            _pred("c3", "negative"),
        ]
        pred = [
            _pred("c1", "positive"),  # TP for positive
            _pred("c2", "neutral"),   # FN for positive, FP for neutral
            _pred("c3", "negative"),  # TP for negative
        ]
        result = compute_sentiment_f1(pred, gold)

        # Positive: TP=1, FP=0, FN=1 -> P=1.0, R=0.5, F1=2/3
        assert result.per_label["positive"].precision == 1.0
        assert result.per_label["positive"].recall == 0.5
        assert abs(result.per_label["positive"].f1 - 2 / 3) < 1e-9

        # Negative: TP=1, FP=0, FN=0 -> P=1.0, R=1.0, F1=1.0
        assert result.per_label["negative"].f1 == 1.0

        # Neutral: TP=0, FP=1, FN=0 -> P=0.0, R=1.0, F1=0.0
        assert result.per_label["neutral"].precision == 0.0
        assert result.per_label["neutral"].recall == 1.0
        assert result.per_label["neutral"].f1 == 0.0

    def test_mixed_label_support(self) -> None:
        gold = [_pred("c1", "mixed")]
        pred = [_pred("c1", "mixed", mix=0.7)]
        result = compute_sentiment_f1(pred, gold)
        assert result.per_label["mixed"].f1 == 1.0
        assert result.per_label["mixed"].support_gold == 1


# ---------------------------------------------------------------------------
# Direction Accuracy
# ---------------------------------------------------------------------------


class TestDirectionAccuracy:
    def test_all_correct(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
        ]
        pred = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
        ]
        result = compute_direction_accuracy(pred, gold)
        assert result.accuracy == 1.0
        assert result.correct == 2
        assert result.total == 2

    def test_all_wrong(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
        ]
        pred = [
            _pred("c1", "negative"),
            _pred("c2", "positive"),
        ]
        result = compute_direction_accuracy(pred, gold)
        assert result.accuracy == 0.0
        assert result.correct == 0
        assert result.total == 2

    def test_neutral_ignored(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "neutral"),
            _pred("c3", "negative"),
        ]
        pred = [
            _pred("c1", "positive"),
            _pred("c2", "positive"),  # gold is neutral, ignored
            _pred("c3", "negative"),
        ]
        result = compute_direction_accuracy(pred, gold)
        assert result.accuracy == 1.0
        assert result.total == 2  # c2 excluded

    def test_mixed_ignored(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "mixed"),
        ]
        pred = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),  # gold is mixed, ignored
        ]
        result = compute_direction_accuracy(pred, gold)
        assert result.accuracy == 1.0
        assert result.total == 1

    def test_pred_neutral_ignored(self) -> None:
        """If predicted is neutral but gold is positive, pair is excluded."""
        gold = [_pred("c1", "positive")]
        pred = [_pred("c1", "neutral")]
        result = compute_direction_accuracy(pred, gold)
        assert result.total == 0
        assert result.accuracy == 1.0  # vacuously true

    def test_empty_inputs(self) -> None:
        result = compute_direction_accuracy([], [])
        assert result.accuracy == 1.0
        assert result.total == 0

    def test_unmatched_ignored(self) -> None:
        gold = [_pred("c1", "positive")]
        pred = [_pred("c_other", "negative")]
        result = compute_direction_accuracy(pred, gold)
        assert result.total == 0


# ---------------------------------------------------------------------------
# Calibration Metrics
# ---------------------------------------------------------------------------


class TestCalibration:
    def test_perfect_calibration(self) -> None:
        """When confidence exactly matches accuracy, ECE should be 0."""
        # All predictions are correct with confidence 1.0
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
        ]
        pred = [
            _pred("c1", "positive", pos=1.0, neg=0.0, neu=0.0),
            _pred("c2", "negative", pos=0.0, neg=1.0, neu=0.0),
        ]
        result = compute_calibration(pred, gold, n_bins=10)
        assert result.ece == 0.0
        assert result.n_samples == 2

    def test_brier_score_perfect(self) -> None:
        """Perfect predictions should have Brier score of 0."""
        gold = [_pred("c1", "positive")]
        pred = [_pred("c1", "positive", pos=1.0, neg=0.0, neu=0.0, mix=0.0)]
        result = compute_calibration(pred, gold, n_bins=10)
        assert result.brier_score == 0.0

    def test_brier_score_worst_case(self) -> None:
        """Completely wrong confidence should have high Brier score."""
        gold = [_pred("c1", "positive")]
        # Predicted negative with full confidence, gold is positive
        pred = [_pred("c1", "negative", pos=0.0, neg=1.0, neu=0.0, mix=0.0)]
        result = compute_calibration(pred, gold, n_bins=10)
        # Brier: (0-1)^2 + (1-0)^2 + (0-0)^2 + (0-0)^2 = 2.0
        assert abs(result.brier_score - 2.0) < 1e-9

    def test_brier_score_uniform_probs(self) -> None:
        """Uniform probabilities across 4 labels."""
        gold = [_pred("c1", "positive")]
        pred = [_pred("c1", "positive", pos=0.25, neg=0.25, neu=0.25, mix=0.25)]
        result = compute_calibration(pred, gold, n_bins=10)
        # Brier: (0.25-1)^2 + (0.25-0)^2 + (0.25-0)^2 + (0.25-0)^2
        # = 0.5625 + 0.0625 + 0.0625 + 0.0625 = 0.75
        assert abs(result.brier_score - 0.75) < 1e-9

    def test_ece_with_overconfidence(self) -> None:
        """High confidence but wrong predictions -> high ECE."""
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "positive"),
        ]
        pred = [
            # Predicts negative with 0.9 confidence, wrong
            _pred("c1", "negative", pos=0.05, neg=0.9, neu=0.05, mix=0.0),
            # Predicts negative with 0.9 confidence, wrong
            _pred("c2", "negative", pos=0.05, neg=0.9, neu=0.05, mix=0.0),
        ]
        result = compute_calibration(pred, gold, n_bins=10)
        # Both have confidence 0.9, both wrong -> fraction_positive=0.0
        # ECE = |0.9 - 0.0| = 0.9
        assert abs(result.ece - 0.9) < 1e-9

    def test_reliability_bins_structure(self) -> None:
        """Reliability bins should cover [0, 1] range."""
        gold = [_pred("c1", "positive")]
        pred = [_pred("c1", "positive", pos=0.7, neg=0.2, neu=0.1)]
        result = compute_calibration(pred, gold, n_bins=5)
        assert len(result.reliability_bins) == 5
        assert result.reliability_bins[0].bin_lower == 0.0
        assert result.reliability_bins[-1].bin_upper == 1.0

    def test_empty_inputs(self) -> None:
        result = compute_calibration([], [], n_bins=10)
        assert result.ece == 0.0
        assert result.brier_score == 0.0
        assert result.n_samples == 0
        assert result.reliability_bins == []

    def test_unmatched_predictions_ignored(self) -> None:
        gold = [_pred("c1", "positive")]
        pred = [_pred("c_other", "positive", pos=0.9)]
        result = compute_calibration(pred, gold, n_bins=10)
        assert result.n_samples == 0

    def test_single_bin(self) -> None:
        """Single bin should contain all samples."""
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
        ]
        pred = [
            _pred("c1", "positive", pos=0.8, neg=0.1, neu=0.1),
            _pred("c2", "negative", pos=0.1, neg=0.7, neu=0.2),
        ]
        result = compute_calibration(pred, gold, n_bins=1)
        assert len(result.reliability_bins) == 1
        assert result.reliability_bins[0].count == 2

    def test_calibration_bins_count_sum(self) -> None:
        """Total count across bins should equal n_samples."""
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
            _pred("c3", "neutral"),
        ]
        pred = [
            _pred("c1", "positive", pos=0.7, neg=0.2, neu=0.1),
            _pred("c2", "negative", pos=0.1, neg=0.6, neu=0.3),
            _pred("c3", "neutral", pos=0.1, neg=0.1, neu=0.8),
        ]
        result = compute_calibration(pred, gold, n_bins=10)
        total_count = sum(b.count for b in result.reliability_bins)
        assert total_count == result.n_samples


# ---------------------------------------------------------------------------
# Full Evaluation Report
# ---------------------------------------------------------------------------


class TestEvaluateSentiment:
    def test_full_evaluation(self) -> None:
        gold = [
            _pred("c1", "positive", pos=0.9, neg=0.05, neu=0.05),
            _pred("c2", "negative", pos=0.1, neg=0.8, neu=0.1),
            _pred("c3", "neutral", pos=0.1, neg=0.1, neu=0.8),
        ]
        pred = [
            _pred("c1", "positive", pos=0.85, neg=0.1, neu=0.05),
            _pred("c2", "negative", pos=0.05, neg=0.9, neu=0.05),
            _pred("c3", "neutral", pos=0.05, neg=0.05, neu=0.9),
        ]
        report = evaluate_sentiment(pred, gold, n_bins=10, document_count=3)

        assert isinstance(report, SentimentEvaluationReport)
        assert isinstance(report.f1_metrics, SentimentF1Result)
        assert isinstance(report.direction_accuracy, DirectionAccuracyResult)
        assert isinstance(report.calibration, CalibrationResult)
        assert report.document_count == 3
        assert report.f1_metrics.macro_f1 == 1.0
        assert report.direction_accuracy.accuracy == 1.0

    def test_report_with_errors(self) -> None:
        gold = [
            _pred("c1", "positive"),
            _pred("c2", "negative"),
        ]
        pred = [
            _pred("c1", "negative", pos=0.1, neg=0.8, neu=0.1),
            _pred("c2", "negative", pos=0.1, neg=0.8, neu=0.1),
        ]
        report = evaluate_sentiment(pred, gold, document_count=2)

        # c1 wrong direction, c2 correct
        assert report.direction_accuracy.accuracy == 0.5
        assert report.direction_accuracy.total == 2
        assert report.f1_metrics.support == 2


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


class TestEdgeCases:
    def test_duplicate_company_ids_uses_last_gold(self) -> None:
        """When gold has duplicate IDs, dict lookup uses last occurrence."""
        gold = [
            _pred("c1", "positive"),
            _pred("c1", "negative"),  # overwrites first
        ]
        pred = [_pred("c1", "negative")]
        result = compute_sentiment_f1(pred, gold)
        # Gold dict will have c1 -> negative (last wins)
        assert result.per_label["negative"].f1 == 1.0

    def test_all_same_label(self) -> None:
        """All predictions and gold are the same label."""
        gold = [_pred(f"c{i}", "positive") for i in range(5)]
        pred = [_pred(f"c{i}", "positive", pos=0.9) for i in range(5)]
        result = compute_sentiment_f1(pred, gold)
        assert result.per_label["positive"].f1 == 1.0
        assert result.macro_f1 == 1.0

    def test_calibration_boundary_confidence(self) -> None:
        """Confidence of exactly 1.0 should be in the last bin."""
        gold = [_pred("c1", "positive")]
        pred = [_pred("c1", "positive", pos=1.0, neg=0.0, neu=0.0, mix=0.0)]
        result = compute_calibration(pred, gold, n_bins=10)
        # Last bin [0.9, 1.0] should have count 1
        assert result.reliability_bins[-1].count == 1

    def test_calibration_zero_confidence(self) -> None:
        """Confidence of 0.0 should be in the first bin."""
        gold = [_pred("c1", "positive")]
        # Label is positive but prob is 0.0 (inconsistent but valid input)
        pred = [_pred("c1", "positive", pos=0.0, neg=0.0, neu=0.0, mix=0.0)]
        result = compute_calibration(pred, gold, n_bins=10)
        # First bin [0.0, 0.1) should have count 1
        assert result.reliability_bins[0].count == 1
