"""Canonical creation-time VM specification.

``VMSpec`` is the single in-memory description of a VM to be created.
Interactive wizards, YAML/JSON loaders, and profile applicators all
converge on an instance of this class; :mod:`mgf.vm.core.creator.validator`
is the single validation gate; the (adapter-bound) builder is the single
consumer that turns a validated spec into an engine invocation.

Type reconciliation (Wave 1 port)
----------------------------------
This ``VMSpec`` is the **creation-layer** spec: a rich, mutable, wizard-
facing configuration object carrying dozens of human-supplied fields
(NICs, extra disks, provisioning, display, firmware, …). It is a
*different layer* from :class:`mgf.vm.core.contracts.VmSpec`, which is
the minimal, frozen, engine-neutral *define-time* spec the adapters
consume (``name`` / ``cpu`` / ``memory_bytes`` / ``schema_version`` /
``metadata``). The two are intentionally kept distinct — the creation
spec is reduced to a contract ``VmSpec`` at the adapter seam (which is
adapter-bound and not ported here). Their differing casing (``VMSpec``
vs ``VmSpec``) means neither shadows the other in the ``mgf.vm.core``
namespace.

**Every field has an explicit typed default.** A ``VMSpec()`` with only
a ``name`` set is enough to create a working Ubuntu VM with sensible
defaults.

The YAML format carries a ``version`` field (currently ``1``). Future
breaking changes to the shape of a spec bump the version and add a
migration step in :func:`_migrate_spec`.
"""

from __future__ import annotations

import json
from dataclasses import MISSING, dataclass, field, fields
from enum import Enum
from pathlib import Path
from typing import Any, Literal, TypeVar, cast

import yaml

from mgf.vm.core.domain import IsolationLevel
from mgf.vm.core.exceptions import SpecValidationError

T = TypeVar("T")

CURRENT_SPEC_VERSION = 1

# Top-level spec field types narrowed with Literal so typos in YAML
# files are caught at load time, not at engine time.
DiskFormat = Literal["qcow2", "raw"]
DiskBus = Literal["virtio", "sata", "scsi", "ide"]
DiskCache = Literal["none", "writeback", "writethrough", "directsync", "unsafe"]
NICModel = Literal["virtio", "e1000", "rtl8139"]
Firmware = Literal["bios", "uefi"]
Display = Literal["spice", "vnc", "none"]
SourceType = Literal["cloud_image", "iso"]
ProvisioningBackend = Literal["cloud_init", "preseed", "kickstart", "none"]


# ---------------------------------------------------------------------------
# Sub-types — each field on VMSpec that needs structure gets its own
# small, frozen-by-convention dataclass.
# ---------------------------------------------------------------------------


@dataclass
class SourceSpec:
    """Where the VM's initial disk contents come from.

    A cloud image is pulled as a URL (HTTP/HTTPS) or from a local path;
    an ISO boot is always a local path. Exactly one of ``url`` / ``path``
    must be set — the validator enforces this at load time.
    """

    type: SourceType = "cloud_image"
    url: str | None = None
    path: Path | None = None


@dataclass
class NICSpec:
    """One network interface attached to the VM at creation time."""

    network: str = "default"  # virtual network name
    model: NICModel = "virtio"
    mac: str | None = None  # None → auto-assigned by the engine


@dataclass
class ExtraDiskSpec:
    """A secondary disk added at creation time.

    The primary disk is described by the top-level ``disk_*`` fields
    on :class:`VMSpec`; additional data disks live in
    ``VMSpec.extra_disks``.
    """

    size_gb: int = 20
    format: DiskFormat = "qcow2"
    bus: DiskBus = "virtio"
    cache: DiskCache = "none"
    pool: str | None = None  # named pool; None → default disk path


@dataclass
class ProvisioningSpec:
    """Cloud-init / preseed / kickstart configuration for first-boot."""

    backend: ProvisioningBackend = "none"
    hostname: str | None = None  # defaults to VMSpec.name at build time
    username: str | None = None
    password: str | None = None
    ssh_authorized_keys: list[str] = field(default_factory=list)
    packages: list[str] = field(default_factory=list)
    run_commands: list[str] = field(default_factory=list)


# ---------------------------------------------------------------------------
# VMSpec
# ---------------------------------------------------------------------------


