"""Unit tests for numeric exact/tolerance-aware matching metrics.

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

from services.intelligence_pipeline_v3.evaluation.numeric_metrics import (
    DEFAULT_TOLERANCE_PCT,
    AccuracyMetric,
    ErrorCategory,
    NumericEvaluationReport,
    NumericFact,
    ToleranceDistribution,
    evaluate_numeric_facts,
    match_numeric_fact,
)

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


def _fact(
    fact_type: str = "eps",
    predicate: str = "actual",
    literal_value: str = "$1.25",
    normalized_value: float | None = 1.25,
    unit: str | None = "USD",
    period: str | None = "Q1 2024",
) -> NumericFact:
    return NumericFact(
        fact_type=fact_type,
        predicate=predicate,
        literal_value=literal_value,
        normalized_value=normalized_value,
        unit=unit,
        period=period,
    )


# ---------------------------------------------------------------------------
# Single Fact Matching - Exact Match
# ---------------------------------------------------------------------------


class TestExactMatch:
    def test_identical_values(self) -> None:
        pred = _fact(normalized_value=1.25)
        gold = _fact(normalized_value=1.25)
        result = match_numeric_fact(pred, gold)
        assert result.exact_match is True
        assert result.within_tolerance is True

    def test_different_values(self) -> None:
        pred = _fact(normalized_value=1.30)
        gold = _fact(normalized_value=1.25)
        result = match_numeric_fact(pred, gold)
        assert result.exact_match is False

    def test_zero_values(self) -> None:
        pred = _fact(normalized_value=0.0)
        gold = _fact(normalized_value=0.0)
        result = match_numeric_fact(pred, gold)
        assert result.exact_match is True

    def test_negative_values(self) -> None:
        pred = _fact(normalized_value=-0.50)
        gold = _fact(normalized_value=-0.50)
        result = match_numeric_fact(pred, gold)
        assert result.exact_match is True

    def test_float_precision(self) -> None:
        """Values that differ only by float rounding should be exact."""
        pred = _fact(normalized_value=0.1 + 0.2)
        gold = _fact(normalized_value=0.3)
        result = match_numeric_fact(pred, gold)
        # 0.1 + 0.2 is ~0.30000000000000004, within 1e-9 of 0.3
        assert result.exact_match is True


# ---------------------------------------------------------------------------
# Single Fact Matching - Tolerance
# ---------------------------------------------------------------------------


class TestToleranceMatch:
    def test_within_5pct_default(self) -> None:
        # 5% of 100 = 5, so 104 is within tolerance
        pred = _fact(normalized_value=104.0)
        gold = _fact(normalized_value=100.0)
        result = match_numeric_fact(pred, gold)
        assert result.within_tolerance is True
        assert result.exact_match is False

    def test_exactly_at_5pct_boundary(self) -> None:
        # 5% of 100 = 5, so 105 is exactly at the boundary (inclusive)
        pred = _fact(normalized_value=105.0)
        gold = _fact(normalized_value=100.0)
        result = match_numeric_fact(pred, gold)
        assert result.within_tolerance is True

    def test_beyond_5pct(self) -> None:
        # 5% of 100 = 5, so 105.01 is beyond
        pred = _fact(normalized_value=105.01)
        gold = _fact(normalized_value=100.0)
        result = match_numeric_fact(pred, gold)
        assert result.within_tolerance is False

    def test_negative_tolerance(self) -> None:
        # 5% of 100 = 5, so 95 is within tolerance (below)
        pred = _fact(normalized_value=95.0)
        gold = _fact(normalized_value=100.0)
        result = match_numeric_fact(pred, gold)
        assert result.within_tolerance is True

    def test_custom_tolerance_1pct(self) -> None:
        pred = _fact(normalized_value=101.0)
        gold = _fact(normalized_value=100.0)
        result = match_numeric_fact(pred, gold, tolerance_pct=1.0)
        assert result.within_tolerance is True
        assert result.tolerance_pct == 1.0

    def test_custom_tolerance_10pct(self) -> None:
        pred = _fact(normalized_value=109.0)
        gold = _fact(normalized_value=100.0)
        result = match_numeric_fact(pred, gold, tolerance_pct=10.0)
        assert result.within_tolerance is True

    def test_zero_gold_value_tolerance(self) -> None:
        """When gold is zero, tolerance uses absolute comparison."""
        pred = _fact(normalized_value=0.01)
        gold = _fact(normalized_value=0.0)
        result = match_numeric_fact(pred, gold, tolerance_pct=5.0)
        # 0.01 < 5/100 = 0.05
        assert result.within_tolerance is True

    def test_zero_gold_value_beyond_tolerance(self) -> None:
        pred = _fact(normalized_value=0.1)
        gold = _fact(normalized_value=0.0)
        result = match_numeric_fact(pred, gold, tolerance_pct=5.0)
        # 0.1 >= 5/100 = 0.05
        assert result.within_tolerance is False


# ---------------------------------------------------------------------------
# Unit Consistency
# ---------------------------------------------------------------------------


class TestUnitConsistency:
    def test_same_units(self) -> None:
        pred = _fact(unit="USD")
        gold = _fact(unit="USD")
        result = match_numeric_fact(pred, gold)
        assert result.unit_consistent is True

    def test_different_units(self) -> None:
        pred = _fact(unit="EUR")
        gold = _fact(unit="USD")
        result = match_numeric_fact(pred, gold)
        assert result.unit_consistent is False

    def test_pred_missing_unit_gold_has_unit(self) -> None:
        pred = _fact(unit=None)
        gold = _fact(unit="USD")
        result = match_numeric_fact(pred, gold)
        assert result.unit_consistent is False

    def test_gold_missing_unit(self) -> None:
        """If gold has no unit, consistency is assumed."""
        pred = _fact(unit="USD")
        gold = _fact(unit=None)
        result = match_numeric_fact(pred, gold)
        assert result.unit_consistent is True

    def test_both_none_units(self) -> None:
        pred = _fact(unit=None)
        gold = _fact(unit=None)
        result = match_numeric_fact(pred, gold)
        assert result.unit_consistent is True


# ---------------------------------------------------------------------------
# Period Match
# ---------------------------------------------------------------------------


class TestPeriodMatch:
    def test_same_period(self) -> None:
        pred = _fact(period="Q1 2024")
        gold = _fact(period="Q1 2024")
        result = match_numeric_fact(pred, gold)
        assert result.period_match is True

    def test_different_period(self) -> None:
        pred = _fact(period="Q2 2024")
        gold = _fact(period="Q1 2024")
        result = match_numeric_fact(pred, gold)
        assert result.period_match is False

    def test_pred_missing_period_gold_has_period(self) -> None:
        pred = _fact(period=None)
        gold = _fact(period="Q1 2024")
        result = match_numeric_fact(pred, gold)
        assert result.period_match is False

    def test_gold_missing_period(self) -> None:
        """If gold has no period, match is assumed."""
        pred = _fact(period="Q1 2024")
        gold = _fact(period=None)
        result = match_numeric_fact(pred, gold)
        assert result.period_match is True

    def test_both_none_periods(self) -> None:
        pred = _fact(period=None)
        gold = _fact(period=None)
        result = match_numeric_fact(pred, gold)
        assert result.period_match is True


# ---------------------------------------------------------------------------
# Error Metrics
# ---------------------------------------------------------------------------


class TestErrorMetrics:
    def test_absolute_error(self) -> None:
        pred = _fact(normalized_value=1.30)
        gold = _fact(normalized_value=1.25)
        result = match_numeric_fact(pred, gold)
        assert result.absolute_error is not None
        assert abs(result.absolute_error - 0.05) < 1e-9

    def test_relative_error(self) -> None:
        pred = _fact(normalized_value=105.0)
        gold = _fact(normalized_value=100.0)
        result = match_numeric_fact(pred, gold)
        assert result.relative_error_pct is not None
        assert abs(result.relative_error_pct - 5.0) < 1e-9

    def test_relative_error_zero_gold(self) -> None:
        pred = _fact(normalized_value=1.0)
        gold = _fact(normalized_value=0.0)
        result = match_numeric_fact(pred, gold)
        assert result.relative_error_pct is None

    def test_none_pred_value(self) -> None:
        pred = _fact(normalized_value=None)
        gold = _fact(normalized_value=1.25)
        result = match_numeric_fact(pred, gold)
        assert result.exact_match is False
        assert result.within_tolerance is False
        assert result.absolute_error is None
        assert result.relative_error_pct is None

    def test_none_gold_value(self) -> None:
        pred = _fact(normalized_value=1.25)
        gold = _fact(normalized_value=None)
        result = match_numeric_fact(pred, gold)
        assert result.exact_match is False
        assert result.within_tolerance is False


# ---------------------------------------------------------------------------
# Batch Evaluation - Overall Accuracy
# ---------------------------------------------------------------------------


class TestBatchEvaluation:
    def test_perfect_match(self) -> None:
        gold = [
            _fact(fact_type="eps", predicate="actual", normalized_value=1.25),
            _fact(fact_type="revenue", predicate="actual", normalized_value=50.0e9),
        ]
        pred = [
            _fact(fact_type="eps", predicate="actual", normalized_value=1.25),
            _fact(fact_type="revenue", predicate="actual", normalized_value=50.0e9),
        ]
        report = evaluate_numeric_facts(pred, gold)
        assert report.exact_match_accuracy.accuracy == 1.0
        assert report.tolerance_accuracy.accuracy == 1.0

    def test_empty_inputs(self) -> None:
        report = evaluate_numeric_facts([], [])
        assert report.exact_match_accuracy.accuracy == 1.0
        assert report.exact_match_accuracy.total == 0
        assert report.tolerance_accuracy.accuracy == 1.0

    def test_no_matches(self) -> None:
        gold = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
        pred = [_fact(fact_type="eps", predicate="actual", normalized_value=2.00)]
        report = evaluate_numeric_facts(pred, gold)
        assert report.exact_match_accuracy.accuracy == 0.0
        assert report.tolerance_accuracy.accuracy == 0.0

    def test_tolerance_only_match(self) -> None:
        gold = [_fact(fact_type="eps", predicate="actual", normalized_value=100.0)]
        pred = [_fact(fact_type="eps", predicate="actual", normalized_value=103.0)]
        report = evaluate_numeric_facts(pred, gold)
        assert report.exact_match_accuracy.accuracy == 0.0
        assert report.tolerance_accuracy.accuracy == 1.0

    def test_unmatched_facts_not_aligned(self) -> None:
        """Facts with different predicates don't align."""
        gold = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
        pred = [_fact(fact_type="eps", predicate="estimate", normalized_value=1.25)]
        report = evaluate_numeric_facts(pred, gold)
        # No pairs aligned
        assert report.exact_match_accuracy.total == 0

    def test_multiple_same_type_predicate(self) -> None:
        """Multiple facts with same type and predicate align one-to-one."""
        gold = [
            _fact(fact_type="eps", predicate="actual", normalized_value=1.25),
            _fact(fact_type="eps", predicate="actual", normalized_value=2.50),
        ]
        pred = [
            _fact(fact_type="eps", predicate="actual", normalized_value=1.25),
            _fact(fact_type="eps", predicate="actual", normalized_value=2.50),
        ]
        report = evaluate_numeric_facts(pred, gold)
        assert report.exact_match_accuracy.matches == 2
        assert report.exact_match_accuracy.total == 2

    def test_document_count(self) -> None:
        report = evaluate_numeric_facts([], [], document_count=5)
        assert report.document_count == 5


