"""The four Protocols — the contract every adapter implements.

This module is the **load-bearing surface** of mgf-vm-core. Every
adapter (``mgf-vm-libvirt``, ``mgf-vm-firecracker``, future
``mgf-vm-hyperv``) MUST satisfy these Protocols; the SDK and the
control plane MUST depend only on them.

The full method-by-method semantics — idempotency, error contracts,
threading, cancellation, conformance suite scenarios — lives in
``vm-vmanager-docs/component-specs/contracts.md``. This module is
the Python materialization of that spec.

Protocols:

* :class:`HypervisorBackend` — VM lifecycle + host-network observation
  (~24 methods; ``list_networks`` / ``network_leases`` /
  ``bridge_for_domain`` per D-G14)
* :class:`StorageBackend` — volume + snapshot lifecycle (~15 methods)
* :class:`DeviceBackend` — broker-mediated device passthrough (~11 methods)
* :class:`MetricsBackend` — OPTIONAL per-domain resource sampling
  (1 method; the 5th, injectable Protocol per D-G13)
* :class:`HostDiskBackend` — OPTIONAL host disk-image I/O for
  backup / clone / restore (5 methods; the 6th, injectable Protocol
  per D-G18)
* :class:`NetworkBackend` — OPTIONAL virtual-network management
  (define / start / stop / delete; the 7th, injectable Protocol per D-G15)
* :class:`LifecycleObserver` — state-machine hooks (1 method)
* :class:`AsyncLifecycleObserver` — async variant of the above

Plus the minimal supporting types the Protocols reference inline.
Larger types (``VmSpec``, ``VmStatus``, etc.) live in
:mod:`mgf.vm.core.domain` and :mod:`mgf.vm.core.ids`.
"""

from __future__ import annotations

from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from enum import StrEnum
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from mgf.vm.core.domain import IsolationLevel

if TYPE_CHECKING:
    from collections.abc import Iterator, Mapping

    from mgf.vm.core.cancellation import CancellationToken
    from mgf.vm.core.capabilities import (
        DeviceCapabilities,
        HypervisorCapabilities,
        StorageCapabilities,
    )
    from mgf.vm.core.exceptions import VmManagerError
    from mgf.vm.core.ids import (
        DeviceId,
        DomainId,
        HostId,
        LeaseId,
        OperationId,
        SnapshotId,
        VmId,
        VolumeId,
    )
    from mgf.vm.core.provisioning.spec import ProvisioningSpec


#: The schema version every freshly-built :class:`VmSpec` carries (D-G8).
#: Bump this — and add a branch to :func:`migrate_vmspec` — whenever the
#: canonical neutral shape changes in a non-additive way.
CURRENT_VMSPEC_SCHEMA_VERSION = 1

#: A shared empty read-only mapping used as the default ``VmSpec.metadata``.
#: Frozen dataclasses cannot use a mutable ``{}`` default; this immutable
#: proxy is safe to share across every spec that does not set metadata.
#: NB: Python 3.11's dataclass machinery rejects a ``MappingProxyType``
#: *instance* used directly as a field default (it is unhashable, so the
#: mutable-default guard trips) even though 3.12+ accepts it. Hand every
#: field ``field(default_factory=_empty_metadata)`` instead — the factory
#: returns this same shared singleton without tripping the 3.11 check.
_EMPTY_METADATA: Mapping[str, str] = MappingProxyType({})


def _empty_metadata() -> Mapping[str, str]:
    """Return the shared empty read-only metadata mapping (3.11-safe default)."""
    return _EMPTY_METADATA


# ---------------------------------------------------------------------------
# Supporting types referenced by the Protocols
# ---------------------------------------------------------------------------


class ClaimMode(StrEnum):
    """How exclusively a device is claimed (``DeviceBackend.claim``)."""

    EXCLUSIVE = "exclusive"
    SHARED = "shared"
    READ_ONLY = "read_only"


@dataclass(frozen=True, slots=True)
class DiskSpec:
    """Engine-neutral disk attached to a :class:`VmSpec` (D-G12).

    Carries only what every engine can honor: a size, an image
    ``format`` (qcow2 / raw, D-E4), an optional ``source`` backing
    image, and a read-only flag. Adapter-tuning knobs — disk ``bus``
    (virtio/scsi/ide) and ``cache`` mode — are deliberately absent;
    they live in the adapter as defaults plus ``VmSpec.metadata``
    overrides, because Firecracker (for one) cannot honor a SCSI bus
    or a libvirt cache mode.
    """

    size_bytes: int
    format: str = "qcow2"
    source: str | None = None
    readonly: bool = False


@dataclass(frozen=True, slots=True)
class NicSpec:
    """Engine-neutral network interface attached to a :class:`VmSpec` (D-G12).

    ``network`` names a virtual network the engine already knows about
    (the CP owns cross-host topology — contracts.md §3 charter).
    ``model`` is the guest-visible NIC model (``virtio`` by default).
    ``mac`` is an optional fixed hardware address; ``None`` lets the
    engine allocate one.
    """

    network: str = "default"
    model: str = "virtio"
    mac: str | None = None


@dataclass(frozen=True, slots=True)
class VmSpec:
    """Engine-neutral VM definition — the canonical 10-field shape (D-G12).

    This IS the contract surface (``contracts.md`` §2, ``api.md`` §6.1).
    Exactly ten engine-neutral fields, mapping 1:1 to the ``VmSpec``
    protobuf message. Adapters translate it to the native format
    (libvirt XML, Firecracker JSON); the native format never leaks above
    the adapter, and **no engine-ism leaks into this spec**.

    Engine-specific tuning (``cpu_model``, ``cpu_pinning``, ``vcpus_max``,
    ``hugepages``, ``balloon``, disk ``cache`` / ``bus``,
    ``firmware`` / ``secure_boot``, ``display`` / spice,
    ``memory_max_bytes``) is NOT here: the adapter applies sensible
    defaults plus opt-in overrides it reads from :attr:`metadata` under a
    namespaced key convention (e.g. ``metadata["libvirt/firmware"]``).
    A rich native spec such as ``LibvirtVmSpec`` is a *mapper* from this
    canonical spec, not a parallel definition.

    Versioned via :attr:`schema_version` (D-G8); see
    :func:`migrate_vmspec` for the forward-migration discipline. Every
    field past the three required positional ones has a default, so the
    minimal ``VmSpec(name=…, cpu=…, memory_bytes=…)`` construction stays
    valid (new fields are additive, D-D6).
    """

    name: str
    cpu: int
    """vCPU count (>= 1)."""
    memory_bytes: int
    disks: tuple[DiskSpec, ...] = ()
    nics: tuple[NicSpec, ...] = ()
    devices: tuple[DeviceSpec, ...] = ()
    """Device-passthrough requests. The proto calls this ``DeviceRequest``;
    the canonical Python type is the engine-neutral :class:`DeviceSpec`
    (kind / selector / mode) — contracts.md §2 / §5."""
    provisioning: ProvisioningSpec | None = None
    isolation: IsolationLevel = IsolationLevel.OFF
    metadata: Mapping[str, str] = field(default_factory=_empty_metadata)
    schema_version: int = CURRENT_VMSPEC_SCHEMA_VERSION

    def __post_init__(self) -> None:
        if not self.name:
            raise ValueError("VmSpec.name must be non-empty")
        if self.cpu <= 0:
            raise ValueError(f"VmSpec.cpu must be > 0; got {self.cpu}")
        if self.memory_bytes <= 0:
            raise ValueError(f"VmSpec.memory_bytes must be > 0; got {self.memory_bytes}")


def migrate_vmspec(raw: Mapping[str, Any], from_version: int) -> Mapping[str, Any]:
    """Forward-migrate a serialized ``VmSpec`` mapping to the current schema.

    This is the D-G8 migration scaffold. The registry stores every spec
    with its :attr:`VmSpec.schema_version`; when a spec at an older
    version is loaded, it passes through this function to be brought up to
    :data:`CURRENT_VMSPEC_SCHEMA_VERSION` *before* it is reconstructed into
    a :class:`VmSpec`.

    Migration discipline (D-G8 / D-D6):

    * Migrations are **forward-only** and **chained**: each version bump
      adds one branch that rewrites the mapping from ``N`` to ``N + 1``;
      this function applies them in order until ``from_version`` reaches
      the current version. A v1→v3 load runs the v1→v2 branch then the
      v2→v3 branch.
    * **Additive** field changes (new neutral field with a default) need
      **no** migration branch — an old mapping simply lacks the key and
      the :class:`VmSpec` default fills it. Only renames, removals, or
      semantic re-shapes need a branch here.
    * Each branch MUST be pure (no I/O) and total (defined for every
      mapping a prior version could have produced).

    At schema v1 this is an identity passthrough — there is no prior
    version to migrate from yet. The ``while`` loop below is the
    extension point: future versions append ``if version == N:`` blocks.

    Raises:
        ValueError: if ``from_version`` is newer than this build knows
            about (a forward-incompatible spec — the caller is older than
            the data and must upgrade).
    """
    if from_version > CURRENT_VMSPEC_SCHEMA_VERSION:
        raise ValueError(
            f"cannot migrate VmSpec from schema v{from_version}: this build "
            f"only knows up to v{CURRENT_VMSPEC_SCHEMA_VERSION} (upgrade required)"
        )

    migrated = dict(raw)
    version = from_version
    while version < CURRENT_VMSPEC_SCHEMA_VERSION:
        # Future migration branches hook in here, e.g.:
        #   if version == 1:
        #       migrated = _migrate_v1_to_v2(migrated)
        raise ValueError(  # pragma: no cover - unreachable until v2 lands
            f"no migration registered for VmSpec schema v{version} -> v{version + 1}"
        )
    migrated["schema_version"] = CURRENT_VMSPEC_SCHEMA_VERSION
    return migrated


