"""
xmldev.repair
~~~~~~~~~~~~~
Deterministic and heuristic XML repair engine.

DeterministicRepairer applies safe, rule-based fixes in order:
  1. Tag balancing
  2. Rename by alias / fuzzy match
  3. Attribute name rename
  4. Type coercion
  5. Default insertion for missing required elements
  6. Move unknown elements to extension element
  7. Child reorder (order_enforced)

HeuristicRepairer (aggressive mode) applies DP-based structural
reconstruction when deterministic fixes are insufficient.
"""
from __future__ import annotations

import copy
import re
from dataclasses import dataclass
from typing import Any

from dateutil.parser import parse as dateutil_parse
from lxml import etree

from .audit import Patch
from .fuzzy import FuzzyMatcher
from .parser import _local_name, xpath_path
from .schema import ChildSpec, ElementSpec, Schema
from .validator import Violation


# ---------------------------------------------------------------------------
# Words-to-number helper
# ---------------------------------------------------------------------------

_WORD_NUMS = {
    "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
    "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
    "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14,
    "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18,
    "nineteen": 19, "twenty": 20, "thirty": 30, "forty": 40,
    "fifty": 50, "sixty": 60, "seventy": 70, "eighty": 80, "ninety": 90,
    "hundred": 100, "thousand": 1000, "million": 1_000_000,
}


def _words_to_int(text: str) -> int | None:
    """Convert English word representation of an integer to int. Returns None on failure."""
    tokens = re.split(r"[\s\-]+", text.lower().strip())
    total = 0
    current = 0
    found_any = False
    for token in tokens:
        if token not in _WORD_NUMS:
            return None
        found_any = True
        val = _WORD_NUMS[token]
        if val == 100:
            current = (current or 1) * 100
        elif val >= 1000:
            total += (current or 1) * val
            current = 0
        else:
            current += val
    if not found_any:
        return None
    return total + current


def _coerce_value(value: str, target_type: str) -> tuple[str | None, float]:
    """
    Try to coerce *value* to *target_type*.
    Returns (coerced_str, confidence) or (None, 0.0) on failure.
    """
    v = value.strip()
    if target_type == "int":
        try:
            return str(int(v)), 1.0
        except ValueError:
            pass
        try:
            return str(int(float(v))), 0.8
        except ValueError:
            pass
        result = _words_to_int(v)
        if result is not None:
            return str(result), 0.6
        return None, 0.0

    if target_type == "float":
        try:
            return str(float(v)), 1.0
        except ValueError:
            return None, 0.0

    if target_type == "bool":
        lower = v.lower()
        mapping = {"true": "true", "1": "true", "yes": "true", "false": "false", "0": "false", "no": "false"}
        if lower in mapping:
            return mapping[lower], 1.0
        return None, 0.0

    if target_type == "date":
        try:
            parsed = dateutil_parse(v)
            return parsed.date().isoformat(), 0.9
        except Exception:
            return None, 0.0

    return None, 0.0


# ---------------------------------------------------------------------------
# Repair result
# ---------------------------------------------------------------------------

@dataclass
class RepairResult:
    root: etree._Element
    patches: list[Patch]
    success: bool


# ---------------------------------------------------------------------------
# Deterministic repairer
# ---------------------------------------------------------------------------

