"""M — :class:`LibvirtMetricsBackend`, the OPTIONAL ``MetricsBackend`` (D-G13).

Implements ``mgf.vm.core.contracts.MetricsBackend`` (Wave-B) against libvirt.
It lives in **its own module** (not folded into
:class:`mgf.vm.libvirt._hypervisor.LibvirtHypervisorBackend`) precisely because
``MetricsBackend`` is a *separate, injectable* Protocol (D-G13): a composition
that wants per-domain sampling constructs one of these over the **same**
:class:`mgf.vm.libvirt._connection.LibvirtConnection` the hypervisor backend
holds and injects it; a composition that wants no metrics simply never builds
it. Keeping it standalone keeps that injection seam clean.

Built **on top of** the foundation slice, exactly like the hypervisor backend:

* every native libvirt call routes through the RLock-guarded connection
  (``with self._conn.lock``, §8 thread-safety);
* the public :meth:`sample_metrics` is decorated with
  :func:`mgf.vm.libvirt._errors.translate_at_seam`, so a raw ``libvirtError``
  becomes a typed ``mgf.vm.core.exceptions`` error at the seam (§7) — no raw
  ``libvirtError`` escapes;
* the live domain XML is parsed with ``defusedxml`` → stdlib Element (the
  boundary convention) to discover disk/nic targets.

Field sourcing mirrors the legacy ``LibvirtProvider.domain_metrics``:

* ``cpu_time_ns`` ← ``domain.info()[4]`` (cumulative vCPU nanoseconds);
* ``cpu_percent`` — best-effort, ``None`` from a single sample (a percentage
  needs two ``cpu_time_ns`` reads differenced over wall-clock; the consumer
  derives it by differencing successive samples' ``sampled_at``);
* ``memory_used_bytes`` / ``memory_available_bytes`` ← ``domain.memoryStats()``
  (``rss``/``available`` in KiB → bytes), falling back to ``domain.info()`` for
  the resident figure when ``memoryStats`` is unavailable;
* per-disk :class:`DiskIo` ← ``domain.blockStats(target)`` over the domain's
  disk targets (legacy used ``stats[1]``/``stats[3]`` for read/write bytes);
* per-nic :class:`NicIo` ← ``domain.interfaceStats(dev)`` over the domain's nic
  targets (legacy used ``stats[0]``/``stats[4]`` for rx/tx bytes);
* ``sampled_at`` — UTC now at the I/O boundary (DP-12). The clock is injectable
  so tests stay deterministic; the default is ``datetime.now(UTC)``, which is
  allowed in the adapter at the I/O boundary (the contract only forbids
  *fabricating* a non-UTC instant).
"""

from __future__ import annotations

from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from defusedxml import ElementTree as DefusedET

from mgf.vm.libvirt._errors import LibvirtErrorTranslator, translate_at_seam

if TYPE_CHECKING:
    from collections.abc import Callable
    from types import TracebackType

    from mgf.vm.core.contracts import DiskIo, MetricsSample, NicIo
    from mgf.vm.core.ids import DomainId

    from mgf.vm.libvirt._connection import LibvirtConnection


def _utc_now() -> datetime:
    """Default clock — UTC always (DP-12)."""
    return datetime.now(UTC)