# ---------------------------------------------------------------------------
# Per-Type Breakdown
# ---------------------------------------------------------------------------


class TestPerTypeBreakdown:
    def test_single_type(self) -> None:
        gold = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
        pred = [_fact(fact_type="eps", predicate="actual", normalized_value=1.25)]
        report = evaluate_numeric_facts(pred, gold)
        assert "eps" in report.per_type_exact
        assert report.per_type_exact["eps"].accuracy == 1.0

    def test_multiple_types(self) -> None:
        gold = [
            _fact(fact_type="eps", predicate="actual", normalized_value=1.25),
            _fact(fact_type="revenue", predicate="actual", normalized_value=50.0e9),
            _fact(fact_type="price_target", predicate="consensus", normalized_value=180.0),
        ]
        pred = [
            _fact(fact_type="eps", predicate="actual", normalized_value=1.25),
            _fact(fact_type="revenue", predicate="actual", normalized_value=51.0e9),
            _fact(fact_type="price_target", predicate="consensus", normalized_value=200.0),
        ]
        report = evaluate_numeric_facts(pred, gold)
        assert report.per_type_exact["eps"].accuracy == 1.0
        assert report.per_type_exact["revenue"].accuracy == 0.0
        # Revenue: 51e9 vs 50e9 = 2% off, within 5% tolerance
        assert report.per_type_tolerance["revenue"].accuracy == 1.0
        # Price target: 200 vs 180 = 11.1% off, beyond 5%
        assert report.per_type_tolerance["price_target"].accuracy == 0.0

    def test_custom_tolerance_per_type(self) -> None:
        gold = [
            _fact(fact_type="guidance", predicate="low", normalized_value=5.0),
        ]
        pred = [
            _fact(fact_type="guidance", predicate="low", normalized_value=5.4),
        ]
        # 5.4 vs 5.0 = 8%, within 10% but not 5%
        report_5 = evaluate_numeric_facts(pred, gold, tolerance_pct=5.0)
        report_10 = evaluate_numeric_facts(pred, gold, tolerance_pct=10.0)
        assert report_5.per_type_tolerance["guidance"].accuracy == 0.0
        assert report_10.per_type_tolerance["guidance"].accuracy == 1.0