@dataclass(frozen=True, slots=True)
class DiskStatus:
    """Engine-neutral **observed** state of one attached disk.

    Observation-shaped: what the engine reports for a disk that is
    actually present on the running/defined domain — distinct from the
    desired-spec :class:`DiskSpec`. The adapter populates this from its
    ``get_domain`` read (libvirt's ``<disk>`` elements + ``blockInfo``).

    * :attr:`target` — the guest-visible target device name
      (``vda`` / ``sda`` / …); the engine's per-domain disk handle.
    * :attr:`source` — backing source path/URI, or ``None`` for a
      sourceless device (empty cdrom, etc.).
    * :attr:`size_bytes` — virtual (capacity) size as the engine reports it.
    * :attr:`readonly` — observed read-only flag.
    * :attr:`allocation_bytes` — actual on-disk allocation when the engine
      reports it; ``None`` when unknown (engine did not surface it).
    """

    target: str
    source: str | None = None
    size_bytes: int = 0
    readonly: bool = False
    allocation_bytes: int | None = None


@dataclass(frozen=True, slots=True)
class NicStatus:
    """Engine-neutral **observed** state of one attached NIC.

    Observation-shaped: the engine's view of a live interface — distinct
    from the desired-spec :class:`NicSpec`. Crucially carries the observed
    :attr:`ip` (the guest's leased address, when the engine/agent can see
    it via DHCP leases or guest-agent), which the spec never has.

    * :attr:`model` — guest-visible NIC model (``virtio`` / ``e1000`` / …).
    * :attr:`mac` — the actual hardware address on the interface.
    * :attr:`network` — the virtual network the interface is attached to.
    * :attr:`ip` — observed guest IP, or ``None`` when not yet known.
    """

    model: str = "virtio"
    mac: str | None = None
    network: str = ""
    ip: str | None = None


@dataclass(frozen=True, slots=True)
class AttachedDevice:
    """Engine-neutral **observed** state of one passthrough device in a guest.

    A ``devices_attached`` entry on :class:`VmStatus`: what the engine
    reports as currently plugged into the domain — distinct from the
    desired-spec :class:`DeviceSpec` (the *request*).

    * :attr:`kind` — ``"pci"`` | ``"usb"`` | ``"gpu"`` (mirrors
      :attr:`DeviceSpec.kind`).
    * :attr:`selector` — how the device was selected (vendor/device, USB
      serial, …); the observed echo of the request's selector.
    * :attr:`lease` — the broker lease URN backing the attachment, when
      one is known; ``None`` for a directly-attached device.
    * :attr:`address` — the engine-visible host address the device is
      plugged at (PCI BDF, USB bus/port), or ``None`` when unknown.
    """

    kind: str
    selector: Mapping[str, str] = field(default_factory=_empty_metadata)
    lease: str | None = None
    address: str | None = None


@dataclass(frozen=True, slots=True)
class VmStatus:
    """Engine-neutral observed status of a VM (``contracts.md`` §2, api.md §6.3).

    The observed half of the :class:`Vm` aggregate: the lifecycle
    :attr:`state` (the state-machine enum) plus the richer observed shape
    the adapter reports through ``get_domain`` — attached :attr:`disks`,
    :attr:`nics`, and :attr:`devices_attached`, the placement
    :attr:`host_id`, the :attr:`last_transition_at` timestamp, and an
    optional :attr:`error` string.

    Every field past the required :attr:`state` has a default, so the
    minimal ``VmStatus(state=…)`` construction stays valid (the observed
    lists / placement are additive, D-D6). The observed sub-types
    (:class:`DiskStatus` / :class:`NicStatus` / :class:`AttachedDevice`)
    are observation-shaped — what the engine reports — and are distinct
    from the desired-spec :class:`DiskSpec` / :class:`NicSpec` /
    :class:`DeviceSpec`.

    Invariants (enforced in :meth:`__post_init__`):

    * :attr:`last_transition_at`, when set, is UTC-aware; naive datetimes
      are rejected (DP-12, D-G6).
    """

    state: VmState
    vm_id: VmId | None = None
    host_id: str = ""
    """Empty if the VM is not scheduled onto a host (mirrors :attr:`Vm.host_id`)."""
    disks: tuple[DiskStatus, ...] = ()
    nics: tuple[NicStatus, ...] = ()
    devices_attached: tuple[AttachedDevice, ...] = ()
    last_transition_at: datetime | None = None
    error: str | None = None

    def __post_init__(self) -> None:
        if self.last_transition_at is not None:
            _require_utc("last_transition_at", self.last_transition_at)


@dataclass(frozen=True, slots=True)
class Vm:
    """The VM aggregate returned by create/get (D-G12, api.md §6.2).

    Pairs the desired-state :class:`VmSpec` with the observed
    :class:`VmStatus` plus the placement/identity metadata the SDK and
    CP/agent round-trip. The SDK's ``create_vm`` / ``get_vm`` return this;
    it replaces the Phase-0 ``DomainInfo`` stand-in. Maps 1:1 to the ``Vm``
    protobuf message.

    Invariants (enforced in :meth:`__post_init__`):

    * :attr:`created_at` / :attr:`last_transition_at` are UTC-aware; naive
      datetimes are rejected (DP-12, D-G6).
    """

    vm_id: VmId
    spec: VmSpec
    status: VmStatus
    created_at: datetime
    last_transition_at: datetime
    tenant_id: str = ""
    host_id: str = ""
    """Empty if the VM is not yet scheduled onto a host."""
    current_op_id: OperationId | None = None
    """Set during an in-flight mutating operation (D-G5)."""

    def __post_init__(self) -> None:
        _require_utc("created_at", self.created_at)
        _require_utc("last_transition_at", self.last_transition_at)


@dataclass(frozen=True, slots=True)
class VolumeSpec:
    """Engine-neutral volume definition for ``StorageBackend.create_volume``."""

    pool: str
    name: str
    size_bytes: int
    format: str = "qcow2"
    backing_file: str | None = None


@dataclass(frozen=True, slots=True)
class PoolSpec:
    """Engine-neutral pool definition for ``StorageBackend.create_pool``."""

    name: str
    pool_type: str
    target_path: str


@dataclass(frozen=True, slots=True)
class DeviceSpec:
    """User-facing device-request shape (selector + mode).

    Distinct from :class:`DeviceInfo` (which is the broker's observed
    state of a specific device).
    """

    kind: str
    """``"pci"`` | ``"usb"`` | ``"gpu"``."""
    selector: dict[str, str]
    """e.g., ``{"vendor": "10de", "device": "1eb1"}`` for PCI."""
    mode: ClaimMode = ClaimMode.EXCLUSIVE


@dataclass(frozen=True, slots=True)
class DeviceInfo:
    """Broker's observed state of a device on this host."""

    device_id: DeviceId
    kind: str
    vendor: str
    product: str
    address: str
    """Bus-specific address (PCI BDF, USB bus/port, …)."""
    assignable: bool
    """``False`` if the host kernel is currently using it."""


@dataclass(frozen=True, slots=True)
class DeviceAssignment:
    """Host-side handle for an attached device, produced by the broker.

    Passed from :meth:`DeviceBackend.assignment_for` to
    :meth:`HypervisorBackend.attach_device` as the integration glue.
    """

    lease: LeaseId
    device_id: DeviceId
    host_handle: str
    """Engine-friendly form (PCI BDF for libvirt; vfio-group path; …)."""


@dataclass(frozen=True, slots=True)
class LeaseInfo:
    """Observed state of a held device lease."""

    lease: LeaseId
    device_id: DeviceId
    claimant: str
    """Client identity holding the lease."""
    mode: ClaimMode
    held_since: datetime
    """Always UTC (DP-12)."""


@dataclass(frozen=True, slots=True)
class CompatibilityResult:
    """Pre-flight compatibility check (``DeviceBackend.check_compatibility``).

    Empty :attr:`reasons` ⇒ compatible. Non-empty ⇒ each reason is
    a human-readable incompatibility (CPU feature missing, IOMMU
    group conflict, …).
    """

    compatible: bool
    reasons: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class DomainInfo:
    """Observed state of a defined domain.

    Returned by ``HypervisorBackend.get_domain`` and
    ``list_domains``. Carries enough for the reconciler to decide if
    actual matches desired without fetching the full status.

    The observed :attr:`disks` / :attr:`nics` / :attr:`devices_attached`
    lists are the channel the adapter populates from its native reads (the
    libvirt adapter fills them from the domain XML + block/interface
    info); the SDK builds a rich :class:`VmStatus` from this return. They
    are additive with empty defaults (D-D6), so an adapter that only
    surfaces the leaner core fields keeps satisfying the contract.
    """

    domain_id: DomainId
    name: str
    cpu: int
    memory_bytes: int
    is_running: bool
    disks: tuple[DiskStatus, ...] = ()
    nics: tuple[NicStatus, ...] = ()
    devices_attached: tuple[AttachedDevice, ...] = ()