class DeterministicRepairer:
    """
    Apply deterministic fixes to a parsed XML tree.

    Parameters
    ----------
    schema : Schema
    fuzzy : FuzzyMatcher
    auto_apply_threshold : float
        Patches with confidence >= this are marked auto_applied=True.
    """

    def __init__(
        self,
        schema: Schema,
        fuzzy: FuzzyMatcher,
        auto_apply_threshold: float = 0.9,
    ) -> None:
        self.schema = schema
        self.fuzzy = fuzzy
        self.auto_apply_threshold = auto_apply_threshold

    def repair(
        self,
        root: etree._Element,
        violations: list[Violation],
        aggressive: bool = False,
    ) -> RepairResult:
        """
        Apply deterministic repairs for each violation.

        Returns a RepairResult with the mutated tree (deep-copied internally)
        and all patches produced.
        """
        root = copy.deepcopy(root)
        self.fuzzy.reset()
        patches: list[Patch] = []

        # Pre-pass: normalize alias tags to canonical names before validating.
        # This ensures alias tags (e.g. <fullname> → <name>) are fixed first,
        # so subsequent validation and violation fixes work against canonical names.
        self._normalize_alias_tags(root, root, self.schema.root, patches)

        # Re-validate against the COPY (with aliases already normalized)
        # so element refs point into the copy.
        from .validator import Validator as _Validator
        fresh_violations = _Validator(self.schema).validate(root)

        self._fix_violations(root, root, fresh_violations, patches)

        return RepairResult(root=root, patches=patches, success=True)

    def _normalize_alias_tags(
        self,
        elem: etree._Element,
        doc_root: etree._Element,
        spec: ElementSpec,
        patches: list[Patch],
    ) -> None:
        """Pre-pass: walk the tree and rename any aliased child tags to canonical names."""
        # Build alias → canonical map for children of this spec
        alias_to_canonical: dict[str, str] = {}
        name_to_spec: dict[str, ElementSpec] = {}
        for cs in spec.children:
            name_to_spec[cs.spec.name] = cs.spec
            for alias in cs.spec.aliases:
                alias_to_canonical[alias] = cs.spec.name

        for child in list(elem):
            local = _local_name(child.tag)
            if local in alias_to_canonical and not self.fuzzy.renames_exhausted:
                canonical = alias_to_canonical[local]
                new_tag = _recompose_tag(child.tag, canonical)
                old_tag = local
                child.tag = new_tag
                self.fuzzy._renames_used += 1
                patches.append(self._make_patch(
                    type_="rename",
                    path=xpath_path(child, doc_root),
                    original=f"<{old_tag}>",
                    replacement=f"<{canonical}>",
                    rule_id="alias_rename_v1",
                    confidence=1.0,
                    notes=f"alias match '{old_tag}' → '{canonical}'",
                ))
                local = canonical  # update local for recursion

            # Recurse into child using its spec
            child_spec = name_to_spec.get(local)
            if child_spec is not None:
                self._normalize_alias_tags(child, doc_root, child_spec, patches)

    def _fix_violations(
        self,
        elem: etree._Element,
        doc_root: etree._Element,
        violations: list[Violation],
        patches: list[Patch],
    ) -> None:
        """Dispatch each violation to the appropriate fixer."""
        for v in violations:
            target_elem = v.element
            if target_elem is None:
                continue

            if v.code == "UNKNOWN_TAG":
                self._fix_unknown_tag(target_elem, doc_root, v, patches)
            elif v.code == "MISSING_REQUIRED":
                self._fix_missing_required(target_elem, v, patches)
            elif v.code == "MISSING_REQUIRED_ATTR":
                self._fix_missing_attr(target_elem, v, patches)
            elif v.code == "TEXT_TYPE_ERROR":
                self._fix_text_type(target_elem, doc_root, v, patches)
            elif v.code == "ATTR_TYPE_ERROR":
                self._fix_attr_type(target_elem, doc_root, v, patches)
            elif v.code == "WRONG_ORDER":
                self._fix_order(target_elem, v, patches, doc_root)
            elif v.code == "UNKNOWN_ATTR":
                # Unknown attrs: remove them (safe drop)
                attr = v.extra.get("attr", "")
                if attr and attr in target_elem.attrib:
                    original = etree.tostring(target_elem, encoding="unicode")
                    del target_elem.attrib[attr]
                    patches.append(self._make_patch(
                        type_="delete",
                        path=v.path,
                        original=f'attr: {attr}="{target_elem.get(attr, "")}"',
                        replacement="",
                        rule_id="drop_unknown_attr",
                        confidence=0.95,
                        notes=f"Removed unknown attribute '{attr}'",
                    ))

    def _fix_unknown_tag(
        self,
        elem: etree._Element,
        doc_root: etree._Element,
        violation: Violation,
        patches: list[Patch],
    ) -> None:
        tag = violation.extra.get("tag", _local_name(elem.tag))
        allow_unknown = violation.extra.get("allow_unknown", self.schema.global_config.allow_unknown)

        # Build known candidates + aliases from spec
        parent = elem.getparent()
        if parent is None:
            return

        parent_spec = self._find_spec_for_elem(parent, doc_root)
        if parent_spec is None:
            return

        candidates = [cs.spec.name for cs in parent_spec.children]
        aliases_map = {cs.spec.name: cs.spec.aliases for cs in parent_spec.children}

        match = self.fuzzy.match(tag, candidates, aliases=aliases_map)
        if match:
            # Rename the tag
            original_xml = etree.tostring(elem, encoding="unicode")
            new_tag = _recompose_tag(elem.tag, match.candidate)
            elem.tag = new_tag
            patches.append(self._make_patch(
                type_="rename",
                path=xpath_path(elem, doc_root),
                original=f"<{tag}>",
                replacement=f"<{match.candidate}>",
                rule_id="fuzzy_rename_v1" if not match.via_alias else "alias_rename_v1",
                confidence=match.score,
                notes=f"{'alias' if match.via_alias else 'fuzzy'} match '{tag}' → '{match.candidate}'",
            ))
            return

        # No rename match → apply allow_unknown policy
        if allow_unknown == "drop":
            original_xml = etree.tostring(elem, encoding="unicode")
            parent.remove(elem)
            patches.append(self._make_patch(
                type_="delete",
                path=violation.path,
                original=original_xml,
                replacement="",
                rule_id="drop_unknown_element",
                confidence=1.0,
                notes=f"Dropped unknown element '{tag}' per allow_unknown=drop policy",
            ))
        elif allow_unknown == "move_to_extension":
            ext_name = self.schema.global_config.extension_element_name
            ext_el = parent.find(ext_name)
            if ext_el is None:
                ext_el = etree.SubElement(parent, ext_name)
            # Guard: don't move an element into itself or its own descendant
            if ext_el is elem or ext_el.getparent() is elem:
                return
            original_xml = etree.tostring(elem, encoding="unicode")
            parent.remove(elem)
            ext_el.append(elem)
            patches.append(self._make_patch(
                type_="move",
                path=violation.path,
                original=original_xml,
                replacement=f"moved to <{ext_name}>",
                rule_id="move_to_extension",
                confidence=1.0,
                notes=f"Moved unknown element '{tag}' to <{ext_name}>",
            ))
        # else: allow_unknown == "keep" → nothing to do

    def _fix_missing_required(
        self,
        parent_elem: etree._Element,
        violation: Violation,
        patches: list[Patch],
    ) -> None:
        cs: ChildSpec | None = violation.extra.get("child_spec")
        if cs is None:
            return
        child_spec = cs.spec
        if child_spec.default is None:
            return  # Cannot insert without a default value

        new_elem = etree.SubElement(parent_elem, child_spec.name)
        new_elem.text = str(child_spec.default)
        patches.append(self._make_patch(
            type_="insert",
            path=f"{violation.path}/{child_spec.name}",
            original="",
            replacement=etree.tostring(new_elem, encoding="unicode"),
            rule_id="default_insertion_v1",
            confidence=0.95,
            notes=f"Inserted missing required element '{child_spec.name}' with default value '{child_spec.default}'",
        ))

    def _fix_missing_attr(
        self,
        elem: etree._Element,
        violation: Violation,
        patches: list[Patch],
    ) -> None:
        attr_name = violation.extra.get("attr", "")
        # Find the attr spec
        spec = self._find_spec_for_elem_direct(elem)
        if spec is None:
            return
        attr_spec = spec.attrs.get(attr_name)
        if attr_spec is None or attr_spec.default is None:
            return
        elem.set(attr_name, str(attr_spec.default))
        patches.append(self._make_patch(
            type_="insert",
            path=violation.path,
            original="",
            replacement=f'{attr_name}="{attr_spec.default}"',
            rule_id="default_attr_insertion_v1",
            confidence=0.95,
            notes=f"Inserted missing required attribute '{attr_name}' with default '{attr_spec.default}'",
        ))

    def _fix_text_type(
        self,
        elem: etree._Element,
        doc_root: etree._Element,
        violation: Violation,
        patches: list[Patch],
    ) -> None:
        spec = self._find_spec_for_elem(elem, doc_root)
        if spec is None:
            return
        original_text = elem.text or ""
        coerced, confidence = _coerce_value(original_text, spec.text_type)
        if coerced is None:
            return

        path = xpath_path(elem, doc_root)
        elem.text = coerced
        patches.append(self._make_patch(
            type_="coerce",
            path=path,
            original=original_text,
            replacement=coerced,
            rule_id="type_coerce_v1",
            confidence=confidence,
            notes=f"Coerced text '{original_text}' → '{coerced}' for type '{spec.text_type}'",
        ))

    def _fix_attr_type(
        self,
        elem: etree._Element,
        doc_root: etree._Element,
        violation: Violation,
        patches: list[Patch],
    ) -> None:
        attr_name = violation.extra.get("attr", "")
        value = violation.extra.get("value", "")
        spec = self._find_spec_for_elem(elem, doc_root)
        if spec is None or attr_name not in spec.attrs:
            return
        attr_spec = spec.attrs[attr_name]
        coerced, confidence = _coerce_value(value, attr_spec.type)
        if coerced is None:
            return
        elem.set(attr_name, coerced)
        patches.append(self._make_patch(
            type_="coerce",
            path=violation.path,
            original=value,
            replacement=coerced,
            rule_id="attr_coerce_v1",
            confidence=confidence,
            notes=f"Coerced attr '{attr_name}' '{value}' → '{coerced}' for type '{attr_spec.type}'",
        ))

    def _fix_order(
        self,
        parent_elem: etree._Element,
        violation: Violation,
        patches: list[Patch],
        doc_root: etree._Element,
    ) -> None:
        spec = self._find_spec_for_elem(parent_elem, doc_root)
        if spec is None or not spec.order_enforced:
            return

        ordered_specs = sorted(
            [cs for cs in spec.children if cs.order_index is not None],
            key=lambda cs: cs.order_index,  # type: ignore[arg-type]
        )
        expected_names = [cs.spec.name for cs in ordered_specs]

        original_order = [_local_name(c.tag) for c in parent_elem]
        children = list(parent_elem)
        for child in children:
            parent_elem.remove(child)

        # Re-insert according to expected order, then unknown remainder
        name_to_children: dict[str, list[etree._Element]] = {}
        others: list[etree._Element] = []
        for child in children:
            local = _local_name(child.tag)
            if local in expected_names:
                name_to_children.setdefault(local, []).append(child)
            else:
                others.append(child)

        new_order: list[str] = []
        for name in expected_names:
            for child in name_to_children.get(name, []):
                parent_elem.append(child)
                new_order.append(name)
        for child in others:
            parent_elem.append(child)

        patches.append(self._make_patch(
            type_="reorder",
            path=xpath_path(parent_elem, doc_root),
            original=str(original_order),
            replacement=str(new_order),
            rule_id="reorder_children_v1",
            confidence=1.0,
            notes="Reordered children to match order_enforced schema",
        ))

    # ------------------------------------------------------------------
    # Spec lookup helpers
    # ------------------------------------------------------------------

    def _find_spec_for_elem(
        self,
        elem: etree._Element,
        doc_root: etree._Element,
    ) -> ElementSpec | None:
        """Walk the schema tree to find the ElementSpec matching elem."""
        path_parts = self._elem_path_parts(elem, doc_root)
        if not path_parts:
            return None
        spec = self.schema.root
        if spec.name != path_parts[0]:
            return None
        for part in path_parts[1:]:
            found = False
            for cs in spec.children:
                if cs.spec.name == part or part in cs.spec.aliases:
                    spec = cs.spec
                    found = True
                    break
            if not found:
                return None
        return spec

    def _find_spec_for_elem_direct(self, elem: etree._Element) -> ElementSpec | None:
        """Find spec without needing doc_root; uses the element itself as anchor."""
        # Walk up to root
        ancestors: list[etree._Element] = []
        node = elem
        while node is not None:
            ancestors.insert(0, node)
            node = node.getparent()
        if not ancestors:
            return None
        doc_root = ancestors[0]
        return self._find_spec_for_elem(elem, doc_root)

    @staticmethod
    def _elem_path_parts(elem: etree._Element, doc_root: etree._Element) -> list[str]:
        parts: list[str] = []
        node = elem
        while node is not None:
            parts.insert(0, _local_name(node.tag))
            if node is doc_root:
                break
            node = node.getparent()
        return parts

    # ------------------------------------------------------------------
    # Patch factory
    # ------------------------------------------------------------------

    def _make_patch(
        self,
        type_: str,
        path: str,
        original: str,
        replacement: str,
        rule_id: str,
        confidence: float,
        notes: str = "",
        source: str = "deterministic",
    ) -> Patch:
        return Patch(
            type=type_,
            path=path,
            original=original,
            replacement=replacement,
            rule_id=rule_id,
            confidence=confidence,
            auto_applied=confidence >= self.auto_apply_threshold,
            source=source,
            notes=notes,
        )


