"""Lazy libvirt connection adapter for the libvirt/QEMU backend."""

from __future__ import annotations

import contextlib
import importlib
import os
import re
import shutil
import tempfile
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import Protocol, cast

from aces_backend_protocols.naming import provider_resource_name
from aces_contracts.diagnostics import Diagnostic, Severity

from aces_backend_libvirt.driver import (
    DomainHandle,
    DomainSpec,
    DriverResult,
    NetworkHandle,
    NetworkSpec,
)

from ._libvirt_xml import _domain_xml, _network_xml, _nwfilter_xml
from .seed import _SEED_DIR_MODE, GenisoimageSeedBuilder, SeedBuilder, write_seed_files

_DOMAIN = "runtime"
_CODE_OPERATION_FAILED = "libvirt-backend.driver.operation-failed"
_CODE_UNAVAILABLE = "libvirt-backend.driver.unavailable"
_CODE_OWNERSHIP_CONFLICT = "libvirt-backend.driver.ownership-conflict"
_DEFAULT_CONNECTION_URI = "qemu:///system"
_WORKSPACE_PREFIX = "aces-libvirt-"
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
# Fixed namespace for deriving a per-address libvirt UUID. The UUID proves an
# existing host object was realized by ACES for *this* address, so convergence
# never destroys a foreign or another-address object that merely shares a name.
_ACES_UUID_NAMESPACE = uuid.UUID("ace50000-0000-5000-8000-000000000001")

# libvirt signals a missing object with a stable VIR_ERR_NO_* code (part of its
# public C ABI) on ``libvirtError.get_error_code()``. Idempotent teardown treats
# only these as "already absent"; every other libvirtError — connection loss,
# permission denial, an ambiguous or internal lookup failure — stays a fail-closed
# diagnostic that preserves the snapshot for retry (issue #604 guardrail:
# "do not treat every libvirt lookup exception as not found").
_VIR_ERR_NO_DOMAIN = 42
_VIR_ERR_NO_NETWORK = 43
# Raised by destroy() on an object that is not running; stopping an already-stopped
# object is a benign no-op on the teardown path, distinct from a permission/internal
# stop failure that must fail closed.
_VIR_ERR_OPERATION_INVALID = 55
_ABSENCE_ERROR_CODES: frozenset[int] = frozenset({_VIR_ERR_NO_DOMAIN, _VIR_ERR_NO_NETWORK})


class _OwnershipConflict(Exception):
    """An existing host object at this name is not the ACES object for this address."""


class _NativeLookupError(Exception):
    """A libvirt lookup failed for a reason other than the object being absent."""


def _error_code(exc: BaseException) -> int | None:
    """Return a libvirtError's ``get_error_code()`` as an int, or None otherwise.

    A non-libvirt exception (no ``get_error_code``), or one whose code is not an
    int, yields None so callers treat it as an unclassified real failure.
    """

    getter = getattr(exc, "get_error_code", None)
    if not callable(getter):
        return None
    try:
        code = getter()
    except Exception:
        return None
    return code if isinstance(code, int) else None


def _is_absence_error(exc: BaseException) -> bool:
    """Return True when ``exc`` is a libvirt "no such object" error.

    Absence is an idempotent teardown success. A non-libvirt exception, or a
    libvirtError with any other code, is a real failure and returns False.
    """

    return _error_code(exc) in _ABSENCE_ERROR_CODES


def _aces_uuid(address: str) -> str:
    return str(uuid.uuid5(_ACES_UUID_NAMESPACE, address))


def _filter_owner_uuid(address: str) -> str:
    """Owner UUID for a domain's nwfilter (namespaced so it never equals the domain UUID)."""

    return str(uuid.uuid5(_ACES_UUID_NAMESPACE, f"nwfilter:{address}"))


class _NativeResource(Protocol):
    def create(self) -> None: ...

    def destroy(self) -> None: ...

    def undefine(self) -> None: ...


def _existing_uuid(native: object) -> str | None:
    """Return an existing object's UUID string, or None when it cannot be read.

    A missing/unreadable UUID is treated as "not ours" by the caller, so an
    object we cannot prove ownership of is never destroyed.
    """

    reader = getattr(native, "UUIDString", None)
    if reader is None:
        return None
    try:
        return reader()
    except Exception:
        return None


class _LibvirtModule(Protocol):
    def open(self, connection_uri: str) -> object | None: ...