# ---------------------------------------------------------------------------
# Unit Consistency Report
# ---------------------------------------------------------------------------


class TestUnitConsistencyReport:
    def test_all_consistent(self) -> None:
        gold = [
            _fact(unit="USD", normalized_value=1.0),
            _fact(fact_type="revenue", predicate="actual", unit="USD", normalized_value=50.0),
        ]
        pred = [
            _fact(unit="USD", normalized_value=1.0),
            _fact(fact_type="revenue", predicate="actual", unit="USD", normalized_value=50.0),
        ]
        report = evaluate_numeric_facts(pred, gold)
        assert report.unit_consistency.accuracy == 1.0

    def test_mixed_consistency(self) -> None:
        gold = [
            _fact(unit="USD", normalized_value=1.0),
            _fact(fact_type="revenue", predicate="actual", unit="USD", normalized_value=50.0),
        ]
        pred = [
            _fact(unit="USD", normalized_value=1.0),
            _fact(fact_type="revenue", predicate="actual", unit="EUR", normalized_value=50.0),
        ]
        report = evaluate_numeric_facts(pred, gold)
        assert report.unit_consistency.accuracy == 0.5
        assert report.unit_consistency.matches == 1
        assert report.unit_consistency.total == 2


# ---------------------------------------------------------------------------
# Period Match Report
# ---------------------------------------------------------------------------