@dataclass(frozen=True, slots=True)
class PoolInfo:
    """Observed state of a storage pool."""

    name: str
    pool_type: str
    target_path: str
    capacity_bytes: int
    allocation_bytes: int
    available_bytes: int
    active: bool


@dataclass(frozen=True, slots=True)
class _VolumeInfoContract:
    """Volume info as the contract uses it.

    Distinct from the legacy :class:`mgf.vm.core.domain.VolumeInfo` —
    the legacy one carries libvirt-specific fields; this one is
    engine-neutral.
    """

    volume_id: VolumeId
    pool: str
    name: str
    path: str
    size_bytes: int
    allocation_bytes: int
    format: str


@dataclass(frozen=True, slots=True)
class _SnapshotInfoContract:
    """Snapshot info as the contract uses it.

    Distinct from the legacy :class:`mgf.vm.core.domain.SnapshotInfo`.

    The Wave-B observation fields (:attr:`description`,
    :attr:`parent_name`) let a consumer render a snapshot **tree** —
    a Description column plus parent↔child linkage by snapshot name —
    off this neutral surface, instead of reading the umbrella
    provider's libvirt-shaped ``domain.SnapshotInfo``. Both are
    additive with defaults (D-D6): an adapter that does not surface a
    description or a parent keeps satisfying the contract, and every
    existing ``_SnapshotInfoContract(...)`` construction stays valid.

    * :attr:`description` — human-readable snapshot description; ``""``
      when the engine reports none.
    * :attr:`parent_name` — the :attr:`name` of this snapshot's parent
      in the snapshot tree, or ``None`` for a root snapshot. Linkage is
      by name (engine-neutral) rather than by :class:`SnapshotId`,
      since that is what a tree view renders and what libvirt's
      ``<parent><name>`` surfaces.
    """

    snapshot_id: SnapshotId
    parent: VolumeId | DomainId
    name: str
    created_at: datetime
    """Always UTC (DP-12)."""
    has_memory: bool = False
    description: str = ""
    parent_name: str | None = None
    state: VmState | None = None
    """The VM run-state captured in this snapshot (D-G21), for a *domain*
    snapshot — ``running`` / ``shutoff`` / ``paused`` etc. ``None`` for a
    *volume* snapshot (no VM run-state) or when the engine reports none."""


# ---------------------------------------------------------------------------
# Metrics observation types (Wave-B) — engine-neutral per-domain sample
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class DiskIo:
    """Engine-neutral cumulative I/O counters for one attached disk.

    Observation-shaped: a point-in-time read of the engine's per-disk
    block stats. Counters are **cumulative since domain start** (not
    rates) — the consumer derives a rate by differencing two samples'
    :attr:`MetricsSample.sampled_at`. ``target`` matches the
    :attr:`DiskStatus.target` guest-visible device name (``vda`` / …),
    so a metrics row links to its disk.
    """

    target: str
    read_bytes: int = 0
    write_bytes: int = 0
    read_reqs: int = 0
    write_reqs: int = 0


@dataclass(frozen=True, slots=True)
class NicIo:
    """Engine-neutral cumulative I/O counters for one attached NIC.

    Observation-shaped: a point-in-time read of the engine's per-interface
    stats, keyed by :attr:`mac` (matching :attr:`NicStatus.mac`). Counters
    are cumulative since domain start; rates are derived by differencing
    samples.
    """

    mac: str
    rx_bytes: int = 0
    tx_bytes: int = 0
    rx_packets: int = 0
    tx_packets: int = 0


@dataclass(frozen=True, slots=True)
class MetricsSample:
    """One engine-neutral resource sample for a single domain (Wave-B).

    The neutral observation surface that lets a consumer port its
    metrics reads off the umbrella provider's libvirt-shaped
    ``domain.VMMetrics``. Produced by
    :meth:`MetricsBackend.sample_metrics`.

    Counters are reported as the engine surfaces them: :attr:`cpu_time_ns`
    and the per-disk / per-nic byte/req counters are **cumulative since
    domain start**; the consumer derives rates by differencing two samples
    keyed on :attr:`sampled_at`. :attr:`cpu_percent` is an optional
    pre-computed utilisation the engine/agent may provide (``None`` when it
    cannot — the consumer falls back to differencing :attr:`cpu_time_ns`).

    * :attr:`cpu_time_ns` — cumulative vCPU time consumed, nanoseconds.
    * :attr:`cpu_percent` — pre-computed CPU utilisation %, or ``None``.
    * :attr:`memory_used_bytes` — resident guest memory the engine reports.
    * :attr:`memory_available_bytes` — guest-reported available memory
      (needs a guest agent / balloon), or ``None`` when unknown.
    * :attr:`disks` — per-disk cumulative I/O counters (:class:`DiskIo`).
    * :attr:`nics` — per-nic cumulative I/O counters (:class:`NicIo`).
    * :attr:`sampled_at` — UTC-aware instant the sample was taken (DP-12).

    Invariants (enforced in :meth:`__post_init__`):

    * :attr:`sampled_at` is UTC-aware; naive datetimes are rejected
      (DP-12, D-G6).
    """

    cpu_time_ns: int
    memory_used_bytes: int
    sampled_at: datetime
    cpu_percent: float | None = None
    memory_available_bytes: int | None = None
    disks: tuple[DiskIo, ...] = ()
    nics: tuple[NicIo, ...] = ()

    def __post_init__(self) -> None:
        _require_utc("sampled_at", self.sampled_at)


# ---------------------------------------------------------------------------
# Guest-agent observation types (Wave-R6) — for the OPTIONAL GuestAgentBackend
# in-guest read seam (D-G19)
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class GuestNic:
    """A guest-reported network interface, as the in-guest agent sees it.

    Produced by :meth:`GuestAgentBackend.guest_interfaces`. Distinct from the
    host-side :class:`NetworkProfile` (a host virtual network) — this is what
    the guest OS reports about its OWN interfaces, the source a consumer uses
    to resolve a VM's reachable IP when there is no DHCP lease.

    * :attr:`name` — guest-visible interface name (``eth0`` / ``ens3`` / …).
    * :attr:`mac` — hardware address, or ``None`` when the agent omits it.
    * :attr:`ip_addresses` — every address the guest reports on the
      interface (IPv4 + IPv6, as bare address strings).
    """

    name: str
    mac: str | None = None
    ip_addresses: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class GuestOsInfo:
    """Guest OS identification reported by the in-guest agent.

    Produced by :meth:`GuestAgentBackend.guest_os_info` (the QEMU
    ``guest-get-osinfo`` shape). All fields default to ``""`` so an agent
    that reports a partial record still satisfies the type. :attr:`id` is the
    machine-readable id (``"ubuntu"`` / ``"rhel"`` / …) a consumer maps to a
    package-manager family; the others are human-facing.
    """

    id: str = ""
    name: str = ""
    version: str = ""
    pretty_name: str = ""


# ---------------------------------------------------------------------------
# Host disk-image I/O types (Wave-R4) — for the OPTIONAL HostDiskBackend
# backup / clone / restore seam (D-G18)
# ---------------------------------------------------------------------------

#: The schema version every freshly-built :class:`BackupManifest` carries.
#: Bump when the archive's on-disk manifest shape changes non-additively.
CURRENT_BACKUP_SCHEMA_VERSION = 1


@dataclass(frozen=True, slots=True)
class DiskImageInfo:
    """Engine-neutral structured view of a host disk image (D-G18).

    Produced by :meth:`HostDiskBackend.image_info`. The neutral counterpart
    of the umbrella's ``disk_ops.DiskInfo`` (it drops ``cluster_size`` and
    uses a ``str`` path so it is wire-portable). Sizes are bytes.

    * :attr:`path` — host path the image was inspected at.
    * :attr:`format` — qemu image format (``qcow2`` / ``raw`` / ``vmdk``).
    * :attr:`virtual_size` — size the guest sees, bytes.
    * :attr:`actual_size` — physical host footprint, bytes.
    * :attr:`backing_file` — backing image path (overlay), or ``None``.
    """

    path: str
    format: str
    virtual_size: int
    actual_size: int
    backing_file: str | None = None


@dataclass(frozen=True, slots=True)
class BackupDiskArtifact:
    """One disk inside a :class:`BackupManifest` (D-G18).

    * :attr:`target` — guest-visible device name (``vda`` / …), the key the
      restore repoints onto :class:`VmSpec` disk sources.
    * :attr:`archive_name` — the disk file's archive-relative name.
    * :attr:`sha256` — hex digest over the disk file (integrity guard;
      NOT a security signature — D-G18 / RA-14).
    """

    target: str
    archive_name: str
    sha256: str


@dataclass(frozen=True, slots=True)
class BackupManifest:
    """The result of :meth:`HostDiskBackend.archive` (D-G18).

    Describes a self-contained backup archive: which disks it holds (each a
    :class:`BackupDiskArtifact` with a checksum) plus an **opaque**
    ``metadata`` map the backend persisted verbatim — the SDK carries the
    serialized engine-neutral :class:`VmSpec` there so the matching
    :meth:`HostDiskBackend.extract` + ``create_vm`` round-trip needs nothing
    else. The backend never interprets ``metadata``.

    * :attr:`vm_name` — the VM the backup was taken of.
    * :attr:`archive_path` — host path of the written archive.
    * :attr:`created_at` — UTC-aware instant the archive was written (DP-12).
    * :attr:`disks` — per-disk artifacts + checksums.
    * :attr:`metadata` — opaque string map (SDK-owned; carries ``VmSpec``).
    * :attr:`schema_version` — manifest schema version.

    Invariants (``__post_init__``): :attr:`created_at` is UTC-aware (DP-12).
    """

    vm_name: str
    archive_path: str
    created_at: datetime
    disks: tuple[BackupDiskArtifact, ...] = ()
    metadata: Mapping[str, str] = field(default_factory=_empty_metadata)
    schema_version: int = CURRENT_BACKUP_SCHEMA_VERSION

    def __post_init__(self) -> None:
        _require_utc("created_at", self.created_at)


