"""
xmldev.parser
~~~~~~~~~~~~~
Tolerant XML parser and canonicalizer.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field

from lxml import etree


# ---------------------------------------------------------------------------
# Result
# ---------------------------------------------------------------------------

@dataclass
class ParseResult:
    root: etree._Element | None
    parse_errors: list[str] = field(default_factory=list)
    recovered: bool = False


# ---------------------------------------------------------------------------
# Error code
# ---------------------------------------------------------------------------

class ParseError(RuntimeError):
    """Raised when parsing fails even in recovery mode (XM02)."""
    code = "XM02"


# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------

def tolerant_parse(xml_input: str | bytes) -> ParseResult:
    """
    Parse XML, falling back to lxml's recovery mode on failure.

    Parameters
    ----------
    xml_input : str | bytes
        Raw XML content.

    Returns
    -------
    ParseResult
        Contains the root element (or None) and any parse errors captured.

    Raises
    ------
    ParseError (XM02)
        If even recovery mode yields an empty/None tree.
    """
    if isinstance(xml_input, str):
        xml_bytes = xml_input.encode("utf-8")
    else:
        xml_bytes = xml_input

    # --- strict attempt ---
    try:
        root = etree.fromstring(xml_bytes)
        return ParseResult(root=root)
    except etree.XMLSyntaxError:
        pass

    # --- recovery attempt ---
    errors: list[str] = []
    parser = etree.XMLParser(recover=True, collect_ids=False)

    try:
        root = etree.fromstring(xml_bytes, parser=parser)
        for err in parser.error_log:
            errors.append(str(err))

        if root is None:
            raise ParseError(
                "[XM02] Parsing failed and recovery returned an empty tree. "
                "The input may not be XML at all."
            )

        return ParseResult(root=root, parse_errors=errors, recovered=True)

    except etree.XMLSyntaxError as exc:
        raise ParseError(f"[XM02] Recovery parse failed: {exc}") from exc


# ---------------------------------------------------------------------------
# Canonicalizer
# ---------------------------------------------------------------------------

_INSIGNIFICANT_WS_RE = re.compile(r"^\s+$")


def canonicalize(root: etree._Element) -> etree._Element:
    """
    Normalize a parsed element tree in-place:

    1. Strip insignificant whitespace (text/tail that is only whitespace).
    2. Normalize namespace prefixes: keep prefix as-is but ensure no
       duplicate/stray namespace declarations on children when the parent
       already holds the same URI.
    3. Flatten multiple adjacent text nodes (lxml already does this, but
       we strip leading/trailing whitespace from text/tail values).

    Returns the root element for convenience (mutation is in-place).
    """
    _strip_whitespace(root)
    _normalize_namespaces(root)
    return root


def _strip_whitespace(elem: etree._Element) -> None:
    """Recursively strip insignificant text/tail whitespace."""
    if elem.text and _INSIGNIFICANT_WS_RE.fullmatch(elem.text):
        elem.text = None
    elif elem.text:
        elem.text = elem.text.strip() or None

    for child in elem:
        _strip_whitespace(child)

    if elem.tail and _INSIGNIFICANT_WS_RE.fullmatch(elem.tail):
        elem.tail = None
    elif elem.tail:
        elem.tail = elem.tail.strip() or None


def _normalize_namespaces(root: etree._Element) -> None:
    """
    Walk the tree. For each element whose tag is Clark-notation {uri}local,
    collect all URI→prefix mappings from the root and ensure consistent usage.

    lxml already handles most of this; here we collect the canonical map from
    the root's nsmap and store it as a convenience in `root.nsmap` which is
    already populated by lxml. This function is a no-op guard that can be
    extended later.
    """
    # Collect all namespaces declared in the document
    all_ns: dict[str, str] = {}
    for elem in root.iter():
        all_ns.update(elem.nsmap)  # type: ignore[arg-type]

    # Nothing to rewrite; lxml resolves prefixes correctly.
    # Future: remap prefixes to canonical short forms here if needed.
    _ = all_ns


def xpath_path(elem: etree._Element, root: etree._Element) -> str:
    """
    Build an XPath-like positional path for *elem* relative to *root*.
    Example: /person[1]/address[2]/city[1]
    """
    parts: list[str] = []
    node = elem
    while node is not None and node is not root.getparent():
        parent = node.getparent()
        if parent is None:
            local = _local_name(node.tag)
            parts.append(f"/{local}[1]")
            break
        siblings = [c for c in parent if _local_name(c.tag) == _local_name(node.tag)]
        idx = siblings.index(node) + 1
        parts.append(f"/{_local_name(node.tag)}[{idx}]")
        node = parent

    parts.reverse()
    return "".join(parts)


def _local_name(tag: str) -> str:
    """Strip Clark-notation namespace from a tag: {uri}local → local."""
    if tag.startswith("{"):
        return tag.split("}", 1)[1]
    return tag
