"""Per-field type-aware comparison of extracted values against gold.

Normalization functions ported from convergence/scripts/score.py.
Each field comparison returns a FieldComparison with match status,
match type, and human-readable notes.
"""

from __future__ import annotations

import re
import unicodedata
from datetime import datetime
from typing import Any

from agentic.config import AgentConfig
from agentic.models import FieldComparison, FieldSchema, GoldStandard


# ── Normalization ────────────────────────────────────────────────────────────


def normalize_text(s: str) -> str:
    """NFKC + normalize quotes + normalize punctuation spacing + collapse whitespace."""
    s = unicodedata.normalize("NFKC", str(s))
    # Normalize quote variants to ASCII
    s = s.replace("\u2018", "'").replace("\u2019", "'")  # curly single quotes
    s = s.replace("\u201c", '"').replace("\u201d", '"')  # curly double quotes
    # Normalize spacing around punctuation (05/ 05 == 05 / 05, 01: 15 == 01 : 15)
    s = re.sub(r"\s*/\s*", "/", s)
    s = re.sub(r"\s*:\s*", ":", s)
    # Normalize space before AM/PM
    s = re.sub(r"\s*(am|pm)\b", r"\1", s, flags=re.IGNORECASE)
    s = re.sub(r"\s+", " ", s)
    return s.strip().lower()


def parse_number(s: str) -> float | None:
    """Try to parse a string as a number, stripping currency symbols and commas."""
    s = re.sub(r"[$,€£¥]", "", str(s).strip())
    s = re.sub(r"\s+", "", s)
    try:
        return float(s)
    except ValueError:
        return None


def parse_date(s: str) -> datetime | None:
    """Try common date formats."""
    s = str(s).strip()
    formats = [
        "%m/%d/%Y",
        "%m/%d/%y",
        "%Y-%m-%d",
        "%Y/%m/%d",
        "%m-%d-%Y",
        "%m-%d-%y",
        "%B %d, %Y",
        "%b %d, %Y",
        "%B %d %Y",
        "%b %d %Y",
        "%d %B %Y",
        "%d %b %Y",
    ]
    for fmt in formats:
        try:
            return datetime.strptime(s, fmt)
        except ValueError:
            continue
    return None


def tokenize(s: str) -> set[str]:
    """Split into lowercase tokens for set comparison."""
    return set(normalize_text(s).split())


# ── List comparison ──────────────────────────────────────────────────────────


def compare_list_field(
    field: FieldSchema,
    expected: Any,
    actual: Any,
    config: AgentConfig,
) -> FieldComparison:
    """Compare list fields element-wise with order tolerance.

    Strategy: find the best 1:1 matching between expected and actual items
    using normalized text comparison. Score = matched / total expected.
    """
    raw_expected = str(expected)
    raw_actual = str(actual)

    if not isinstance(expected, list):
        expected = [expected]
    if not isinstance(actual, list):
        actual = [actual]

    if not expected:
        # Nothing expected — if actual is also empty, match
        return FieldComparison(
            field_name=field.name, expected=expected, actual=actual,
            match=len(actual) == 0, match_type="exact" if len(actual) == 0 else "wrong",
            score=1.0 if len(actual) == 0 else 0.0,
            raw_expected=raw_expected, raw_actual=raw_actual,
        )

    # Normalize all items for comparison
    exp_normed = [normalize_text(str(e)) for e in expected]
    act_normed = [normalize_text(str(a)) for a in actual]

    # Greedy matching: for each expected item, find best actual match
    used_actual = set()
    matched = 0
    mismatches = []

    for i, exp in enumerate(exp_normed):
        best_j = None
        for j, act in enumerate(act_normed):
            if j in used_actual:
                continue
            if exp == act:
                best_j = j
                break
        if best_j is not None:
            used_actual.add(best_j)
            matched += 1
        else:
            mismatches.append(f"'{expected[i]}'")

    score = matched / len(expected)
    is_match = score >= 0.8  # allow some tolerance for list ordering/missing items

    notes = f"List: {matched}/{len(expected)} matched"
    if mismatches:
        notes += f", unmatched: {', '.join(mismatches[:3])}"
    if len(actual) != len(expected):
        notes += f" (expected {len(expected)} items, got {len(actual)})"

    return FieldComparison(
        field_name=field.name,
        expected=expected,
        actual=actual,
        match=is_match,
        match_type="normalized" if is_match else "wrong",
        score=score,  # fractional — gives convergence signal even for partial matches
        raw_expected=raw_expected,
        raw_actual=raw_actual,
        notes=notes,
    )