@dataclass(frozen=True, slots=True)
class RestoredDisk:
    """One restored disk from :meth:`HostDiskBackend.extract` (D-G18).

    * :attr:`target` — guest-visible device name (``vda`` / …).
    * :attr:`path` — host path the disk was extracted to.
    """

    target: str
    path: str


@dataclass(frozen=True, slots=True)
class RestoredImages:
    """The result of :meth:`HostDiskBackend.extract` (D-G18).

    The restored disks (each a :class:`RestoredDisk`) plus the opaque
    ``metadata`` map the matching :meth:`HostDiskBackend.archive` persisted —
    from which the SDK reads the carried :class:`VmSpec`, repoints its disk
    sources at the restored paths, and calls ``create_vm`` (no XML rewrite).

    * :attr:`disks` — restored disks (guest-target → host path).
    * :attr:`metadata` — opaque string map (SDK-owned; carries ``VmSpec``).
    """

    disks: tuple[RestoredDisk, ...] = ()
    metadata: Mapping[str, str] = field(default_factory=_empty_metadata)


# ---------------------------------------------------------------------------
# Host networking observation types (Wave-B) — for the HypervisorBackend
# host-network observation methods (D-G14)
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class NetworkProfile:
    """Engine-neutral **observed** state of one host virtual network.

    Observation-shaped: what :meth:`HypervisorBackend.list_networks` reports
    for a virtual network the engine knows about. Distinct from (and
    leaner than) the legacy libvirt-shaped
    :class:`mgf.vm.core.domain.NetworkInfo` — this neutral surface is what
    a consumer ports its network reads onto.

    * :attr:`name` — the virtual network's name (the handle ``NicSpec.network``
      and :meth:`HypervisorBackend.network_leases` use).
    * :attr:`bridge` — the host bridge device backing it, or ``None``.
    * :attr:`active` — whether the network is currently up.
    * :attr:`forward_mode` — ``"nat"`` / ``"bridge"`` / ``"isolated"`` /
      ``"route"`` / …, or ``None`` when the engine does not classify it.
    """

    name: str
    bridge: str | None = None
    active: bool = False
    forward_mode: str | None = None


@dataclass(frozen=True, slots=True)
class NetworkLease:
    """Engine-neutral **observed** DHCP lease on a host virtual network.

    Observation-shaped: one entry from :meth:`HypervisorBackend.network_leases`.
    Distinct from the legacy :class:`mgf.vm.core.domain.LeaseInfo` (which
    carries a libvirt-shaped ``vm_name``); this neutral surface carries only
    what every engine can observe about a lease.

    * :attr:`mac` — the leased interface's hardware address (links to
      :attr:`NicStatus.mac`).
    * :attr:`ip` — the leased address, or ``None`` when not yet assigned.
    * :attr:`hostname` — the DHCP-advertised hostname, or ``None``.
    * :attr:`expires_at` — UTC-aware lease expiry, or ``None`` when the
      engine reports no expiry. UTC-aware when set (DP-12, D-G6).

    Invariants (enforced in :meth:`__post_init__`):

    * :attr:`expires_at`, when set, is UTC-aware; naive datetimes are
      rejected (DP-12, D-G6).
    """

    mac: str
    ip: str | None = None
    hostname: str | None = None
    expires_at: datetime | None = None

    def __post_init__(self) -> None:
        if self.expires_at is not None:
            _require_utc("expires_at", self.expires_at)


# ---------------------------------------------------------------------------
# Virtual-network management types (Wave-R3) — for the OPTIONAL NetworkBackend
# define / start / stop / delete seam (D-G15)
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class NetworkSpec:
    """Engine-neutral **desired-state** definition of a virtual network (D-G15).

    The write-side counterpart of the observed :class:`NetworkProfile`: what
    :meth:`NetworkBackend.define_network` consumes. Carries only what every
    engine can honor; the adapter maps it to the native format (libvirt
    network XML), which never leaks above the adapter.

    * :attr:`name` — the network's name (the handle every other network
      method + :attr:`NicSpec.network` use).
    * :attr:`forward_mode` — ``"nat"`` / ``"bridge"`` / ``"route"``, or
      ``None`` for an **isolated** network (no ``<forward>`` element). Matches
      :attr:`NetworkProfile.forward_mode`, so observed ⇄ desired round-trip.
    * :attr:`bridge` — the host bridge device to back the network, or ``None``
      to let the engine allocate one. Required for a ``"bridge"`` network
      (the pre-existing host bridge).
    * :attr:`cidr` — the subnet the engine owns (gateway + DHCP), e.g.
      ``"192.168.50.0/24"``; used for nat / route / isolated networks, ignored
      for ``"bridge"`` (the host bridge carries its own IP). ``None`` = no
      engine-managed IP.
    * :attr:`dhcp` — when a ``cidr`` is set, hand out DHCP leases on it.
    * :attr:`dns_enabled` — run the engine's DNS for the network; isolated
      malware-analysis networks set this ``False``.
    * :attr:`autostart` — start the network on host boot.
    """

    name: str
    forward_mode: str | None = "nat"
    bridge: str | None = None
    cidr: str | None = None
    dhcp: bool = True
    dns_enabled: bool = True
    autostart: bool = True


# ---------------------------------------------------------------------------
# Migration types
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class MigrationTarget:
    """Where a migration is going."""

    host: HostId
    libvirt_uri: str | None = None
    """For libvirt-to-libvirt within a homogeneous pool."""


@dataclass(frozen=True, slots=True)
class MigrationPlan:
    """The CP's pre-computed migration recipe.

    Per ``contracts.md`` §3 ``migrate``: this carries the
    pre-flight checks the adapter MUST verify before transferring
    state, plus the device-handoff sequence the agent will execute
    around the adapter call.
    """

    compatibility: CompatibilityResult
    device_handoff: tuple[LeaseId, ...] = ()
    """Leases to transfer to the destination via
    :meth:`DeviceBackend.transfer_lease` as part of the migration
    transaction."""
    storage_strategy: str = "drive-mirror"
    """``"shared"`` for shared storage (no copy needed); ``"drive-mirror"``
    for local-storage migrations."""


# ---------------------------------------------------------------------------
# Event types (state-machine + adapter event streams)
# ---------------------------------------------------------------------------


class VmState(StrEnum):
    """The lifecycle state machine's states.

    See ``contracts.md`` §2.2. Distinct from the legacy
    :class:`mgf.vm.core.domain.VMState` (which is a *live state*
    dataclass, not the enum). The legacy one will be renamed to
    ``LiveState`` in a follow-up.
    """

    DEFINED = "defined"
    STARTING = "starting"
    RUNNING = "running"
    PAUSING = "pausing"
    PAUSED = "paused"
    RESUMING = "resuming"
    STOPPING = "stopping"
    STOPPED = "stopped"
    MIGRATING = "migrating"
    FAILED = "failed"


class TransitionCause(StrEnum):
    """Why a transition happened (carried in :class:`TransitionEvent`)."""

    USER_REQUEST = "user_request"
    RECONCILER = "reconciler"
    ENGINE_EVENT = "engine_event"
    FAULT = "fault"


@dataclass(frozen=True, slots=True)
class TransitionEvent:
    """One state-machine transition.

    Fired to every registered :class:`LifecycleObserver`. Per
    ``contracts.md`` §6:

    * ``from_state`` is ``None`` on the first transition (define).
    * ``timestamp`` is UTC (DP-12).
    """

    vm_id: VmId
    from_state: VmState | None
    to_state: VmState
    timestamp: datetime
    cause: TransitionCause
    operation_id: OperationId | None = None
    metadata: dict[str, str] = field(default_factory=dict)


@dataclass(frozen=True, slots=True)
class VmEvent:
    """A domain event from the hypervisor's event stream
    (:meth:`HypervisorBackend.watch_events`).

    Carries causal ordering per domain. The adapter delivers events
    in order; cross-domain ordering is not guaranteed.
    """

    domain_id: DomainId
    sequence: int
    """Monotonic per-domain. Streams resume from a last-seen
    sequence; gaps trigger :class:`EventGap`."""
    timestamp: datetime
    payload: dict[str, Any]


@dataclass(frozen=True, slots=True)
class EventGap:
    """Synthetic event emitted when ``watch_events`` reconnects after
    the engine's retention window has elapsed.

    Consumers see this and know they must reconcile (they missed
    events).
    """

    domain_id: DomainId | None
    """``None`` if the gap is global (e.g., engine restarted)."""
    after_sequence: int
    detected_at: datetime


@dataclass(frozen=True, slots=True)
class LeaseEvent:
    """An event from the device-backend lease stream
    (:meth:`DeviceBackend.watch_leases`)."""

    lease: LeaseId
    device_id: DeviceId
    kind: str
    """``"claimed"`` | ``"released"`` | ``"safe_reset"`` | ``"fenced"``."""
    timestamp: datetime
    claimant: str | None = None