@dataclass
class VMSpec:
    """Canonical creation-time VM specification.

    Any change to the shape of a VM created by VManager should touch
    this class and the property tests in lockstep.
    """

    # ── Identity ────────────────────────────────────────────────────────
    name: str  # required — validator.py enforces the regex
    version: int = CURRENT_SPEC_VERSION
    description: str = ""

    # ── Isolation posture ──────────────────────────────────────────────
    # Defaults to OFF so every existing spec round-trips unchanged.
    isolation: IsolationLevel = IsolationLevel.OFF

    # ── OS ──────────────────────────────────────────────────────────────
    os_variant: str = "generic"
    source: SourceSpec | None = None

    # ── CPU ─────────────────────────────────────────────────────────────
    vcpus: int = 2
    vcpus_max: int | None = None
    cpu_model: str = "host-passthrough"
    cpu_pinning: str | None = None

    # ── Memory ──────────────────────────────────────────────────────────
    ram_mb: int = 2048
    ram_max_mb: int | None = None
    hugepages: bool = False
    balloon: bool = True

    # ── Primary disk ────────────────────────────────────────────────────
    disk_gb: int = 20
    disk_format: DiskFormat = "qcow2"
    disk_bus: DiskBus = "virtio"
    disk_cache: DiskCache = "none"
    storage_pool: str | None = None

    # ── Additional disks ────────────────────────────────────────────────
    extra_disks: list[ExtraDiskSpec] = field(default_factory=list)

    # ── Networking ──────────────────────────────────────────────────────
    nics: list[NICSpec] = field(default_factory=lambda: [NICSpec()])

    # ── Firmware ────────────────────────────────────────────────────────
    firmware: Firmware = "bios"

    # ── Display ─────────────────────────────────────────────────────────
    display: Display = "spice"
    display_listen: str = "127.0.0.1"
    display_port: int | None = None

    # ── Provisioning ────────────────────────────────────────────────────
    provisioning: ProvisioningSpec = field(default_factory=ProvisioningSpec)

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

    def to_dict(self) -> dict[str, Any]:
        """Return a plain dict suitable for ``yaml.safe_dump`` / ``json.dump``.

        ``Path`` values become strings; nested dataclasses become nested
        dicts; ``None`` values are preserved (they round-trip correctly).
        """
        return cast("dict[str, Any]", _as_yaml_dict(self))

    def to_yaml(self) -> str:
        """Serialise to a YAML string."""
        return yaml.safe_dump(
            self.to_dict(),
            sort_keys=False,
            default_flow_style=False,
            allow_unicode=True,
        )

    def to_json(self, *, indent: int | None = 2) -> str:
        """Serialise to a JSON string. Useful for ``--json`` CLI output."""
        return json.dumps(self.to_dict(), indent=indent, sort_keys=False)

    @classmethod
    def from_dict(cls, raw: dict[str, Any]) -> VMSpec:
        """Build a :class:`VMSpec` from a plain dict.

        Unknown top-level keys raise :class:`SpecValidationError` so a
        typo in a YAML spec file fails loudly instead of being silently
        ignored. Nested sub-dicts are routed through the matching
        sub-type constructor.
        """
        return cast("VMSpec", _from_dict(cls, raw))

    @classmethod
    def from_yaml(cls, text: str) -> VMSpec:
        """Parse a YAML string into a :class:`VMSpec`.

        Propagates :class:`SpecValidationError` on malformed input,
        unknown keys, or type mismatches.
        """
        try:
            raw = yaml.safe_load(text)
        except yaml.YAMLError as e:
            raise SpecValidationError([f"invalid YAML: {e}"]) from e
        if raw is None:
            raise SpecValidationError(["empty VMSpec document"])
        if not isinstance(raw, dict):
            raise SpecValidationError([f"VMSpec YAML must be a mapping, got {type(raw).__name__}"])
        return cls.from_dict(raw)

    @classmethod
    def from_yaml_file(cls, path: Path) -> VMSpec:
        """Read a YAML file and parse it into a :class:`VMSpec`."""
        try:
            text = path.read_text(encoding="utf-8")
        except OSError as e:
            raise SpecValidationError([f"could not read {path}: {e}"]) from e
        return cls.from_yaml(text)

    def merged_with(self, overrides: dict[str, Any]) -> VMSpec:
        """Return a copy of this spec with ``overrides`` applied on top.

        Used by profile / template application. Keys that do not exist on
        :class:`VMSpec` raise :class:`SpecValidationError`.
        """
        base = self.to_dict()
        _deep_update(base, overrides)
        return VMSpec.from_dict(base)


# ---------------------------------------------------------------------------
# Internal helpers — dict ↔ dataclass conversion with strict key checking
# ---------------------------------------------------------------------------


def _as_yaml_dict(obj: Any) -> Any:
    """Recursive dataclass → plain-dict conversion used by ``to_dict``.

    Unlike :func:`dataclasses.asdict`, this preserves ``None`` values,
    stringifies ``Path`` fields, and walks nested dataclass instances.
    """
    # Enum subclasses (e.g., IsolationLevel — a StrEnum) need explicit
    # handling: ``safe_dump`` dispatches on exact type so a StrEnum value
    # raises RepresenterError even though it's a real ``str`` instance.
    # Coerce to the underlying value here.
    if isinstance(obj, Enum):
        return obj.value
    if obj is None or isinstance(obj, (str, int, float, bool)):
        return obj
    if isinstance(obj, Path):
        return str(obj)
    if isinstance(obj, (list, tuple)):
        return [_as_yaml_dict(x) for x in obj]
    if isinstance(obj, dict):
        return {k: _as_yaml_dict(v) for k, v in obj.items()}
    # dataclass instance → dict, walking every field.
    if hasattr(obj, "__dataclass_fields__"):
        out: dict[str, Any] = {}
        for f in fields(obj):
            out[f.name] = _as_yaml_dict(getattr(obj, f.name))
        return out
    raise TypeError(f"cannot serialise {type(obj).__name__} to a YAML-compatible value")


