"""Single validation gate for every :class:`VMSpec` created by VManager.

Every creation path — interactive wizard, YAML file loader, GUI
wizard, profile applicator, template applicator, OVA importer —
routes its assembled :class:`VMSpec` through :func:`validate` before
anything else happens. This gives the user a single predictable
error surface and keeps creation-time checks in one place.

The rules are exhaustive and run in a fixed order; every failure is
collected into a :class:`SpecValidationError` so one badly-typed spec
file produces a single error report instead of forcing the user to
fix one problem at a time:

1. Name character rules (regex).
2. Resource minimums (ram >= 128, disk >= 1, vcpus >= 1, etc.).
3. Literal-valued fields match one of the allowed strings.
4. Source existence (URL shape, path exists) unless the spec is
   source-less (imported / adopted).
5. NIC MAC address format, NIC model allowed value, NIC network
   exists in the provider (when one is supplied).
6. Name uniqueness via the injected provider.

The provider is optional — most of the rules can be checked without
one, which is what the interactive wizard does during prompting.
Only the "does this network exist" and "is this name free" rules
require a live provider.

Provider seam (Wave 1 port)
---------------------------
The live-state rules (name uniqueness, network existence) need a
backend. The legacy ``mgf.vm.providers.base.VMProvider`` ABC is the
adapter-bound libvirt seam and is **not** part of pure core. The four
``mgf.vm.core.contracts`` Protocols describe a different call surface
(``get_domain(DomainId) -> DomainInfo``, no ``list_networks``). So the
validator declares its own narrow, structural :class:`_NameNetworkProvider`
Protocol naming *exactly* the two methods it calls. Any object that
supplies them — including the legacy ``VMProvider`` and any test fake —
satisfies it, with no adapter import reaching into pure core.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from urllib.parse import urlparse

from mgf.vm.core.creator.spec import (
    DiskBus,
    DiskCache,
    DiskFormat,
    Display,
    Firmware,
    NICModel,
    ProvisioningBackend,
    SourceType,
    VMSpec,
)
from mgf.vm.core.exceptions import (
    DomainNotFoundError,
    ProviderError,
    SpecValidationError,
)
from mgf.vm.core.names import VM_NAME_REGEX

if TYPE_CHECKING:
    from collections.abc import Iterable

    from mgf.vm.core.domain import VMHandle


@runtime_checkable
class _Named(Protocol):
    """Anything with a ``name`` — the validator only reads network names."""

    @property
    def name(self) -> str: ...


@runtime_checkable
class _NameNetworkProvider(Protocol):
    """The minimal backend surface the validator's live-state rules use.

    Structural by design — the legacy ``VMProvider`` ABC, a test fake, and a
    thin SDK-client adapter all satisfy it without an import dependency, so the
    creator no longer pins the adapter-bound ``mgf.vm.providers`` package. The
    ``list_networks`` element type is loosened to :class:`_Named` so a client
    that yields engine-neutral ``NetworkProfile`` objects works unmapped (only
    ``.name`` is read).
    """

    def list_networks(self) -> Iterable[_Named]:
        """Return the networks the backend knows about (only ``.name`` is read)."""

    def get_domain(self, name: str) -> VMHandle:
        """Resolve a name; raise :class:`DomainNotFoundError` if absent."""


# Libvirt MAC addresses: six colon-separated hex bytes. Case-insensitive.
MAC_REGEX = re.compile(r"^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$")

# Resource minimums.
MIN_RAM_MB = 128
MIN_DISK_GB = 1
MIN_VCPUS = 1

# Enum-like Literal field allowed values. Kept in sync with
# mgf.vm.core.creator.spec by the import-time lock-in below.
ALLOWED_DISK_FORMATS: frozenset[str] = frozenset({"qcow2", "raw"})
ALLOWED_DISK_BUSES: frozenset[str] = frozenset({"virtio", "sata", "scsi", "ide"})
ALLOWED_DISK_CACHES: frozenset[str] = frozenset(
    {"none", "writeback", "writethrough", "directsync", "unsafe"}
)
ALLOWED_NIC_MODELS: frozenset[str] = frozenset({"virtio", "e1000", "rtl8139"})
ALLOWED_FIRMWARES: frozenset[str] = frozenset({"bios", "uefi"})
ALLOWED_DISPLAYS: frozenset[str] = frozenset({"spice", "vnc", "none"})
ALLOWED_SOURCE_TYPES: frozenset[str] = frozenset({"cloud_image", "iso"})
ALLOWED_PROVISIONING_BACKENDS: frozenset[str] = frozenset(
    {"cloud_init", "preseed", "kickstart", "none"}
)


def validate(
    spec: VMSpec,
    provider: _NameNetworkProvider | None = None,
    *,
    lenient_source: bool = False,
) -> None:
    """Validate ``spec`` in full; raise :class:`SpecValidationError`.

    Args:
        spec: the :class:`VMSpec` to validate. The caller owns
            construction; the validator never mutates.
        provider: a backend for live-state checks (name uniqueness,
            network existence). When ``None``, live-state rules are
            skipped — the interactive wizard uses this mode while
            prompting before committing.
        lenient_source: if ``True``, source-path existence is downgraded
            from a hard error to a skip. Used by the OVA importer so an
            OVA whose embedded disk does not match VManager's catalog can
            still pass the shared gate. Always ``False`` in the default
            path.

    Every failure is appended to a single list and raised as one
    :class:`SpecValidationError`.
    """
    errors: list[str] = []

    # 1. Identity
    _check_name(spec, errors)

    # 2. Resources
    _check_resources(spec, errors)

    # 3. Literal fields (enum-like strings)
    _check_literal_fields(spec, errors)

    # 4. Source
    _check_source(spec, errors, lenient=lenient_source)

    # 5. NICs
    _check_nics(spec, errors, provider)

    # 6. Live state (only if provider supplied)
    if provider is not None:
        _check_name_unique(spec, provider, errors)

    if errors:
        raise SpecValidationError(errors)


# ---------------------------------------------------------------------------
# Individual rule helpers
# ---------------------------------------------------------------------------


def _check_name(spec: VMSpec, errors: list[str]) -> None:
    if not isinstance(spec.name, str) or not spec.name:
        errors.append("name is required and must be a non-empty string")
        return
    if not VM_NAME_REGEX.match(spec.name):
        errors.append(
            f"name {spec.name!r} does not match "
            f"{VM_NAME_REGEX.pattern!r} (must start with a letter or digit; "
            f"then letters, digits, underscore, dot, or hyphen; 1-63 chars)"
        )


def _check_resources(spec: VMSpec, errors: list[str]) -> None:
    if not isinstance(spec.vcpus, int) or spec.vcpus < MIN_VCPUS:
        errors.append(f"vcpus must be an integer >= {MIN_VCPUS}, got {spec.vcpus!r}")
    if spec.vcpus_max is not None:
        if not isinstance(spec.vcpus_max, int) or spec.vcpus_max < 1:
            errors.append(f"vcpus_max must be an integer >= 1 or null, got {spec.vcpus_max!r}")
        elif isinstance(spec.vcpus, int) and spec.vcpus_max < spec.vcpus:
            errors.append(f"vcpus_max ({spec.vcpus_max}) must be >= vcpus ({spec.vcpus})")

    if not isinstance(spec.ram_mb, int) or spec.ram_mb < MIN_RAM_MB:
        errors.append(f"ram_mb must be an integer >= {MIN_RAM_MB}, got {spec.ram_mb!r}")
    if spec.ram_max_mb is not None:
        if not isinstance(spec.ram_max_mb, int) or spec.ram_max_mb < MIN_RAM_MB:
            errors.append(
                f"ram_max_mb must be an integer >= {MIN_RAM_MB} or null, got {spec.ram_max_mb!r}"
            )
        elif isinstance(spec.ram_mb, int) and spec.ram_max_mb < spec.ram_mb:
            errors.append(f"ram_max_mb ({spec.ram_max_mb}) must be >= ram_mb ({spec.ram_mb})")

    if not isinstance(spec.disk_gb, int) or spec.disk_gb < MIN_DISK_GB:
        errors.append(f"disk_gb must be an integer >= {MIN_DISK_GB}, got {spec.disk_gb!r}")

    for index, disk in enumerate(spec.extra_disks):
        if not isinstance(disk.size_gb, int) or disk.size_gb < MIN_DISK_GB:
            errors.append(
                f"extra_disks[{index}].size_gb must be an integer >= {MIN_DISK_GB}, "
                f"got {disk.size_gb!r}"
            )
        if disk.format not in ALLOWED_DISK_FORMATS:
            errors.append(
                f"extra_disks[{index}].format must be one of "
                f"{sorted(ALLOWED_DISK_FORMATS)}, got {disk.format!r}"
            )
        if disk.bus not in ALLOWED_DISK_BUSES:
            errors.append(
                f"extra_disks[{index}].bus must be one of "
                f"{sorted(ALLOWED_DISK_BUSES)}, got {disk.bus!r}"
            )
        if disk.cache not in ALLOWED_DISK_CACHES:
            errors.append(
                f"extra_disks[{index}].cache must be one of "
                f"{sorted(ALLOWED_DISK_CACHES)}, got {disk.cache!r}"
            )


def _check_literal_fields(spec: VMSpec, errors: list[str]) -> None:
    if spec.disk_format not in ALLOWED_DISK_FORMATS:
        errors.append(
            f"disk_format must be one of {sorted(ALLOWED_DISK_FORMATS)}, got {spec.disk_format!r}"
        )
    if spec.disk_bus not in ALLOWED_DISK_BUSES:
        errors.append(
            f"disk_bus must be one of {sorted(ALLOWED_DISK_BUSES)}, got {spec.disk_bus!r}"
        )
    if spec.disk_cache not in ALLOWED_DISK_CACHES:
        errors.append(
            f"disk_cache must be one of {sorted(ALLOWED_DISK_CACHES)}, got {spec.disk_cache!r}"
        )
    if spec.firmware not in ALLOWED_FIRMWARES:
        errors.append(f"firmware must be one of {sorted(ALLOWED_FIRMWARES)}, got {spec.firmware!r}")
    if spec.display not in ALLOWED_DISPLAYS:
        errors.append(f"display must be one of {sorted(ALLOWED_DISPLAYS)}, got {spec.display!r}")
    if spec.provisioning.backend not in ALLOWED_PROVISIONING_BACKENDS:
        errors.append(
            f"provisioning.backend must be one of "
            f"{sorted(ALLOWED_PROVISIONING_BACKENDS)}, "
            f"got {spec.provisioning.backend!r}"
        )

    # ide bus is incompatible with UEFI firmware.
    if spec.firmware == "uefi" and spec.disk_bus == "ide":
        errors.append("disk_bus 'ide' is incompatible with firmware 'uefi'; use 'virtio' or 'sata'")

    # Display port must be in the valid TCP range when specified.
    if spec.display_port is not None and not (1 <= spec.display_port <= 65535):
        errors.append(f"display_port must be 1..65535 or null, got {spec.display_port!r}")


def _check_source(spec: VMSpec, errors: list[str], *, lenient: bool) -> None:
    if spec.source is None:
        # Source-less specs are valid — for example, an adopt path
        # registers an existing domain whose disk already exists
        # on-host. The (adapter-bound) builder rejects source-less specs
        # only if the caller actually wants to *create* a VM.
        return

    source = spec.source
    if source.type not in ALLOWED_SOURCE_TYPES:
        errors.append(
            f"source.type must be one of {sorted(ALLOWED_SOURCE_TYPES)}, got {source.type!r}"
        )
        return

    if source.url is not None and source.path is not None:
        errors.append("source.url and source.path are mutually exclusive; set exactly one")
        return
    if source.url is None and source.path is None:
        errors.append("source must set exactly one of url or path")
        return

    if source.url is not None:
        parsed = urlparse(source.url)
        if parsed.scheme not in {"http", "https"}:
            errors.append(f"source.url scheme must be http or https, got {parsed.scheme!r}")
        if not parsed.netloc:
            errors.append(f"source.url is missing a host: {source.url!r}")

    if source.path is not None:
        if not isinstance(source.path, Path):
            errors.append(f"source.path must be a Path, got {type(source.path).__name__}")
            return
        if not lenient and not source.path.exists():
            errors.append(f"source.path does not exist: {source.path}")


def _check_nics(spec: VMSpec, errors: list[str], provider: _NameNetworkProvider | None) -> None:
    if not spec.nics:
        errors.append("at least one NIC is required")
        return

    # If a provider is available, collect the names of the networks it
    # knows about so we can validate every NIC against reality.
    known_networks: set[str] | None = None
    if provider is not None:
        try:
            known_networks = {net.name for net in provider.list_networks()}
        except ProviderError:
            # Network lookup failures are not fatal — we still validate
            # everything we can. The run that actually creates the VM
            # will re-attempt and surface the provider error.
            known_networks = None

    for index, nic in enumerate(spec.nics):
        if nic.model not in ALLOWED_NIC_MODELS:
            errors.append(
                f"nics[{index}].model must be one of "
                f"{sorted(ALLOWED_NIC_MODELS)}, got {nic.model!r}"
            )
        if nic.mac is not None and not MAC_REGEX.match(nic.mac):
            errors.append(f"nics[{index}].mac must match {MAC_REGEX.pattern!r}, got {nic.mac!r}")
        if known_networks is not None and nic.network not in known_networks:
            errors.append(
                f"nics[{index}].network {nic.network!r} not defined on this "
                f"host; known networks: {sorted(known_networks)}"
            )


def _check_name_unique(spec: VMSpec, provider: _NameNetworkProvider, errors: list[str]) -> None:
    """Verify ``spec.name`` is free.

    A ``DomainNotFoundError`` means we are good — there is no domain
    with that name. Any other :class:`ProviderError` is passed through
    as a validation error rather than crashing the caller.
    """
    try:
        provider.get_domain(spec.name)
    except DomainNotFoundError:
        return
    except ProviderError as e:
        errors.append(f"could not verify name uniqueness: {e}")
        return
    errors.append(f"a VM named {spec.name!r} already exists; pick a different name")


# ---------------------------------------------------------------------------
# Cross-check at import time: every Literal in spec.py has its allowed set
# here. A drift between the two would let an invalid value slip through.
# ---------------------------------------------------------------------------


def _literal_values(alias: object) -> frozenset[str]:
    """Return the value set of a ``Literal[...]`` alias."""
    from typing import get_args

    return frozenset(str(v) for v in get_args(alias))


# Assertion runs once at module import. If the Literal aliases in
# ``spec.py`` ever drift from the allowed sets here, the module fails
# to import, which is loud and surfaces immediately in test runs.
_LITERAL_LOCK_INS: dict[str, tuple[object, frozenset[str]]] = {
    "DiskFormat": (DiskFormat, ALLOWED_DISK_FORMATS),
    "DiskBus": (DiskBus, ALLOWED_DISK_BUSES),
    "DiskCache": (DiskCache, ALLOWED_DISK_CACHES),
    "NICModel": (NICModel, ALLOWED_NIC_MODELS),
    "Firmware": (Firmware, ALLOWED_FIRMWARES),
    "Display": (Display, ALLOWED_DISPLAYS),
    "SourceType": (SourceType, ALLOWED_SOURCE_TYPES),
    "ProvisioningBackend": (ProvisioningBackend, ALLOWED_PROVISIONING_BACKENDS),
}

for _name, (_alias, _allowed) in _LITERAL_LOCK_INS.items():
    if _literal_values(_alias) != _allowed:
        raise AssertionError(
            f"Literal {_name} drifted from the validator's allowed set: "
            f"spec.py has {sorted(_literal_values(_alias))}, "
            f"validator.py has {sorted(_allowed)}"
        )


# Referenced only for the startup assertion above; kept out of __all__.
del _name, _alias, _allowed


__all__ = [
    "ALLOWED_DISK_BUSES",
    "ALLOWED_DISK_CACHES",
    "ALLOWED_DISK_FORMATS",
    "ALLOWED_DISPLAYS",
    "ALLOWED_FIRMWARES",
    "ALLOWED_NIC_MODELS",
    "ALLOWED_PROVISIONING_BACKENDS",
    "ALLOWED_SOURCE_TYPES",
    "MAC_REGEX",
    "MIN_DISK_GB",
    "MIN_RAM_MB",
    "MIN_VCPUS",
    "VM_NAME_REGEX",
    "validate",
]