# ---------------------------------------------------------------------------
# Operation model (D-G11) — the engine- AND transport-neutral async-op type
# ---------------------------------------------------------------------------
#
# Per D-G5 every mutating operation is async: the caller gets an
# ``OperationId`` immediately and learns the outcome by polling
# (``GetOperation``) or watching (``WatchOp``). ``OperationHandle`` is the
# Python source of truth for the thing that travels back to the caller —
# the in-process SDK transport (Phase 0) and the gRPC transport plus the
# CP/agent (Phase 1) round-trip the SAME type, so the in-process path stays
# in proto parity with the wire ``OperationHandle`` message
# (``vm-vmanager-docs/component-specs/api.md`` §4.2). This type is PURE
# data — no engine, no transport — so it stays inside the import-linter
# purity contract the same way ``OperationId`` / ``VmSpec`` do.


class OperationStatus(StrEnum):
    """Lifecycle of an async mutating operation (D-G5, D-G11).

    Maps 1:1 to the ``OperationStatus`` protobuf enum (api.md §4.2).
    ``SUCCEEDED`` / ``FAILED`` / ``CANCELLED`` are the terminal states.
    """

    PENDING = "pending"
    RUNNING = "running"
    SUCCEEDED = "succeeded"
    FAILED = "failed"
    CANCELLED = "cancelled"

    @property
    def is_terminal(self) -> bool:
        """``True`` for the three terminal states (``finished_at`` required)."""
        return self in _TERMINAL_STATUSES


_TERMINAL_STATUSES = frozenset(
    {OperationStatus.SUCCEEDED, OperationStatus.FAILED, OperationStatus.CANCELLED}
)


class OperationKind(StrEnum):
    """The finite set of mutating operations across the three backends.

    Enumerated from ``contracts.md`` §3-§5 (``HypervisorBackend``,
    ``StorageBackend``, ``DeviceBackend``). Finite in Phase 0; additive
    later (D-D6 — adding a member is forward-compatible). Maps 1:1 to the
    ``OperationKind`` protobuf enum (api.md §4.2).
    """

    # --- VM lifecycle (HypervisorBackend) ---------------------------------
    VM_CREATE = "vm_create"
    VM_REDEFINE = "vm_redefine"
    VM_UNDEFINE = "vm_undefine"
    VM_RENAME = "vm_rename"
    VM_START = "vm_start"
    VM_STOP = "vm_stop"
    VM_PAUSE = "vm_pause"
    VM_RESUME = "vm_resume"
    VM_REBOOT = "vm_reboot"
    VM_SNAPSHOT = "vm_snapshot"
    VM_REVERT_SNAPSHOT = "vm_revert_snapshot"
    VM_DELETE_SNAPSHOT = "vm_delete_snapshot"
    VM_MIGRATE = "vm_migrate"
    VM_ATTACH_DEVICE = "vm_attach_device"
    VM_DETACH_DEVICE = "vm_detach_device"

    # --- domain policy (DomainPolicyBackend, D-G25) -----------------------
    VM_SET_AUTOSTART = "vm_set_autostart"
    VM_MANAGED_SAVE = "vm_managed_save"

    # --- volume + pool lifecycle (StorageBackend) -------------------------
    VOLUME_CREATE = "volume_create"
    VOLUME_RESIZE = "volume_resize"
    VOLUME_DELETE = "volume_delete"
    VOLUME_SNAPSHOT = "volume_snapshot"
    VOLUME_REVERT = "volume_revert"
    VOLUME_CONVERT = "volume_convert"
    POOL_CREATE = "pool_create"
    POOL_DELETE = "pool_delete"

    # --- device leases (DeviceBackend) ------------------------------------
    DEVICE_CLAIM = "device_claim"
    DEVICE_RELEASE = "device_release"
    DEVICE_TRANSFER = "device_transfer"

    # --- host disk-image I/O (HostDiskBackend, D-G18) ---------------------
    # Orchestrated server-side (the in-process transport / the Fleet agent):
    # each is a get_vm → HostDiskBackend disk-file op → create_vm sequence
    # over the canonical VmSpec disk sources — no native-format rewrite.
    VM_BACKUP = "vm_backup"
    VM_CLONE = "vm_clone"
    VM_RESTORE = "vm_restore"

    # --- virtual-network management (NetworkBackend, D-G15) ---------------
    NETWORK_DEFINE = "network_define"
    NETWORK_START = "network_start"
    NETWORK_STOP = "network_stop"
    NETWORK_DELETE = "network_delete"


@dataclass(frozen=True, slots=True)
class OperationHandle:
    """An async mutating operation's handle — what the caller round-trips.

    Extends the bare :class:`~mgf.vm.core.ids.OperationId` with the
    ``kind`` / ``status`` / timing / outcome the caller needs to learn an
    op's fate without a separate result type per RPC (D-G11). Maps 1:1 to
    the ``OperationHandle`` protobuf message (api.md §4.2).

    The outcome is an explicit ``oneof`` — EXACTLY ONE OF
    {pending, success_value, error} — enforced against :attr:`status` in
    :meth:`__post_init__`:

    * ``PENDING`` / ``RUNNING`` — neither :attr:`result` nor :attr:`error`.
    * ``SUCCEEDED`` — :attr:`result` set, :attr:`error` ``None``.
    * ``FAILED`` — :attr:`error` set.
    * ``CANCELLED`` — neither.

    :attr:`result` is intentionally ``object`` / generic — the op-specific
    success value (a ``Vm``, a ``VolumeId``, ``None``…). Core never couples
    to consumer result types; the SDK narrows at the call site. On the wire
    this is the ``success_value`` field; :attr:`error` is the ``Error``
    proto (D-G3).

    Invariants (also enforced in ``__post_init__``):

    * :attr:`started_at` / :attr:`finished_at` are UTC-aware; naive
      datetimes are rejected (DP-12, D-G6).
    * ``finished_at >= started_at`` when both are set.
    * Terminal statuses (:attr:`OperationStatus.is_terminal`) require
      :attr:`finished_at`; non-terminal statuses forbid it.
    """

    op_id: OperationId
    kind: OperationKind
    status: OperationStatus
    started_at: datetime
    finished_at: datetime | None = None
    result: object | None = None
    """The op-specific success value (``success_value`` on the wire).
    Generic ``object`` so core stays decoupled from consumer types."""
    error: VmManagerError | None = None
    """The failure (the ``Error`` proto, D-G3). Set iff ``status`` is FAILED."""

    def __post_init__(self) -> None:
        _require_utc("started_at", self.started_at)
        if self.finished_at is not None:
            _require_utc("finished_at", self.finished_at)
            if self.finished_at < self.started_at:
                raise ValueError(
                    f"finished_at ({self.finished_at.isoformat()}) is before "
                    f"started_at ({self.started_at.isoformat()})"
                )

        if self.status.is_terminal and self.finished_at is None:
            raise ValueError(f"terminal status {self.status.value!r} requires finished_at")
        if not self.status.is_terminal and self.finished_at is not None:
            raise ValueError(f"non-terminal status {self.status.value!r} forbids finished_at")

        # The oneof outcome invariant, keyed on status.
        if self.status is OperationStatus.SUCCEEDED:
            if self.error is not None:
                raise ValueError("SUCCEEDED operation must not carry an error")
        elif self.status is OperationStatus.FAILED:
            if self.error is None:
                raise ValueError("FAILED operation requires an error")
            if self.result is not None:
                raise ValueError("FAILED operation must not carry a result")
        else:
            # PENDING / RUNNING / CANCELLED carry neither outcome value.
            if self.result is not None:
                raise ValueError(f"{self.status.value!r} operation must not carry a result")
            if self.error is not None:
                raise ValueError(f"{self.status.value!r} operation must not carry an error")

    @classmethod
    def pending(
        cls,
        op_id: OperationId,
        kind: OperationKind,
        started_at: datetime,
    ) -> OperationHandle:
        """Construct a freshly-issued ``PENDING`` handle."""
        return cls(op_id=op_id, kind=kind, status=OperationStatus.PENDING, started_at=started_at)

    def started(self) -> OperationHandle:
        """Immutably transition ``PENDING`` → ``RUNNING``."""
        return replace(self, status=OperationStatus.RUNNING)

    def succeeded(self, result: object, *, finished_at: datetime) -> OperationHandle:
        """Immutably transition to ``SUCCEEDED`` with a success value."""
        return replace(
            self,
            status=OperationStatus.SUCCEEDED,
            finished_at=finished_at,
            result=result,
            error=None,
        )

    def failed(self, error: VmManagerError, *, finished_at: datetime) -> OperationHandle:
        """Immutably transition to ``FAILED`` with the typed error."""
        return replace(
            self,
            status=OperationStatus.FAILED,
            finished_at=finished_at,
            result=None,
            error=error,
        )

    def cancelled(self, *, finished_at: datetime) -> OperationHandle:
        """Immutably transition to ``CANCELLED``."""
        return replace(
            self,
            status=OperationStatus.CANCELLED,
            finished_at=finished_at,
            result=None,
            error=None,
        )


