from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass


_TOKEN_RE = re.compile(r"[^0-9a-zA-Z]+")


@dataclass(frozen=True, slots=True)
class TextComparison:
    expected: str
    actual: str
    normalized_expected: str
    normalized_actual: str
    wer: float
    substitutions: int
    deletions: int
    insertions: int
    missing_terms: tuple[str, ...]

    @property
    def terms_ok(self) -> bool:
        return not self.missing_terms


def normalize_text(text: str) -> str:
    value = unicodedata.normalize("NFD", str(text or "").strip().lower())
    value = "".join(ch for ch in value if unicodedata.category(ch) != "Mn")
    value = _TOKEN_RE.sub(" ", value)
    return " ".join(value.split())


def _edit_counts(expected_tokens: list[str], actual_tokens: list[str]) -> tuple[int, int, int]:
    rows = len(expected_tokens) + 1
    cols = len(actual_tokens) + 1
    dp: list[list[tuple[int, int, int, int]]] = [
        [(0, 0, 0, 0) for _ in range(cols)]
        for _ in range(rows)
    ]

    for i in range(1, rows):
        cost, sub, dele, ins = dp[i - 1][0]
        dp[i][0] = (cost + 1, sub, dele + 1, ins)
    for j in range(1, cols):
        cost, sub, dele, ins = dp[0][j - 1]
        dp[0][j] = (cost + 1, sub, dele, ins + 1)

    for i in range(1, rows):
        for j in range(1, cols):
            if expected_tokens[i - 1] == actual_tokens[j - 1]:
                candidates = [dp[i - 1][j - 1]]
            else:
                cost, sub, dele, ins = dp[i - 1][j - 1]
                candidates = [(cost + 1, sub + 1, dele, ins)]

            cost, sub, dele, ins = dp[i - 1][j]
            candidates.append((cost + 1, sub, dele + 1, ins))

            cost, sub, dele, ins = dp[i][j - 1]
            candidates.append((cost + 1, sub, dele, ins + 1))

            dp[i][j] = min(candidates, key=lambda item: (item[0], item[1], item[2], item[3]))

    _, substitutions, deletions, insertions = dp[-1][-1]
    return substitutions, deletions, insertions


def word_error_rate(expected: str, actual: str) -> tuple[float, int, int, int]:
    expected_tokens = normalize_text(expected).split()
    actual_tokens = normalize_text(actual).split()
    if not expected_tokens:
        return (0.0 if not actual_tokens else 1.0, 0, 0, len(actual_tokens))

    substitutions, deletions, insertions = _edit_counts(expected_tokens, actual_tokens)
    wer = (substitutions + deletions + insertions) / len(expected_tokens)
    return wer, substitutions, deletions, insertions


def compare_text(
    *,
    expected: str,
    actual: str,
    critical_terms: list[str] | tuple[str, ...],
) -> TextComparison:
    normalized_expected = normalize_text(expected)
    normalized_actual = normalize_text(actual)
    actual_tokens = set(normalized_actual.split())
    missing_terms = tuple(
        term
        for term in (normalize_text(item) for item in critical_terms)
        if term and term not in actual_tokens
    )
    wer, substitutions, deletions, insertions = word_error_rate(expected, actual)
    return TextComparison(
        expected=expected,
        actual=actual,
        normalized_expected=normalized_expected,
        normalized_actual=normalized_actual,
        wer=wer,
        substitutions=substitutions,
        deletions=deletions,
        insertions=insertions,
        missing_terms=missing_terms,
    )