def _from_dict(cls: type, raw: dict[str, Any]) -> Any:
    """Instantiate a dataclass from a dict, strictly.

    - Unknown keys → :class:`SpecValidationError` with *all* unknown
      keys listed at once.
    - Nested sub-types (``source``, ``nics``, ``extra_disks``,
      ``provisioning``) are routed through the matching sub-class.
    - ``Path``-typed fields auto-convert from str.
    - Missing required fields re-raise as :class:`SpecValidationError`.
    """
    known = {f.name for f in fields(cls)}
    unknown = sorted(set(raw.keys()) - known)
    if unknown:
        raise SpecValidationError([f"unknown VMSpec key: {k!r}" for k in unknown])

    kwargs: dict[str, Any] = {}
    for f in fields(cls):
        if f.name not in raw:
            continue
        value = raw[f.name]
        kwargs[f.name] = _coerce(f.name, f.type, value)

    # Apply defaults for missing fields that have them, surface the rest
    # as a single typed error. VMSpec.name is the only required field.
    missing_required: list[str] = []
    for f in fields(cls):
        if f.name in kwargs:
            continue
        if f.default is not MISSING or f.default_factory is not MISSING:
            continue
        missing_required.append(f.name)
    if missing_required:
        raise SpecValidationError([f"missing required field: {name}" for name in missing_required])

    try:
        return cls(**kwargs)
    except (TypeError, ValueError) as e:
        raise SpecValidationError([f"invalid VMSpec: {e}"]) from e


def _coerce(name: str, type_hint: Any, value: Any) -> Any:
    """Convert a YAML-native value into the type the dataclass expects.

    1. ``None`` passes through (optional fields default to ``None``).
    2. Dict values for a sub-type field are routed to the matching
       sub-class's ``_from_dict``.
    3. List values for ``nics`` / ``extra_disks`` are walked element
       by element.
    4. ``Path``-annotated fields accept strings and are lifted via
       ``Path(value)``.
    """
    if value is None:
        return None

    if name == "source" and isinstance(value, dict):
        value = dict(value)
        if "path" in value and isinstance(value["path"], str):
            value["path"] = Path(value["path"])
        return _from_dict(SourceSpec, value)

    if name == "provisioning" and isinstance(value, dict):
        return _from_dict(ProvisioningSpec, value)

    if name == "nics" and isinstance(value, list):
        return [_from_dict(NICSpec, item) for item in value]

    if name == "extra_disks" and isinstance(value, list):
        return [_from_dict(ExtraDiskSpec, item) for item in value]

    # Path fields at the top level (none today, but future-proof).
    type_str = str(type_hint)
    if "Path" in type_str and isinstance(value, str):
        return Path(value)

    # IsolationLevel: YAML stores the StrEnum as its plain string value;
    # round-tripping needs the lookup back to the enum so type checks
    # downstream stay sound.
    if name == "isolation" and isinstance(value, str):
        try:
            return IsolationLevel(value)
        except ValueError as e:
            valid = ", ".join(repr(level.value) for level in IsolationLevel)
            raise SpecValidationError(
                [f"invalid isolation level {value!r}; expected one of {valid}"]
            ) from e

    return value


def _deep_update(base: dict[str, Any], overrides: dict[str, Any]) -> None:
    """In-place recursive dict update used by :meth:`VMSpec.merged_with`.

    Leaf values in ``overrides`` replace leaves in ``base``; sub-dicts
    recurse. Lists always replace.
    """
    for key, value in overrides.items():
        if key in base and isinstance(base[key], dict) and isinstance(value, dict):
            _deep_update(base[key], value)
        else:
            base[key] = value


def _migrate_spec(raw: dict[str, Any], *, from_version: int) -> dict[str, Any]:
    """Upgrade a VMSpec YAML dict from ``from_version`` to the current.

    Currently a no-op (schema version is 1). Future breaking changes
    add their migration step here and bump :data:`CURRENT_SPEC_VERSION`.
    """
    del from_version  # unused while there is only one version
    return raw


__all__ = [
    "CURRENT_SPEC_VERSION",
    "DiskBus",
    "DiskCache",
    "DiskFormat",
    "Display",
    "ExtraDiskSpec",
    "Firmware",
    "NICModel",
    "NICSpec",
    "ProvisioningBackend",
    "ProvisioningSpec",
    "SourceSpec",
    "SourceType",
    "VMSpec",
]
