"""G — :class:`LibvirtGuestAgentBackend`, the OPTIONAL ``GuestAgentBackend`` (D-G19).

Implements ``mgf.vm.core.contracts.GuestAgentBackend`` (Wave-R6) against the
in-guest QEMU guest agent, over the **same** :class:`LibvirtConnection` the
hypervisor backend holds (a separate, injectable Protocol, exactly like
:class:`LibvirtMetricsBackend` / D-G13).

Field sourcing mirrors the legacy ``providers.guest_agent.GuestAgent``:

* ``guest_ping`` ← ``domain.qemuAgentCommand('{"execute":"guest-ping"}')`` —
  returns ``True`` on a clean reply, ``False`` when the agent is unreachable
  (a guest with no running agent is the common non-error case).
* ``guest_os_info`` ← ``guest-get-osinfo`` (``id`` / ``name`` / ``version-id``
  / ``pretty-name``).
* ``guest_interfaces`` ← ``domain.interfaceAddresses(SRC_AGENT)`` — the agent's
  own view of the guest's NICs (name / hwaddr / addresses).

Error mapping (§7): the domain lookup normalises to ``DomainNotFound`` (the
``DomainId`` is the libvirt UUID handle); an **agent-unreachable** libvirt
error is raised as the typed ``GuestUnreachableError`` (an ``AppError``, so it
passes the seam unchanged — deliberately NOT the generic ``GuestUnresponsive``
the bare code-map would give, so the surface matches the umbrella's
``GuestAgent`` contract that the app consumers catch). Any other raw
``libvirtError`` is translated at the seam by ``translate_at_seam``.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any

from mgf.vm.core.contracts import GuestNic, GuestOsInfo
from mgf.vm.core.exceptions import GuestUnreachableError

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

if TYPE_CHECKING:
    from types import TracebackType

    from mgf.vm.core.ids import DomainId

    from mgf.vm.libvirt._connection import LibvirtConnection


# libvirt constants, hardcoded (not imported) so this module loads without the
# binding present — same convention as ``_errors.py``.
_SRC_AGENT = 1  # VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT
_ADDR_TYPE_NAMES = {0: "ipv4", 1: "ipv6"}  # VIR_IP_ADDR_TYPE_{IPV4,IPV6}
_AGENT_TIMEOUT_S = 5  # qemuAgentCommand block timeout, seconds
# Error codes that mean "the in-guest agent is not reachable", per
# ``_errors.py``: VIR_ERR_AGENT_UNRESPONSIVE / VIR_ERR_AGENT_UNSYNCED.
_AGENT_UNREACHABLE_CODES = frozenset({86, 97})


def _is_agent_unreachable(exc: Exception) -> bool:
    """True iff ``exc`` is a libvirt error meaning the agent is not reachable.

    Detects by error code (preferred) and falls back to a message substring
    (older bindings surface the agent state in the text, not always a distinct
    code).
    """
    code = None
    getter = getattr(exc, "get_error_code", None)
    if callable(getter):
        try:
            code = getter()
        except Exception:
            code = None
    if code in _AGENT_UNREACHABLE_CODES:
        return True
    text = str(exc).lower()
    return "guest agent is not" in text or "agent not" in text or "not connected" in text


class LibvirtGuestAgentBackend:
    """``GuestAgentBackend`` over a single libvirt connection (D-G19).

    Construct with an already-wrapped :class:`LibvirtConnection` — the same one
    the hypervisor backend uses — so a composition injects this alongside the
    lifecycle surface without opening a second connection.
    """

    def __init__(self, connection: LibvirtConnection) -> None:
        self._conn = connection
        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 GuestAgentBackend Protocol -------------------------------------

    @translate_at_seam
    def guest_ping(self, d: DomainId) -> bool:
        """``True`` iff the in-guest agent answers ``guest-ping``.

        Returns ``False`` (does not raise) when the agent is unreachable.
        Raises ``DomainNotFound`` for an absent domain.
        """
        with self._conn.lock:
            dom = self._resolve(self._conn.raw(), d)
            try:
                dom.qemuAgentCommand('{"execute":"guest-ping"}', _AGENT_TIMEOUT_S, 0)
            except Exception as exc:
                if _is_agent_unreachable(exc):
                    return False
                raise
            return True

    @translate_at_seam
    def guest_os_info(self, d: DomainId) -> GuestOsInfo:
        """Guest OS identification via ``guest-get-osinfo``.

        Raises ``GuestUnreachableError`` when the agent is unreachable and
        ``DomainNotFound`` for an absent domain.
        """
        with self._conn.lock:
            dom = self._resolve(self._conn.raw(), d)
            payload = self._agent_call(dom, '{"execute":"guest-get-osinfo"}')
        ret = payload.get("return", {}) if isinstance(payload, dict) else {}
        return GuestOsInfo(
            id=str(ret.get("id", "")),
            name=str(ret.get("name", "")),
            version=str(ret.get("version-id") or ret.get("version") or ""),
            pretty_name=str(ret.get("pretty-name", "")),
        )

    @translate_at_seam
    def guest_interfaces(self, d: DomainId) -> tuple[GuestNic, ...]:
        """Guest-reported NICs via ``interfaceAddresses(SRC_AGENT)``.

        Raises ``GuestUnreachableError`` when the agent is unreachable and
        ``DomainNotFound`` for an absent domain.
        """
        with self._conn.lock:
            dom = self._resolve(self._conn.raw(), d)
            try:
                raw_ifaces = dom.interfaceAddresses(_SRC_AGENT, 0)
            except Exception as exc:
                if _is_agent_unreachable(exc):
                    raise GuestUnreachableError(f"guest agent unreachable on {d.value!r}") from exc
                raise
        return tuple(_to_nic(name, info) for name, info in (raw_ifaces or {}).items())

    # -- helpers ------------------------------------------------------------

    def _agent_call(self, dom: Any, command: str) -> dict[str, Any]:
        """Run one ``qemuAgentCommand`` and parse its JSON reply.

        Translates an agent-unreachable libvirt error into
        ``GuestUnreachableError``; a non-JSON reply degrades to ``{}``.
        """
        try:
            reply = dom.qemuAgentCommand(command, _AGENT_TIMEOUT_S, 0)
        except Exception as exc:
            if _is_agent_unreachable(exc):
                raise GuestUnreachableError("guest agent unreachable") from exc
            raise
        try:
            parsed = json.loads(reply)
        except (TypeError, ValueError):
            return {}
        return parsed if isinstance(parsed, dict) else {}

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

        Caller MUST hold the connection lock. Mirrors
        :meth:`LibvirtMetricsBackend._resolve`: any lookup failure (absent or
        malformed UUID) collapses to ``DomainNotFound`` (an ``AppError``, so it
        passes 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

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

    def __enter__(self) -> LibvirtGuestAgentBackend:
        return self

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


def _to_nic(name: str, info: Any) -> GuestNic:
    """Map one ``interfaceAddresses`` entry to a :class:`GuestNic`.

    ``info`` is ``{"hwaddr": str|None, "addrs": [{"addr": str, "type": int,
    "prefix": int}, ...] | None}``. Both IPv4 and IPv6 addresses are kept (the
    consumer filters); a missing ``addrs`` degrades to no addresses.
    """
    hwaddr = info.get("hwaddr") if isinstance(info, dict) else None
    addrs_raw = info.get("addrs") if isinstance(info, dict) else None
    ips = tuple(str(a["addr"]) for a in (addrs_raw or ()) if isinstance(a, dict) and a.get("addr"))
    return GuestNic(name=str(name), mac=hwaddr or None, ip_addresses=ips)


__all__ = ["LibvirtGuestAgentBackend"]
