"""``FakeHostDiskBackend`` — in-memory implementation of the OPTIONAL
:class:`~mgf.vm.core.contracts.HostDiskBackend` (Wave-R4, D-G18).

Modeled on the sibling fakes (``FakeMetricsBackend`` et al., D-F3): a working
in-memory adapter with correct semantics, not a mock. It keeps a virtual
image store mapping host *path* → image bytes (+ format / virtual size /
backing), so :meth:`copy_image` / :meth:`create_overlay` / :meth:`archive` /
:meth:`extract` exercise the real round-trip — checksums included — without
touching ``qemu-img`` / ``shutil`` / ``tarfile`` or a real filesystem.

This backend is OPTIONAL (D-G18): a composition that passes no disk backend
is unaffected. Where one is wanted (the SDK's backup / clone / restore
orchestration), this fake satisfies ``HostDiskBackend`` so downstream suites
and the dev loop can exercise the surface without a host.
"""

from __future__ import annotations

import hashlib
import threading
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from mgf.vm.core.contracts import (
    BackupDiskArtifact,
    BackupManifest,
    DiskImageInfo,
    RestoredDisk,
    RestoredImages,
)
from mgf.vm.core.exceptions import BackupError, DiskOperationError

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping

    from mgf.vm.core.cancellation import CancellationToken


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


@dataclass
class _Image:
    """One in-memory disk image in the virtual store."""

    content: bytes
    fmt: str
    virtual_size: int
    backing: str | None = None


@dataclass
class _ArchivedDisk:
    """One disk captured inside an in-memory archive."""

    target: str
    archive_name: str
    content: bytes
    sha256: str
    fmt: str
    virtual_size: int


@dataclass
class _Archive:
    """An in-memory backup archive (keyed by its ``dest`` path)."""

    vm_name: str
    created_at: datetime
    disks: list[_ArchivedDisk]
    metadata: dict[str, str] = field(default_factory=dict)