class LibvirtMetricsBackend:
    """``MetricsBackend`` over a single libvirt connection (D-G13).

    Construct with an already-wrapped :class:`LibvirtConnection` — the same
    one the hypervisor backend uses — so a composition can inject this
    alongside the lifecycle surface without opening a second connection. The
    clock is injectable for deterministic tests.
    """

    def __init__(
        self,
        connection: LibvirtConnection,
        *,
        clock: Callable[[], datetime] = _utc_now,
    ) -> None:
        self._conn = connection
        self._clock = clock
        self._translator = LibvirtErrorTranslator()

    # -- seam glue ----------------------------------------------------------

    def translate_exception(self, exc: Exception) -> Any:
        """Delegate to the foundation translator (used by ``translate_at_seam``)."""
        return self._translator.translate_exception(exc)

    # -- the MetricsBackend Protocol ----------------------------------------

    @translate_at_seam
    def sample_metrics(self, d: DomainId) -> MetricsSample:
        """Take one engine-neutral :class:`MetricsSample` of the live domain.

        Read-only and idempotent. Resolves the domain by UUID and reads its
        live CPU / memory / per-disk / per-nic counters under the connection
        lock (one XML parse discovers the disk + nic targets, mirroring the
        legacy single-parse hot path). An unknown domain raises
        ``DomainNotFound``: the ``DomainId`` is the libvirt UUID handle, so a
        lookup that does not resolve a domain — whether the UUID is absent
        (``VIR_ERR_NO_DOMAIN``) or malformed (``VIR_ERR_INVALID_ARG``) — means
        "no such domain" and is normalised to ``DomainNotFound`` here, rather
        than leaking the generic spec-invalid mapping the bare code would give.
        """
        from mgf.vm.core.contracts import MetricsSample

        with self._conn.lock:
            conn = self._conn.raw()
            dom = self._resolve(conn, d)
            info = dom.info()  # [state, maxMem, mem, cpuCount, cpuTime]
            cpu_time_ns = int(info[4])
            memory_used_bytes, memory_available_bytes = self._memory(dom, info)
            disk_targets, nic_targets = self._targets(dom.XMLDesc(0))
            disks = tuple(dio for t in disk_targets if (dio := self._disk_io(dom, t)) is not None)
            nics = tuple(nio for dev in nic_targets if (nio := self._nic_io(dom, dev)) is not None)

        return MetricsSample(
            cpu_time_ns=cpu_time_ns,
            # A percentage needs two cpu_time_ns reads differenced over wall
            # time; a single sample cannot compute it, so None (best-effort).
            cpu_percent=None,
            memory_used_bytes=memory_used_bytes,
            memory_available_bytes=memory_available_bytes,
            disks=disks,
            nics=nics,
            sampled_at=self._clock(),
        )

    @staticmethod
    def _resolve(conn: Any, d: DomainId) -> Any:
        """Resolve a ``virDomain`` by UUID, normalising failure to not-found.

        Caller MUST hold the connection lock. A malformed UUID raises
        ``VIR_ERR_INVALID_ARG`` (→ ``SpecInvalid`` under the bare seam) while
        an absent one raises ``VIR_ERR_NO_DOMAIN`` (→ ``DomainNotFound``). For
        a domain *handle* both mean the same thing — there is no such domain —
        so we collapse any lookup error to ``DomainNotFound`` (which, being an
        ``AppError``, passes through the seam unchanged).
        """
        from mgf.vm.core.exceptions import DomainNotFound

        try:
            return conn.lookupByUUIDString(d.value)
        except Exception as exc:
            raise DomainNotFound(f"no domain {d.value!r}") from exc

    # -- field sourcing helpers (under the caller's lock hold) --------------

    @staticmethod
    def _memory(dom: Any, info: Any) -> tuple[int, int | None]:
        """Resident + (optional) guest-available memory, in bytes.

        Prefers ``domain.memoryStats()`` (keyed in KiB): ``rss`` is the
        resident set, ``available``/``usable`` the guest-reported available
        (needs a balloon / guest agent). Falls back to ``info()[2]`` (the
        resident figure libvirt always reports, in KiB) when ``memoryStats``
        is unavailable or empty. A missing available figure degrades to
        ``None`` rather than fabricating one.
        """
        used_kib: int | None = None
        available_kib: int | None = None
        try:
            stats = dom.memoryStats()
        except Exception:
            stats = None
        if isinstance(stats, dict):
            rss = stats.get("rss")
            if rss is not None:
                used_kib = int(rss)
            avail = stats.get("available")
            usable = stats.get("usable")
            # ``usable`` (free the guest could hand back) is the closer match
            # to "available"; fall back to ``available`` (total guest RAM).
            if usable is not None:
                available_kib = int(usable)
            elif avail is not None:
                available_kib = int(avail)
        if used_kib is None:
            # info()[2] is the current memory in KiB (libvirt reports KiB).
            used_kib = int(info[2])
        memory_used_bytes = used_kib * 1024
        memory_available_bytes = available_kib * 1024 if available_kib is not None else None
        return memory_used_bytes, memory_available_bytes

    @staticmethod
    def _targets(xml: str) -> tuple[list[str], list[str]]:
        """``(disk_targets, nic_targets)`` from one parse of the domain XML.

        Disk targets are ``<disk>/<target dev=...>`` (e.g. ``vda``); nic
        targets are ``<interface>/<target dev=...>`` (e.g. ``vnet0``).
        Mirrors the legacy ``_xml_targets`` single-pass walk. A parse failure
        degrades to empty lists (no metrics for that sample's I/O), never an
        error.
        """
        try:
            root = DefusedET.fromstring(xml)
        except Exception:
            return [], []
        disks: list[str] = []
        nics: list[str] = []
        for disk in root.findall(".//devices/disk"):
            target = disk.find("target")
            if target is not None and target.get("dev"):
                disks.append(target.get("dev", ""))
        for iface in root.findall(".//devices/interface"):
            target = iface.find("target")
            if target is not None and target.get("dev"):
                nics.append(target.get("dev", ""))
        return disks, nics

    @staticmethod
    def _disk_io(dom: Any, target: str) -> DiskIo | None:
        """Per-disk cumulative counters via ``domain.blockStats(target)``.

        ``blockStats`` returns ``[rd_req, rd_bytes, wr_req, wr_bytes, errs]``.
        A per-disk failure (disk gone, off domain) degrades that one disk to
        ``None`` rather than failing the whole sample (legacy ``continue``).
        """
        from mgf.vm.core.contracts import DiskIo

        try:
            stats = dom.blockStats(target)
        except Exception:
            return None
        return DiskIo(
            target=target,
            read_bytes=int(stats[1]),
            write_bytes=int(stats[3]),
            read_reqs=int(stats[0]),
            write_reqs=int(stats[2]),
        )

    @staticmethod
    def _nic_io(dom: Any, dev: str) -> NicIo | None:
        """Per-nic cumulative counters via ``domain.interfaceStats(dev)``.

        ``interfaceStats`` returns
        ``[rx_bytes, rx_packets, rx_errs, rx_drop, tx_bytes, tx_packets, ...]``.
        The :class:`NicIo` is keyed by MAC (matching ``NicStatus.mac``); the
        MAC is read back from the domain XML by ``dev`` (the host-side tap
        name carries no MAC itself). A per-nic failure degrades that one nic
        to ``None`` (legacy ``continue``).
        """
        from mgf.vm.core.contracts import NicIo

        try:
            stats = dom.interfaceStats(dev)
        except Exception:
            return None
        mac = LibvirtMetricsBackend._mac_for_dev(dom, dev)
        return NicIo(
            mac=mac,
            rx_bytes=int(stats[0]),
            tx_bytes=int(stats[4]),
            rx_packets=int(stats[1]),
            tx_packets=int(stats[5]),
        )

    @staticmethod
    def _mac_for_dev(dom: Any, dev: str) -> str:
        """Best-effort MAC for the host tap ``dev`` from the domain XML.

        Looks up the ``<interface>`` whose ``<target dev=...>`` matches and
        returns its ``<mac address=...>``. Degrades to the device name when
        the MAC cannot be read so the :class:`NicIo` still keys to *something*
        non-empty (the contract wants a MAC; the device name is the next-best
        stable handle).
        """
        try:
            root = DefusedET.fromstring(dom.XMLDesc(0))
        except Exception:
            return dev
        for iface in root.findall(".//devices/interface"):
            target = iface.find("target")
            if target is not None and target.get("dev") == dev:
                mac_el = iface.find("mac")
                if mac_el is not None and mac_el.get("address"):
                    return mac_el.get("address", dev)
        return dev

    # -- lifecycle ----------------------------------------------------------

    def __enter__(self) -> LibvirtMetricsBackend:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        # The connection lifecycle is owned by whoever opened it (typically
        # shared with the hypervisor backend), so this is a no-op exit — we
        # never close a connection we did not open.
        return None


__all__ = ["LibvirtMetricsBackend"]
