"""Unit tests for :class:`LibvirtHypervisorBackend` (mock libvirt).

Tier-1: no real libvirt connection. A fake ``virConnect`` / ``virDomain``
double drives every public method + error path. The fake raises
``FakeLibvirtError`` (the ``conftest`` stand-in carrying
``get_error_code()``) so the foundation translation seam is exercised
end-to-end.

Coverage target ≥90% on ``_hypervisor`` + ``_events``.
"""

from __future__ import annotations

from typing import Any

import pytest
from mgf.vm.core.cancellation import CancellationEvent
from mgf.vm.core.capabilities import HypervisorCapabilities
from mgf.vm.core.contracts import (
    CompatibilityResult,
    DeviceAssignment,
    MigrationPlan,
    MigrationTarget,
    VmSpec,
)
from mgf.vm.core.exceptions import (
    DeviceAttachFailed,
    DeviceDetachFailed,
    DomainAlreadyExistsError,
    DomainNotFound,
    DomainStateInvalid,
    GuestUnresponsive,
    MigrationCancelled,
    MigrationFailed,
    MigrationIncompatible,
    OperationCancelled,
    SnapshotExists,
    SnapshotInUse,
    SnapshotNotFound,
    SpecConflict,
)
from mgf.vm.core.ids import DeviceId, DomainId, LeaseId, SnapshotId

from mgf.vm.libvirt._connection import LibvirtConnection
from mgf.vm.libvirt._hypervisor import LibvirtHypervisorBackend
from mgf.vm.libvirt._spec import LibvirtVmSpec

# Local state-code constants mirroring the production ones.
_RUNNING = 1
_PAUSED = 3
_SHUTOFF = 5


class FakeLibvirtError(Exception):
    """Mirror of the conftest double (kept local so the basename is unique)."""

    def __init__(self, code: int | None, message: str = "libvirt failure") -> None:
        super().__init__(message)
        self._code = code

    def get_error_code(self) -> int | None:
        return self._code


def _domain_xml(name: str, uuid: str, *, vcpu: int = 1, mem: int = 1048576) -> str:
    return (
        f'<domain type="test"><name>{name}</name><uuid>{uuid}</uuid>'
        f'<memory unit="bytes">{mem}</memory>'
        f'<currentMemory unit="bytes">{mem}</currentMemory>'
        f"<vcpu>{vcpu}</vcpu><os><type>hvm</type></os>"
        "<devices></devices></domain>"
    )


class FakeSnapshot:
    def __init__(self, name: str, *, children: int = 0, has_memory: bool = False) -> None:
        self._name = name
        self._children = children
        self._has_memory = has_memory
        self.deleted = False

    def getName(self) -> str:  # noqa: N802 - mirror libvirt
        return self._name

    def numChildren(self, _flags: int = 0) -> int:  # noqa: N802
        return self._children

    def delete(self, _flags: int = 0) -> None:
        self.deleted = True

    def getXMLDesc(self, _flags: int = 0) -> str:  # noqa: N802
        mem = "internal" if self._has_memory else "no"
        return (
            f"<domainsnapshot><name>{self._name}</name>"
            f"<memory snapshot='{mem}'/><creationTime>1700000000</creationTime>"
            "</domainsnapshot>"
        )


class FakeStream:
    """A minimal libvirt virStream double for screenshot draining (D-G22)."""

    def __init__(self) -> None:
        self._data = b""
        self._pos = 0
        self.finished = False

    def recv(self, n: int) -> bytes:
        chunk = self._data[self._pos : self._pos + n]
        self._pos += len(chunk)
        return chunk

    def finish(self) -> int:
        self.finished = True
        return 0