# ── Table comparison ─────────────────────────────────────────────────────────


def normalize_column_name(s: str) -> str:
    """Normalize a column name for comparison: lowercase, hyphens→underscores, strip other punctuation, snake_case."""
    s = str(s).lower().strip()
    s = s.replace("-", "_")  # In-District → in_district
    s = re.sub(r"[^\w\s]", "", s)
    s = re.sub(r"\s+", "_", s)
    return s


def _normalize_row(row: dict) -> dict:
    """Normalize column names in a row dict."""
    return {normalize_column_name(k): v for k, v in row.items()}


def _cell_match(expected: Any, actual: Any, config: AgentConfig) -> bool:
    """Type-aware cell comparison: numbers with tolerance, strings normalized."""
    if expected is None and actual is None:
        return True
    if expected is None or actual is None:
        return False

    # Try numeric comparison first
    exp_num = parse_number(str(expected))
    act_num = parse_number(str(actual))
    if exp_num is not None and act_num is not None:
        tolerance = config.number_tolerance * max(abs(exp_num), 1)
        return abs(exp_num - act_num) <= tolerance

    # Fall back to normalized text
    return normalize_text(str(expected)) == normalize_text(str(actual))


def _row_similarity(exp_row: dict, act_row: dict, config: AgentConfig) -> float:
    """Fraction of expected columns that match in the actual row."""
    if not exp_row:
        return 1.0
    matches = sum(1 for k, v in exp_row.items() if k in act_row and _cell_match(v, act_row[k], config))
    return matches / len(exp_row)


def compare_table_field(
    field: FieldSchema,
    expected: Any,
    actual: Any,
    config: AgentConfig,
) -> FieldComparison:
    """Compare table fields (list[dict]) with column normalization and row matching.

    Strategy:
    1. Normalize column names in both expected and actual rows
    2. Greedy row matching by similarity (order-agnostic)
    3. Cell-level scoring across matched rows
    """
    raw_expected = str(expected)[:200] + "..." if len(str(expected)) > 200 else str(expected)
    raw_actual = str(actual)[:200] + "..." if len(str(actual)) > 200 else str(actual)

    if not isinstance(expected, list):
        expected = [expected] if expected else []
    if not isinstance(actual, list):
        actual = [actual] if actual else []

    if not expected:
        return FieldComparison(
            field_name=field.name, expected=expected, actual=actual,
            match=len(actual) == 0, match_type="exact" if not actual else "wrong",
            score=1.0 if not actual else 0.0,
            raw_expected=raw_expected, raw_actual=raw_actual,
        )

    # Normalize column names
    exp_rows = [_normalize_row(r) if isinstance(r, dict) else {"value": r} for r in expected]
    act_rows = [_normalize_row(r) if isinstance(r, dict) else {"value": r} for r in actual]

    # Greedy row matching by similarity
    used_actual = set()
    total_cells = 0
    matched_cells = 0
    row_details = []

    for i, exp_row in enumerate(exp_rows):
        best_j = None
        best_sim = 0.0
        for j, act_row in enumerate(act_rows):
            if j in used_actual:
                continue
            sim = _row_similarity(exp_row, act_row, config)
            if sim > best_sim:
                best_sim = sim
                best_j = j

        if best_j is not None and best_sim > 0.3:
            used_actual.add(best_j)
            act_row = act_rows[best_j]
            for k, v in exp_row.items():
                total_cells += 1
                if k in act_row and _cell_match(v, act_row[k], config):
                    matched_cells += 1
                elif len(row_details) < 5:
                    act_val = act_row.get(k, "<missing>")
                    row_details.append(f"row {i + 1} col '{k}': expected '{v}', got '{act_val}'")
        else:
            total_cells += len(exp_row)
            if len(row_details) < 5:
                # Show first key-value to identify the missing row
                preview = next(iter(exp_row.items()), ("?", "?"))
                row_details.append(f"row {i + 1} ({preview[0]}='{preview[1]}'): no match in extracted")

    score = matched_cells / total_cells if total_cells > 0 else 0.0
    is_match = score >= 0.8

    notes = f"Table: {matched_cells}/{total_cells} cells matched ({len(used_actual)}/{len(exp_rows)} rows)"
    if row_details:
        notes += " | " + "; ".join(row_details[:3])
    if len(act_rows) != len(exp_rows):
        notes += f" (expected {len(exp_rows)} rows, got {len(act_rows)})"

    return FieldComparison(
        field_name=field.name,
        expected=expected,
        actual=actual,
        match=is_match,
        match_type="normalized" if is_match else "wrong",
        score=score,
        raw_expected=raw_expected,
        raw_actual=raw_actual,
        notes=notes,
    )


