"""In-memory VM-provider test double (ported app-local from the retiring
umbrella ``mgf.vm.providers.mock_provider`` so the interchange's tests no longer
import ``mgf-vm``). It is a standalone duck-typed fake — it no longer
subclasses the deleted ``VMProvider`` ABC — providing the same surface the
remaining provider-shaped test doubles rely on.

:class:`MockProvider` is deterministic, makes no system calls, and never
touches libvirt. It gives the core modules a realistic backend to drive
during tests without needing a running libvirt daemon — which is the
whole point of the ``VMProvider`` abstraction.

Failure injection
-----------------

The :class:`FailurePlan` hook lets tests pre-register targeted
:class:`~mgf.vm.exceptions.ProviderError` (or any subclass) on specific
calls — matching by method name, by nth invocation, or by VM name — so
callers can be exercised through their degradation paths without having
to simulate a real libvirt outage.

See the "Chaos / Failure-Injection Tests" section of
``docs/ARCHITECTURE.md`` for the documented design.
"""

from __future__ import annotations

import threading
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from uuid import uuid4

from mgf.vm.core.domain import (
    LeaseInfo,
    NetworkInfo,
    SnapshotInfo,
    StoragePoolInfo,
    VMHandle,
    VMMetrics,
    VMStatus,
    VolumeInfo,
)
from mgf.vm.core.exceptions import (
    DomainAlreadyExistsError,
    DomainNotFoundError,
    HotplugNotSupportedError,
    ProviderError,
)

if TYPE_CHECKING:
    from collections.abc import Callable
    from pathlib import Path


# ---------------------------------------------------------------------------
# Internal state objects
# ---------------------------------------------------------------------------


@dataclass
class _MockDomain:
    """Mutable record of a single fake VM."""

    name: str
    uuid: str
    status: VMStatus = VMStatus.STOPPED
    xml: str = ""
    disks: list[Path] = field(default_factory=list)
    autostart: bool = False
    snapshots: dict[str, SnapshotInfo] = field(default_factory=dict)
    snapshot_xml: dict[str, str] = field(default_factory=dict)


@dataclass
class _MockNetwork:
    info: NetworkInfo
    leases: list[LeaseInfo] = field(default_factory=list)


@dataclass
class _MockPool:
    """Mutable wrapper around :class:`StoragePoolInfo` + its volumes."""

    info: StoragePoolInfo
    volumes: dict[str, VolumeInfo] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# FailurePlan — targeted error injection for tests
# ---------------------------------------------------------------------------


@dataclass
class _Injection:
    """A single scheduled failure."""

    error: ProviderError
    match_name: str | None = None
    remaining_calls: int | None = None  # None → fires on every matching call
    skip_count: int = 0  # fire on the (skip_count + 1)th call onward

    def should_fire(self, vm_name: str | None) -> bool:
        if self.match_name is not None and vm_name != self.match_name:
            return False
        if self.skip_count > 0:
            self.skip_count -= 1
            return False
        if self.remaining_calls is not None:
            if self.remaining_calls <= 0:
                return False
            self.remaining_calls -= 1
        return True


class FailurePlan:
    """Registry of errors to inject into :class:`MockProvider` method calls.

    Tests can pre-register any number of injections. Each injection fires
    the next time its target method is called (optionally filtered by VM
    name, and optionally limited to a number of calls).

    Example::

        plan = FailurePlan()
        plan.inject(
            "get_domain",
            DomainNotFoundError("ghost"),
            match_name="ghost",
        )
        provider = MockProvider(failure_plan=plan)
        with pytest.raises(DomainNotFoundError):
            provider.get_domain("ghost")

    Without a filter, the next call to ``method_name`` raises the supplied
    error and the injection is consumed (one-shot by default).
    """

    def __init__(self) -> None:
        self._plans: dict[str, list[_Injection]] = {}

    def inject(
        self,
        method_name: str,
        error: ProviderError,
        *,
        match_name: str | None = None,
        count: int | None = 1,
        skip: int = 0,
    ) -> None:
        """Schedule an error to be raised from ``method_name``.

        Args:
            method_name: name of the :class:`MockProvider` method.
            error: the :class:`ProviderError` (or subclass) instance to raise.
            match_name: if provided, the injection only fires for calls
                whose first ``VMHandle``/name argument matches this value.
            count: how many times the injection should fire before being
                removed. ``None`` means "every matching call forever".
                Default is 1 — a one-shot trigger.
            skip: skip the first ``skip`` matching calls before firing.
                Useful for "succeed the first two times, fail the third".
        """
        if not isinstance(error, ProviderError):
            raise TypeError(
                f"FailurePlan only injects ProviderError subclasses, got {type(error).__name__}"
            )
        injection = _Injection(
            error=error,
            match_name=match_name,
            remaining_calls=count,
            skip_count=skip,
        )
        self._plans.setdefault(method_name, []).append(injection)

    def check(self, method_name: str, vm_name: str | None = None) -> None:
        """Raise the next matching injection, if any.

        Called by every :class:`MockProvider` method before doing any
        work. ``vm_name`` is the name of the VM the call targets (when
        applicable), used for name-filtered injections.
        """
        plans = self._plans.get(method_name)
        if not plans:
            return
        for injection in list(plans):
            if injection.should_fire(vm_name):
                if injection.remaining_calls is not None and injection.remaining_calls <= 0:
                    plans.remove(injection)
                raise injection.error

    def clear(self) -> None:
        """Remove every scheduled injection. Useful between test cases."""
        self._plans.clear()