class FakeHostDiskBackend:
    """In-memory host disk-image adapter — D-F3 first-class fake.

    Thread-safe via a single ``RLock`` (consistent with the other fakes'
    threading rules, ``contracts.md`` §8). It carries a virtual image store
    (host path → :class:`_Image`); tests seed images via
    :meth:`_put_image`. Disk-file operations refuse to overwrite an existing
    destination and raise ``DiskOperationError`` for a missing source, and
    :meth:`archive` / :meth:`extract` round-trip bytes + SHA-256 checksums
    so the conformance round-trip (CONF-O-01) is meaningful. Real adapters
    perform the same operations against the host filesystem.

    The clock is injectable so tests can drive :attr:`BackupManifest.created_at`
    deterministically.
    """

    def __init__(
        self,
        *,
        clock: Callable[[], datetime] = _utc_now,
    ) -> None:
        self._clock = clock
        self._lock = threading.RLock()
        self._images: dict[str, _Image] = {}
        self._archives: dict[str, _Archive] = {}
        self._closed = False

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

    # --- lifecycle -------------------------------------------------------

    def close(self) -> None:
        with self._lock:
            self._closed = True

    def __enter__(self) -> FakeHostDiskBackend:
        return self

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

    # --- the HostDiskBackend Protocol ------------------------------------

    def image_info(self, path: str) -> DiskImageInfo:
        """Inspect an image in the virtual store (D-G18)."""
        with self._lock:
            img = self._require_image(path)
            return DiskImageInfo(
                path=path,
                format=img.fmt,
                virtual_size=img.virtual_size,
                actual_size=len(img.content),
                backing_file=img.backing,
            )

    def copy_image(
        self,
        source: str,
        dest: str,
        *,
        token: CancellationToken | None = None,
    ) -> None:
        """Full-copy ``source`` to ``dest`` in the virtual store (D-G18)."""
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            img = self._require_image(source)
            self._refuse_existing(dest)
            self._images[dest] = _Image(
                content=img.content,
                fmt=img.fmt,
                virtual_size=img.virtual_size,
                backing=None,
            )

    def create_overlay(self, backing: str, overlay: str) -> None:
        """Create a qcow2 ``overlay`` backed by ``backing`` (D-G18)."""
        with self._lock:
            backing_img = self._require_image(backing)
            self._refuse_existing(overlay)
            self._images[overlay] = _Image(
                content=b"",
                fmt="qcow2",
                virtual_size=backing_img.virtual_size,
                backing=backing,
            )

    def archive(
        self,
        vm_name: str,
        disk_sources: Mapping[str, str],
        dest: str,
        *,
        metadata: Mapping[str, str] | None = None,
        token: CancellationToken | None = None,
    ) -> BackupManifest:
        """Archive ``disk_sources`` (target → path) into ``dest`` (D-G18)."""
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            if dest in self._archives:
                msg = f"archive {dest} already exists; refusing to overwrite"
                raise BackupError(msg)
            captured: list[_ArchivedDisk] = []
            for target, path in disk_sources.items():
                img = self._require_image(path, error=BackupError)
                archive_name = f"{target}.{img.fmt}"
                captured.append(
                    _ArchivedDisk(
                        target=target,
                        archive_name=archive_name,
                        content=img.content,
                        sha256=hashlib.sha256(img.content).hexdigest(),
                        fmt=img.fmt,
                        virtual_size=img.virtual_size,
                    )
                )
            md = dict(metadata or {})
            self._archives[dest] = _Archive(
                vm_name=vm_name,
                created_at=self._clock(),
                disks=captured,
                metadata=md,
            )
            return BackupManifest(
                vm_name=vm_name,
                archive_path=dest,
                created_at=self._archives[dest].created_at,
                disks=tuple(
                    BackupDiskArtifact(
                        target=d.target,
                        archive_name=d.archive_name,
                        sha256=d.sha256,
                    )
                    for d in captured
                ),
                metadata=md,
            )

    def extract(
        self,
        archive: str,
        dest_dir: str,
        *,
        token: CancellationToken | None = None,
    ) -> RestoredImages:
        """Extract ``archive`` into ``dest_dir``, verifying checksums (D-G18)."""
        if token is not None:
            token.raise_if_cancelled()
        with self._lock:
            arc = self._archives.get(archive)
            if arc is None:
                msg = f"no archive {archive}"
                raise BackupError(msg)
            restored: list[RestoredDisk] = []
            base = dest_dir.rstrip("/")
            for d in arc.disks:
                digest = hashlib.sha256(d.content).hexdigest()
                if digest != d.sha256:
                    msg = (
                        f"checksum mismatch for {d.archive_name}: "
                        f"manifest {d.sha256}, archive {digest}"
                    )
                    raise BackupError(msg)
                path = f"{base}/{d.archive_name}"
                self._images[path] = _Image(
                    content=d.content,
                    fmt=d.fmt,
                    virtual_size=d.virtual_size,
                    backing=None,
                )
                restored.append(RestoredDisk(target=d.target, path=path))
            return RestoredImages(disks=tuple(restored), metadata=dict(arc.metadata))

    # --- internal helpers ------------------------------------------------

    def _require_image(
        self,
        path: str,
        *,
        error: type[Exception] = DiskOperationError,
    ) -> _Image:
        img = self._images.get(path)
        if img is None:
            msg = f"disk image does not exist: {path}"
            raise error(msg)
        return img

    def _refuse_existing(self, dest: str) -> None:
        if dest in self._images:
            msg = f"destination {dest} already exists; refusing to overwrite"
            raise DiskOperationError(msg)

    # --- test helpers (not part of the Protocol) -------------------------

    def _put_image(
        self,
        path: str,
        *,
        content: bytes = b"disk",
        fmt: str = "qcow2",
        virtual_size: int = 1 << 30,
        backing: str | None = None,
    ) -> None:
        """Seed an image into the virtual store (tests inject disks)."""
        with self._lock:
            self._images[path] = _Image(
                content=content,
                fmt=fmt,
                virtual_size=virtual_size,
                backing=backing,
            )

    def _has_image(self, path: str) -> bool:
        with self._lock:
            return path in self._images


__all__ = ["FakeHostDiskBackend"]