# ── Field comparison ─────────────────────────────────────────────────────────


def compare_field(
    field: FieldSchema,
    expected: Any,
    actual: Any,
    config: AgentConfig,
) -> FieldComparison:
    """Compare one extracted field value to its gold value."""
    raw_expected = str(expected) if expected is not None else ""
    raw_actual = str(actual) if actual is not None else None

    # Missing extraction
    if actual is None:
        return FieldComparison(
            field_name=field.name,
            expected=expected,
            actual=None,
            match=False,
            match_type="missing",
            score=0.0,
            raw_expected=raw_expected,
            raw_actual=None,
            notes="Field not extracted",
        )

    expected_str = str(expected)
    actual_str = str(actual)

    # ── Number / Currency ────────────────────────────────────────────────
    if field.field_type in ("number", "currency"):
        exp_num = parse_number(expected_str)
        act_num = parse_number(actual_str)
        if exp_num is not None and act_num is not None:
            tolerance = config.number_tolerance * max(abs(exp_num), 1)
            if abs(exp_num - act_num) <= tolerance:
                return FieldComparison(
                    field_name=field.name,
                    expected=expected,
                    actual=actual,
                    match=True,
                    match_type="numeric",
                    score=1.0,
                    raw_expected=raw_expected,
                    raw_actual=raw_actual,
                    notes=f"Numeric match: {exp_num} ≈ {act_num}",
                )
        # Numbers didn't match or couldn't parse
        return FieldComparison(
            field_name=field.name,
            expected=expected,
            actual=actual,
            match=False,
            match_type="wrong",
            score=0.0,
            raw_expected=raw_expected,
            raw_actual=raw_actual,
            notes=f"Numeric mismatch: expected {exp_num}, got {act_num}",
        )

    # ── Date ─────────────────────────────────────────────────────────────
    if field.field_type == "date":
        exp_date = parse_date(expected_str)
        act_date = parse_date(actual_str)
        if exp_date and act_date and exp_date.date() == act_date.date():
            return FieldComparison(
                field_name=field.name,
                expected=expected,
                actual=actual,
                match=True,
                match_type="date",
                score=1.0,
                raw_expected=raw_expected,
                raw_actual=raw_actual,
                notes=f"Date match: {exp_date.date()}",
            )
        # Fall through to text comparison if date parsing fails
        if normalize_text(expected_str) == normalize_text(actual_str):
            return FieldComparison(
                field_name=field.name,
                expected=expected,
                actual=actual,
                match=True,
                match_type="normalized",
                score=1.0,
                raw_expected=raw_expected,
                raw_actual=raw_actual,
                notes="Date text normalized match",
            )
        return FieldComparison(
            field_name=field.name,
            expected=expected,
            actual=actual,
            match=False,
            match_type="wrong",
            score=0.0,
            raw_expected=raw_expected,
            raw_actual=raw_actual,
            notes=f"Date mismatch: expected {exp_date}, got {act_date}",
        )

    # ── Boolean ──────────────────────────────────────────────────────────
    if field.field_type == "boolean":
        truthy = {"true", "yes", "1", "y", "t"}
        exp_bool = normalize_text(expected_str) in truthy
        act_bool = normalize_text(actual_str) in truthy
        if exp_bool == act_bool:
            return FieldComparison(
                field_name=field.name,
                expected=expected,
                actual=actual,
                match=True,
                match_type="exact",
                score=1.0,
                raw_expected=raw_expected,
                raw_actual=raw_actual,
            )
        return FieldComparison(
            field_name=field.name,
            expected=expected,
            actual=actual,
            match=False,
            match_type="wrong",
            score=0.0,
            raw_expected=raw_expected,
            raw_actual=raw_actual,
            notes=f"Boolean mismatch: expected {exp_bool}, got {act_bool}",
        )

    # ── Address ──────────────────────────────────────────────────────────
    if field.field_type == "address":
        exp_tokens = tokenize(expected_str)
        act_tokens = tokenize(actual_str)
        if not exp_tokens:
            return FieldComparison(
                field_name=field.name,
                expected=expected,
                actual=actual,
                match=False,
                match_type="wrong",
                score=0.0,
                raw_expected=raw_expected,
                raw_actual=raw_actual,
            )
        overlap = len(exp_tokens & act_tokens) / len(exp_tokens)
        if overlap >= config.address_jaccard:
            return FieldComparison(
                field_name=field.name,
                expected=expected,
                actual=actual,
                match=True,
                match_type="normalized",
                score=1.0,
                raw_expected=raw_expected,
                raw_actual=raw_actual,
                notes=f"Address token overlap: {overlap:.0%}",
            )
        return FieldComparison(
            field_name=field.name,
            expected=expected,
            actual=actual,
            match=False,
            match_type="wrong",
            score=0.0,
            raw_expected=raw_expected,
            raw_actual=raw_actual,
            notes=f"Address token overlap: {overlap:.0%} (threshold: {config.address_jaccard:.0%})",
        )

    # ── Table ────────────────────────────────────────────────────────────
    if field.field_type == "table":
        return compare_table_field(field, expected, actual, config)

    # ── List ─────────────────────────────────────────────────────────────
    if field.field_type == "list" or (isinstance(expected, list) and isinstance(actual, list)):
        # If list items are dicts, treat as table
        if (isinstance(expected, list) and expected and isinstance(expected[0], dict)):
            return compare_table_field(field, expected, actual, config)
        return compare_list_field(field, expected, actual, config)

    # ── String (default) ─────────────────────────────────────────────────
    # Check exact match first for clearer telemetry
    if expected_str.strip() == actual_str.strip():
        return FieldComparison(
            field_name=field.name,
            expected=expected,
            actual=actual,
            match=True,
            match_type="exact",
            score=1.0,
            raw_expected=raw_expected,
            raw_actual=raw_actual,
        )
    if normalize_text(expected_str) == normalize_text(actual_str):
        return FieldComparison(
            field_name=field.name,
            expected=expected,
            actual=actual,
            match=True,
            match_type="normalized",
            score=1.0,
            raw_expected=raw_expected,
            raw_actual=raw_actual,
        )
    return FieldComparison(
        field_name=field.name,
        expected=expected,
        actual=actual,
        match=False,
        match_type="wrong",
        score=0.0,
        raw_expected=raw_expected,
        raw_actual=raw_actual,
        notes=f"String mismatch",
    )