class FakeDomain:
    def __init__(
        self,
        name: str,
        uuid: str,
        *,
        state: int = _SHUTOFF,
    ) -> None:
        self._name = name
        self._uuid = uuid
        self._state = state
        self.snapshots: dict[str, FakeSnapshot] = {}
        self.hostdev_xml: list[str] = []
        self.actions: list[str] = []
        self.create_raises: BaseException | None = None
        self.shutdown_to_shutoff = True
        self._xml = _domain_xml(name, uuid)

    # identity / state
    def UUIDString(self) -> str:  # noqa: N802
        return self._uuid

    def name(self) -> str:
        return self._name

    def state(self, _flags: int = 0) -> list[int]:
        return [self._state, 0]

    def XMLDesc(self, _flags: int = 0) -> str:  # noqa: N802
        # Reflect attached hostdevs into the devices section.
        if self.hostdev_xml:
            devices = "".join(self.hostdev_xml)
            return self._xml.replace("<devices></devices>", f"<devices>{devices}</devices>")
        return self._xml

    # forensic acquisition (D-G22 escape hatch)
    def coreDumpWithFormat(self, path: str, fmt: int, flags: int) -> int:  # noqa: N802
        from pathlib import Path as _Path

        self.actions.append("coredump")
        self.dump_format = fmt
        self.dump_flags = flags
        _Path(path).write_bytes(b"ELFCORE")
        return 0

    def screenshot(self, stream: FakeStream, screen: int, _flags: int = 0) -> str:
        self.actions.append("screenshot")
        self.screenshot_screen = screen
        stream._data = b"PNGSCREENSHOT"
        return "image/png"

    def rename(self, new_name: str, _flags: int = 0) -> int:  # D-G26
        self.actions.append("rename")
        self._name = new_name
        return 0

    # power
    def create(self) -> int:
        if self.create_raises is not None:
            raise self.create_raises
        self.actions.append("create")
        self._state = _RUNNING
        return 0

    def destroy(self) -> int:
        self.actions.append("destroy")
        self._state = _SHUTOFF
        return 0

    def shutdown(self) -> int:
        self.actions.append("shutdown")
        if self.shutdown_to_shutoff:
            self._state = _SHUTOFF
        return 0

    def suspend(self) -> int:
        self.actions.append("suspend")
        self._state = _PAUSED
        return 0

    def resume(self) -> int:
        self.actions.append("resume")
        self._state = _RUNNING
        return 0

    def reboot(self, _flags: int = 0) -> int:
        self.actions.append("reboot")
        return 0

    def reset(self, _flags: int = 0) -> int:
        self.actions.append("reset")
        return 0

    # definition
    def undefine(self) -> int:
        self.actions.append("undefine")
        return 0

    def undefineFlags(self, _flags: int = 0) -> int:  # noqa: N802
        self.actions.append("undefineFlags")
        return 0

    def migrateToURI(  # noqa: N802
        self, _uri: str, _flags: int, _dname: Any, _bw: int
    ) -> int:
        self.actions.append("migrate")
        return 0

    def abortJob(self) -> int:  # noqa: N802 - mirror libvirt
        self.actions.append("abortJob")
        return 0

    # devices
    def attachDeviceFlags(self, xml: str, _flags: int) -> int:  # noqa: N802
        self.hostdev_xml.append(xml)
        self.actions.append("attach")
        return 0

    def detachDeviceFlags(self, _xml: str, _flags: int) -> int:  # noqa: N802
        self.hostdev_xml.clear()
        self.actions.append("detach")
        return 0

    # snapshots
    def snapshotCreateXML(self, xml: str, _flags: int = 0) -> FakeSnapshot:  # noqa: N802
        import xml.etree.ElementTree as ET

        root = ET.fromstring(xml)
        name = root.findtext("name") or ""
        mem_el = root.find("memory")
        has_mem = mem_el is not None and mem_el.get("snapshot", "no") not in {"no", ""}
        snap = FakeSnapshot(name, has_memory=has_mem)
        self.snapshots[name] = snap
        return snap

    def snapshotLookupByName(self, name: str, _flags: int = 0) -> FakeSnapshot:  # noqa: N802
        if name not in self.snapshots:
            raise FakeLibvirtError(72, f"no snapshot {name}")  # VIR_ERR_NO_DOMAIN_SNAPSHOT
        return self.snapshots[name]

    def listAllSnapshots(self, _flags: int = 0) -> list[FakeSnapshot]:  # noqa: N802
        return list(self.snapshots.values())

    def revertToSnapshot(self, _snap: FakeSnapshot, _flags: int = 0) -> int:  # noqa: N802
        self.actions.append("revert")
        return 0


class FakeConn:
    def __init__(self, *, driver: str = "TEST") -> None:
        self.domains: dict[str, FakeDomain] = {}
        self._driver = driver
        self.closed = 0

    def _add(self, dom: FakeDomain) -> None:
        self.domains[dom._uuid] = dom

    def getType(self) -> str:  # noqa: N802
        return self._driver

    def getVersion(self) -> int:  # noqa: N802
        return 10_000_000

    def getCapabilities(self) -> str:  # noqa: N802
        return (
            "<capabilities><host><cpu><arch>x86_64</arch>"
            "<model>Skylake</model><feature name='vmx'/></cpu></host>"
            "</capabilities>"
        )

    def listAllDomains(self, _flags: int = 0) -> list[FakeDomain]:  # noqa: N802
        return list(self.domains.values())

    def lookupByUUIDString(self, uuid: str) -> FakeDomain:  # noqa: N802
        if uuid not in self.domains:
            raise FakeLibvirtError(42, f"no domain {uuid}")  # VIR_ERR_NO_DOMAIN
        return self.domains[uuid]

    def lookupByName(self, name: str) -> FakeDomain:  # noqa: N802
        for dom in self.domains.values():
            if dom._name == name:
                return dom
        raise FakeLibvirtError(42, f"no domain named {name}")

    def newStream(self, _flags: int = 0) -> FakeStream:  # noqa: N802
        return FakeStream()

    def defineXML(self, xml: str) -> FakeDomain:  # noqa: N802
        import xml.etree.ElementTree as ET

        root = ET.fromstring(xml)
        name = root.findtext("name") or "unnamed"
        uuid = root.findtext("uuid") or f"uuid-{name}"
        existing = None
        for dom in self.domains.values():
            if dom._name == name:
                existing = dom
                break
        dom = FakeDomain(name, existing._uuid if existing else uuid, state=_SHUTOFF)
        dom._xml = (
            xml.replace("</domain>", "<devices></devices></domain>")
            if ("<devices>" not in xml)
            else xml
        )
        self._add(dom)
        return dom

    def close(self) -> int:
        self.closed += 1
        return 0


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


class FakeEventPump:
    """Infra-free stand-in for the D-G17 pump (DP-02 seam).

    Records subscribe/unsubscribe so tests can assert teardown; never
    starts a libvirt event loop. Tests drive ``_EventStream.push_raw``
    directly to simulate the fan-out the real pump would perform.
    """

    def __init__(self) -> None:
        self.subscribed: list[Any] = []
        self.unsubscribed: list[int] = []
        self._next = 0

    def subscribe(self, conn: Any, domain_filter: Any, push: Any) -> int:
        sub_id = self._next
        self._next += 1
        self.subscribed.append((sub_id, domain_filter, push))
        return sub_id

    def unsubscribe(self, sub_id: int) -> None:
        self.unsubscribed.append(sub_id)