# ---------------------------------------------------------------------------
# Heuristic repairer (aggressive mode)
# ---------------------------------------------------------------------------

class HeuristicRepairer:
    """
    Aggressive heuristic repairs for deeply broken XML that the
    deterministic pass could not fully fix.

    Uses a simple DP-based minimal-edit approach to reconstruct the tree.
    Patches from this repairer are marked auto_applied=False unless
    confidence >= auto_apply_threshold.
    """

    def __init__(self, schema: Schema, auto_apply_threshold: float = 0.9) -> None:
        self.schema = schema
        self.auto_apply_threshold = auto_apply_threshold

    def repair(self, root: etree._Element, doc_root: etree._Element) -> list[Patch]:
        """
        Apply heuristic repairs. Returns new patches (does not re-run deterministic).
        Mutates root in place.
        """
        patches: list[Patch] = []
        self._rebalance_tags(root, doc_root, patches)
        return patches

    def _rebalance_tags(
        self,
        root: etree._Element,
        doc_root: etree._Element,
        patches: list[Patch],
    ) -> None:
        """
        Attempt to detect and fix mismatched/missing closing tags by
        reconstructing the tree from a stack-based walk.

        lxml's recover=True already handles many such cases; this is a
        secondary pass to detect structural inconsistencies and merge
        accidentally split nodes.
        """
        spec = self.schema.root
        self._merge_duplicate_siblings(root, spec, doc_root, patches)

    def _merge_duplicate_siblings(
        self,
        parent: etree._Element,
        spec: ElementSpec,
        doc_root: etree._Element,
        patches: list[Patch],
    ) -> None:
        """Merge adjacent siblings that should appear only once per schema."""
        from collections import Counter

        child_specs = {cs.spec.name: cs for cs in spec.children}
        tag_counts: Counter[str] = Counter(_local_name(c.tag) for c in parent)

        for tag, count in tag_counts.items():
            cs = child_specs.get(tag)
            if cs is None:
                continue
            max_oc = cs.max_occurs
            if max_oc == "unbounded":
                continue
            if isinstance(max_oc, int) and count <= max_oc:
                continue

            # Merge excess occurrences into the first one
            occurrences = [c for c in parent if _local_name(c.tag) == tag]
            primary = occurrences[0]
            for extra in occurrences[1:]:
                # Move children of extra into primary
                for grandchild in list(extra):
                    primary.append(grandchild)
                if extra.text and primary.text is None:
                    primary.text = extra.text
                original_xml = etree.tostring(extra, encoding="unicode")
                parent.remove(extra)
                patches.append(Patch(
                    type="delete",
                    path=xpath_path(primary, doc_root),
                    original=original_xml,
                    replacement="(merged into primary)",
                    rule_id="heuristic_merge_v1",
                    confidence=0.7,
                    auto_applied=False,
                    source="heuristic",
                    notes=f"Merged duplicate <{tag}> element (max_occurs={max_oc})",
                ))

        # Recurse
        for child in parent:
            local = _local_name(child.tag)
            cs = child_specs.get(local)
            if cs:
                self._merge_duplicate_siblings(child, cs.spec, doc_root, patches)


# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------

def _recompose_tag(original_tag: str, new_local: str) -> str:
    """Preserve namespace if present: {uri}old → {uri}new."""
    if original_tag.startswith("{"):
        ns, _ = original_tag[1:].split("}", 1)
        return f"{{{ns}}}{new_local}"
    return new_local
