"""Pure domain-XML rewrite helpers (inject-Element only).

Extracted from ``mgf.vm.operations.clone`` and
``mgf.vm.operations.backup`` (Wave 1). The originals each began with a
``parse_xml_string(...)`` call — that parse is an **adapter** concern
(untrusted XML, ``defusedxml``, forbidden in the pure core). It is
removed here.

The boundary convention (see :mod:`mgf.vm.core.common.xml`): the adapter
parses the untrusted XML into a stdlib :class:`~xml.etree.ElementTree.Element`
and injects that tree into these functions. Core mutates and serializes
the tree using only the stdlib :mod:`xml.etree.ElementTree` API — no byte
stream is touched, so no XXE surface exists here.

Each rewrite returns the serialized ``<domain>...</domain>`` string. The
caller (adapter) decides what to do with it (hand to ``define_domain``).
"""

from __future__ import annotations

import re
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, TypeVar

from mgf.vm.core.exceptions import BackupError, SpecValidationError

if TYPE_CHECKING:
    from collections.abc import Sequence
    from pathlib import Path

_PathT = TypeVar("_PathT")


def default_disk_target(index: int) -> str:
    """Return the synthetic guest target dev for the ``index``-th disk.

    libvirt names virtio disks ``vda``, ``vdb``, ``vdc`` ... — i.e.
    ``vd`` followed by the ``index``-th lowercase letter. Used when the
    domain XML does not carry an explicit ``<target dev="...">`` for a
    disk (or when the adapter could not parse the XML at all and falls
    back to positional naming).
    """
    return f"vd{chr(ord('a') + index)}"


def pair_disk_targets(
    root: ET.Element,
    disk_paths: Sequence[_PathT],
) -> list[tuple[str, _PathT]]:
    """Pair each disk path with its guest target dev, positionally.

    Extracted from ``mgf.vm.operations.backup._parse_disk_targets``. The
    original began with a ``parse_xml_string`` call and a
    ``ParseError`` fallback to synthetic names — both of those are
    **adapter** concerns (untrusted parse). Here the adapter has already
    parsed the live domain XML into ``root`` and injects it; if the
    adapter could not parse it, it pairs with :func:`default_disk_target`
    on its own side.

    ``disk_paths`` is matched against ``<devices>/<disk>`` elements in
    document order. For disk ``i`` we read ``<disk>[i]/<target dev>``;
    if that element (or its ``dev`` attribute) is absent — or there are
    more paths than disk elements — we fall back to
    :func:`default_disk_target`. The path objects are returned
    unchanged, so the caller's path type (``str`` or ``Path``) is
    preserved.
    """
    disk_elements = root.findall("devices/disk")
    paired: list[tuple[str, _PathT]] = []
    for i, path in enumerate(disk_paths):
        target: str | None = None
        if i < len(disk_elements):
            target_el = disk_elements[i].find("target")
            if target_el is not None:
                target = target_el.get("dev")
        paired.append((target or default_disk_target(i), path))
    return paired


def find_parent(root: ET.Element, child: ET.Element) -> ET.Element | None:
    """Return the parent of ``child`` inside ``root``, or ``None``.

    ElementTree does not expose a parent pointer, so we walk the tree
    once. Only called when there is actually an element to remove, so the
    cost is negligible.
    """
    for candidate in root.iter():
        if child in list(candidate):
            return candidate
    return None


def strip_extra_whitespace(xml_text: str) -> str:
    """Normalise serialized XML so tests assert on stable text.

    :func:`xml.etree.ElementTree.tostring` can leave long runs of
    whitespace between tags. This collapses blank-line runs only; it does
    not touch element content.
    """
    return re.sub(r"\n\s*\n", "\n", xml_text)