@pytest.fixture
def conn() -> FakeConn:
    c = FakeConn()
    return c


@pytest.fixture
def event_pump() -> FakeEventPump:
    return FakeEventPump()


@pytest.fixture
def backend(conn: FakeConn, event_pump: FakeEventPump) -> Any:
    wrapped = LibvirtConnection.adopt(conn, uri="test:///unit")
    be = LibvirtHypervisorBackend(connection=wrapped, event_pump=event_pump)
    yield be
    be.close()


def _spec(name: str = "vm1", cpu: int = 1, mem: int = 1048576) -> VmSpec:
    return VmSpec(name=name, cpu=cpu, memory_bytes=mem)


def _seed_domain(
    conn: FakeConn, name: str = "vm1", uuid: str = "u-1", state: int = _SHUTOFF
) -> DomainId:
    dom = FakeDomain(name, uuid, state=state)
    conn._add(dom)
    return DomainId(uuid)


# ---------------------------------------------------------------------------
# construction / close
# ---------------------------------------------------------------------------


def test_close_is_idempotent(backend: Any, conn: FakeConn) -> None:
    backend.close()
    backend.close()
    assert conn.closed == 1


def test_context_manager_closes(conn: FakeConn) -> None:
    wrapped = LibvirtConnection.adopt(conn, uri="test:///unit")
    with LibvirtHypervisorBackend(connection=wrapped) as be:
        assert be is not None
    assert conn.closed == 1


def test_requires_a_connection_source() -> None:
    from mgf.vm.core.exceptions import HypervisorError

    with pytest.raises(HypervisorError):
        LibvirtHypervisorBackend()


# ---------------------------------------------------------------------------
# H1 read / identity
# ---------------------------------------------------------------------------


def test_list_domains_empty(backend: Any) -> None:
    assert backend.list_domains() == []


def test_list_domains_reports_running(backend: Any, conn: FakeConn) -> None:
    _seed_domain(conn, "running-vm", "u-run", state=_RUNNING)
    _seed_domain(conn, "stopped-vm", "u-stop", state=_SHUTOFF)
    infos = backend.list_domains()
    by_name = {i.name: i for i in infos}
    assert by_name["running-vm"].is_running is True
    assert by_name["stopped-vm"].is_running is False


def test_get_domain_returns_info(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm1", "u-1", state=_RUNNING)
    info = backend.get_domain(d)
    assert info.domain_id == d
    assert info.name == "vm1"
    assert info.is_running is True


def test_get_domain_missing_raises_domain_not_found(backend: Any) -> None:
    with pytest.raises(DomainNotFound):
        backend.get_domain(DomainId("nope"))


def test_capabilities_parses_and_caches(backend: Any, conn: FakeConn) -> None:
    caps = backend.capabilities()
    assert isinstance(caps, HypervisorCapabilities)
    assert caps.engine == "libvirt-test"
    assert caps.engine_version == "10.0.0"
    assert "vmx" in caps.cpu_features
    assert "model:Skylake" in caps.cpu_features
    # Test driver advertises no live migration / memory snapshots.
    assert caps.supports_live_migration is False
    # Cached: second call returns the same instance.
    assert backend.capabilities() is caps


def test_capabilities_kvm_engine_label() -> None:
    conn = FakeConn(driver="QEMU")
    with LibvirtHypervisorBackend(connection=LibvirtConnection.adopt(conn)) as be:
        caps = be.capabilities()
    assert caps.engine == "libvirt-kvm"
    assert caps.supports_live_migration is True


# ---------------------------------------------------------------------------
# H2 define / redefine / undefine
# ---------------------------------------------------------------------------


def test_define_new_domain(backend: Any, conn: FakeConn) -> None:
    d = backend.define(_spec("fresh"))
    assert isinstance(d, DomainId)
    assert any(dom._name == "fresh" for dom in conn.domains.values())


def test_define_idempotent_same_spec(backend: Any) -> None:
    spec = _spec("idem")
    d1 = backend.define(spec)
    d2 = backend.define(spec)
    assert d1 == d2


def test_define_conflict_different_spec(backend: Any) -> None:
    backend.define(_spec("conflict", cpu=1))
    with pytest.raises(SpecConflict):
        backend.define(_spec("conflict", cpu=8, mem=4 * 1048576))


def test_define_idempotent_via_signature_marker(backend: Any, conn: FakeConn) -> None:
    # The defined domain must carry the embedded spec signature so a
    # second identical define short-circuits without comparing the
    # (libvirt-decorated) live XML.
    backend.define(_spec("sigvm"))
    dom = next(d for d in conn.domains.values() if d._name == "sigvm")
    assert "urn:mgf:vm:libvirt:1" in dom.XMLDesc()


def test_define_idempotent_out_of_band_domain_via_semantic_equal(
    backend: Any, conn: FakeConn
) -> None:
    # A domain defined out-of-band (no stored signature) falls back to
    # semantic_equal. We seed one whose XML matches the generated spec's
    # *bare* form (no metadata) and assert idempotent return.
    from mgf.vm.libvirt import vmspec_to_xml

    spec = _spec("oob", cpu=1)
    bare = backend._retype_domain(vmspec_to_xml(LibvirtVmSpec.from_core(spec)), "test")
    dom = FakeDomain("oob", "u-oob", state=_SHUTOFF)
    dom._xml = bare  # no <metadata> signature → fallback path
    conn._add(dom)
    result = backend.define(spec)
    assert result == DomainId("u-oob")


def test_redefine_replaces_stopped_domain(backend: Any) -> None:
    d = backend.define(_spec("redef", cpu=1))
    backend.redefine(d, _spec("redef", cpu=2))
    # No exception → success; new vcpu is observable.
    assert backend.get_domain(d).cpu == 2


def test_redefine_same_spec_is_noop(backend: Any) -> None:
    d = backend.define(_spec("redef2", cpu=1))
    backend.redefine(d, _spec("redef2", cpu=1))  # no raise


def test_redefine_running_raises_state_invalid(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "run", "u-run", state=_RUNNING)
    with pytest.raises(DomainStateInvalid):
        backend.redefine(d, _spec("run"))


def test_undefine_missing_is_success(backend: Any) -> None:
    backend.undefine(DomainId("ghost"))  # idempotent, no raise


def test_undefine_stopped_domain(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    backend.undefine(d)
    assert "undefineFlags" in conn.domains["u-1"].actions


def test_undefine_running_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    with pytest.raises(DomainStateInvalid):
        backend.undefine(d)


def test_rename_stopped_domain_updates_name(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "before", "u-1", state=_SHUTOFF)
    backend.rename(d, "after")
    assert conn.domains["u-1"].name() == "after"
    assert "rename" in conn.domains["u-1"].actions


def test_rename_to_same_name_is_noop(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "same", "u-1", state=_SHUTOFF)
    backend.rename(d, "same")
    assert "rename" not in conn.domains["u-1"].actions


def test_rename_running_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "run", "u-1", state=_RUNNING)
    with pytest.raises(DomainStateInvalid):
        backend.rename(d, "new")


def test_rename_to_taken_name_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "one", "u-1", state=_SHUTOFF)
    _seed_domain(conn, "two", "u-2", state=_SHUTOFF)
    with pytest.raises(DomainAlreadyExistsError):
        backend.rename(d, "two")