Connector = Callable[[str], object | None]


class LibvirtDeploymentDriver:
    """Realize portable specs against a libvirt connection."""

    driver_mode = "generic"

    def __init__(
        self,
        *,
        connection: object | None = None,
        connection_uri: str = _DEFAULT_CONNECTION_URI,
        connector: Connector | None = None,
        name_prefix: str = "aces",
        workspace: str | Path | None = None,
        seed_builder: SeedBuilder | None = None,
    ) -> None:
        if not connection_uri or not connection_uri.strip():
            raise ValueError("LibvirtDeploymentDriver connection_uri must be non-empty.")
        if not name_prefix or not name_prefix.strip():
            raise ValueError("LibvirtDeploymentDriver name_prefix must be non-empty.")
        self._connection = connection
        self._connection_uri = connection_uri
        self._connector = connector or _default_connector
        self._name_prefix = _safe_name(name_prefix, fallback="aces", prefix="")
        self._workspace = Path(workspace) if workspace is not None else None
        self._seed_builder = seed_builder if seed_builder is not None else GenisoimageSeedBuilder()
        self._names: dict[str, str] = {}
        self._realized: set[str] = set()
        self._seeds: dict[str, Path] = {}
        self._filters: dict[str, str] = {}

    def realize(
        self,
        *,
        networks: tuple[NetworkSpec, ...],
        domains: tuple[DomainSpec, ...],
    ) -> DriverResult:
        diagnostics: list[Diagnostic] = []
        network_handles: list[NetworkHandle] = []
        domain_handles: list[DomainHandle] = []
        # Addresses this call newly created (no owned object pre-existed). Only
        # these are rolled back on failure — never a pre-existing resource an
        # UPDATE converged, whose destruction would contradict the baseline
        # snapshot the failed apply preserves.
        created_networks: list[str] = []
        created_domains: list[str] = []
        try:
            connection = self._conn()
        except Exception:
            return DriverResult(diagnostics=(_failure("runtime.libvirt.connection", _CODE_UNAVAILABLE),))

        for spec in networks:
            failure = self._realize_network(connection, spec, created_networks)
            if failure is not None:
                diagnostics.append(failure)
            else:
                network_handles.append(NetworkHandle(address=spec.address, realized=True))

        for spec in domains:
            failure = self._realize_domain(connection, spec, created_domains)
            if failure is not None:
                diagnostics.append(failure)
            else:
                domain_handles.append(DomainHandle(address=spec.address, realized=True))

        if diagnostics:
            # Roll back only newly-created objects — including a domain whose XML was
            # defined before native.create() failed — so a partial CREATE never
            # orphans a defined domain, its seed media, or its nwfilter, while a
            # pre-existing resource an UPDATE converged is left intact (its baseline
            # snapshot entry remains truthful). destroy() is idempotent and
            # ownership-safe, so a never-defined address is a harmless no-op.
            self._rollback(created_networks, created_domains)
            return DriverResult(diagnostics=tuple(diagnostics))
        return DriverResult(networks=tuple(network_handles), domains=tuple(domain_handles))

    def _realize_network(self, connection: object, spec: NetworkSpec, created: list[str]) -> Diagnostic | None:
        """Realize one network, or return a redacted failure diagnostic.

        Records the address in ``created`` when no owned object pre-existed so a
        partial define is rolled back; on success adds it to the realized set.
        """

        name = self._runtime_name(spec.address, spec.name)
        # Record the runtime name before touching the host so a partial define can
        # still be located and rolled back by address; the value is deterministic,
        # so re-setting it on success is a no-op.
        self._names[spec.address] = name
        try:
            # The provisioner only dispatches CREATE/UPDATE specs (UNCHANGED is
            # filtered upstream), so a spec that reaches the driver must be
            # (re)applied. Converge any object a prior apply left behind — stop it
            # and drop its stale definition — before redefining, so the new state is
            # genuinely enforced and no duplicate is ever created.
            pre_existing = self._converge_existing(connection, "networkLookupByName", name, spec.address)
            if not pre_existing:
                created.append(spec.address)
            network_xml = _network_xml(spec, name, _aces_uuid(spec.address))
            native = _call_libvirt(connection, "networkDefineXML", network_xml)
            native.create()
        except _OwnershipConflict:
            return _failure(spec.address, _CODE_OWNERSHIP_CONFLICT)
        except Exception:
            return _failure(spec.address, _CODE_OPERATION_FAILED)
        self._realized.add(spec.address)
        return None

    def _realize_domain(self, connection: object, spec: DomainSpec, created: list[str]) -> Diagnostic | None:
        """Realize one domain (plus its seed media and nwfilter), or return a failure.

        Records the address in ``created`` when no owned object pre-existed so a
        partial define is rolled back; on success adds it to the realized set.
        """

        name = self._runtime_name(spec.address, spec.name)
        self._names[spec.address] = name
        network_names = tuple(self._name_for(address) for address in spec.networks)
        try:
            # Converge first so a tightened ACL, a disabled account, a changed
            # seed/image, or an existing-but-inactive domain is actually applied —
            # never silently skipped while reporting realized.
            pre_existing = self._converge_existing(connection, "lookupByName", name, spec.address)
            if not pre_existing:
                created.append(spec.address)
            seed_path = self._build_seed(spec, name)
            filter_name = self._define_nwfilter(connection, spec, name)
            xml = _domain_xml(spec, name, network_names, seed_path, _aces_uuid(spec.address), filter_name)
            native = _call_libvirt(connection, "defineXML", xml)
            native.create()
        except _OwnershipConflict:
            return _failure(spec.address, _CODE_OWNERSHIP_CONFLICT)
        except Exception:
            return _failure(spec.address, _CODE_OPERATION_FAILED)
        self._realized.add(spec.address)
        return None

    def destroy(
        self,
        *,
        networks: tuple[str, ...],
        domains: tuple[str, ...],
    ) -> DriverResult:
        diagnostics: list[Diagnostic] = []
        try:
            connection = self._conn()
        except Exception:
            return DriverResult(diagnostics=(_failure("runtime.libvirt.connection", _CODE_UNAVAILABLE),))

        domain_handles: list[DomainHandle] = []
        for address in domains:
            try:
                ok = self._destroy_one(connection, "lookupByName", address)
            except _OwnershipConflict:
                # Never delete an object this plan does not own (name collision).
                diagnostics.append(_failure(address, _CODE_OWNERSHIP_CONFLICT))
                domain_handles.append(DomainHandle(address=address, realized=True))
                continue
            if ok:
                self._realized.discard(address)
                self._names.pop(address, None)
                self._cleanup_seed(address)
                self._undefine_nwfilter(connection, address)
            else:
                diagnostics.append(_failure(address, _CODE_OPERATION_FAILED))
            domain_handles.append(DomainHandle(address=address, realized=not ok))

        network_handles: list[NetworkHandle] = []
        for address in networks:
            try:
                ok = self._destroy_one(connection, "networkLookupByName", address)
            except _OwnershipConflict:
                diagnostics.append(_failure(address, _CODE_OWNERSHIP_CONFLICT))
                network_handles.append(NetworkHandle(address=address, realized=True))
                continue
            if ok:
                self._realized.discard(address)
                self._names.pop(address, None)
            else:
                diagnostics.append(_failure(address, _CODE_OPERATION_FAILED))
            network_handles.append(NetworkHandle(address=address, realized=not ok))

        return DriverResult(
            networks=tuple(network_handles),
            domains=tuple(domain_handles),
            diagnostics=tuple(diagnostics),
        )

    def realized_addresses(self) -> frozenset[str]:
        return frozenset(self._realized)

    def _conn(self) -> object:
        if self._connection is None:
            self._connection = self._connector(self._connection_uri)
        if self._connection is None:
            raise RuntimeError("libvirt connection unavailable")
        return self._connection

    def _runtime_name(self, address: str, preferred: str) -> str:
        del preferred
        return provider_resource_name(address, prefix=self._name_prefix)

    def _name_for(self, address: str) -> str:
        return self._names.get(address, self._runtime_name(address, ""))

    def _build_seed(self, spec: DomainSpec, name: str) -> Path | None:
        cloud_init = spec.cloud_init
        if cloud_init is None or cloud_init.is_empty:
            return None
        seed_dir = self._seed_workspace() / name
        write_seed_files(cloud_init, seed_dir)
        seed_path = self._seed_builder.build(seed_dir=seed_dir)
        self._seeds[spec.address] = seed_path
        return seed_path

    def _seed_workspace(self) -> Path:
        if self._workspace is None:
            # mkdtemp atomically creates an unpredictable directory; relax it to
            # 0o711 (traversable, not listable) so the libvirt/QEMU process can
            # reach the attached seed ISO while other local users still cannot
            # enumerate it or read the 0o600 source files inside.
            self._workspace = Path(tempfile.mkdtemp(prefix=_WORKSPACE_PREFIX))
            os.chmod(self._workspace, _SEED_DIR_MODE)
        else:
            # A configured workspace is attacker-influenceable, so verify it
            # resolves to a directory we own and is not a symlink before trusting
            # it to hold rendered seed content.
            workspace = self._workspace
            if workspace.is_symlink():
                raise PermissionError(f"seed workspace '{workspace}' is a symlink")
            workspace.mkdir(parents=True, exist_ok=True)
            info = os.lstat(workspace)
            if info.st_uid != os.getuid():
                raise PermissionError(f"seed workspace '{workspace}' is not owned by the current user")
            os.chmod(workspace, _SEED_DIR_MODE)
        return self._workspace

    def _cleanup_seed(self, address: str) -> None:
        seed_path = self._seeds.pop(address, None)
        if seed_path is not None:
            shutil.rmtree(seed_path.parent, ignore_errors=True)

    def _define_nwfilter(self, connection: object, spec: DomainSpec, name: str) -> str | None:
        if not spec.network_acls:
            return None
        filter_name = _safe_name(f"{name}-acl", fallback="acl", prefix="")
        owner = _filter_owner_uuid(spec.address)
        # nwfilters are host-global and named-only, so — like domains/networks —
        # refuse to redefine a filter at this name unless it is owner-stamped for
        # this ACES address. A foreign filter (or one for another address that
        # normalizes to the same name) is never overwritten; apply fails closed.
        existing = _lookup(connection, "nwfilterLookupByName", filter_name)
        if existing is not None and _existing_uuid(existing) != owner:
            raise _OwnershipConflict(filter_name)
        define = getattr(connection, "nwfilterDefineXML", None)
        if define is not None:
            # nwfilterDefineXML is upsert: redefining replaces our own filter in
            # place, so a tightened/loosened ACL on re-apply is genuinely enforced.
            define(_nwfilter_xml(filter_name, owner, spec.network_acls))
        self._filters[spec.address] = filter_name
        return filter_name

    @staticmethod
    def _converge_existing(connection: object, lookup_method: str, name: str, address: str) -> bool:
        """Stop and undefine the ACES object this apply owns at ``name``.

        Convergence is destructive, so it only proceeds when the existing object's
        UUID proves it is the ACES realization of *this* ``address``. A name hit
        whose UUID is absent or different — a foreign object, or one realized for a
        different ACES address that merely normalizes to the same name — raises
        :class:`_OwnershipConflict` so the apply fails closed instead of replacing
        an object it does not own. A running object we own is stopped first via
        :func:`_stop_native`, which tolerates an already-inactive object but lets a
        permission/internal stop failure propagate — so convergence never undefines
        (and silently replaces) a domain it could not actually stop.

        Returns True when an existing ACES-owned object was converged (this address
        is an UPDATE of a pre-existing resource) and False when none existed (a
        fresh CREATE). Callers use this to roll back only newly-created objects on
        failure, never a pre-existing resource an UPDATE would otherwise destroy.
        """

        native = _lookup(connection, lookup_method, name)
        if native is None:
            return False
        if _existing_uuid(native) != _aces_uuid(address):
            raise _OwnershipConflict(name)
        _stop_native(native)
        cast(_NativeResource, native).undefine()
        return True

    def _undefine_nwfilter(self, connection: object, address: str) -> None:
        filter_name = self._filters.pop(address, None)
        if filter_name is None:
            return
        native = _lookup(connection, "nwfilterLookupByName", filter_name)
        if native is None:
            return
        if _existing_uuid(native) != _filter_owner_uuid(address):
            # A filter at this name we do not own must never be undefined.
            return
        # Best-effort cleanup of our own filter: a filter that cannot be undefined
        # (in use, missing) must not fail the destroy.
        with contextlib.suppress(Exception):
            cast(_NativeResource, native).undefine()

    def _destroy_one(self, connection: object, lookup_method: str, address: str) -> bool:
        try:
            native = _find_native(connection, lookup_method, self._name_for(address))
        except _NativeLookupError:
            # Connection/permission/internal lookup failure: fail closed so the
            # snapshot is preserved for retry instead of claiming the object gone.
            return False
        # A None result is genuine absence — teardown is idempotently satisfied.
        # A present object is torn down only when its UUID proves ACES ownership
        # (the same invariant as convergence), never a foreign name collision.
        if native is not None:
            if _existing_uuid(native) != _aces_uuid(address):
                raise _OwnershipConflict(address)
            try:
                _stop_native(native)
                cast(_NativeResource, native).undefine()
            except Exception as exc:
                # An object that vanished between lookup and undefine is still torn
                # down; a stop/undefine that failed for permission or an internal
                # reason fails closed and preserves the snapshot for retry.
                return _is_absence_error(exc)
        return True

    def _rollback(self, networks: list[str], domains: list[str]) -> None:
        if networks or domains:
            self.destroy(networks=tuple(networks), domains=tuple(domains))