# ── Scoring ──────────────────────────────────────────────────────────────────


def score_extraction(
    extracted: dict | None,
    gold: GoldStandard,
    schema: list[FieldSchema],
    config: AgentConfig,
) -> list[FieldComparison]:
    """Compare extracted dict to gold, field by field.

    Fields in gold.suspect_fields are still compared but marked as suspect
    so overall_score can exclude them from the denominator.
    """
    comparisons = []
    for field_def in schema:
        expected = gold.fields.get(field_def.name)
        actual = extracted.get(field_def.name) if extracted else None
        comparison = compare_field(field_def, expected, actual, config)
        if field_def.name in gold.suspect_fields:
            comparison.notes = (comparison.notes + " [suspect gold]").strip()
        comparisons.append(comparison)
    return comparisons


def overall_score(comparisons: list[FieldComparison]) -> float:
    """Fraction of non-suspect fields that matched.

    Fields flagged as [suspect gold] are excluded from the denominator
    so the agent isn't penalized for unfindable hallucinated values.
    """
    scored = [c for c in comparisons if "[suspect gold]" not in (c.notes or "")]
    if not scored:
        # All fields are suspect — fall back to full set
        scored = comparisons
    if not scored:
        return 0.0
    return sum(c.score for c in scored) / len(scored)
