"""Register an existing domain with VManager — pure orchestration.

``vmanager vm adopt <name>`` brings a domain that was created outside
VManager (for example by ``virsh define`` or ``virt-manager``) under
our observation without touching the domain itself. Nothing about the
domain's config changes — we simply:

1. Resolve the domain by name via the injected
   :class:`DomainResolver`.
2. Check every disk path the domain references exists, via the
   **injected** :class:`DiskExistenceCheck` (so ``vmanager vm list``
   never hands the user a handle whose disks are already gone).
3. Insert a :class:`~mgf.vm.core.domain.VMState` entry into the
   :class:`StateStore` so the VM shows up in ``vmanager vm list`` from
   this invocation onward.

A missing name is a :class:`~mgf.vm.core.exceptions.DomainNotFound`.
Missing disk paths are a non-fatal report — the adopt still succeeds
so the user can fix the underlying issue and retry; the CLI surfaces
``missing_disks`` as a warning.

Purity
------
The legacy ``adopt`` called ``Path.exists()`` directly — that is
host-filesystem I/O, forbidden in the pure core. Here the existence
check is **injected** as :class:`DiskExistenceCheck`; the adapter
performs the real ``stat``. Likewise the disk-path enumeration and the
by-name resolution are injected Protocols, so this module performs no
concrete engine or file I/O. The disk paths flow as opaque ``str``
(engine/host-relative), not ``pathlib.Path`` — the core does not model
host paths.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from mgf.common.observability import operation_span

from mgf.vm.core.domain import VMHandle, VMStatus
from mgf.vm.core.exceptions import (
    DomainNotFound,
    OperationError,
    ProviderError,
)

if TYPE_CHECKING:
    from collections.abc import Sequence

    from mgf.vm.core.common.progress import ProgressReporter
    from mgf.vm.core.contracts import DomainInfo
    from mgf.vm.core.ids import DomainId
    from mgf.vm.core.monitor.state import StateStore


@runtime_checkable
class DomainResolver(Protocol):
    """Narrow engine seam: resolve a domain *by name*.

    :class:`~mgf.vm.core.contracts.HypervisorBackend.get_domain` keys
    on :class:`~mgf.vm.core.ids.DomainId`, but adopt starts from a
    user-supplied *name*. This injected Protocol does the name lookup
    and returns both the id and the observed :class:`DomainInfo`,
    raising :class:`~mgf.vm.core.exceptions.DomainNotFound` for a free
    name. The adapter implements it over its engine's by-name lookup.
    """

    def resolve_by_name(self, name: str) -> tuple[DomainId, DomainInfo]:
        """Return ``(domain_id, info)`` for ``name`` or raise ``DomainNotFound``."""
        ...

    def disk_paths(self, domain: DomainId) -> Sequence[str]:
        """Return the opaque disk-path strings the domain references."""
        ...


@runtime_checkable
class DiskExistenceCheck(Protocol):
    """Narrow filesystem seam: does this disk path exist on the host?

    The pure core must not ``stat`` the filesystem, so the existence
    check is injected. The adapter wires this to ``Path(p).exists()``.
    """

    def disk_exists(self, path: str) -> bool:
        """Return ``True`` if the disk at ``path`` exists on the host."""
        ...


@dataclass(frozen=True)
class AdoptReport:
    """Return value of :func:`adopt`.

    ``handle`` is the :class:`VMHandle` for the adopted domain.
    ``disk_paths`` is every disk-image path the domain references.
    ``missing_disks`` is the subset of ``disk_paths`` the injected
    existence check reports absent — the CLI surfaces this so the user
    can decide whether to worry.
    """

    domain_id: DomainId
    handle: VMHandle
    disk_paths: list[str]
    missing_disks: list[str]


def adopt(
    resolver: DomainResolver,
    existence: DiskExistenceCheck,
    store: StateStore,
    name: str,
    *,
    reporter: ProgressReporter | None = None,
) -> AdoptReport:
    """Resolve ``name`` and register it with the store.

    Returns an :class:`AdoptReport`. The adopt succeeds even when some
    disk files are missing so the user can fix the underlying issue and
    retry; the CLI prints a warning when ``missing_disks`` is non-empty.

    Args:
        resolver: injected :class:`DomainResolver` for by-name lookup
            and disk-path enumeration.
        existence: injected :class:`DiskExistenceCheck` for the per-disk
            stat (the core never touches the filesystem).
        store: the :class:`StateStore` to register the VM into.
        name: the user-supplied domain name to adopt.
        reporter: optional progress reporter.

    Raises:
        :class:`~mgf.vm.core.exceptions.DomainNotFound`: no such domain.
        :class:`~mgf.vm.core.exceptions.OperationError`: the engine
            failed during resolution or disk-path enumeration.
    """
    with operation_span("vm.adopt", vm_name=name):
        _announce(reporter, f"looking up {name}")
        try:
            domain_id, info = resolver.resolve_by_name(name)
        except DomainNotFound:
            raise
        except ProviderError as e:
            raise OperationError(f"could not look up {name!r}: {e}") from e

        try:
            disk_paths = list(resolver.disk_paths(domain_id))
        except ProviderError as e:
            raise OperationError(f"could not read disk paths for {name!r}: {e}") from e

        missing = [p for p in disk_paths if not existence.disk_exists(p)]

        handle = VMHandle(
            name=info.name,
            uuid=domain_id.value,
            state=VMStatus.RUNNING if info.is_running else VMStatus.STOPPED,
        )
        # Insert into the store. If the domain was already tracked
        # (defensive path — the caller may have seeded before calling
        # adopt), insert() acts as an update.
        store.insert(handle)

        if missing:
            _announce(reporter, f"{name}: adopted with {len(missing)} missing disk file(s)")
        else:
            _announce(reporter, f"{name}: adopted")

        return AdoptReport(
            domain_id=domain_id,
            handle=handle,
            disk_paths=disk_paths,
            missing_disks=missing,
        )


def _announce(reporter: ProgressReporter | None, message: str) -> None:
    if reporter is not None:
        reporter.report(message)


__all__ = [
    "AdoptReport",
    "DiskExistenceCheck",
    "DomainResolver",
    "adopt",
]
