"""Numeric exact/tolerance-aware matching metrics for extracted financial facts.

Implements evaluation metrics for numeric extraction quality against a gold
standard corpus. Supports exact match, default 5% tolerance, and configurable
tolerance matching. Provides per-fact-type breakdowns, unit consistency
checks, and period matching.

Input model fields: fact_type, predicate, literal_value, normalized_value,
unit, period, evidence_ids.

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

from enum import Enum

from pydantic import BaseModel, Field

# ---------------------------------------------------------------------------
# Domain Models
# ---------------------------------------------------------------------------


class FactType(str, Enum):
    """Known financial fact types for per-type breakdown."""

    eps = "eps"
    revenue = "revenue"
    percentage_change = "percentage_change"
    price_target = "price_target"
    guidance = "guidance"
    dividend = "dividend"
    margin = "margin"
    growth_rate = "growth_rate"
    other = "other"


class NumericFact(BaseModel):
    """A single extracted numeric fact with normalization and context."""

    fact_type: str
    predicate: str
    literal_value: str
    normalized_value: float | None = None
    unit: str | None = None
    period: str | None = None
    evidence_ids: list[str] = Field(default_factory=list)
    document_id: str = ""


# ---------------------------------------------------------------------------
# Result Models
# ---------------------------------------------------------------------------


class NumericMatchResult(BaseModel):
    """Result of matching a single predicted fact against gold."""

    exact_match: bool = False
    within_tolerance: bool = False
    tolerance_pct: float = 0.0
    unit_consistent: bool = True
    period_match: bool = True
    absolute_error: float | None = None
    relative_error_pct: float | None = None


class AccuracyMetric(BaseModel):
    """Simple accuracy metric with support count."""

    accuracy: float = Field(ge=0.0, le=1.0)
    matches: int = Field(ge=0)
    total: int = Field(ge=0)


class ToleranceDistribution(BaseModel):
    """Distribution of relative errors across tolerance buckets."""

    exact: int = Field(ge=0, default=0)
    within_1pct: int = Field(ge=0, default=0)
    within_5pct: int = Field(ge=0, default=0)
    within_10pct: int = Field(ge=0, default=0)
    beyond_10pct: int = Field(ge=0, default=0)
    not_comparable: int = Field(ge=0, default=0)


class ErrorCategory(str, Enum):
    """Common numeric extraction error categories."""

    unit_mismatch = "unit_mismatch"
    period_mismatch = "period_mismatch"
    magnitude_error = "magnitude_error"
    sign_error = "sign_error"
    parsing_failure = "parsing_failure"
    missing_value = "missing_value"


class ErrorBreakdown(BaseModel):
    """Counts of errors by category."""

    counts: dict[str, int] = Field(default_factory=dict)
    total_errors: int = Field(ge=0, default=0)


class NumericEvaluationReport(BaseModel):
    """Complete numeric extraction evaluation report."""

    exact_match_accuracy: AccuracyMetric
    tolerance_accuracy: AccuracyMetric
    tolerance_pct_used: float = Field(ge=0.0)
    per_type_exact: dict[str, AccuracyMetric] = Field(default_factory=dict)
    per_type_tolerance: dict[str, AccuracyMetric] = Field(default_factory=dict)
    unit_consistency: AccuracyMetric
    period_match: AccuracyMetric
    tolerance_distribution: ToleranceDistribution
    error_breakdown: ErrorBreakdown
    document_count: int = Field(ge=0, default=0)


# ---------------------------------------------------------------------------
# Matching Logic
# ---------------------------------------------------------------------------

DEFAULT_TOLERANCE_PCT = 5.0


def _is_exact_match(pred_value: float, gold_value: float) -> bool:
    """Check if predicted value exactly equals gold value (within float epsilon)."""
    return abs(pred_value - gold_value) < 1e-9


def _is_within_tolerance(
    pred_value: float, gold_value: float, tolerance_pct: float
) -> bool:
    """Check if predicted value is within ±tolerance_pct of gold value.

    For zero gold values, uses absolute comparison with a small epsilon
    derived from the tolerance percentage.
    """
    if abs(gold_value) < 1e-12:
        # For zero gold, allow small absolute tolerance
        return abs(pred_value) < tolerance_pct / 100.0
    threshold = abs(gold_value) * (tolerance_pct / 100.0)
    return abs(pred_value - gold_value) <= threshold


def _compute_relative_error_pct(pred_value: float, gold_value: float) -> float | None:
    """Compute relative error as a percentage of gold value.

    Returns None if gold value is zero (relative error undefined).
    """
    if abs(gold_value) < 1e-12:
        return None
    return abs(pred_value - gold_value) / abs(gold_value) * 100.0


def _classify_error(
    pred: NumericFact, gold: NumericFact, pred_value: float | None, gold_value: float
) -> str | None:
    """Classify the type of error for a mismatched prediction."""
    if pred_value is None:
        if pred.normalized_value is None:
            return ErrorCategory.parsing_failure.value
        return ErrorCategory.missing_value.value

    # Check sign error (opposite signs, both non-zero)
    if pred_value * gold_value < 0 and abs(pred_value) > 1e-9 and abs(gold_value) > 1e-9:
        return ErrorCategory.sign_error.value

    # Check magnitude error (off by factor of 10+)
    if abs(gold_value) > 1e-9:
        ratio = abs(pred_value / gold_value)
        if ratio >= 10.0 or ratio <= 0.1:
            return ErrorCategory.magnitude_error.value

    # Unit mismatch (if units don't match)
    if pred.unit and gold.unit and pred.unit != gold.unit:
        return ErrorCategory.unit_mismatch.value

    # Period mismatch
    if pred.period and gold.period and pred.period != gold.period:
        return ErrorCategory.period_mismatch.value

    return None


def _bucket_relative_error(relative_error_pct: float | None) -> str:
    """Assign a relative error to a tolerance bucket name."""
    if relative_error_pct is None:
        return "not_comparable"
    if relative_error_pct < 1e-7:
        return "exact"
    if relative_error_pct <= 1.0:
        return "within_1pct"
    if relative_error_pct <= 5.0:
        return "within_5pct"
    if relative_error_pct <= 10.0:
        return "within_10pct"
    return "beyond_10pct"


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


def match_numeric_fact(
    pred: NumericFact,
    gold: NumericFact,
    tolerance_pct: float = DEFAULT_TOLERANCE_PCT,
) -> NumericMatchResult:
    """Match a predicted numeric fact against a gold standard fact.

    Compares normalized values, checks unit consistency and period match.

    Args:
        pred: Predicted numeric fact.
        gold: Gold standard numeric fact.
        tolerance_pct: Tolerance percentage for approximate matching.

    Returns:
        NumericMatchResult with match details.
    """
    # Unit consistency check
    unit_consistent = True
    if pred.unit is not None and gold.unit is not None:
        unit_consistent = pred.unit == gold.unit
    elif pred.unit is None and gold.unit is not None:
        unit_consistent = False
    # If gold has no unit, we consider it consistent regardless

    # Period match check
    period_match = True
    if pred.period is not None and gold.period is not None:
        period_match = pred.period == gold.period
    elif pred.period is None and gold.period is not None:
        period_match = False

    # Value comparison
    pred_value = pred.normalized_value
    gold_value = gold.normalized_value

    if pred_value is None or gold_value is None:
        return NumericMatchResult(
            exact_match=False,
            within_tolerance=False,
            tolerance_pct=tolerance_pct,
            unit_consistent=unit_consistent,
            period_match=period_match,
            absolute_error=None,
            relative_error_pct=None,
        )

    absolute_error = abs(pred_value - gold_value)
    relative_error_pct = _compute_relative_error_pct(pred_value, gold_value)
    exact = _is_exact_match(pred_value, gold_value)
    within_tol = _is_within_tolerance(pred_value, gold_value, tolerance_pct)

    return NumericMatchResult(
        exact_match=exact,
        within_tolerance=within_tol,
        tolerance_pct=tolerance_pct,
        unit_consistent=unit_consistent,
        period_match=period_match,
        absolute_error=absolute_error,
        relative_error_pct=relative_error_pct,
    )


# ---------------------------------------------------------------------------
# Batch Evaluation
# ---------------------------------------------------------------------------


def _align_facts(
    predicted: list[NumericFact],
    gold: list[NumericFact],
) -> list[tuple[NumericFact, NumericFact]]:
    """Align predicted facts to gold facts using greedy matching.

    Matches on fact_type and predicate. Each gold fact can match at most
    one predicted fact.
    """
    pairs: list[tuple[NumericFact, NumericFact]] = []
    matched_gold: set[int] = set()

    for pred in predicted:
        for g_idx, g in enumerate(gold):
            if g_idx in matched_gold:
                continue
            if pred.fact_type == g.fact_type and pred.predicate == g.predicate:
                pairs.append((pred, g))
                matched_gold.add(g_idx)
                break

    return pairs


def evaluate_numeric_facts(
    predicted: list[NumericFact],
    gold: list[NumericFact],
    tolerance_pct: float = DEFAULT_TOLERANCE_PCT,
    document_count: int = 1,
) -> NumericEvaluationReport:
    """Run full numeric extraction evaluation.

    Aligns predicted facts to gold facts by fact_type and predicate,
    then computes exact match accuracy, tolerance-based accuracy,
    per-type breakdowns, unit consistency, period match accuracy,
    tolerance distribution histogram, and error categories.

    Args:
        predicted: All predicted numeric facts.
        gold: All gold standard numeric facts.
        tolerance_pct: Tolerance percentage for approximate matching.
        document_count: Number of documents evaluated.

    Returns:
        NumericEvaluationReport with complete evaluation results.
    """
    pairs = _align_facts(predicted, gold)
    total_aligned = len(pairs)

    # Track results
    exact_matches = 0
    tolerance_matches = 0
    unit_matches = 0
    period_matches = 0
    comparable_count = 0

    # Per-type tracking
    per_type_exact_counts: dict[str, tuple[int, int]] = {}  # type -> (matches, total)
    per_type_tol_counts: dict[str, tuple[int, int]] = {}

    # Tolerance distribution
    dist = ToleranceDistribution()

    # Error tracking
    error_counts: dict[str, int] = {}

    for pred, g in pairs:
        result = match_numeric_fact(pred, g, tolerance_pct)

        # Unit consistency
        if result.unit_consistent:
            unit_matches += 1

        # Period match
        if result.period_match:
            period_matches += 1

        # Only count value comparisons when both values exist
        if pred.normalized_value is not None and g.normalized_value is not None:
            comparable_count += 1

            if result.exact_match:
                exact_matches += 1
            if result.within_tolerance:
                tolerance_matches += 1

            # Per-type tracking
            ft = pred.fact_type
            ex_m, ex_t = per_type_exact_counts.get(ft, (0, 0))
            tol_m, tol_t = per_type_tol_counts.get(ft, (0, 0))
            per_type_exact_counts[ft] = (
                ex_m + (1 if result.exact_match else 0),
                ex_t + 1,
            )
            per_type_tol_counts[ft] = (
                tol_m + (1 if result.within_tolerance else 0),
                tol_t + 1,
            )

            # Tolerance distribution
            bucket = _bucket_relative_error(result.relative_error_pct)
            if bucket == "exact":
                dist.exact += 1
            elif bucket == "within_1pct":
                dist.within_1pct += 1
            elif bucket == "within_5pct":
                dist.within_5pct += 1
            elif bucket == "within_10pct":
                dist.within_10pct += 1
            elif bucket == "beyond_10pct":
                dist.beyond_10pct += 1
            else:
                dist.not_comparable += 1

            # Error classification for non-exact matches
            if not result.exact_match:
                error_cat = _classify_error(pred, g, pred.normalized_value, g.normalized_value)
                if error_cat:
                    error_counts[error_cat] = error_counts.get(error_cat, 0) + 1
        else:
            dist.not_comparable += 1
            # Classify missing value error
            if pred.normalized_value is None:
                error_cat = ErrorCategory.parsing_failure.value
            elif g.normalized_value is None:
                error_cat = ErrorCategory.missing_value.value
            else:
                error_cat = None
            if error_cat:
                error_counts[error_cat] = error_counts.get(error_cat, 0) + 1

    # Build accuracy metrics
    exact_accuracy = AccuracyMetric(
        accuracy=exact_matches / comparable_count if comparable_count > 0 else 1.0,
        matches=exact_matches,
        total=comparable_count,
    )
    tolerance_accuracy = AccuracyMetric(
        accuracy=tolerance_matches / comparable_count if comparable_count > 0 else 1.0,
        matches=tolerance_matches,
        total=comparable_count,
    )
    unit_consistency = AccuracyMetric(
        accuracy=unit_matches / total_aligned if total_aligned > 0 else 1.0,
        matches=unit_matches,
        total=total_aligned,
    )
    period_match_metric = AccuracyMetric(
        accuracy=period_matches / total_aligned if total_aligned > 0 else 1.0,
        matches=period_matches,
        total=total_aligned,
    )

    # Per-type exact accuracy
    per_type_exact: dict[str, AccuracyMetric] = {}
    for ft, (m, t) in sorted(per_type_exact_counts.items()):
        per_type_exact[ft] = AccuracyMetric(
            accuracy=m / t if t > 0 else 1.0,
            matches=m,
            total=t,
        )

    # Per-type tolerance accuracy
    per_type_tolerance: dict[str, AccuracyMetric] = {}
    for ft, (m, t) in sorted(per_type_tol_counts.items()):
        per_type_tolerance[ft] = AccuracyMetric(
            accuracy=m / t if t > 0 else 1.0,
            matches=m,
            total=t,
        )

    total_errors = sum(error_counts.values())

    return NumericEvaluationReport(
        exact_match_accuracy=exact_accuracy,
        tolerance_accuracy=tolerance_accuracy,
        tolerance_pct_used=tolerance_pct,
        per_type_exact=per_type_exact,
        per_type_tolerance=per_type_tolerance,
        unit_consistency=unit_consistency,
        period_match=period_match_metric,
        tolerance_distribution=dist,
        error_breakdown=ErrorBreakdown(counts=error_counts, total_errors=total_errors),
        document_count=document_count,
    )