def test_rename_missing_raises_not_found(backend: Any) -> None:
    with pytest.raises(DomainNotFound):
        backend.rename(DomainId("ghost"), "x")


def test_undefine_falls_back_when_flags_unsupported(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    dom = conn.domains["u-1"]

    def _boom(_flags: int = 0) -> int:
        raise FakeLibvirtError(84, "flags unsupported")

    dom.undefineFlags = _boom  # type: ignore[method-assign]
    backend.undefine(d)
    assert "undefine" in dom.actions


# ---------------------------------------------------------------------------
# H3 power
# ---------------------------------------------------------------------------


def test_start_boots_stopped_domain(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    backend.start(d)
    assert conn.domains["u-1"]._state == _RUNNING


def test_start_already_running_is_noop(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.start(d)
    assert "create" not in conn.domains["u-1"].actions


def test_start_with_token_succeeds(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    token = CancellationEvent()
    backend.start(d, token=token)
    assert conn.domains["u-1"]._state == _RUNNING


def test_start_cancelled_before_completion_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    dom = conn.domains["u-1"]
    token = CancellationEvent()

    # Make create() block until cancelled, never reaching running.
    import threading

    started = threading.Event()

    def _blocking_create() -> int:
        started.set()
        token.wait(timeout=5.0)
        # Stay stopped — never transitions to running.
        return 0

    dom.create = _blocking_create  # type: ignore[method-assign]

    def _cancel_soon() -> None:
        started.wait(timeout=2.0)
        token.cancel()

    canceller = threading.Thread(target=_cancel_soon)
    canceller.start()
    with pytest.raises(OperationCancelled):
        backend.start(d, token=token)
    canceller.join()


def test_stop_graceful_succeeds(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.stop(d)
    assert conn.domains["u-1"]._state == _SHUTOFF


def test_stop_already_stopped_is_noop(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    backend.stop(d)
    assert "shutdown" not in conn.domains["u-1"].actions


def test_stop_force_destroys(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.stop(d, force=True)
    assert "destroy" in conn.domains["u-1"].actions


def test_stop_graceful_timeout_raises_guest_unresponsive(
    backend: Any, conn: FakeConn, monkeypatch: pytest.MonkeyPatch
) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    conn.domains["u-1"].shutdown_to_shutoff = False  # never stops
    # Shrink the graceful timeout so the test is fast.
    import mgf.vm.libvirt._hypervisor as h

    monkeypatch.setattr(h, "_DEFAULT_GRACEFUL_TIMEOUT", 0.2)
    monkeypatch.setattr(h, "_POLL_INTERVAL", 0.05)
    with pytest.raises(GuestUnresponsive):
        backend.stop(d)


def test_stop_graceful_cancelled_raises_operation_cancelled(
    backend: Any, conn: FakeConn, monkeypatch: pytest.MonkeyPatch
) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    conn.domains["u-1"].shutdown_to_shutoff = False
    import mgf.vm.libvirt._hypervisor as h

    monkeypatch.setattr(h, "_POLL_INTERVAL", 0.02)
    token = CancellationEvent()
    token.cancel()
    with pytest.raises(OperationCancelled):
        backend.stop(d, token=token)


def test_pause_and_resume(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.pause(d)
    assert conn.domains["u-1"]._state == _PAUSED
    backend.resume(d)
    assert conn.domains["u-1"]._state == _RUNNING


def test_pause_idempotent(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_PAUSED)
    backend.pause(d)
    assert "suspend" not in conn.domains["u-1"].actions


def test_resume_idempotent_when_running(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.resume(d)
    assert "resume" not in conn.domains["u-1"].actions


def test_reboot_graceful(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.reboot(d)
    assert "reboot" in conn.domains["u-1"].actions


def test_reboot_force_resets(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.reboot(d, force=True)
    assert "reset" in conn.domains["u-1"].actions


def test_reboot_with_token(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.reboot(d, token=CancellationEvent())
    assert "reboot" in conn.domains["u-1"].actions


def test_start_engine_error_translated(backend: Any, conn: FakeConn) -> None:
    from mgf.vm.core.exceptions import ResourceUnavailable

    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    conn.domains["u-1"].create_raises = FakeLibvirtError(2, "no memory")  # VIR_ERR_NO_MEMORY
    with pytest.raises(ResourceUnavailable):
        backend.start(d)


# ---------------------------------------------------------------------------
# H4 snapshots
# ---------------------------------------------------------------------------


def test_snapshot_creates_and_returns_id(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    s = backend.snapshot(d, "snap1")
    assert isinstance(s, SnapshotId)
    assert s.value == "u-1/snap1"
    assert "snap1" in conn.domains["u-1"].snapshots


def test_snapshot_idempotent_same_name_same_memory(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    s1 = backend.snapshot(d, "snap1", include_memory=False)
    s2 = backend.snapshot(d, "snap1", include_memory=False)
    assert s1 == s2


def test_snapshot_conflicting_memory_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.snapshot(d, "snap1", include_memory=False)
    with pytest.raises(SnapshotExists):
        backend.snapshot(d, "snap1", include_memory=True)


def test_snapshot_create_fallback_without_memory_element(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    dom = conn.domains["u-1"]
    original = dom.snapshotCreateXML
    calls: list[str] = []

    def _picky(xml: str, flags: int = 0) -> FakeSnapshot:
        calls.append(xml)
        if "<memory" in xml:
            raise FakeLibvirtError(9, "memory snapshot unsupported")
        return original(xml, flags)

    dom.snapshotCreateXML = _picky  # type: ignore[method-assign]
    s = backend.snapshot(d, "snap1")
    assert isinstance(s, SnapshotId)
    assert len(calls) == 2  # first with memory failed, fallback succeeded


def test_list_snapshots(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    backend.snapshot(d, "a")
    backend.snapshot(d, "b")
    snaps = backend.list_snapshots(d)
    names = {s.name for s in snaps}
    assert names == {"a", "b"}
    assert all(s.parent == d for s in snaps)


def test_revert_snapshot(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    s = backend.snapshot(d, "snap1")
    backend.revert_snapshot(d, s)
    assert "revert" in conn.domains["u-1"].actions


def test_revert_missing_snapshot_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    with pytest.raises(SnapshotNotFound):
        backend.revert_snapshot(d, SnapshotId("u-1/ghost"))


def test_revert_cancelled_token_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    s = backend.snapshot(d, "snap1")
    token = CancellationEvent()
    token.cancel()
    with pytest.raises(OperationCancelled):
        backend.revert_snapshot(d, s, token=token)


def test_delete_snapshot(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    s = backend.snapshot(d, "snap1")
    backend.delete_snapshot(d, s)
    assert conn.domains["u-1"].snapshots["snap1"].deleted is True


def test_delete_missing_snapshot_is_success(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    backend.delete_snapshot(d, SnapshotId("u-1/ghost"))  # no raise


def test_delete_snapshot_with_children_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    dom = conn.domains["u-1"]
    dom.snapshots["parent"] = FakeSnapshot("parent", children=2)
    with pytest.raises(SnapshotInUse):
        backend.delete_snapshot(d, SnapshotId("u-1/parent"))


# ---------------------------------------------------------------------------
# H6 migration
# ---------------------------------------------------------------------------


def _plan(compatible: bool = True) -> MigrationPlan:
    reasons = () if compatible else ("cpu",)
    return MigrationPlan(compatibility=CompatibilityResult(compatible=compatible, reasons=reasons))


def _target(uri: str | None = "qemu+ssh://dest/system") -> MigrationTarget:
    from mgf.vm.core.ids import HostId

    return MigrationTarget(host=HostId("host-dest"), libvirt_uri=uri)


def test_migrate_success(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.migrate(d, _target(), plan=_plan())
    assert "migrate" in conn.domains["u-1"].actions


def test_migrate_incompatible_plan_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    with pytest.raises(MigrationIncompatible):
        backend.migrate(d, _target(), plan=_plan(compatible=False))


def test_migrate_without_uri_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    with pytest.raises(MigrationIncompatible):
        backend.migrate(d, _target(uri=None), plan=_plan())


def test_migrate_cancelled_before_start_raises(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    token = CancellationEvent()
    token.cancel()
    with pytest.raises(MigrationCancelled):
        backend.migrate(d, _target(), plan=_plan(), token=token)


def test_migrate_non_running_raises_state_invalid(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_SHUTOFF)
    with pytest.raises(DomainStateInvalid):
        backend.migrate(d, _target(), plan=_plan())


def test_migrate_engine_failure_raises_migration_failed(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    dom = conn.domains["u-1"]

    def _boom(_uri: str, _flags: int, _dname: Any, _bw: int) -> int:
        raise FakeLibvirtError(69, "persist failed")  # VIR_ERR_MIGRATE_PERSIST_FAILED

    dom.migrateToURI = _boom  # type: ignore[method-assign]
    with pytest.raises(MigrationFailed):
        backend.migrate(d, _target(), plan=_plan())


def test_migrate_engine_abort_raises_cancelled(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    dom = conn.domains["u-1"]

    def _abort(_uri: str, _flags: int, _dname: Any, _bw: int) -> int:
        raise FakeLibvirtError(78, "aborted")  # VIR_ERR_OPERATION_ABORTED

    dom.migrateToURI = _abort  # type: ignore[method-assign]
    with pytest.raises(MigrationCancelled):
        backend.migrate(d, _target(), plan=_plan())


def test_migrate_with_token_succeeds(backend: Any, conn: FakeConn) -> None:
    # A token that is never cancelled runs the worker path to completion.
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.migrate(d, _target(), plan=_plan(), token=CancellationEvent())
    assert "migrate" in conn.domains["u-1"].actions


def test_migrate_cancelled_midflight_aborts_and_rolls_back(
    backend: Any, conn: FakeConn, monkeypatch: pytest.MonkeyPatch
) -> None:
    # §3/§9: token cancelled while migrateToURI blocks → adapter calls
    # abortJob on the source; the engine surfaces the abort as an error
    # → MigrationCancelled (source rolled back, left running).
    import threading

    import mgf.vm.libvirt._hypervisor as h

    monkeypatch.setattr(h, "_POLL_INTERVAL", 0.02)

    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    dom = conn.domains["u-1"]
    token = CancellationEvent()
    in_migrate = threading.Event()
    abort_seen = threading.Event()

    def _blocking_migrate(_uri: str, _flags: int, _dname: Any, _bw: int) -> int:
        dom.actions.append("migrate")
        in_migrate.set()
        # Block until abortJob is called, then raise as libvirt would when
        # the job is aborted.
        abort_seen.wait(timeout=5.0)
        raise FakeLibvirtError(78, "operation aborted")  # VIR_ERR_OPERATION_ABORTED

    def _abort() -> int:
        dom.actions.append("abortJob")
        abort_seen.set()
        return 0

    dom.migrateToURI = _blocking_migrate  # type: ignore[method-assign]
    dom.abortJob = _abort  # type: ignore[method-assign]

    def _cancel_soon() -> None:
        in_migrate.wait(timeout=2.0)
        token.cancel()

    canceller = threading.Thread(target=_cancel_soon)
    canceller.start()
    with pytest.raises(MigrationCancelled):
        backend.migrate(d, _target(), plan=_plan(), token=token)
    canceller.join()
    assert "abortJob" in dom.actions


def test_migrate_cancelled_midflight_abort_fails_raises_failed(
    backend: Any, conn: FakeConn, monkeypatch: pytest.MonkeyPatch
) -> None:
    # If abortJob raises but migrate then completes cleanly, success wins
    # over the late cancel (the ``start`` rule) — no error.
    import threading

    import mgf.vm.libvirt._hypervisor as h

    monkeypatch.setattr(h, "_POLL_INTERVAL", 0.02)

    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    dom = conn.domains["u-1"]
    token = CancellationEvent()
    in_migrate = threading.Event()
    aborted = threading.Event()

    def _migrate(_uri: str, _flags: int, _dname: Any, _bw: int) -> int:
        dom.actions.append("migrate")
        in_migrate.set()
        aborted.wait(timeout=5.0)
        return 0  # completes successfully despite the abort attempt

    def _abort_boom() -> int:
        aborted.set()
        raise FakeLibvirtError(9, "abort failed")  # swallowed best-effort

    dom.migrateToURI = _migrate  # type: ignore[method-assign]
    dom.abortJob = _abort_boom  # type: ignore[method-assign]

    def _cancel_soon() -> None:
        in_migrate.wait(timeout=2.0)
        token.cancel()

    canceller = threading.Thread(target=_cancel_soon)
    canceller.start()
    # migrate finished cleanly → success wins, no raise.
    backend.migrate(d, _target(), plan=_plan(), token=token)
    canceller.join()


# ---------------------------------------------------------------------------
# H6 device passthrough
# ---------------------------------------------------------------------------


def _assignment(bdf: str = "0000:01:00.0") -> DeviceAssignment:
    return DeviceAssignment(
        lease=LeaseId("lease-1"),
        device_id=DeviceId("dev-1"),
        host_handle=bdf,
    )


def test_attach_device(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.attach_device(d, _assignment())
    assert "attach" in conn.domains["u-1"].actions


def test_attach_device_idempotent(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.attach_device(d, _assignment())
    conn.domains["u-1"].actions.clear()
    backend.attach_device(d, _assignment())  # already present
    assert "attach" not in conn.domains["u-1"].actions


def test_attach_device_failure_translated(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    dom = conn.domains["u-1"]

    def _boom(_xml: str, _flags: int) -> int:
        raise FakeLibvirtError(1, "internal error")  # → HypervisorError

    dom.attachDeviceFlags = _boom  # type: ignore[method-assign]
    with pytest.raises(DeviceAttachFailed):
        backend.attach_device(d, _assignment())


def test_detach_device(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.attach_device(d, _assignment())
    backend.detach_device(d, _assignment())
    assert "detach" in conn.domains["u-1"].actions


def test_detach_device_idempotent_when_absent(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.detach_device(d, _assignment())  # nothing attached
    assert "detach" not in conn.domains["u-1"].actions


def test_detach_device_failure_translated(backend: Any, conn: FakeConn) -> None:
    d = _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    backend.attach_device(d, _assignment())
    dom = conn.domains["u-1"]

    def _boom(_xml: str, _flags: int) -> int:
        raise FakeLibvirtError(1, "internal error")

    dom.detachDeviceFlags = _boom  # type: ignore[method-assign]
    with pytest.raises(DeviceDetachFailed):
        backend.detach_device(d, _assignment())


def test_hostdev_xml_parses_full_bdf() -> None:
    xml = LibvirtHypervisorBackend._hostdev_xml(_assignment("0000:3f:1a.2"))
    assert 'domain="0x0000"' in xml
    assert 'bus="0x3f"' in xml
    assert 'slot="0x1a"' in xml
    assert 'function="0x2"' in xml


def test_hostdev_xml_parses_short_bdf() -> None:
    xml = LibvirtHypervisorBackend._hostdev_xml(_assignment("01:00.0"))
    assert 'bus="0x01"' in xml
    assert 'slot="0x00"' in xml


def test_hostdev_xml_unparseable_handle_degrades() -> None:
    # A garbage handle does not raise; libvirt would reject at attach.
    xml = LibvirtHypervisorBackend._hostdev_xml(_assignment("not-a-bdf"))
    assert "<hostdev" in xml


# ---------------------------------------------------------------------------
# libvirt escape hatch: read_domain_xml / redefine_domain_xml
# (engine-specific; OFF the neutral HypervisorBackend Protocol)
# ---------------------------------------------------------------------------


def test_read_domain_xml_returns_raw_xml_by_name(backend: Any, conn: FakeConn) -> None:
    _seed_domain(conn, "vm-x", "u-x", state=_RUNNING)
    xml = backend.read_domain_xml("vm-x")
    assert isinstance(xml, str)
    assert "<name>vm-x</name>" in xml
    assert "<uuid>u-x</uuid>" in xml


def test_read_domain_xml_passes_flags_zero(backend: Any, conn: FakeConn) -> None:
    # Preserve the legacy reader's behavior: XMLDesc(0) — no secure/inactive
    # flags. Capture the flags the adapter passes to the binding.
    dom = FakeDomain("vm-flags", "u-flags", state=_SHUTOFF)
    captured: list[int] = []

    def _xmldesc(flags: int = 0) -> str:
        captured.append(flags)
        return dom._xml

    dom.XMLDesc = _xmldesc  # type: ignore[method-assign]
    conn._add(dom)

    backend.read_domain_xml("vm-flags")
    assert captured == [0]


def test_read_domain_xml_unknown_domain_raises_not_found(backend: Any) -> None:
    # The seam maps the raised libvirtError (VIR_ERR_NO_DOMAIN) to the core
    # DomainNotFound — not a raw libvirtError.
    with pytest.raises(DomainNotFound) as ei:
        backend.read_domain_xml("ghost")
    assert not isinstance(ei.value, FakeLibvirtError)


# ---------------------------------------------------------------------------
# libvirt escape hatch: dump_memory / screenshot (forensic acquisition, D-G22)
# ---------------------------------------------------------------------------


def test_dump_memory_core_dumps_live(backend: Any, conn: FakeConn, tmp_path: Any) -> None:
    _seed_domain(conn, "vm-d", "u-d", state=_RUNNING)
    dom = conn.lookupByName("vm-d")
    dest = tmp_path / "case" / "mem.raw"
    backend.dump_memory("vm-d", dest, live=True)
    assert dest.read_bytes() == b"ELFCORE"
    assert "coredump" in dom.actions
    assert dom.dump_flags == 1  # DUMP_LIVE


def test_dump_memory_not_live_clears_flag(backend: Any, conn: FakeConn, tmp_path: Any) -> None:
    _seed_domain(conn, "vm-d", "u-d", state=_RUNNING)
    dom = conn.lookupByName("vm-d")
    backend.dump_memory("vm-d", tmp_path / "mem.raw", live=False)
    assert dom.dump_flags == 0


def test_dump_memory_unknown_domain_raises_not_found(backend: Any, tmp_path: Any) -> None:
    with pytest.raises(DomainNotFound) as ei:
        backend.dump_memory("ghost", tmp_path / "mem.raw")
    assert not isinstance(ei.value, FakeLibvirtError)


def test_screenshot_drains_stream_to_dest(backend: Any, conn: FakeConn, tmp_path: Any) -> None:
    _seed_domain(conn, "vm-s", "u-s", state=_RUNNING)
    dest = tmp_path / "case" / "shot.png"
    backend.screenshot("vm-s", dest)
    assert dest.read_bytes() == b"PNGSCREENSHOT"


def test_screenshot_unknown_domain_raises_not_found(backend: Any, tmp_path: Any) -> None:
    with pytest.raises(DomainNotFound) as ei:
        backend.screenshot("ghost", tmp_path / "shot.png")
    assert not isinstance(ei.value, FakeLibvirtError)


def test_read_domain_xml_translates_engine_error(backend: Any, conn: FakeConn) -> None:
    # A non-not-found libvirtError raised by XMLDesc is translated at the
    # seam rather than leaking the raw error class.
    from mgf.vm.core.exceptions import HypervisorTransientError

    dom = FakeDomain("vm-boom", "u-boom", state=_SHUTOFF)

    def _boom(flags: int = 0) -> str:
        raise FakeLibvirtError(87, "resource busy")  # VIR_ERR_RESOURCE_BUSY

    dom.XMLDesc = _boom  # type: ignore[method-assign]
    conn._add(dom)

    with pytest.raises(HypervisorTransientError) as ei:
        backend.read_domain_xml("vm-boom")
    assert not isinstance(ei.value, FakeLibvirtError)


def test_redefine_domain_xml_calls_definexml(backend: Any, conn: FakeConn) -> None:
    captured: list[str] = []
    orig_define = conn.defineXML

    def _define(xml: str) -> FakeDomain:
        captured.append(xml)
        return orig_define(xml)

    conn.defineXML = _define  # type: ignore[method-assign]

    xml = _domain_xml("vm-redef", "u-redef")
    assert backend.redefine_domain_xml(xml) is None
    assert captured == [xml]
    # The domain is now persistently defined on the engine.
    assert any(d._name == "vm-redef" for d in conn.domains.values())


def test_redefine_domain_xml_translates_engine_error(backend: Any, conn: FakeConn) -> None:
    from mgf.vm.core.exceptions import SpecInvalid

    def _boom(xml: str) -> FakeDomain:
        raise FakeLibvirtError(27, "bad xml")  # VIR_ERR_XML_ERROR

    conn.defineXML = _boom  # type: ignore[method-assign]

    with pytest.raises(SpecInvalid) as ei:
        backend.redefine_domain_xml("<domain/>")
    assert not isinstance(ei.value, FakeLibvirtError)


# ---------------------------------------------------------------------------
# H5 watch_events (the live wiring; sequencer logic tested separately)
# ---------------------------------------------------------------------------


def test_watch_events_primes_current_state(backend: Any, conn: FakeConn) -> None:
    # The stream is a blocking live iterator: read the one primed event,
    # then close (never ``list()`` an open stream — it blocks for live).
    _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    stream = backend.watch_events()
    from mgf.vm.core.contracts import VmEvent

    first = next(stream)
    assert isinstance(first, VmEvent)
    assert first.domain_id == DomainId("u-1")
    stream.close()


def test_watch_events_subscribes_and_unsubscribes_from_pump(
    backend: Any, conn: FakeConn, event_pump: FakeEventPump
) -> None:
    # D-G17: construction subscribes to the pump; close() unsubscribes
    # (deterministic teardown, DP-13).
    _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    stream = backend.watch_events()
    assert len(event_pump.subscribed) == 1
    sub_id = event_pump.subscribed[0][0]
    stream.close()
    assert event_pump.unsubscribed == [sub_id]
    stream.close()  # idempotent — no second unsubscribe
    assert event_pump.unsubscribed == [sub_id]


def test_watch_events_filtered_to_one_domain(
    backend: Any, conn: FakeConn, event_pump: FakeEventPump
) -> None:
    _seed_domain(conn, "a", "u-a", state=_RUNNING)
    _seed_domain(conn, "b", "u-b", state=_SHUTOFF)
    stream = backend.watch_events(DomainId("u-a"))
    # The domain filter is primed locally AND handed to the pump for
    # live fan-out filtering.
    first = next(stream)
    assert first.domain_id == DomainId("u-a")  # type: ignore[union-attr]
    assert event_pump.subscribed[0][1] == DomainId("u-a")
    stream.close()


def test_watch_events_close_is_idempotent_and_stops(backend: Any, conn: FakeConn) -> None:
    _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    stream = backend.watch_events()
    stream.close()
    stream.close()  # idempotent
    # Closed + drained → StopIteration immediately (does not block).
    assert list(stream) == []


def test_watch_events_context_manager(backend: Any, conn: FakeConn) -> None:
    _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    with backend.watch_events() as stream:
        first = next(iter(stream))
    from mgf.vm.core.contracts import VmEvent

    assert isinstance(first, VmEvent)


def test_watch_events_push_raw_after_close_noop(backend: Any, conn: FakeConn) -> None:
    _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    stream = backend.watch_events()
    next(stream)  # consume the one primed event
    stream.close()
    stream.push_raw(DomainId("u-1"), {"e": "x"})
    assert list(stream) == []


def test_watch_events_nonexistent_domain_raises_typed_not_raw(backend: Any, conn: FakeConn) -> None:
    # §7/EH-02 seam: priming a filter on a missing domain makes raw
    # libvirt lookups; the escaping libvirtError MUST be translated to a
    # typed core exception (DomainNotFound), never leak raw.
    with pytest.raises(DomainNotFound):
        backend.watch_events(DomainId("does-not-exist"))


def test_watch_events_nonexistent_domain_not_libvirt_error(backend: Any, conn: FakeConn) -> None:
    # Belt-and-braces: assert the leaked type is NOT the fake libvirt
    # error class (which is what slipped through before the seam fix).
    with pytest.raises(DomainNotFound) as ei:
        backend.watch_events(DomainId("ghost"))
    assert not isinstance(ei.value, FakeLibvirtError)
    assert isinstance(ei.value, DomainNotFound)


def test_watch_events_transient_connection_issue_translated(backend: Any, conn: FakeConn) -> None:
    # A transient engine hiccup while priming the (unfiltered) stream maps
    # to HypervisorTransientError via the seam, not a raw libvirtError.
    from mgf.vm.core.exceptions import HypervisorTransientError

    def _boom(_flags: int = 0) -> list[FakeDomain]:
        raise FakeLibvirtError(87, "resource busy")  # VIR_ERR_RESOURCE_BUSY

    conn.listAllDomains = _boom  # type: ignore[method-assign]
    with pytest.raises(HypervisorTransientError):
        backend.watch_events()


def test_watch_events_push_raw_delivers(backend: Any, conn: FakeConn) -> None:
    _seed_domain(conn, "vm", "u-1", state=_RUNNING)
    stream = backend.watch_events()
    next(stream)  # the primed current-state event
    # Simulate the pump fanning a live lifecycle event into this stream.
    stream.push_raw(DomainId("u-1"), {"e": "lifecycle"})
    from mgf.vm.core.contracts import VmEvent

    live = next(stream)
    assert isinstance(live, VmEvent)
    assert live.payload == {"e": "lifecycle"}
    stream.close()