def _require_utc(field_name: str, value: datetime) -> None:
    """Reject naive datetimes (DP-12 / D-G6). UTC-aware only at the boundary."""
    if value.tzinfo is None or value.utcoffset() is None:
        raise ValueError(f"{field_name} must be timezone-aware (UTC); got naive {value!r}")
    if value.utcoffset() != UTC.utcoffset(None):
        raise ValueError(f"{field_name} must be UTC; got offset {value.utcoffset()}")


# ---------------------------------------------------------------------------
# The four Protocols
# ---------------------------------------------------------------------------


@runtime_checkable
class HypervisorBackend(Protocol):
    """The per-host VM-lifecycle contract (``contracts.md`` §3).

    Implemented by every hypervisor adapter (``mgf-vm-libvirt``,
    ``mgf-vm-firecracker``, …). Tested via the conformance suite
    (``CONF-H-*`` scenarios).
    """

    def close(self) -> None:
        """Release the engine connection (DP-13). Idempotent."""

    def __enter__(self) -> HypervisorBackend: ...

    def __exit__(self, *exc: object) -> None: ...

    # --- read-only queries -------------------------------------------------

    def list_domains(self) -> list[DomainInfo]:
        """List all domains the engine knows about. Idempotent."""

    def get_domain(self, d: DomainId) -> DomainInfo:
        """Fetch one domain. Raises ``DomainNotFound`` if absent."""

    def capabilities(self) -> HypervisorCapabilities:
        """Report what this engine instance supports. Idempotent; cached
        by adapter."""

    # --- host-network observation (D-G14) ---------------------------------
    #
    # DECISION (ADR **D-G14**) — host-network observation lives as methods
    # **on** ``HypervisorBackend``, NOT in a separate ``NetworkBackend``
    # Protocol. Rationale: the engine serves these reads from the very same
    # connection it serves :meth:`list_domains` / :meth:`get_domain` from;
    # in practice no engine separates "observe my domains" from "observe my
    # host networks." Folding them in keeps the contract set unchanged (no
    # cardinality bump) and lets a consumer holding a ``HypervisorBackend``
    # resolve a domain's bridge without a second injected dependency. All
    # three are read-only and idempotent.

    def list_networks(self) -> tuple[NetworkProfile, ...]:
        """List the host virtual networks the engine knows about.
        Idempotent read."""

    def network_leases(self, network: str) -> tuple[NetworkLease, ...]:
        """List observed DHCP leases on ``network`` (by network name).
        Idempotent read."""

    def bridge_for_domain(self, d: DomainId) -> str | None:
        """Resolve the host bridge device a domain's interface is on, or
        ``None`` when the domain has no bridged interface / is unknown.
        Idempotent read."""

    # --- definition + lifecycle -------------------------------------------

    def define(self, spec: VmSpec) -> DomainId:
        """Define a new domain. **Idempotent on same spec**: returns the
        existing ``DomainId`` if a domain with this name + matching
        spec already exists; raises ``SpecConflict`` if the existing
        spec differs."""

    def redefine(self, d: DomainId, spec: VmSpec) -> None:
        """Replace the definition of an existing (stopped) domain.
        Idempotent on same spec. Raises ``DomainStateInvalid`` if
        running."""

    def undefine(self, d: DomainId, *, delete_storage: bool = False) -> None:
        """Remove the domain. Idempotent (undefining a missing domain is
        success). Raises ``DomainStateInvalid`` if running."""

    def rename(self, d: DomainId, new_name: str) -> None:
        """Rename the domain ``d`` to ``new_name``, preserving its identity
        (the ``DomainId`` / engine UUID is stable across the rename — D-G26).

        A definition-time operation, not a lifecycle transition: it changes the
        domain's name only. Raises ``DomainStateInvalid`` if the domain is
        running (engines rename only an inactive domain), ``DomainAlreadyExists``
        if ``new_name`` is taken by another domain, and ``DomainNotFound`` if
        ``d`` is absent. Renaming to the current name is a no-op success.
        """

    def start(self, d: DomainId, *, token: CancellationToken | None = None) -> None:
        """Boot the domain. Idempotent (already-running is success).
        Honors ``token`` at engine-yield points."""

    def stop(
        self,
        d: DomainId,
        *,
        force: bool = False,
        token: CancellationToken | None = None,
    ) -> None:
        """Shut down the domain. Idempotent. ``force=False`` is graceful;
        raises ``GuestUnresponsive`` on timeout. ``force=True`` is the
        engine equivalent of pulling the plug — must succeed."""

    def pause(self, d: DomainId) -> None:
        """Pause execution (CPU only; memory retained). Idempotent."""

    def resume(self, d: DomainId) -> None:
        """Resume from paused. Idempotent."""

    def reboot(
        self,
        d: DomainId,
        *,
        force: bool = False,
        token: CancellationToken | None = None,
    ) -> None:
        """Reboot the guest. ``force`` mirrors :meth:`stop`."""

    # --- snapshots --------------------------------------------------------

    def snapshot(
        self,
        d: DomainId,
        name: str,
        *,
        include_memory: bool = False,
    ) -> SnapshotId:
        """Take a snapshot. Idempotent on same name + same content;
        raises ``SnapshotExists`` if same name but different content
        (e.g., different ``include_memory``)."""

    def list_snapshots(self, d: DomainId) -> list[_SnapshotInfoContract]:
        """List snapshots for this domain."""

    def revert_snapshot(
        self,
        d: DomainId,
        s: SnapshotId,
        *,
        token: CancellationToken | None = None,
    ) -> None:
        """Restore the domain to a snapshot."""

    def delete_snapshot(self, d: DomainId, s: SnapshotId) -> None:
        """Delete a snapshot. Idempotent. Raises ``SnapshotInUse`` if
        it has descendants."""

    # --- migration --------------------------------------------------------

    def migrate(
        self,
        d: DomainId,
        dest: MigrationTarget,
        *,
        plan: MigrationPlan,
        token: CancellationToken | None = None,
    ) -> None:
        """Live-migrate to ``dest``. Atomic — on failure the source is
        left ``running``. See ``contracts.md`` §3 ``migrate()`` for the
        full failure-mode contract."""

    # --- device passthrough (hypervisor-side half) ------------------------

    def attach_device(
        self,
        d: DomainId,
        assignment: DeviceAssignment,
        *,
        live: bool = True,
    ) -> None:
        """Plug an already-leased device (from the broker) into the
        guest. Idempotent on (domain, device)."""

    def detach_device(
        self,
        d: DomainId,
        assignment: DeviceAssignment,
        *,
        live: bool = True,
    ) -> None:
        """Unplug a device from the guest. Idempotent."""

    # --- events -----------------------------------------------------------

    def watch_events(
        self,
        d: DomainId | None = None,
    ) -> Iterator[VmEvent | EventGap]:
        """Stream domain events. If ``d`` is None, all domains. Resumable
        — closing the iterator releases the engine subscription
        deterministically (DP-13)."""


@runtime_checkable
class StorageBackend(Protocol):
    """The volume + snapshot lifecycle contract (``contracts.md`` §4).

    A single adapter object MAY implement both ``HypervisorBackend``
    and ``StorageBackend`` (the libvirt adapter does), but the
    contracts stay distinct so storage can be replaced per-path
    later (D-F4).
    """

    def close(self) -> None:
        """Release the backend connection. Idempotent."""

    def __enter__(self) -> StorageBackend: ...

    def __exit__(self, *exc: object) -> None: ...

    # --- pools ------------------------------------------------------------

    def list_pools(self) -> list[PoolInfo]: ...

    def get_pool(self, name: str) -> PoolInfo: ...

    def refresh_pool(self, name: str) -> PoolInfo:
        """Rescan the pool's backing store and return its fresh
        :class:`PoolInfo`. The rescan is an idempotent side effect; the
        return mirrors :meth:`get_pool` (D-G20)."""

    def create_pool(self, spec: PoolSpec) -> None:
        """Idempotent on same spec; raises ``PoolExists`` on different spec."""

    def delete_pool(self, name: str, *, force: bool = False) -> None:
        """Idempotent. Raises ``PoolNotEmpty`` if force=False and the
        pool has volumes."""

    # --- volumes ----------------------------------------------------------

    def list_volumes(self, pool: str) -> list[_VolumeInfoContract]: ...

    def get_volume(self, v: VolumeId) -> _VolumeInfoContract: ...

    def create_volume(self, spec: VolumeSpec) -> VolumeId:
        """Idempotent on (pool, name) when specs match; raises
        ``VolumeExists`` on mismatch."""

    def resize_volume(self, v: VolumeId, new_size_bytes: int) -> None:
        """Idempotent. Raises ``ResizeUnsupported`` for engines that
        can't shrink."""

    def delete_volume(self, v: VolumeId) -> None:
        """Idempotent (already-gone is success). Raises ``VolumeInUse``
        if a running VM is attached; ``VolumeHasSnapshots`` if there
        are derived snapshots."""

    # --- snapshots --------------------------------------------------------

    def snapshot_volume(self, v: VolumeId, name: str) -> SnapshotId:
        """Volume-level snapshot (bytes only, no memory state).
        Idempotent on same name + same content."""

    def list_snapshots(self, v: VolumeId) -> list[_SnapshotInfoContract]: ...

    def delete_snapshot(self, s: SnapshotId) -> None:
        """Idempotent. Raises ``SnapshotHasChildren`` if derived snapshots
        exist."""

    def revert_volume_to_snapshot(
        self,
        v: VolumeId,
        s: SnapshotId,
        *,
        token: CancellationToken | None = None,
    ) -> None:
        """Restore the volume bytes to a snapshot. Idempotent on
        revert-to-current."""

    # --- format conversion ------------------------------------------------

    def convert_format(
        self,
        src: VolumeId,
        dst_format: str,
        dst_spec: VolumeSpec,
        *,
        token: CancellationToken | None = None,
    ) -> VolumeId:
        """Produce a new volume in ``dst_format``. Always a copy.
        Idempotent on idempotent ``dst_spec``."""

    def capabilities(self) -> StorageCapabilities: ...