def rewrite_clone_domain(
    root: ET.Element,
    *,
    new_name: str,
    new_uuid: str,
    disk_substitutions: dict[str, str],
) -> str:
    """Rewrite a parsed clone-source domain tree in place and serialize.

    Replaces ``<name>``, ``<uuid>``, and matching ``<devices>/<disk>/
    <source file="...">`` paths; strips every ``<mac>`` so libvirt picks
    a fresh address per NIC. Every other field (NICs, CPU, firmware,
    display) is inherited verbatim.

    ``disk_substitutions`` maps an old source-file path string to its new
    path string. The adapter is responsible for building this map (it
    knows the on-disk layout) and for parsing ``root`` from the source
    XML beforehand.

    Raises :class:`SpecValidationError` if the serialized result is not a
    ``<domain>`` blob a provider would accept.
    """
    # <name>
    name_el = root.find("name")
    if name_el is not None:
        name_el.text = new_name
    else:
        # Extraordinarily rare — every libvirt domain XML has a name.
        new_el = ET.Element("name")
        new_el.text = new_name
        root.insert(0, new_el)

    # <uuid>
    uuid_el = root.find("uuid")
    if uuid_el is not None:
        uuid_el.text = new_uuid
    else:
        new_uuid_el = ET.Element("uuid")
        new_uuid_el.text = new_uuid
        root.insert(0, new_uuid_el)

    # <disk>/<source file="...">
    for source_el in root.findall(".//devices/disk/source"):
        file_attr = source_el.get("file")
        if file_attr is None:
            continue
        if file_attr in disk_substitutions:
            source_el.set("file", disk_substitutions[file_attr])

    # Strip any MAC address so libvirt picks a fresh one per NIC;
    # without this, clones would collide on the same MAC.
    for mac_el in root.findall(".//devices/interface/mac"):
        parent = find_parent(root, mac_el)
        if parent is not None:
            parent.remove(mac_el)

    serialized = ET.tostring(root, encoding="unicode")
    if not serialized.lstrip().startswith("<domain"):
        raise SpecValidationError(["source XML does not have a <domain> root element"])
    return strip_extra_whitespace(serialized)


def rewrite_restore_domain(
    root: ET.Element,
    *,
    new_name: str,
    disk_files: tuple[str, ...],
    destination_dir: Path,
) -> str:
    """Rewrite a parsed restore-manifest domain tree in place and serialize.

    Renames the domain, drops the ``<uuid>`` (so libvirt assigns a fresh
    one), points each ``<devices>/<disk>/<source>`` at the restored file
    under ``destination_dir`` (positionally, matching ``disk_files``
    order), and strips every ``<mac>``.

    ``disk_files`` are the archive-relative disk filenames in manifest
    order; ``destination_dir`` is the host directory string where the
    restored files now live (a :class:`~pathlib.Path`). The adapter parses ``root`` from the
    manifest's ``domain_xml`` (untrusted) before calling.
    """
    name_el = root.find("name")
    if name_el is None:
        name_el = ET.SubElement(root, "name")
    name_el.text = new_name

    uuid_el = root.find("uuid")
    if uuid_el is not None:
        root.remove(uuid_el)

    # Replace each <source file='...'/> with the restored path, matching
    # disks positionally against the manifest order.
    disk_elements = root.findall("devices/disk")
    for i, disk_el in enumerate(disk_elements):
        if i >= len(disk_files):
            break
        source = disk_el.find("source")
        if source is None:
            continue
        new_path = destination_dir / disk_files[i]
        source.set("file", str(new_path))
        source.attrib.pop("dev", None)

    # Strip MAC addresses so libvirt assigns fresh ones.
    for mac in root.findall("devices/interface/mac"):
        parent = find_parent(root, mac)
        if parent is not None:
            parent.remove(mac)

    serialized = ET.tostring(root, encoding="unicode")
    if not serialized.lstrip().startswith("<domain"):
        raise BackupError("backup manifest XML does not have a <domain> root element")
    return serialized


__all__ = [
    "default_disk_target",
    "find_parent",
    "pair_disk_targets",
    "rewrite_clone_domain",
    "rewrite_restore_domain",
    "strip_extra_whitespace",
]
