"""``FakeHypervisorBackend`` — in-memory implementation of
:class:`~mgf.vm.core.contracts.HypervisorBackend`.

Honors the contract precisely: idempotent ``define``,
``DomainNotFound`` for missing IDs, ``SpecConflict`` for re-defines
with conflicting specs, ``DomainStateInvalid`` for illegal-in-state
operations, cancellation token raised in long-running methods.

State is observable via the ``state`` property — tests can inspect
which domains are running, what snapshots exist, what devices are
attached.
"""

from __future__ import annotations

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

from mgf.vm.core.capabilities import HypervisorCapabilities
from mgf.vm.core.contracts import (
    DeviceAssignment,
    DomainInfo,
    EventGap,
    MigrationPlan,
    MigrationTarget,
    NetworkLease,
    NetworkProfile,
    VmEvent,
    VmSpec,
    VmState,
    _SnapshotInfoContract,
)
from mgf.vm.core.exceptions import (
    DeviceAttachFailed,
    DomainAlreadyExistsError,
    DomainNotFound,
    DomainStateInvalid,
    GuestUnresponsive,
    MigrationIncompatible,
    SnapshotExists,
    SnapshotInUse,
    SnapshotNotFound,
    SpecConflict,
)
from mgf.vm.core.ids import DomainId, SnapshotId

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator

    from mgf.vm.core.cancellation import CancellationToken


def _utc_now() -> datetime:
    """Default clock — UTC always (DP-12)."""
    return datetime.now(UTC)


@dataclass
class _DomainRecord:
    """Internal state for one defined domain."""

    domain_id: DomainId
    spec: VmSpec
    is_running: bool = False
    is_paused: bool = False
    snapshots: dict[SnapshotId, _SnapshotInfoContract] = field(default_factory=dict)
    attached_devices: set[DeviceAssignment] = field(default_factory=set)