def _default_connector(connection_uri: str) -> object | None:
    libvirt = cast(_LibvirtModule, importlib.import_module("libvirt"))
    return libvirt.open(connection_uri)


def _call_libvirt(connection: object, method_name: str, payload: str) -> _NativeResource:
    method = cast(Callable[[str], _NativeResource], getattr(connection, method_name))
    return method(payload)


def _lookup(connection: object, method_name: str, name: str) -> object | None:
    """Return an existing native resource by name, or None when absent.

    Any lookup failure (not-found or otherwise) returns None so the caller
    attempts a define; a genuine define-time conflict is then surfaced as a
    redacted diagnostic rather than a duplicate resource.
    """

    method = getattr(connection, method_name, None)
    if method is None:
        return None
    try:
        return method(name)
    except Exception:
        return None


def _stop_native(native: object) -> None:
    """Stop a running native object before it is undefined.

    Stopping an object that is already inactive is a benign no-op (libvirt raises
    ``VIR_ERR_OPERATION_INVALID``), and one that has already vanished is absent;
    either lets teardown proceed to ``undefine()``. Any other stop failure —
    permission, internal — propagates so teardown fails closed instead of
    undefining (and dropping the snapshot entry for) a still-running resource
    (issue #604: permission and unconfirmed teardown failures must fail closed).
    """

    try:
        cast(_NativeResource, native).destroy()
    except Exception as exc:
        code = _error_code(exc)
        if code == _VIR_ERR_OPERATION_INVALID or code in _ABSENCE_ERROR_CODES:
            return
        raise