# ---------------------------------------------------------------------------
# MockProvider
# ---------------------------------------------------------------------------


class MockProvider:
    """Deterministic in-memory :class:`VMProvider` for tests.

    State is held in plain dicts keyed by VM / network / pool name.
    Methods that change state are protected by a single :class:`threading.
    RLock` so tests that exercise the event subscription (which fires on
    a background thread) do not race.

    Supplying a :class:`FailurePlan` lets tests exercise caller
    degradation without having to subclass anything.
    """

    def __init__(self, *, failure_plan: FailurePlan | None = None) -> None:
        self._failures = failure_plan or FailurePlan()
        self._lock = threading.RLock()
        self._domains: dict[str, _MockDomain] = {}
        self._networks: dict[str, _MockNetwork] = {}
        self._pools: dict[str, _MockPool] = {}
        self._event_callback: Callable[[VMHandle, VMStatus], None] | None = None
        # Phase 5: per-VM guest-agent responses. Tests seed this
        # via seed_agent_response("web", "guest-ping", {"pong": True}).
        # Unseeded calls raise ProviderError so operations.guest_update
        # can be driven through its failure paths.
        self._agent_responses: dict[tuple[str, str], dict[str, object]] = {}

        # Phase 17: per-VM bridge interface for forensic network capture.
        # Tests seed via seed_bridge_for_vm("suspect-1", "virbr-vman-q").
        # Unseeded VMs return None from bridge_for_vm (matches "no NIC"
        # / "macvtap" cases that need the --bridge override).
        self._bridge_for_vm: dict[str, str | None] = {}

        # Seed a single default network so tests that immediately call
        # list_networks() see something sensible. Tests that need a clean
        # slate can delete it via delete_network("default").
        default_net = NetworkInfo(
            name="default",
            uuid=str(uuid4()),
            active=True,
            autostart=True,
            bridge_name="virbr0",
            forward_mode="nat",
        )
        self._networks["default"] = _MockNetwork(info=default_net)

    # ------------------------------------------------------------------
    # Test affordances — small helpers that are not part of VMProvider
    # ------------------------------------------------------------------

    @property
    def failure_plan(self) -> FailurePlan:
        """Expose the :class:`FailurePlan` so tests can add injections
        after the provider is constructed."""
        return self._failures

    def seed_domain(
        self,
        name: str,
        *,
        status: VMStatus = VMStatus.STOPPED,
        disks: list[Path] | None = None,
        xml: str = "",
        autostart: bool = False,
    ) -> VMHandle:
        """Create a fake domain directly — skips ``define_domain``.

        Useful for tests that want to start from a populated state
        without going through an XML round-trip.
        """
        with self._lock:
            if name in self._domains:
                raise DomainAlreadyExistsError(name)
            domain = _MockDomain(
                name=name,
                uuid=str(uuid4()),
                status=status,
                xml=xml or f"<domain><name>{name}</name></domain>",
                disks=list(disks or []),
                autostart=autostart,
            )
            self._domains[name] = domain
            return VMHandle(name=name, uuid=domain.uuid, state=domain.status)

    def seed_network(self, info: NetworkInfo) -> None:
        with self._lock:
            self._networks[info.name] = _MockNetwork(info=info)

    def seed_pool(self, pool: StoragePoolInfo) -> None:
        with self._lock:
            existing = self._pools.get(pool.name)
            if existing is None:
                self._pools[pool.name] = _MockPool(info=pool)
            else:
                existing.info = pool

    def seed_volume(self, volume: VolumeInfo) -> None:
        """Attach a volume to an already-seeded pool (test affordance)."""
        with self._lock:
            pool = self._pools.get(volume.pool)
            if pool is None:
                raise ProviderError(f"cannot seed volume in missing pool {volume.pool!r}")
            pool.volumes[volume.name] = volume

    def seed_lease(self, network_name: str, lease: LeaseInfo) -> None:
        with self._lock:
            self._networks[network_name].leases.append(lease)

    def seed_agent_response(self, vm_name: str, command: str, response: dict[str, object]) -> None:
        """Pre-register a response for a guest-agent command.

        Lets tests drive :meth:`guest_agent_command` through a
        canned reply without a real libvirt channel.
        """
        with self._lock:
            self._agent_responses[(vm_name, command)] = response

    # Internal helper: look up a domain by handle or raise a typed error.
    def _resolve(self, handle: VMHandle) -> _MockDomain:
        domain = self._domains.get(handle.name)
        if domain is None:
            raise DomainNotFoundError(handle.name)
        return domain

    def _fire_event(self, domain: _MockDomain) -> None:
        """Notify the subscribed callback that this domain's state changed.

        Matches the real libvirt contract: events fire after the state
        transition has committed.
        """
        cb = self._event_callback
        if cb is None:
            return
        handle = VMHandle(name=domain.name, uuid=domain.uuid, state=domain.status)
        cb(handle, domain.status)

    # ------------------------------------------------------------------
    # Domain management
    # ------------------------------------------------------------------

    def list_domains(self) -> list[VMHandle]:
        self._failures.check("list_domains")
        with self._lock:
            return [
                VMHandle(name=d.name, uuid=d.uuid, state=d.status) for d in self._domains.values()
            ]

    def get_domain(self, name: str) -> VMHandle:
        self._failures.check("get_domain", name)
        with self._lock:
            domain = self._domains.get(name)
            if domain is None:
                raise DomainNotFoundError(name)
            return VMHandle(name=domain.name, uuid=domain.uuid, state=domain.status)

    def define_domain(self, xml: str) -> VMHandle:
        self._failures.check("define_domain")
        # Extract name crudely from XML — good enough for test fixtures.
        name = _extract_name(xml)
        with self._lock:
            if name in self._domains:
                raise DomainAlreadyExistsError(name)
            domain = _MockDomain(name=name, uuid=str(uuid4()), xml=xml)
            self._domains[name] = domain
            handle = VMHandle(name=name, uuid=domain.uuid, state=domain.status)
        self._fire_event(domain)
        return handle

    def redefine_domain(self, handle: VMHandle, xml: str) -> None:
        self._failures.check("redefine_domain", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            domain.xml = xml

    def undefine_domain(self, handle: VMHandle) -> None:
        self._failures.check("undefine_domain", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            del self._domains[domain.name]
        # Real libvirt fires an "undefined" event — mirror that behaviour
        # so monitor/events.py can be exercised end-to-end.
        if self._event_callback is not None:
            self._event_callback(
                VMHandle(name=domain.name, uuid=domain.uuid, state=VMStatus.UNKNOWN),
                VMStatus.UNKNOWN,
            )

    def domain_state(self, handle: VMHandle) -> VMStatus:
        self._failures.check("domain_state", handle.name)
        with self._lock:
            return self._resolve(handle).status

    def domain_xml(self, handle: VMHandle) -> str:
        self._failures.check("domain_xml", handle.name)
        with self._lock:
            return self._resolve(handle).xml

    def domain_disk_paths(self, handle: VMHandle) -> list[Path]:
        self._failures.check("domain_disk_paths", handle.name)
        with self._lock:
            return list(self._resolve(handle).disks)

    # ------------------------------------------------------------------
    # Power control
    # ------------------------------------------------------------------

    def start_domain(self, handle: VMHandle) -> None:
        self._failures.check("start_domain", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            domain.status = VMStatus.RUNNING
        self._fire_event(domain)

    def stop_domain(self, handle: VMHandle, force: bool = False) -> None:
        self._failures.check("stop_domain", handle.name)
        del force  # Mock accepts both graceful and forced identically.
        with self._lock:
            domain = self._resolve(handle)
            domain.status = VMStatus.STOPPED
        self._fire_event(domain)

    def pause_domain(self, handle: VMHandle) -> None:
        self._failures.check("pause_domain", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            domain.status = VMStatus.PAUSED
        self._fire_event(domain)

    def resume_domain(self, handle: VMHandle) -> None:
        self._failures.check("resume_domain", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            domain.status = VMStatus.RUNNING
        self._fire_event(domain)

    def reboot_domain(self, handle: VMHandle, force: bool = False) -> None:
        self._failures.check("reboot_domain", handle.name)
        del force
        with self._lock:
            domain = self._resolve(handle)
            domain.status = VMStatus.RUNNING
        self._fire_event(domain)

    # ------------------------------------------------------------------
    # Device hotplug
    # ------------------------------------------------------------------

    def attach_device(self, handle: VMHandle, device_xml: str, live: bool = True) -> None:
        self._failures.check("attach_device", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            if live and domain.status is not VMStatus.RUNNING:
                raise HotplugNotSupportedError(
                    f"{handle.name} is not running; cannot hotplug a device"
                )
            # The mock does not need to track per-device state — tests can
            # inspect domain.xml if they care about the attachment.
            domain.xml = f"{domain.xml}\n<!-- attached: {device_xml} -->"

    def detach_device(self, handle: VMHandle, device_xml: str, live: bool = True) -> None:
        self._failures.check("detach_device", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            if live and domain.status is not VMStatus.RUNNING:
                raise HotplugNotSupportedError(
                    f"{handle.name} is not running; cannot hot-unplug a device"
                )
            domain.xml = f"{domain.xml}\n<!-- detached: {device_xml} -->"

    # ------------------------------------------------------------------
    # Snapshots
    # ------------------------------------------------------------------

    def list_snapshots(self, handle: VMHandle) -> list[SnapshotInfo]:
        self._failures.check("list_snapshots", handle.name)
        with self._lock:
            return list(self._resolve(handle).snapshots.values())

    def create_snapshot(self, handle: VMHandle, name: str, description: str = "") -> None:
        self._failures.check("create_snapshot", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            if name in domain.snapshots:
                raise ProviderError(f"snapshot {name!r} already exists on {handle.name!r}")
            domain.snapshots[name] = SnapshotInfo(
                name=name,
                description=description,
                created_at=datetime.now(UTC),
                parent_name=None,
                state=domain.status,
            )
            domain.snapshot_xml[name] = f"<snapshot><name>{name}</name></snapshot>"

    def revert_snapshot(self, handle: VMHandle, name: str) -> None:
        self._failures.check("revert_snapshot", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            snap = domain.snapshots.get(name)
            if snap is None:
                raise ProviderError(f"no snapshot {name!r} on {handle.name!r}")
            domain.status = snap.state
        self._fire_event(domain)

    def delete_snapshot(self, handle: VMHandle, name: str) -> None:
        self._failures.check("delete_snapshot", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            if name not in domain.snapshots:
                raise ProviderError(f"no snapshot {name!r} on {handle.name!r}")
            del domain.snapshots[name]
            domain.snapshot_xml.pop(name, None)

    def snapshot_xml(self, handle: VMHandle, name: str) -> str:
        self._failures.check("snapshot_xml", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            xml = domain.snapshot_xml.get(name)
            if xml is None:
                raise ProviderError(f"no snapshot {name!r} on {handle.name!r}")
            return xml

    # ------------------------------------------------------------------
    # Metrics
    # ------------------------------------------------------------------

    def domain_metrics(self, handle: VMHandle) -> VMMetrics:
        self._failures.check("domain_metrics", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            running = domain.status is VMStatus.RUNNING
            return VMMetrics(
                cpu_pct=5.0 if running else 0.0,
                ram_used_mb=512 if running else 0,
                ram_used_mb_guest=None,
                disk_read_bps=0,
                disk_write_bps=0,
                disk_actual_bytes=10 * 1024 * 1024 * 1024,
                net_rx_bps=0,
                net_tx_bps=0,
                sampled_at=datetime.now(UTC),
            )

    # ------------------------------------------------------------------
    # Events
    # ------------------------------------------------------------------

    def subscribe_events(self, callback: Callable[[VMHandle, VMStatus], None]) -> None:
        self._failures.check("subscribe_events")
        self._event_callback = callback

    def unsubscribe_events(self) -> None:
        self._failures.check("unsubscribe_events")
        self._event_callback = None

    # ------------------------------------------------------------------
    # Extended lifecycle
    # ------------------------------------------------------------------

    def managed_save(self, handle: VMHandle) -> None:
        self._failures.check("managed_save", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            domain.status = VMStatus.STOPPED
        self._fire_event(domain)

    def set_autostart(self, handle: VMHandle, enabled: bool) -> None:
        self._failures.check("set_autostart", handle.name)
        with self._lock:
            self._resolve(handle).autostart = enabled

    def get_autostart(self, handle: VMHandle) -> bool:
        self._failures.check("get_autostart", handle.name)
        with self._lock:
            return self._resolve(handle).autostart

    def rename_domain(self, handle: VMHandle, new_name: str) -> VMHandle:
        self._failures.check("rename_domain", handle.name)
        with self._lock:
            domain = self._resolve(handle)
            if new_name in self._domains:
                raise DomainAlreadyExistsError(new_name)
            del self._domains[domain.name]
            domain.name = new_name
            self._domains[new_name] = domain
            return VMHandle(name=new_name, uuid=domain.uuid, state=domain.status)

    # ------------------------------------------------------------------
    # Networks
    # ------------------------------------------------------------------

    def list_networks(self) -> list[NetworkInfo]:
        self._failures.check("list_networks")
        with self._lock:
            return [n.info for n in self._networks.values()]

    def get_network(self, name: str) -> NetworkInfo:
        self._failures.check("get_network", name)
        with self._lock:
            net = self._networks.get(name)
            if net is None:
                raise ProviderError(f"no network named {name!r}")
            return net.info

    def define_network(self, xml: str) -> NetworkInfo:
        self._failures.check("define_network")
        name = _extract_name(xml)
        with self._lock:
            if name in self._networks:
                raise ProviderError(f"network {name!r} already defined")
            info = NetworkInfo(name=name, uuid=str(uuid4()), active=False, autostart=False)
            self._networks[name] = _MockNetwork(info=info)
            return info

    def start_network(self, name: str) -> None:
        self._failures.check("start_network", name)
        with self._lock:
            net = self._networks.get(name)
            if net is None:
                raise ProviderError(f"no network named {name!r}")
            # Frozen dataclass — replace the info object rather than mutate.
            net.info = NetworkInfo(
                name=net.info.name,
                uuid=net.info.uuid,
                active=True,
                autostart=net.info.autostart,
                bridge_name=net.info.bridge_name,
                forward_mode=net.info.forward_mode,
            )

    def stop_network(self, name: str) -> None:
        self._failures.check("stop_network", name)
        with self._lock:
            net = self._networks.get(name)
            if net is None:
                raise ProviderError(f"no network named {name!r}")
            net.info = NetworkInfo(
                name=net.info.name,
                uuid=net.info.uuid,
                active=False,
                autostart=net.info.autostart,
                bridge_name=net.info.bridge_name,
                forward_mode=net.info.forward_mode,
            )

    def delete_network(self, name: str) -> None:
        self._failures.check("delete_network", name)
        with self._lock:
            if name not in self._networks:
                raise ProviderError(f"no network named {name!r}")
            if self._networks[name].info.active:
                raise ProviderError(f"network {name!r} is active; stop it first")
            del self._networks[name]

    def network_leases(self, network_name: str) -> list[LeaseInfo]:
        self._failures.check("network_leases", network_name)
        with self._lock:
            net = self._networks.get(network_name)
            if net is None:
                raise ProviderError(f"no network named {network_name!r}")
            return list(net.leases)

    # ------------------------------------------------------------------
    # Storage pools
    # ------------------------------------------------------------------

    def list_pools(self) -> list[StoragePoolInfo]:
        self._failures.check("list_pools")
        with self._lock:
            return [p.info for p in self._pools.values()]

    def get_pool(self, name: str) -> StoragePoolInfo:
        self._failures.check("get_pool", name)
        with self._lock:
            pool = self._pools.get(name)
            if pool is None:
                raise ProviderError(f"no storage pool named {name!r}")
            return pool.info

    def define_pool(self, name: str, path: Path) -> StoragePoolInfo:
        self._failures.check("define_pool", name)
        with self._lock:
            if name in self._pools:
                raise ProviderError(f"storage pool {name!r} already defined")
            info = StoragePoolInfo(
                name=name,
                path=str(path),
                capacity_bytes=0,
                allocation_bytes=0,
                available_bytes=0,
                active=True,
            )
            self._pools[name] = _MockPool(info=info)
            return info

    def delete_pool(self, name: str) -> None:
        self._failures.check("delete_pool", name)
        with self._lock:
            pool = self._pools.get(name)
            if pool is None:
                raise ProviderError(f"no storage pool named {name!r}")
            if pool.volumes or pool.info.volumes:
                count = len(pool.volumes) or len(pool.info.volumes)
                raise ProviderError(f"storage pool {name!r} is not empty ({count} volume(s))")
            del self._pools[name]

    def refresh_pool(self, name: str) -> StoragePoolInfo:
        self._failures.check("refresh_pool", name)
        with self._lock:
            pool = self._pools.get(name)
            if pool is None:
                raise ProviderError(f"no storage pool named {name!r}")
            return pool.info

    def list_pool_volumes(self, name: str) -> list[VolumeInfo]:
        self._failures.check("list_pool_volumes", name)
        with self._lock:
            pool = self._pools.get(name)
            if pool is None:
                raise ProviderError(f"no storage pool named {name!r}")
            return list(pool.volumes.values())

    # ------------------------------------------------------------------
    # Guest agent (Phase 5)
    # ------------------------------------------------------------------

    def guest_agent_command(
        self, handle: VMHandle, command: str, arguments: dict[str, object] | None = None
    ) -> dict[str, object]:
        del arguments  # Mock does not distinguish by arguments today.
        self._failures.check("guest_agent_command", handle.name)
        with self._lock:
            self._resolve(handle)  # raises DomainNotFoundError if missing
            response = self._agent_responses.get((handle.name, command))
            if response is None:
                raise ProviderError(
                    f"guest agent on {handle.name!r} did not respond to "
                    f"{command!r} (no seed registered)"
                )
            return dict(response)

    # ------------------------------------------------------------------
    # Forensics (Phase 17)
    # ------------------------------------------------------------------

    def bridge_for_vm(self, handle: VMHandle) -> str | None:
        """Return the seeded bridge name for this VM, or None.

        Tests seed via :meth:`seed_bridge_for_vm`; without a seed the
        return is ``None`` (matches "no NIC attached" / "macvtap" / etc.).
        """
        self._failures.check("bridge_for_vm", handle.name)
        with self._lock:
            self._resolve(handle)
            return self._bridge_for_vm.get(handle.name)

    def dump_memory(self, handle: VMHandle, dest: Path, *, live: bool = True) -> None:
        """Write a stub memory dump to ``dest``.

        The stub is a 4 KiB sentinel file so tests can assert that
        ``dest`` exists + has nonzero size + the calling code wrote
        the right manifest entry.
        """
        del live  # Mock does not distinguish live vs offline.
        self._failures.check("dump_memory", handle.name)
        with self._lock:
            self._resolve(handle)
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_bytes(b"VMANAGER-MOCK-MEMORY-DUMP\n" + b"\x00" * 4096)

    def screenshot(self, handle: VMHandle, dest: Path) -> None:
        """Write a stub PNG-ish screenshot to ``dest``.

        Production wraps ``virDomainScreenshot``; the mock writes a
        tiny PNG signature so tests can assert "a file was written"
        without needing a real graphics device.
        """
        self._failures.check("screenshot", handle.name)
        with self._lock:
            self._resolve(handle)
        dest.parent.mkdir(parents=True, exist_ok=True)
        # Real PNG magic + a single IHDR-ish stub. Not a valid PNG —
        # tests should not try to decode it; they just assert presence.
        dest.write_bytes(b"\x89PNG\r\n\x1a\nVMANAGER-MOCK-SCREENSHOT")

    def seed_bridge_for_vm(self, vm_name: str, bridge: str | None) -> None:
        """Test helper: register a bridge name for a seeded VM.

        Pass ``bridge=None`` to explicitly mark a VM as having no bridge
        (useful for testing the macvtap / no-NIC fallback paths).
        """
        with self._lock:
            self._bridge_for_vm[vm_name] = bridge


# ---------------------------------------------------------------------------
# XML name extraction — just enough for the mock's test fixtures
# ---------------------------------------------------------------------------


def _extract_name(xml: str) -> str:
    """Return the text inside the first ``<name>...</name>`` tag.

    The real providers parse proper XML — the mock only needs enough to
    support tests that hand-build minimal domain/network fragments.
    """
    import re

    match = re.search(r"<name>\s*([^<\s][^<]*?)\s*</name>", xml)
    if match is None:
        raise ProviderError("XML payload is missing a <name>…</name> element")
    return match.group(1)


__all__ = ["FailurePlan", "MockProvider"]