class FakeHypervisorBackend:
    """In-memory hypervisor adapter — D-F3 first-class fake.

    Thread-safe via a single ``RLock``; supports concurrent test
    workers per the contract's threading rules (``contracts.md`` §8).
    """

    def __init__(
        self,
        *,
        clock: Callable[[], datetime] = _utc_now,
        graceful_stop_succeeds: bool = True,
    ) -> None:
        """Construct a fresh in-memory backend.

        :param clock: time source for snapshots and events. Tests
            inject a fixed clock for deterministic ordering.
        :param graceful_stop_succeeds: when ``False``, graceful
            ``stop`` raises ``GuestUnresponsive`` — lets tests
            exercise the force-stop path (CONF-H-04).
        """
        self._clock = clock
        self._graceful_stop_succeeds = graceful_stop_succeeds
        self._lock = threading.RLock()
        self._domains: dict[DomainId, _DomainRecord] = {}
        # name → DomainId index for define-idempotency.
        self._by_name: dict[str, DomainId] = {}
        self._closed = False
        # Host-network observation state (D-G14). Seeded with one active NAT
        # ``default`` network + a single observed lease — the default a real
        # libvirt host ships, and the old FakeNetworkBackend.with_default_network
        # behavior folded in here.
        self._networks: dict[str, NetworkProfile] = {
            "default": NetworkProfile(
                name="default",
                bridge="virbr0",
                active=True,
                forward_mode="nat",
            ),
        }
        self._network_leases: dict[str, tuple[NetworkLease, ...]] = {
            "default": (
                NetworkLease(
                    mac="52:54:00:ab:cd:ef",
                    ip="192.168.122.10",
                    hostname="guest-1",
                    expires_at=None,
                ),
            ),
        }
        self._domain_bridges: dict[DomainId, str] = {}

    # -- introspection (test-friendly; not part of the Protocol) -----------

    @property
    def state(self) -> dict[DomainId, _DomainRecord]:
        """Snapshot of internal state (read-only view; tests inspect)."""
        with self._lock:
            return dict(self._domains)

    @property
    def closed(self) -> bool:
        return self._closed

    # -- connection lifecycle ---------------------------------------------

    def close(self) -> None:
        """Idempotent. No external resources to release; just sets a flag."""
        with self._lock:
            self._closed = True

    def __enter__(self) -> FakeHypervisorBackend:
        return self

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

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

    def list_domains(self) -> list[DomainInfo]:
        with self._lock:
            return [self._info_from_record(r) for r in self._domains.values()]

    def get_domain(self, d: DomainId) -> DomainInfo:
        return self._info_from_record(self._require(d))

    def capabilities(self) -> HypervisorCapabilities:
        return HypervisorCapabilities(
            engine="fake",
            engine_version="0.3.0",
            supports_live_migration=True,
            supports_memory_snapshots=True,
            supports_device_attach_live=True,
            max_cpus=128,
            max_memory_bytes=1 << 40,  # 1 TiB
            cpu_features=frozenset({"vmx", "ept", "sse4_2"}),
            storage_pool_types=frozenset({"dir", "lvm"}),
            isolation_profiles=frozenset(),
        )

    # -- host-network observation (D-G14) ---------------------------------

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

        The fake ships one active NAT ``default`` network carrying a single
        observed lease (folding in the old ``FakeNetworkBackend
        .with_default_network`` behavior), so a smoke test gets a working
        host-network surface without seeding topology.
        """
        with self._lock:
            return tuple(self._networks.values())

    def network_leases(self, network: str) -> tuple[NetworkLease, ...]:
        """List observed DHCP leases on ``network`` (by name). Idempotent;
        an unknown network has no observed leases (``()``)."""
        with self._lock:
            return self._network_leases.get(network, ())

    def bridge_for_domain(self, d: DomainId) -> str | None:
        """Resolve the host bridge a domain's interface is on, or ``None``
        when no mapping is seeded. Idempotent read."""
        with self._lock:
            return self._domain_bridges.get(d)

    # -- test helpers for host-network seeding (not part of the Protocol) --

    def _register_network(
        self,
        profile: NetworkProfile,
        *,
        leases: tuple[NetworkLease, ...] = (),
    ) -> None:
        """Add (or replace) a seeded host network + its leases."""
        with self._lock:
            self._networks[profile.name] = profile
            self._network_leases[profile.name] = leases

    def _set_domain_bridge(self, d: DomainId, bridge: str) -> None:
        """Record a domain→bridge mapping (tests inject placement)."""
        with self._lock:
            self._domain_bridges[d] = bridge

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

    def define(self, spec: VmSpec) -> DomainId:
        with self._lock:
            existing_id = self._by_name.get(spec.name)
            if existing_id is not None:
                existing = self._domains[existing_id].spec
                if existing == spec:
                    # Same spec → idempotent return.
                    return existing_id
                msg = (
                    f"domain {spec.name!r} already defined with a different "
                    f"spec; redefine() explicitly to replace it"
                )
                raise SpecConflict(msg)
            new_id = DomainId(f"fake-{uuid.uuid4()}")
            self._domains[new_id] = _DomainRecord(domain_id=new_id, spec=spec)
            self._by_name[spec.name] = new_id
            return new_id

    def redefine(self, d: DomainId, spec: VmSpec) -> None:
        with self._lock:
            record = self._require(d)
            if record.is_running:
                msg = f"cannot redefine running domain {record.spec.name!r}"
                raise DomainStateInvalid(msg)
            if record.spec.name != spec.name:
                # Update the name index.
                self._by_name.pop(record.spec.name, None)
                self._by_name[spec.name] = d
            record.spec = spec

    def undefine(self, d: DomainId, *, delete_storage: bool = False) -> None:
        with self._lock:
            record = self._domains.get(d)
            if record is None:
                # Idempotent — undefining a missing domain is success.
                return
            if record.is_running:
                msg = f"cannot undefine running domain {record.spec.name!r}"
                raise DomainStateInvalid(msg)
            self._by_name.pop(record.spec.name, None)
            del self._domains[d]

    def rename(self, d: DomainId, new_name: str) -> None:
        with self._lock:
            record = self._require(d)
            if new_name == record.spec.name:
                return  # no-op
            if record.is_running:
                msg = f"cannot rename running domain {record.spec.name!r}"
                raise DomainStateInvalid(msg)
            if new_name in self._by_name:
                msg = f"domain {new_name!r} already exists"
                raise DomainAlreadyExistsError(msg)
            self._by_name.pop(record.spec.name, None)
            self._by_name[new_name] = d
            record.spec = replace(record.spec, name=new_name)

    def start(
        self,
        d: DomainId,
        *,
        token: CancellationToken | None = None,
    ) -> None:
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            record = self._require(d)
            # Idempotent: already-running is success.
            record.is_running = True
            record.is_paused = False

    def stop(
        self,
        d: DomainId,
        *,
        force: bool = False,
        token: CancellationToken | None = None,
    ) -> None:
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            record = self._require(d)
            if not record.is_running:
                # Idempotent.
                return
            if not force and not self._graceful_stop_succeeds:
                msg = f"graceful stop timed out for {record.spec.name!r}"
                raise GuestUnresponsive(msg)
            record.is_running = False
            record.is_paused = False

    def pause(self, d: DomainId) -> None:
        with self._lock:
            record = self._require(d)
            if not record.is_running:
                msg = f"cannot pause non-running domain {record.spec.name!r}"
                raise DomainStateInvalid(msg)
            record.is_paused = True

    def resume(self, d: DomainId) -> None:
        with self._lock:
            record = self._require(d)
            if not record.is_paused:
                # Idempotent: already running.
                return
            record.is_paused = False

    def reboot(
        self,
        d: DomainId,
        *,
        force: bool = False,
        token: CancellationToken | None = None,
    ) -> None:
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            record = self._require(d)
            if not record.is_running:
                msg = f"cannot reboot non-running domain {record.spec.name!r}"
                raise DomainStateInvalid(msg)
            # In-memory; no real reboot, just mark we processed it.

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

    def snapshot(
        self,
        d: DomainId,
        name: str,
        *,
        include_memory: bool = False,
    ) -> SnapshotId:
        with self._lock:
            record = self._require(d)
            for snap_id, snap in record.snapshots.items():
                if snap.name == name:
                    # Same name → check semantic equivalence.
                    if snap.has_memory == include_memory:
                        return snap_id
                    msg = (
                        f"snapshot {name!r} exists on {record.spec.name!r} "
                        f"with different include_memory"
                    )
                    raise SnapshotExists(msg)
            snap_id = SnapshotId(f"fake-snap-{uuid.uuid4()}")
            if record.is_paused:
                captured = VmState.PAUSED
            elif record.is_running:
                captured = VmState.RUNNING
            else:
                captured = VmState.STOPPED
            record.snapshots[snap_id] = _SnapshotInfoContract(
                snapshot_id=snap_id,
                parent=d,
                name=name,
                created_at=self._clock(),
                has_memory=include_memory,
                state=captured,
            )
            return snap_id

    def list_snapshots(self, d: DomainId) -> list[_SnapshotInfoContract]:
        with self._lock:
            record = self._require(d)
            return list(record.snapshots.values())

    def revert_snapshot(
        self,
        d: DomainId,
        s: SnapshotId,
        *,
        token: CancellationToken | None = None,
    ) -> None:
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            record = self._require(d)
            if s not in record.snapshots:
                msg = f"snapshot {s} not found on {record.spec.name!r}"
                raise SnapshotNotFound(msg)
            # In-memory revert is a no-op against state; the snapshot
            # itself is the canonical "what would be restored."

    def delete_snapshot(self, d: DomainId, s: SnapshotId) -> None:
        with self._lock:
            record = self._require(d)
            if s not in record.snapshots:
                # Idempotent — already-gone is success.
                return
            # No descendants supported in this fake; nothing to refuse.
            del record.snapshots[s]

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

    def migrate(
        self,
        d: DomainId,
        dest: MigrationTarget,
        *,
        plan: MigrationPlan,
        token: CancellationToken | None = None,
    ) -> None:
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            record = self._require(d)
            if not plan.compatibility.compatible:
                msg = f"destination not compatible: {', '.join(plan.compatibility.reasons)}"
                raise MigrationIncompatible(msg)
            if not record.is_running:
                msg = f"cannot migrate non-running domain {record.spec.name!r}"
                raise DomainStateInvalid(msg)
            # In-memory: source "migrated away." Mark stopped to
            # simulate that the source no longer hosts the VM.
            record.is_running = False

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

    def attach_device(
        self,
        d: DomainId,
        assignment: DeviceAssignment,
        *,
        live: bool = True,
    ) -> None:
        with self._lock:
            record = self._require(d)
            # Idempotent on (domain, device).
            for existing in record.attached_devices:
                if existing.device_id == assignment.device_id:
                    # Already attached.
                    return
            record.attached_devices.add(assignment)

    def detach_device(
        self,
        d: DomainId,
        assignment: DeviceAssignment,
        *,
        live: bool = True,
    ) -> None:
        with self._lock:
            record = self._require(d)
            # Find by device_id (assignment instances may differ).
            to_remove = next(
                (a for a in record.attached_devices if a.device_id == assignment.device_id),
                None,
            )
            if to_remove is None:
                # Idempotent — not-attached is success.
                return
            record.attached_devices.discard(to_remove)

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

    def watch_events(
        self,
        d: DomainId | None = None,
    ) -> Iterator[VmEvent | EventGap]:
        """Empty event stream for this fake.

        Tests that need event-driven flows can inject events via the
        ``_emit_event`` helper or subclass and override.
        """
        # An exhausted iterator. Adapter authors can subclass to
        # implement real event streaming.
        return iter([])

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

    def _require(self, d: DomainId) -> _DomainRecord:
        record = self._domains.get(d)
        if record is None:
            msg = f"no domain {d}"
            raise DomainNotFound(msg)
        return record

    @staticmethod
    def _info_from_record(record: _DomainRecord) -> DomainInfo:
        return DomainInfo(
            domain_id=record.domain_id,
            name=record.spec.name,
            cpu=record.spec.cpu,
            memory_bytes=record.spec.memory_bytes,
            is_running=record.is_running,
        )


# Quiet a few "unused import" warnings — these names are used in
# default-argument annotations / TYPE_CHECKING-only paths above.
_ = (SnapshotInUse, DeviceAttachFailed)  # re-export awareness for adapter authors