class TestPeriodMatchReport:
    def test_all_periods_match(self) -> None:
        gold = [_fact(period="Q1 2024", normalized_value=1.0)]
        pred = [_fact(period="Q1 2024", normalized_value=1.0)]
        report = evaluate_numeric_facts(pred, gold)
        assert report.period_match.accuracy == 1.0

    def test_period_mismatch(self) -> None:
        gold = [_fact(period="Q1 2024", normalized_value=1.0)]
        pred = [_fact(period="FY 2024", normalized_value=1.0)]
        report = evaluate_numeric_facts(pred, gold)
        assert report.period_match.accuracy == 0.0


# ---------------------------------------------------------------------------
# Tolerance Distribution
# ---------------------------------------------------------------------------


class TestToleranceDistribution:
    def test_exact_bucket(self) -> None:
        gold = [_fact(normalized_value=1.0)]
        pred = [_fact(normalized_value=1.0)]
        report = evaluate_numeric_facts(pred, gold)
        assert report.tolerance_distribution.exact == 1

    def test_within_1pct_bucket(self) -> None:
        gold = [_fact(normalized_value=100.0)]
        pred = [_fact(normalized_value=100.5)]  # 0.5% off
        report = evaluate_numeric_facts(pred, gold)
        assert report.tolerance_distribution.within_1pct == 1

    def test_within_5pct_bucket(self) -> None:
        gold = [_fact(normalized_value=100.0)]
        pred = [_fact(normalized_value=103.0)]  # 3% off
        report = evaluate_numeric_facts(pred, gold)
        assert report.tolerance_distribution.within_5pct == 1

    def test_within_10pct_bucket(self) -> None:
        gold = [_fact(normalized_value=100.0)]
        pred = [_fact(normalized_value=108.0)]  # 8% off
        report = evaluate_numeric_facts(pred, gold)
        assert report.tolerance_distribution.within_10pct == 1

    def test_beyond_10pct_bucket(self) -> None:
        gold = [_fact(normalized_value=100.0)]
        pred = [_fact(normalized_value=115.0)]  # 15% off
        report = evaluate_numeric_facts(pred, gold)
        assert report.tolerance_distribution.beyond_10pct == 1

    def test_not_comparable(self) -> None:
        gold = [_fact(normalized_value=None)]
        pred = [_fact(normalized_value=1.0)]
        report = evaluate_numeric_facts(pred, gold)
        assert report.tolerance_distribution.not_comparable == 1