@runtime_checkable
class DeviceBackend(Protocol):
    """The broker-mediated device-passthrough contract (``contracts.md`` §5).

    Reference implementation is the ``mgf-hrb`` client living in
    ``vm-vmanager-agent`` (D-B4). The boundary: the *broker* mediates
    who holds the device (lease); the *hypervisor* mediates the
    device's presence in the guest (hostdev attachment). The agent
    coordinates the two as a transaction.
    """

    def close(self) -> None:
        """Release the broker connection. Idempotent."""

    def __enter__(self) -> DeviceBackend: ...

    def __exit__(self, *exc: object) -> None: ...

    # --- queries ----------------------------------------------------------

    def list_assignable(self) -> list[DeviceInfo]:
        """Devices on this host the holder of our capability may claim."""

    def list_leased(self) -> list[LeaseInfo]:
        """Currently-held leases (by any client)."""

    def get_device(self, d: DeviceId) -> DeviceInfo: ...

    def check_compatibility(
        self,
        domain: DomainId,
        device: DeviceId,
    ) -> CompatibilityResult:
        """Pre-flight: can ``domain`` accept ``device`` on this host?

        Verifies CPU features, IOMMU groups, kernel-driver state.
        """

    # --- lease lifecycle --------------------------------------------------

    def claim(
        self,
        device: DeviceId,
        *,
        mode: ClaimMode = ClaimMode.EXCLUSIVE,
        ttl: float | None = None,
    ) -> LeaseId:
        """Acquire a lease on ``device``. Idempotent (same client +
        same device returns same lease). Raises ``DeviceContended``
        when another client holds an incompatible lease."""

    def assignment_for(self, lease: LeaseId) -> DeviceAssignment:
        """Materialize a hypervisor-side handle (PCI BDF, vfio path)
        for a held lease. Pure read; idempotent."""

    def release(self, lease: LeaseId) -> None:
        """Release the lease. Broker safe-resets the device.
        Idempotent."""

    def transfer_lease(
        self,
        lease: LeaseId,
        dest_host: HostId,
        *,
        token: CancellationToken | None = None,
    ) -> LeaseId:
        """Hand off a lease to a destination host (migration with
        passthrough device). The broker coordinates the safe-reset on
        each side."""

    # --- events -----------------------------------------------------------

    def watch_leases(self) -> Iterator[LeaseEvent]:
        """Stream lease events (claimed / released / fenced).
        Resumable; closing the iterator releases the broker
        subscription."""

    def capabilities(self) -> DeviceCapabilities: ...


@runtime_checkable
class MetricsBackend(Protocol):
    """OPTIONAL per-domain resource-sampling contract (Wave-B, D-G13).

    Modeled on :class:`DeviceBackend`: a **separate, injectable** Protocol
    that a composition MAY pass or MAY leave ``None``. It is the neutral
    surface a consumer ports its metrics reads onto — instead of reaching
    into the umbrella provider's libvirt-shaped ``domain.VMMetrics``.

    DECISION (ADR **D-G13**) — per-domain metrics sampling lives in this
    dedicated OPTIONAL ``MetricsBackend`` Protocol, **not** as a method on
    :class:`HypervisorBackend`. Rationale: sampling is an *optional,
    potentially expensive* read (it may poll a guest agent / balloon, or
    aggregate per-vCPU and per-block-device stats) that a control plane may
    schedule on a different cadence — or not at all — than the lifecycle
    surface. Modeling it like :class:`DeviceBackend` keeps it injectable
    and keeps a composition that wants no metrics completely unaffected.
    This makes ``MetricsBackend`` the **5th** optional Protocol; the
    contract-set cardinality change lives here.

    The single method is read-only and idempotent and takes the same
    ``DomainId`` handle every :class:`HypervisorBackend` method uses, so a
    consumer holding a domain can sample it from the injected metrics
    backend.
    """

    def sample_metrics(self, d: DomainId) -> MetricsSample:
        """Take one engine-neutral resource :class:`MetricsSample` of ``d``.

        Read-only and idempotent (each call returns a fresh point-in-time
        sample; calling N times does not mutate the domain). Counters are
        cumulative-since-start; the consumer derives rates by differencing
        successive samples' :attr:`MetricsSample.sampled_at`. Raises
        ``DomainNotFound`` if ``d`` is absent.
        """


@runtime_checkable
class HostDiskBackend(Protocol):
    """OPTIONAL host disk-image I/O contract (Wave-R4, D-G18).

    Modeled on :class:`MetricsBackend` / :class:`DeviceBackend`: a
    **separate, injectable** Protocol a composition MAY pass or MAY leave
    ``None``. It owns the **host filesystem** disk-image seam — copying,
    overlaying, archiving and extracting image *files* — the operations the
    umbrella's ``mgf.vm.operations`` backup / clone / restore perform with
    ``qemu-img`` / ``shutil`` / ``tarfile``. The other contracts
    (:class:`HypervisorBackend` / :class:`StorageBackend`) deliberately
    never touch image files, so this is where that host I/O lives.

    DECISION (ADR **D-G18**) — backup / clone / restore are **host-FS
    operations**, not engine-neutral primitives, yet they are the last
    operations pinning a consumer to the umbrella ``mgf.vm.operations``.
    They get a dedicated OPTIONAL ``HostDiskBackend`` Protocol rather than
    methods on :class:`HypervisorBackend`. Rationale: in **Fleet** mode the
    SDK has no host filesystem (the disks live on the remote agent's host),
    so putting ``shutil`` / ``qemu-img`` / ``tarfile`` in the engine-neutral
    SDK would break that — instead the **SDK orchestrates the
    backup/clone/restore *sequence*** (via the canonical :class:`VmSpec`'s
    disk *sources*, D-G12) and delegates each disk *file* operation to this
    backend. In **Local** mode the app injects a local-FS impl; in Fleet
    mode the agent provides it (it co-locates engine + host FS). This makes
    ``HostDiskBackend`` the **6th** optional Protocol; the contract-set
    cardinality change lives here.

    Method granularity is **fine** (deliberately): primitive disk-file
    operations (``copy_image`` / ``create_overlay`` / ``archive`` /
    ``extract``) — NOT coarse ``backup`` / ``clone`` / ``restore`` (those
    would need engine access too and become "the agent"; the orchestration
    sequence stays in the SDK). Paths are ``str`` (wire-portable, not
    ``pathlib.Path``). All operations honor a :class:`CancellationToken`
    where they may run long.
    """

    def image_info(self, path: str) -> DiskImageInfo:
        """Inspect a disk image and return its :class:`DiskImageInfo`.

        Read-only. Raises ``DiskOperationError`` if ``path`` is missing or
        the engine cannot parse it.
        """

    def copy_image(
        self,
        source: str,
        dest: str,
        *,
        token: CancellationToken | None = None,
    ) -> None:
        """Full-copy the image at ``source`` to ``dest`` (full clone).

        Creates ``dest``'s parent if absent; refuses to overwrite an
        existing ``dest``. Raises ``DiskOperationError`` on failure.
        """

    def create_overlay(self, backing: str, overlay: str) -> None:
        """Create a qcow2 ``overlay`` backed by ``backing`` (linked clone).

        ``backing`` must exist; ``overlay`` must not. Raises
        ``DiskOperationError`` on failure.
        """

    def archive(
        self,
        vm_name: str,
        disk_sources: Mapping[str, str],
        dest: str,
        *,
        metadata: Mapping[str, str] = _EMPTY_METADATA,
        token: CancellationToken | None = None,
    ) -> BackupManifest:
        """Archive ``disk_sources`` (guest-target → host path) into ``dest``.

        Produces a single archive at ``dest`` containing each disk image and
        a per-disk SHA-256 (integrity-against-corruption, not a security
        signature — D-G18 / RA-14). ``metadata`` is an **opaque** string map
        the backend persists verbatim and returns on :meth:`extract` — the
        SDK uses it to carry the engine-neutral :class:`VmSpec` (serialized)
        so a restore is self-contained, without the backend interpreting it.
        Returns the resulting :class:`BackupManifest`. Raises
        ``BackupError`` on failure.
        """

    def extract(
        self,
        archive: str,
        dest_dir: str,
        *,
        token: CancellationToken | None = None,
    ) -> RestoredImages:
        """Extract ``archive`` into ``dest_dir``, verifying checksums.

        Re-hashes every disk and raises ``BackupError`` on any mismatch
        (corruption guard). Returns :class:`RestoredImages` — the restored
        disks (guest-target → host path) plus the opaque ``metadata`` map
        the matching :meth:`archive` persisted. The SDK reads the carried
        :class:`VmSpec` out of ``metadata``, repoints its disk sources at the
        restored paths, and calls ``create_vm`` — no XML rewrite (D-G18).
        """


