"""
xmldev.fuzzy
~~~~~~~~~~~~
Fuzzy string matching for tag and attribute name repair.
Uses rapidfuzz for Damerau-Levenshtein-based similarity.
"""
from __future__ import annotations

import re
import string
from dataclasses import dataclass

from rapidfuzz import fuzz, process

# ---------------------------------------------------------------------------
# Profiles
# ---------------------------------------------------------------------------

_PROFILES: dict[str, dict[str, float]] = {
    "strict":     {"name_threshold": 0.95, "permissive_threshold": 0.80},
    "balanced":   {"name_threshold": 0.85, "permissive_threshold": 0.70},
    "permissive": {"name_threshold": 0.75, "permissive_threshold": 0.60},
}

_SYNONYM_MAP: dict[str, str] = {
    "fullname": "name",
    "full_name": "name",
    "firstname": "first_name",
    "lastname": "last_name",
    "emailaddress": "email",
    "phone_number": "phone",
}

_PUNCT_RE = re.compile(r"[%s]" % re.escape(string.punctuation))
_SEP_RE = re.compile(r"[\s_\-]+")


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

def normalize(s: str) -> str:
    """Lowercase, remove punctuation, collapse separators, apply synonyms."""
    s = s.lower()
    s = _PUNCT_RE.sub(" ", s)
    s = _SEP_RE.sub("_", s).strip("_")
    return _SYNONYM_MAP.get(s, s)


def similarity(a: str, b: str) -> float:
    """Return normalized similarity in [0, 1] using token-set ratio."""
    return fuzz.ratio(normalize(a), normalize(b)) / 100.0


# ---------------------------------------------------------------------------
# Main matcher
# ---------------------------------------------------------------------------

@dataclass
class MatchResult:
    candidate: str
    score: float
    via_alias: bool = False


class FuzzyMatcher:
    """
    Matches an unknown tag/attribute name to a set of known candidates.

    Parameters
    ----------
    profile : str
        One of "strict", "balanced", "permissive".
    max_renames : int
        Hard cap on renames per document.
    name_threshold : float | None
        Override the profile's name_threshold.
    permissive_threshold : float | None
        Override the profile's permissive_threshold.
    """

    def __init__(
        self,
        profile: str = "balanced",
        max_renames: int = 10,
        name_threshold: float | None = None,
        permissive_threshold: float | None = None,
    ) -> None:
        thresholds = _PROFILES.get(profile, _PROFILES["balanced"])
        self.name_threshold = name_threshold if name_threshold is not None else thresholds["name_threshold"]
        self.permissive_threshold = (
            permissive_threshold if permissive_threshold is not None else thresholds["permissive_threshold"]
        )
        self.max_renames = max_renames
        self._renames_used = 0

    def reset(self) -> None:
        """Reset rename counter (call at the start of each document)."""
        self._renames_used = 0

    @property
    def renames_exhausted(self) -> bool:
        return self._renames_used >= self.max_renames

    def match(
        self,
        unknown: str,
        candidates: list[str],
        aliases: dict[str, list[str]] | None = None,
        context_parent: str | None = None,
    ) -> MatchResult | None:
        """
        Try to find the best candidate for *unknown*.

        1. Check aliases first (score 1.0).
        2. Exact match after normalization.
        3. Fuzzy similarity >= name_threshold.

        Returns None if no match above threshold or rename cap is reached.

        Parameters
        ----------
        unknown : str
            The tag/attribute name to resolve.
        candidates : list[str]
            Known valid names.
        aliases : dict mapping candidate -> list of alias strings, optional
        context_parent : str, optional
            Parent element name for tie-breaking (unused currently, reserved).
        """
        if self.renames_exhausted:
            return None

        aliases = aliases or {}

        # 1. Alias check
        for canonical, alias_list in aliases.items():
            if unknown in alias_list or normalize(unknown) in [normalize(a) for a in alias_list]:
                self._renames_used += 1
                return MatchResult(candidate=canonical, score=1.0, via_alias=True)

        # 2. Normalized exact match
        norm_unknown = normalize(unknown)
        for c in candidates:
            if normalize(c) == norm_unknown:
                self._renames_used += 1
                return MatchResult(candidate=c, score=1.0, via_alias=False)

        # 3. Fuzzy
        if not candidates:
            return None

        norm_candidates = {c: normalize(c) for c in candidates}
        best_candidate, best_score_raw, _ = process.extractOne(
            norm_unknown,
            list(norm_candidates.values()),
            scorer=fuzz.ratio,
        ) or (None, 0, None)

        if best_candidate is None:
            return None

        best_score = best_score_raw / 100.0
        if best_score < self.name_threshold:
            return None

        # Map normalized value back to original candidate name
        reverse = {v: k for k, v in norm_candidates.items()}
        original_candidate = reverse.get(best_candidate, best_candidate)

        self._renames_used += 1
        return MatchResult(candidate=original_candidate, score=best_score, via_alias=False)

    def match_enum(
        self,
        unknown: str,
        enum_values: list[str],
    ) -> MatchResult | None:
        """
        Match an unknown enum value to the closest known enum value.
        Uses permissive_threshold (not name_threshold) since enum values
        may differ more than element names.
        """
        if not enum_values:
            return None

        norm_unknown = normalize(unknown)
        best, score_raw, _ = process.extractOne(
            norm_unknown,
            [normalize(e) for e in enum_values],
            scorer=fuzz.ratio,
        ) or (None, 0, None)

        if best is None:
            return None

        score = score_raw / 100.0
        if score < self.permissive_threshold:
            return None

        # Map back
        norm_to_original = {normalize(e): e for e in enum_values}
        return MatchResult(candidate=norm_to_original.get(best, best), score=score)