# ---------------------------------------------------------------------------
# Error Breakdown
# ---------------------------------------------------------------------------


class TestErrorBreakdown:
    def test_sign_error(self) -> None:
        gold = [_fact(normalized_value=1.0)]
        pred = [_fact(normalized_value=-1.0)]
        report = evaluate_numeric_facts(pred, gold)
        assert ErrorCategory.sign_error.value in report.error_breakdown.counts
        assert report.error_breakdown.total_errors >= 1

    def test_magnitude_error(self) -> None:
        gold = [_fact(normalized_value=1.0)]
        pred = [_fact(normalized_value=100.0)]  # 100x off
        report = evaluate_numeric_facts(pred, gold)
        assert ErrorCategory.magnitude_error.value in report.error_breakdown.counts

    def test_parsing_failure(self) -> None:
        gold = [_fact(normalized_value=1.0)]
        pred = [_fact(normalized_value=None)]
        report = evaluate_numeric_facts(pred, gold)
        assert ErrorCategory.parsing_failure.value in report.error_breakdown.counts

    def test_no_errors_on_exact_match(self) -> None:
        gold = [_fact(normalized_value=1.0)]
        pred = [_fact(normalized_value=1.0)]
        report = evaluate_numeric_facts(pred, gold)
        assert report.error_breakdown.total_errors == 0


# ---------------------------------------------------------------------------
# Report Model Validation
# ---------------------------------------------------------------------------


class TestReportModel:
    def test_report_fields(self) -> None:
        report = evaluate_numeric_facts([], [], tolerance_pct=7.5, document_count=3)
        assert isinstance(report, NumericEvaluationReport)
        assert report.tolerance_pct_used == 7.5
        assert report.document_count == 3
        assert isinstance(report.tolerance_distribution, ToleranceDistribution)
        assert isinstance(report.exact_match_accuracy, AccuracyMetric)

    def test_default_tolerance(self) -> None:
        report = evaluate_numeric_facts([], [])
        assert report.tolerance_pct_used == DEFAULT_TOLERANCE_PCT