@runtime_checkable
class NetworkBackend(Protocol):
    """OPTIONAL virtual-network management contract (Wave-R3, D-G15).

    Modeled on :class:`MetricsBackend` / :class:`HostDiskBackend`: a
    **separate, injectable** Protocol a composition MAY pass or MAY leave
    ``None``. It owns virtual-network **management** — define / start / stop /
    delete a host virtual network — the write-side counterpart of the D-G14
    **observation** reads (:meth:`HypervisorBackend.list_networks` /
    ``network_leases`` / ``bridge_for_domain``).

    DECISION (ADR **D-G15**) — network *management* is a privileged, optional
    surface a composition may not wire at all (a read-only / metrics-only
    consumer wants none of it), so it lives in a dedicated OPTIONAL Protocol
    rather than as mandatory methods on :class:`HypervisorBackend`. The ADR
    recommended methods-on-`HypervisorBackend` for D-G14 symmetry; the user
    ratified the inverse (the `MetricsBackend` opt-out shape). This makes
    ``NetworkBackend`` the **7th** Protocol; the contract-set cardinality
    change lives here. The asymmetry — observation on `HypervisorBackend`,
    management here — is accepted: observation is a universal read; management
    is the opt-in write. A backend implementing this typically shares the
    engine connection with the `HypervisorBackend` (e.g. the libvirt adapter's
    `LibvirtNetworkBackend` over the same `LibvirtConnection`), so management's
    effects are observable through `list_networks`.

    The adapter owns the :class:`NetworkSpec` → native (libvirt network XML)
    mapping; no native format leaks across this surface. Names are the network
    handle (``str``). Typed errors: ``NetworkExists`` (define collision),
    ``NetworkNotFound`` (start/stop/delete of an unknown network).
    """

    def define_network(self, spec: NetworkSpec) -> NetworkProfile:
        """Define a virtual network from ``spec``; return its observed profile.

        Defines but does **not** start it (the caller follows with
        :meth:`start_network`). Idempotent on an identical spec; raises
        ``NetworkExists`` if a *different* network already holds the name.
        """

    def start_network(self, name: str) -> None:
        """Activate the defined network ``name``. Idempotent (already-active is
        success). Raises ``NetworkNotFound`` if ``name`` is not defined."""

    def stop_network(self, name: str) -> None:
        """Deactivate the running network ``name``. Idempotent (already-stopped
        is success). Raises ``NetworkNotFound`` if ``name`` is not defined."""

    def delete_network(self, name: str) -> None:
        """Undefine the network ``name`` (the engine requires it stopped first).
        Idempotent (already-absent is success). Raises ``NetworkNotFound`` only
        where the engine distinguishes it."""


@runtime_checkable
class LifecycleObserver(Protocol):
    """The state-machine observer Protocol (``contracts.md`` §6).

    Fired on every legal transition. Observers MUST be non-blocking
    (<50 ms budget); observer exceptions are caught by the state
    machine and logged but never re-raised.

    Replaces today's ``mgf.vm.hooks`` per D-C4.
    """

    def on_transition(self, event: TransitionEvent) -> None: ...


@runtime_checkable
class AsyncLifecycleObserver(Protocol):
    """Async variant of :class:`LifecycleObserver`.

    Same <50 ms budget; same exception-isolation rule. Sync and async
    observers may be registered on the same state machine.
    """

    async def on_transition(self, event: TransitionEvent) -> None: ...


@runtime_checkable
class GuestAgentBackend(Protocol):
    """OPTIONAL in-guest agent observation contract (Wave-R6, D-G19).

    Modeled on :class:`MetricsBackend` (D-G13): a **separate, injectable**
    Protocol a composition MAY pass or MAY leave ``None``. It is the neutral
    surface a consumer ports its guest-agent reads onto — instead of reaching
    into the umbrella ``providers.guest_agent.GuestAgent`` — covering the
    three live operations the app needs: resolve a VM's reachable IP, detect
    the guest OS family, and probe agent liveness (the ``vm agent`` CLI).

    DECISION (ADR **D-G19**) — guest-agent reads live in this dedicated
    OPTIONAL Protocol, not on :class:`HypervisorBackend`. Rationale (same as
    D-G13): talking to the in-guest agent is an *optional, may-be-absent*
    read (the guest may have no agent installed / running) that a control
    plane may schedule differently; modeling it like :class:`MetricsBackend`
    keeps a composition that wants no guest surface completely unaffected.

    All methods take the same ``DomainId`` handle every other backend uses,
    and raise ``DomainNotFound`` if the domain is absent. The umbrella's
    ``fsfreeze``/``fsthaw`` and ``guest_ram`` reads are deliberately NOT
    modeled here — the former is unused by the SDK consumers, the latter was
    never implemented (a dormant no-op).
    """

    def guest_ping(self, d: DomainId) -> bool:
        """Return ``True`` iff the in-guest agent answers a liveness probe.

        Does NOT raise when the agent is merely unreachable (a guest with no
        running agent is the common, non-error case) — returns ``False``.
        Raises ``DomainNotFound`` if ``d`` is absent.
        """

    def guest_os_info(self, d: DomainId) -> GuestOsInfo:
        """Return the guest's :class:`GuestOsInfo` (``guest-get-osinfo``).

        Raises ``GuestUnreachableError`` when the agent is unreachable, and
        ``DomainNotFound`` if ``d`` is absent.
        """

    def guest_interfaces(self, d: DomainId) -> tuple[GuestNic, ...]:
        """Return the guest's reported NICs (``guest-network-get-interfaces``).

        Raises ``GuestUnreachableError`` when the agent is unreachable, and
        ``DomainNotFound`` if ``d`` is absent.
        """


class DomainPolicyBackend(Protocol):
    """OPTIONAL per-domain power/boot-policy contract (Wave-R7, D-G25).

    Modeled on :class:`MetricsBackend` (D-G13) / :class:`GuestAgentBackend`
    (D-G19): a **separate, injectable** Protocol a composition MAY pass or MAY
    leave ``None``. It is the neutral surface for the two umbrella-only domain
    operations the app's ``vm`` CLI still needs — the host-boot **autostart**
    policy flag (``vm autostart`` / the ``vm info`` autostart field) and
    **managed-save** suspend-to-disk (``vm hibernate``) — instead of reaching
    into the umbrella ``providers`` package.

    DECISION (ADR **D-G25**) — autostart + managed-save live in this dedicated
    OPTIONAL Protocol, **not** on :class:`HypervisorBackend`. Rationale (same
    as D-G13/D-G19): neither is universal — a host-boot autostart flag and a
    ``managedSave`` equivalent are libvirt-shaped and absent on other engines
    (e.g. Firecracker) — and both are policy/admin ops a control plane may not
    expose at all. Modeling them like :class:`MetricsBackend` keeps a
    composition that wants neither completely unaffected. This makes
    ``DomainPolicyBackend`` the **6th** optional Protocol. (This ratifies
    moving autostart onto a neutral contract; the umbrella previously kept it
    provider-only, off ``HypervisorBackend`` / ``VmStatus``.)

    All methods take the same ``DomainId`` handle every other backend uses and
    raise ``DomainNotFound`` if the domain is absent.
    """

    def get_autostart(self, d: DomainId) -> bool:
        """Return whether ``d`` is flagged to auto-start on host boot.

        Read-only + idempotent. Raises ``DomainNotFound`` if ``d`` is absent.
        """

    def set_autostart(self, d: DomainId, *, enabled: bool) -> None:
        """Set ``d``'s host-boot autostart flag to ``enabled``.

        Raises ``DomainNotFound`` if ``d`` is absent.
        """

    def managed_save(self, d: DomainId) -> None:
        """Suspend the running domain ``d`` to a managed-save image and stop
        it (libvirt ``managedSave``); the next start restores from the image.

        Raises ``DomainNotFound`` if ``d`` is absent; raises
        ``OperationError`` if the domain is not running.
        """


__all__ = [
    "CURRENT_BACKUP_SCHEMA_VERSION",
    "CURRENT_VMSPEC_SCHEMA_VERSION",
    "AsyncLifecycleObserver",
    "AttachedDevice",
    "BackupDiskArtifact",
    "BackupManifest",
    "ClaimMode",
    "CompatibilityResult",
    "DeviceAssignment",
    "DeviceBackend",
    "DeviceInfo",
    "DeviceSpec",
    "DiskImageInfo",
    "DiskIo",
    "DiskSpec",
    "DiskStatus",
    "DomainInfo",
    "DomainPolicyBackend",
    "EventGap",
    "GuestAgentBackend",
    "GuestNic",
    "GuestOsInfo",
    "HostDiskBackend",
    "HypervisorBackend",
    "LeaseEvent",
    "LeaseInfo",
    "LifecycleObserver",
    "MetricsBackend",
    "MetricsSample",
    "MigrationPlan",
    "MigrationTarget",
    "NetworkBackend",
    "NetworkLease",
    "NetworkProfile",
    "NetworkSpec",
    "NicIo",
    "NicSpec",
    "NicStatus",
    "OperationHandle",
    "OperationKind",
    "OperationStatus",
    "PoolInfo",
    "PoolSpec",
    "RestoredDisk",
    "RestoredImages",
    "StorageBackend",
    "TransitionCause",
    "TransitionEvent",
    "Vm",
    "VmEvent",
    "VmSpec",
    "VmState",
    "VmStatus",
    "VolumeSpec",
    "migrate_vmspec",
]
