"""``semantic_equal`` — semantic XML equality for define() idempotency.

This is the **load-bearing** gate for the ``define()`` idempotency
contract (contracts.md §3): when a domain already exists, the backend
compares the spec's generated XML against libvirt's ``XMLDesc()`` of the
live domain. Equal → return the existing ``DomainId`` (idempotent).
Different → raise ``SpecConflict``. Getting this wrong breaks the
contract in both directions:

* a false *negative* (declaring equal-things-different) turns every
  reconcile into a spurious ``SpecConflict``;
* a false *positive* (declaring different-things-equal) silently ignores
  a real config drift.

Why never byte equality: libvirt's ``XMLDesc()`` **reorders** children,
injects defaulted elements (``<clock>``, ``<seclabel>``, auto-assigned
``<address>`` PCI slots), rewrites whitespace, and adds comments. Byte
comparison against our generator's output would *always* report
"different". So we **canonicalize** both sides and compare the
canonical forms.

Canonicalization (decision: compare load-bearing fields only):

1. parse untrusted XML with :mod:`defusedxml`;
2. drop comments and processing instructions;
3. normalise each element's whitespace-only text/tail to empty;
4. **stable-sort** every element's children by a key derived from
   ``(tag, sorted-attributes, text)`` so sibling order never matters;
5. sort attributes within each element;
6. serialise the canonical tree to a string and compare.

The comparison is intentionally *structural over the whole tree* rather
than a hand-picked field list: that keeps it honest (a new element the
generator emits is automatically compared) while the canonical form
absorbs libvirt's cosmetic reordering. Elements libvirt is known to
inject that we never emit are **not** stripped here — instead the
backend compares its *own generated XML* on both sides where it can
(define path), and uses this comparator's canonical form for the
spec-vs-live gate where libvirt-injected defaults are pre-filtered by
:func:`strip_runtime` before the call.
"""

from __future__ import annotations

from xml.etree import ElementTree as ET

from defusedxml import ElementTree as DefusedET

# Elements libvirt injects at define/start time that carry no spec intent
# — stripped before comparison so a freshly-generated spec compares equal
# to a live domain that libvirt has decorated. Conservative list: only
# elements that are pure runtime/host bookkeeping.
_RUNTIME_ELEMENTS = frozenset(
    {
        "uuid",  # libvirt assigns if the spec omitted it
        "seclabel",  # sVirt label, host-assigned
        "address",  # auto-assigned PCI/USB addresses
        "alias",  # libvirt-internal device aliases
        "clock",  # defaulted offset
        "on_poweroff",
        "on_reboot",
        "on_crash",
        "pm",  # power-management defaults
        "resource",  # cgroup partition, host-assigned
    }
)


def canonicalize(xml: str, *, strip_runtime_elements: bool = True) -> str:
    """Return the canonical form of ``xml`` (see module docstring).

    ``strip_runtime_elements=True`` removes the libvirt-injected runtime
    elements so spec-vs-live comparisons line up; pass ``False`` to
    canonicalize while keeping every element (used when comparing two
    generated specs against each other).
    """
    root = _parse(xml)
    if strip_runtime_elements:
        _strip(root)
    _normalize(root)
    return _serialize_canonical(root)


def semantic_equal(
    spec_xml: str,
    live_xml: str,
    *,
    strip_runtime_elements: bool = True,
) -> bool:
    """True if the two domain XML documents are semantically equivalent.

    Order-insensitive, whitespace-insensitive, comment-insensitive.
    Used by ``define()`` to decide idempotent-return vs ``SpecConflict``.
    """
    return canonicalize(spec_xml, strip_runtime_elements=strip_runtime_elements) == canonicalize(
        live_xml, strip_runtime_elements=strip_runtime_elements
    )


# ---------------------------------------------------------------------------
# internals
# ---------------------------------------------------------------------------


def _parse(xml: str) -> ET.Element:
    """Defused-parse, dropping comments/PIs at the parser level."""
    try:
        root: ET.Element = DefusedET.fromstring(xml)
    except Exception as exc:
        raise ValueError(f"malformed XML for comparison: {exc}") from exc
    return root


def _strip(el: ET.Element) -> None:
    """Recursively remove runtime-only child elements in place."""
    to_remove = [child for child in el if _localname(child.tag) in _RUNTIME_ELEMENTS]
    for child in to_remove:
        el.remove(child)
    for child in el:
        _strip(child)


def _normalize(el: ET.Element) -> None:
    """Blank whitespace-only text/tail; recurse. (Comments are gone post-parse.)"""
    if el.text is not None and not el.text.strip():
        el.text = None
    if el.tail is not None and not el.tail.strip():
        el.tail = None
    for child in el:
        _normalize(child)


def _serialize_canonical(el: ET.Element) -> str:
    """Serialise with children stable-sorted and attributes sorted.

    Builds a fresh tree (so the sort is stable and recursive) rather
    than mutating ``el`` in place, then emits a single line.
    """
    canon = _rebuild(el)
    return ET.tostring(canon, encoding="unicode")


def _rebuild(el: ET.Element) -> ET.Element:
    """Return a copy of ``el`` with sorted attrs and stable-sorted kids.

    Children are sorted by their *own full canonical serialisation*, so
    the ordering is deterministic to arbitrary depth (two same-tag
    siblings that differ only deep inside still sort stably).
    """
    new = ET.Element(_localname(el.tag), {k: el.get(k, "") for k in sorted(el.keys())})
    text = (el.text or "").strip()
    new.text = text or None
    rebuilt = [_rebuild(child) for child in el]
    rebuilt.sort(key=lambda c: ET.tostring(c, encoding="unicode"))
    new.extend(rebuilt)
    return new


def _localname(tag: str) -> str:
    """Strip any ``{namespace}`` prefix ElementTree prepends."""
    return tag.rsplit("}", 1)[-1] if "}" in tag else tag


__all__ = ["canonicalize", "semantic_equal"]
