"""``xml_to_domaininfo`` — domain XML → core :class:`DomainInfo`.

Reuses the per-subsystem parsing shape from the legacy
``configurator/reader.py`` ``_read_*`` helpers, but outputs the
**operational** :class:`mgf.vm.core.contracts.DomainInfo` (the
engine-neutral view the reconciler consumes) rather than the legacy
``VMConfig`` aggregate.

Security: the XML here is **untrusted** — it comes back from libvirt's
``XMLDesc()`` (which can reflect operator-injected metadata) or off
disk (OVF import). It is parsed with :mod:`defusedxml` to neutralise
the billion-laughs / external-entity / DTD-retrieval classes (SH-02 /
SH-03 carried forward from the old vmanager hardening). Output XML is
built with stdlib ElementTree elsewhere. **This is the adapter boundary**
(per ``mgf.vm.core.common.xml``): core never parses untrusted XML, the
adapter does, here.

State inference: ``DomainInfo`` carries a single ``is_running`` bool.
Domain XML alone does not encode the *live* run state (that lives on the
``virDomain`` object, not in the persistent XML), so :func:`xml_to_domaininfo`
defaults ``is_running=False`` and exposes :func:`infer_is_running` for
the backend to fold in the libvirt domain-state code when it has one.

Observed sub-state (``disks`` / ``nics`` / ``devices_attached``): the XML
gives the static shape (target/source/model/mac/network, hostdev
addresses); the *live* enrichment (a disk's capacity/allocation, a NIC's
leased IP) lives on the ``virDomain`` object, not the XML. So
:func:`xml_to_domaininfo` takes two optional callables —
:paramref:`block_info` (wraps ``virDomain.blockInfo``) and
:paramref:`interface_addresses` (wraps ``virDomain.interfaceAddresses``)
— which the backend supplies under its connection lock. Enrichment is
**best-effort**: a callable that returns ``None`` (or raises, swallowed
here) degrades that one field to its default and never aborts the read.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from defusedxml import ElementTree as DefusedET
from mgf.vm.core.contracts import AttachedDevice, DiskStatus, DomainInfo, NicStatus
from mgf.vm.core.exceptions import SpecInvalid
from mgf.vm.core.ids import DomainId

if TYPE_CHECKING:
    from collections.abc import Callable
    from xml.etree.ElementTree import Element

# libvirt virDomainState values (stable ABI). Running == 1, blocked == 2;
# everything else is "not running" for the coarse DomainInfo bool.
_VIR_DOMAIN_RUNNING = 1
_VIR_DOMAIN_BLOCKED = 2
_RUNNING_STATES = frozenset({_VIR_DOMAIN_RUNNING, _VIR_DOMAIN_BLOCKED})

#: Enrichment callable signatures. The backend wires these to the live
#: ``virDomain`` object (under the connection lock); ``read`` calls them
#: best-effort and swallows any failure.
#:
#: * ``BlockInfoFn(target) -> (capacity_bytes, allocation_bytes) | None``
#: * ``IfaceAddrsFn() -> {iface_name: {"hwaddr": mac, "addrs": [{"addr": ip}]}} | None``
if TYPE_CHECKING:
    BlockInfoFn = Callable[[str], tuple[int, int] | None]
    IfaceAddrsFn = Callable[[], dict[str, object] | None]


def _parse(xml: str) -> Element:
    """Defused-parse ``xml`` to a root element, or raise ``SpecInvalid``."""
    try:
        # defusedxml.ElementTree.fromstring returns a stdlib Element but
        # with the dangerous parser features disabled.
        root: Element = DefusedET.fromstring(xml)
    except Exception as exc:
        raise SpecInvalid(f"malformed domain XML: {exc}") from exc
    return root


def xml_to_domaininfo(
    xml: str,
    *,
    state_code: int | None = None,
    block_info: BlockInfoFn | None = None,
    interface_addresses: IfaceAddrsFn | None = None,
) -> DomainInfo:
    """Parse domain XML into a core :class:`DomainInfo`.

    Args:
        xml: a libvirt domain XML document (persistent or live form).
        state_code: optional libvirt ``virDomainState`` integer from the
            ``virDomain.state()`` call. When supplied, ``is_running`` is
            inferred from it; otherwise it defaults to ``False`` (the XML
            cannot tell us the live state on its own).
        block_info: optional best-effort enrichment for disk sizes — given
            a disk ``target`` (e.g. ``vda``) it returns
            ``(capacity_bytes, allocation_bytes)`` or ``None``. Wired to
            ``virDomain.blockInfo`` by the backend. A failure on one disk
            degrades *that* disk's sizes to defaults; it never aborts.
        interface_addresses: optional best-effort enrichment for NIC IPs —
            returns the ``virDomain.interfaceAddresses`` mapping (keyed by
            interface name, each ``{"hwaddr": …, "addrs": [{"addr": …}]}``)
            or ``None``. Used to fill :attr:`NicStatus.ip` by MAC match.

    Raises:
        SpecInvalid: if the XML is malformed or missing required fields.
    """
    root = _parse(xml)

    name = _findtext(root, "name")
    if not name:
        raise SpecInvalid("domain XML missing required <name> element")

    domain_uuid = _findtext(root, "uuid") or name

    return DomainInfo(
        domain_id=DomainId(domain_uuid),
        name=name,
        cpu=_read_vcpus(root),
        memory_bytes=_read_memory_bytes(root),
        is_running=infer_is_running(state_code),
        disks=_read_disks(root, block_info=block_info),
        nics=_read_nics(root, interface_addresses=interface_addresses),
        devices_attached=_read_devices_attached(root),
    )


def infer_is_running(state_code: int | None) -> bool:
    """Map a libvirt ``virDomainState`` code to the coarse running bool."""
    if state_code is None:
        return False
    return state_code in _RUNNING_STATES


# ---------------------------------------------------------------------------
# Per-subsystem readers (lifted from configurator/reader._read_*)
# ---------------------------------------------------------------------------


def _read_vcpus(root: Element) -> int:
    """Read the active vCPU count (``current`` attr, else element text)."""
    el = _find(root, "vcpu")
    if el is None:
        return 1
    current = el.get("current")
    if current:
        return _int(current, default=1)
    return _int((el.text or "").strip(), default=1)


def _read_memory_bytes(root: Element) -> int:
    """Read ``<currentMemory>`` (falling back to ``<memory>``) in bytes.

    libvirt encodes a ``unit`` attribute; the legacy reader handled
    KiB/MiB/GiB/bytes and we keep that table.
    """
    el = _find(root, "currentMemory")
    if el is None:
        el = _find(root, "memory")
    if el is None or not (el.text or "").strip():
        return 0
    value = _int(el.text or "", default=0)
    unit = (el.get("unit") or "KiB").lower()
    factors = {
        "bytes": 1,
        "b": 1,
        "kib": 1024,
        "k": 1024,
        "kb": 1000,
        "mib": 1024 * 1024,
        "m": 1024 * 1024,
        "gib": 1024 * 1024 * 1024,
        "g": 1024 * 1024 * 1024,
    }
    return value * factors.get(unit, 1024)


# ---------------------------------------------------------------------------
# Observed sub-state: disks / nics / devices_attached.
#
# Static shape comes from the XML (parsed here, the adapter boundary). The
# live enrichment (disk sizes, NIC IPs) comes from the optional callables
# the backend wires to the ``virDomain`` object — applied best-effort so a
# stopped domain or a transient engine hiccup yields partial fields, never
# an exception.
# ---------------------------------------------------------------------------


def _read_disks(
    root: Element,
    *,
    block_info: BlockInfoFn | None,
) -> tuple[DiskStatus, ...]:
    """Parse ``<devices>/<disk>`` into ``DiskStatus`` tuples.

    ``<target dev=>`` → ``target`` (the per-domain handle — a disk with no
    target is unaddressable and skipped). ``<source file=|dev=>`` →
    ``source``. ``<readonly/>`` → ``readonly``. Sizes are enriched via
    :paramref:`block_info` keyed by target; a failure / ``None`` leaves the
    defaults (``size_bytes=0``, ``allocation_bytes=None``) for that disk.
    """
    out: list[DiskStatus] = []
    for disk in root.findall("devices/disk"):
        target_el = disk.find("target")
        target = target_el.get("dev") if target_el is not None else None
        if not target:
            # No guest-visible handle → not an addressable disk; skip.
            continue
        source = _disk_source(disk)
        readonly = disk.find("readonly") is not None
        size_bytes = 0
        allocation_bytes: int | None = None
        if block_info is not None:
            sizes = _safe_block_info(block_info, target)
            if sizes is not None:
                size_bytes, allocation_bytes = sizes
        out.append(
            DiskStatus(
                target=target,
                source=source,
                size_bytes=size_bytes,
                readonly=readonly,
                allocation_bytes=allocation_bytes,
            )
        )
    return tuple(out)


def _disk_source(disk: Element) -> str | None:
    """Extract a disk's backing source path/URI, or ``None`` if sourceless.

    libvirt's ``<source>`` carries the path under different attributes per
    disk ``type``: ``file`` (file-backed), ``dev`` (block device), ``name``
    (network/rbd), ``volume`` (pool volume), ``dir`` (dir-backed).
    """
    source_el = disk.find("source")
    if source_el is None:
        return None  # e.g. an empty cdrom — sourceless
    for attr in ("file", "dev", "name", "volume", "dir"):
        value = source_el.get(attr)
        if value:
            return value
    return None


def _safe_block_info(block_info: BlockInfoFn, target: str) -> tuple[int, int] | None:
    """Call ``block_info(target)`` best-effort; swallow any failure.

    Degrade-on-failure: a ``libvirtError`` (or anything) on one disk —
    e.g. the backing file is gone, or the domain is off and the engine
    refuses ``blockInfo`` — yields ``None`` so only *that* disk's sizes
    fall back to defaults. The whole-domain read is never aborted.
    """
    try:
        return block_info(target)
    except Exception:
        return None


def _read_nics(
    root: Element,
    *,
    interface_addresses: IfaceAddrsFn | None,
) -> tuple[NicStatus, ...]:
    """Parse ``<devices>/<interface>`` into ``NicStatus`` tuples.

    ``<model type=>`` → ``model`` (default ``virtio``). ``<mac address=>``
    → ``mac``. ``<source network=|bridge=>`` → ``network``. The observed
    ``ip`` is enriched by matching the NIC's MAC against the
    :paramref:`interface_addresses` mapping; if that callable is absent,
    returns ``None``, raises, or has no matching/usable entry, ``ip`` stays
    ``None`` (e.g. the domain is off, or no DHCP lease yet).
    """
    addrs_by_mac = _addrs_by_mac(interface_addresses)
    out: list[NicStatus] = []
    for iface in root.findall("devices/interface"):
        model_el = iface.find("model")
        model = (model_el.get("type") if model_el is not None else None) or "virtio"
        mac_el = iface.find("mac")
        mac = mac_el.get("address") if mac_el is not None else None
        network = _nic_network(iface)
        ip = addrs_by_mac.get(mac.lower()) if mac else None
        out.append(NicStatus(model=model, mac=mac, network=network, ip=ip))
    return tuple(out)


def _nic_network(iface: Element) -> str:
    """The virtual network/bridge a NIC attaches to (``""`` if neither)."""
    source_el = iface.find("source")
    if source_el is None:
        return ""
    for attr in ("network", "bridge", "dev"):
        value = source_el.get(attr)
        if value:
            return value
    return ""


def _addrs_by_mac(interface_addresses: IfaceAddrsFn | None) -> dict[str, str]:
    """Build a ``{mac: first-ip}`` map from the enrichment callable.

    Best-effort: a missing callable, a raise, or an unusable shape yields
    an empty map (so every NIC's ``ip`` stays ``None``). The libvirt
    ``interfaceAddresses`` return is keyed by interface name, each entry a
    ``{"hwaddr": mac, "addrs": [{"addr": ip, ...}, ...]}`` mapping.
    """
    if interface_addresses is None:
        return {}
    try:
        raw = interface_addresses()
    except Exception:
        return {}
    if not isinstance(raw, dict):
        return {}
    by_mac: dict[str, str] = {}
    for entry in raw.values():
        if not isinstance(entry, dict):
            continue
        hwaddr = entry.get("hwaddr")
        addrs = entry.get("addrs")
        if not isinstance(hwaddr, str) or not isinstance(addrs, list):
            continue
        for addr in addrs:
            if isinstance(addr, dict):
                ip = addr.get("addr")
                if isinstance(ip, str) and ip:
                    by_mac[hwaddr.lower()] = ip
                    break  # first usable address wins
    return by_mac


def _read_devices_attached(root: Element) -> tuple[AttachedDevice, ...]:
    """Parse ``<devices>/<hostdev>`` passthrough into ``AttachedDevice`` tuples.

    Each ``<hostdev type=>`` becomes an :class:`AttachedDevice` whose
    ``kind`` is the hostdev type (``pci`` / ``usb`` / …), whose
    ``selector`` echoes the source address attributes (PCI BDF, USB
    vendor/product/bus/device), and whose ``address`` is a stringified
    PCI BDF / USB bus:device when present. ``lease`` is always ``None`` —
    the broker owns lease state and libvirt does not know it.
    """
    out: list[AttachedDevice] = []
    for hostdev in root.findall("devices/hostdev"):
        kind = hostdev.get("type") or "pci"
        selector, address = _hostdev_selector_address(hostdev, kind)
        out.append(
            AttachedDevice(
                kind=kind,
                selector=selector,
                lease=None,
                address=address,
            )
        )
    return tuple(out)


def _hostdev_selector_address(
    hostdev: Element,
    kind: str,
) -> tuple[dict[str, str], str | None]:
    """Build (selector, address) from a ``<hostdev>`` source element."""
    source = hostdev.find("source")
    if source is None:
        return {}, None
    if kind == "pci":
        addr = source.find("address")
        if addr is None:
            return {}, None
        selector = {
            k: v
            for k, v in (
                ("domain", addr.get("domain")),
                ("bus", addr.get("bus")),
                ("slot", addr.get("slot")),
                ("function", addr.get("function")),
            )
            if v is not None
        }
        return selector, _pci_bdf(addr)
    if kind == "usb":
        usb_selector: dict[str, str] = {}
        vendor = source.find("vendor")
        product = source.find("product")
        if vendor is not None and vendor.get("id"):
            usb_selector["vendor"] = vendor.get("id", "")
        if product is not None and product.get("id"):
            usb_selector["product"] = product.get("id", "")
        addr = source.find("address")
        address: str | None = None
        if addr is not None:
            bus = addr.get("bus")
            device = addr.get("device")
            if bus is not None:
                usb_selector["bus"] = bus
            if device is not None:
                usb_selector["device"] = device
            if bus is not None and device is not None:
                address = f"{bus}:{device}"
        return usb_selector, address
    # Unknown hostdev kind: surface whatever <address> attrs exist.
    addr = source.find("address")
    if addr is None:
        return {}, None
    return {k: v for k, v in addr.attrib.items()}, None


def _pci_bdf(addr: Element) -> str | None:
    """Render a PCI ``<address>`` element as a ``domain:bus:slot.function`` BDF.

    Normalises the ``0x``-prefixed hex libvirt emits to the canonical
    zero-padded BDF form. Returns ``None`` if the address is unparseable.
    """
    try:
        domain = int(addr.get("domain", "0x0000"), 16)
        bus = int(addr.get("bus", "0x00"), 16)
        slot = int(addr.get("slot", "0x00"), 16)
        func = int(addr.get("function", "0x0"), 16)
    except (TypeError, ValueError):
        return None
    return f"{domain:04x}:{bus:02x}:{slot:02x}.{func:x}"


# ---------------------------------------------------------------------------
# tiny element helpers (duck-typed over the stdlib Element defusedxml returns)
# ---------------------------------------------------------------------------


def _find(root: Element, path: str) -> Element | None:
    return root.find(path)


def _findtext(root: Element, path: str) -> str:
    el = root.find(path)
    if el is None:
        return ""
    return (el.text or "").strip()


def _int(raw: str, *, default: int) -> int:
    try:
        return int(raw)
    except (TypeError, ValueError):
        return default


__all__ = ["infer_is_running", "xml_to_domaininfo"]