def _find_native(connection: object, method_name: str, name: str) -> object | None:
    """Return an existing native resource by name, or None when genuinely absent.

    Unlike :func:`_lookup`, this distinguishes idempotent absence from a real
    lookup failure: a libvirt "no such object" error maps to None, while a
    connection/permission/internal lookup failure is raised as
    :class:`_NativeLookupError` so teardown fails closed and preserves the snapshot for
    retry rather than falsely reporting the object gone (issue #604).
    """

    method = getattr(connection, method_name, None)
    if method is None:
        return None
    try:
        return method(name)
    except Exception as exc:
        if _is_absence_error(exc):
            return None
        raise _NativeLookupError(method_name) from exc


def _safe_name(candidate: str, *, fallback: str, prefix: str) -> str:
    raw = candidate.strip() or fallback.strip() or "resource"
    normalized = _SAFE_NAME_RE.sub("-", raw).strip("-._")
    if not normalized:
        normalized = _SAFE_NAME_RE.sub("-", fallback).strip("-._") or "resource"
    prefixed = f"{prefix}-{normalized}" if prefix else normalized
    return prefixed[:63].strip("-._") or "resource"


_FAILURE_MESSAGES = {
    _CODE_UNAVAILABLE: "Libvirt connection is unavailable for this backend operation.",
    _CODE_OWNERSHIP_CONFLICT: (
        "Libvirt object for '{address}' already exists under the same name but is not "
        "owned by this ACES address; refusing to converge an object this plan does not own."
    ),
}


def _failure(address: str, code: str) -> Diagnostic:
    template = _FAILURE_MESSAGES.get(code, "Libvirt operation for '{address}' did not succeed.")
    return Diagnostic(
        code=code, domain=_DOMAIN, address=address, message=template.format(address=address), severity=Severity.ERROR
    )
