"""Tests for the canonical engine-neutral ``VmSpec`` + ``Vm`` aggregate (D-G12).

Covers the 10-field canonical shape (api.md §6.1), the engine-neutral
sub-types (``DiskSpec`` / ``NicSpec`` / reused ``DeviceSpec`` /
``ProvisioningSpec``), the ``Vm`` aggregate (api.md §6.2), the
``schema_version`` / :func:`migrate_vmspec` scaffold (D-G8), and the
``__post_init__`` validation / UTC invariants (DP-12).
"""

from __future__ import annotations

import dataclasses
from datetime import UTC, datetime, timedelta, timezone

import pytest

import mgf.vm.core as core
from mgf.vm.core.contracts import (
    CURRENT_VMSPEC_SCHEMA_VERSION,
    AttachedDevice,
    DeviceSpec,
    DiskSpec,
    DiskStatus,
    DomainInfo,
    NicSpec,
    NicStatus,
    Vm,
    VmSpec,
    VmState,
    VmStatus,
    migrate_vmspec,
)
from mgf.vm.core.domain import IsolationLevel
from mgf.vm.core.ids import DomainId, OperationId, VmId
from mgf.vm.core.provisioning.spec import ProvisioningSpec


def _utc() -> datetime:
    return datetime.now(UTC)


# ---------------------------------------------------------------------------
# Canonical VmSpec — the 10-field shape
# ---------------------------------------------------------------------------


def test_vmspec_has_exactly_the_ten_canonical_fields() -> None:
    names = [f.name for f in dataclasses.fields(VmSpec)]
    assert names == [
        "name",
        "cpu",
        "memory_bytes",
        "disks",
        "nics",
        "devices",
        "provisioning",
        "isolation",
        "metadata",
        "schema_version",
    ]


def test_minimal_construction_still_works() -> None:
    """The additive expansion must not break the pre-existing minimal shape."""
    spec = VmSpec(name="vm", cpu=2, memory_bytes=1 << 30)
    assert spec.disks == ()
    assert spec.nics == ()
    assert spec.devices == ()
    assert spec.provisioning is None
    assert spec.isolation is IsolationLevel.OFF
    assert dict(spec.metadata) == {}
    assert spec.schema_version == CURRENT_VMSPEC_SCHEMA_VERSION == 1


def test_full_construction_with_all_neutral_fields() -> None:
    prov = ProvisioningSpec(backend="cloud-init", hostname="h")
    spec = VmSpec(
        name="full",
        cpu=4,
        memory_bytes=2 << 30,
        disks=(DiskSpec(size_bytes=1 << 30, format="raw", readonly=True),),
        nics=(NicSpec(network="br0", model="e1000", mac="52:54:00:ab:cd:ef"),),
        devices=(DeviceSpec(kind="pci", selector={"vendor": "10de"}),),
        provisioning=prov,
        isolation=IsolationLevel.STRICT,
        metadata={"libvirt/firmware": "uefi"},
    )
    assert spec.disks[0].format == "raw"
    assert spec.nics[0].mac == "52:54:00:ab:cd:ef"
    assert spec.devices[0].kind == "pci"
    assert spec.provisioning is prov
    assert spec.isolation is IsolationLevel.STRICT
    assert spec.metadata["libvirt/firmware"] == "uefi"


def test_vmspec_is_frozen() -> None:
    spec = VmSpec(name="vm", cpu=1, memory_bytes=1024)
    with pytest.raises(dataclasses.FrozenInstanceError):
        spec.cpu = 2  # type: ignore[misc]


def test_default_metadata_is_immutable_and_shared() -> None:
    a = VmSpec(name="a", cpu=1, memory_bytes=1024)
    b = VmSpec(name="b", cpu=1, memory_bytes=1024)
    # Shared empty default, and not mutable.
    assert a.metadata is b.metadata
    with pytest.raises(TypeError):
        a.metadata["k"] = "v"  # type: ignore[index]


@pytest.mark.parametrize(
    ("kwargs", "match"),
    [
        ({"name": "", "cpu": 1, "memory_bytes": 1}, "name"),
        ({"name": "x", "cpu": 0, "memory_bytes": 1}, "cpu"),
        ({"name": "x", "cpu": -1, "memory_bytes": 1}, "cpu"),
        ({"name": "x", "cpu": 1, "memory_bytes": 0}, "memory_bytes"),
        ({"name": "x", "cpu": 1, "memory_bytes": -5}, "memory_bytes"),
    ],
)
def test_vmspec_validation_rejects_bad_values(kwargs: dict[str, object], match: str) -> None:
    with pytest.raises(ValueError, match=match):
        VmSpec(**kwargs)  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# Engine-neutral sub-types
# ---------------------------------------------------------------------------


def test_diskspec_defaults_and_neutrality() -> None:
    disk = DiskSpec(size_bytes=10)
    assert disk.format == "qcow2"
    assert disk.source is None
    assert disk.readonly is False
    # No engine-tuning knobs leaked in (D-G12).
    field_names = {f.name for f in dataclasses.fields(DiskSpec)}
    assert field_names == {"size_bytes", "format", "source", "readonly"}
    assert "bus" not in field_names
    assert "cache" not in field_names


def test_nicspec_defaults_and_neutrality() -> None:
    nic = NicSpec()
    assert nic.network == "default"
    assert nic.model == "virtio"
    assert nic.mac is None
    field_names = {f.name for f in dataclasses.fields(NicSpec)}
    assert field_names == {"network", "model", "mac"}


def test_subtypes_are_frozen() -> None:
    assert all(f.name for f in dataclasses.fields(DiskSpec))  # sanity it is a dataclass
    with pytest.raises(dataclasses.FrozenInstanceError):
        DiskSpec(size_bytes=1).size_bytes = 2  # type: ignore[misc]
    with pytest.raises(dataclasses.FrozenInstanceError):
        NicSpec().model = "x"  # type: ignore[misc]


def test_devicespec_is_the_reused_core_type() -> None:
    """``devices`` reuses the existing core DeviceSpec, not a new proto type."""
    assert DeviceSpec is core.DeviceSpec


# ---------------------------------------------------------------------------
# Vm aggregate (api.md §6.2)
# ---------------------------------------------------------------------------


def test_vm_aggregate_fields_and_defaults() -> None:
    now = _utc()
    spec = VmSpec(name="vm", cpu=1, memory_bytes=1024)
    vm = Vm(
        vm_id=VmId("vm-1"),
        spec=spec,
        status=VmStatus(state=VmState.DEFINED),
        created_at=now,
        last_transition_at=now,
    )
    assert vm.tenant_id == ""
    assert vm.host_id == ""
    assert vm.current_op_id is None
    assert vm.spec is spec
    assert vm.status.state is VmState.DEFINED


def test_vm_aggregate_full() -> None:
    now = _utc()
    vm = Vm(
        vm_id=VmId("vm-2"),
        spec=VmSpec(name="vm", cpu=1, memory_bytes=1024),
        status=VmStatus(state=VmState.RUNNING, error=None),
        created_at=now,
        last_transition_at=now,
        tenant_id="tenant-1",
        host_id="host-1",
        current_op_id=OperationId("op-1"),
    )
    assert vm.tenant_id == "tenant-1"
    assert vm.host_id == "host-1"
    assert vm.current_op_id == OperationId("op-1")


def test_vm_is_frozen() -> None:
    now = _utc()
    vm = Vm(
        vm_id=VmId("vm-3"),
        spec=VmSpec(name="vm", cpu=1, memory_bytes=1024),
        status=VmStatus(state=VmState.DEFINED),
        created_at=now,
        last_transition_at=now,
    )
    with pytest.raises(dataclasses.FrozenInstanceError):
        vm.host_id = "host-x"  # type: ignore[misc]


@pytest.mark.parametrize("field_name", ["created_at", "last_transition_at"])
def test_vm_rejects_naive_datetimes(field_name: str) -> None:
    now = _utc()
    naive = datetime(2026, 5, 30, 12, 0, 0)
    created_at = naive if field_name == "created_at" else now
    last_transition_at = naive if field_name == "last_transition_at" else now
    with pytest.raises(ValueError, match=field_name):
        Vm(
            vm_id=VmId("vm-4"),
            spec=VmSpec(name="vm", cpu=1, memory_bytes=1024),
            status=VmStatus(state=VmState.DEFINED),
            created_at=created_at,
            last_transition_at=last_transition_at,
        )


def test_vm_rejects_non_utc_offset() -> None:
    now = _utc()
    plus_two = now.astimezone(timezone(timedelta(hours=2)))
    with pytest.raises(ValueError, match="UTC"):
        Vm(
            vm_id=VmId("vm-5"),
            spec=VmSpec(name="vm", cpu=1, memory_bytes=1024),
            status=VmStatus(state=VmState.DEFINED),
            created_at=plus_two,
            last_transition_at=now,
        )


def test_vmstatus_is_frozen_with_optional_error() -> None:
    assert VmStatus(state=VmState.FAILED, error="boom").error == "boom"
    assert VmStatus(state=VmState.STOPPED).error is None
    with pytest.raises(dataclasses.FrozenInstanceError):
        VmStatus(state=VmState.DEFINED).state = VmState.RUNNING  # type: ignore[misc]


# ---------------------------------------------------------------------------
# Rich observed VmStatus + observed sub-types (contracts.md §2 / §6.3)
# ---------------------------------------------------------------------------


def test_vmstatus_has_the_rich_observed_field_set() -> None:
    names = [f.name for f in dataclasses.fields(VmStatus)]
    assert names == [
        "state",
        "vm_id",
        "host_id",
        "disks",
        "nics",
        "devices_attached",
        "last_transition_at",
        "error",
    ]


def test_vmstatus_minimal_construction_still_works() -> None:
    """ADDITIVE-WITH-DEFAULTS: the pre-existing minimal shape stays valid."""
    status = VmStatus(state=VmState.RUNNING)
    assert status.state is VmState.RUNNING
    assert status.vm_id is None
    assert status.host_id == ""
    assert status.disks == ()
    assert status.nics == ()
    assert status.devices_attached == ()
    assert status.last_transition_at is None
    assert status.error is None


def test_vmstatus_carries_the_rich_observed_shape() -> None:
    now = _utc()
    status = VmStatus(
        state=VmState.RUNNING,
        vm_id=VmId("vm-rich"),
        host_id="host-1",
        disks=(DiskStatus(target="vda", source="/p/d.qcow2", size_bytes=2048),),
        nics=(
            NicStatus(model="virtio", mac="52:54:00:aa:bb:cc", network="default", ip="10.0.0.5"),
        ),
        devices_attached=(AttachedDevice(kind="pci", selector={"vendor": "10de"}),),
        last_transition_at=now,
        error=None,
    )
    assert status.vm_id == VmId("vm-rich")
    assert status.host_id == "host-1"
    assert status.disks[0].target == "vda"
    assert status.nics[0].ip == "10.0.0.5"
    assert status.devices_attached[0].kind == "pci"
    assert status.last_transition_at == now


def test_vmstatus_rejects_naive_last_transition_at() -> None:
    naive = datetime(2026, 5, 30, 12, 0, 0)
    with pytest.raises(ValueError, match="last_transition_at"):
        VmStatus(state=VmState.RUNNING, last_transition_at=naive)


def test_vmstatus_rejects_non_utc_last_transition_at() -> None:
    plus_two = _utc().astimezone(timezone(timedelta(hours=2)))
    with pytest.raises(ValueError, match="UTC"):
        VmStatus(state=VmState.RUNNING, last_transition_at=plus_two)


def test_diskstatus_shape_defaults_and_frozen() -> None:
    minimal = DiskStatus(target="vda")
    assert minimal.source is None
    assert minimal.size_bytes == 0
    assert minimal.readonly is False
    assert minimal.allocation_bytes is None
    full = DiskStatus(
        target="sda",
        source="/pool/x.raw",
        size_bytes=4096,
        readonly=True,
        allocation_bytes=1024,
    )
    assert full.allocation_bytes == 1024
    with pytest.raises(dataclasses.FrozenInstanceError):
        minimal.target = "vdb"  # type: ignore[misc]


def test_nicstatus_shape_defaults_and_frozen() -> None:
    minimal = NicStatus()
    assert minimal.model == "virtio"
    assert minimal.mac is None
    assert minimal.network == ""
    assert minimal.ip is None
    full = NicStatus(model="e1000", mac="52:54:00:00:00:01", network="br0", ip="192.168.1.2")
    assert full.ip == "192.168.1.2"
    with pytest.raises(dataclasses.FrozenInstanceError):
        minimal.model = "x"  # type: ignore[misc]


def test_attacheddevice_shape_defaults_and_frozen() -> None:
    minimal = AttachedDevice(kind="usb")
    assert dict(minimal.selector) == {}
    assert minimal.lease is None
    assert minimal.address is None
    full = AttachedDevice(
        kind="pci",
        selector={"vendor": "10de", "device": "1eb1"},
        lease="lease-urn-1",
        address="0000:01:00.0",
    )
    assert full.lease == "lease-urn-1"
    assert full.address == "0000:01:00.0"
    with pytest.raises(dataclasses.FrozenInstanceError):
        minimal.kind = "gpu"  # type: ignore[misc]


def test_observed_subtypes_are_distinct_from_spec_types() -> None:
    """Observation-shaped types must not be the desired-spec types.

    The observed types carry engine-reported fields the spec types lack
    (``DiskStatus.target`` / ``allocation_bytes``, ``NicStatus.ip``,
    ``AttachedDevice.lease`` / ``address``).
    """
    disk_status_fields = {f.name for f in dataclasses.fields(DiskStatus)}
    assert {"target", "allocation_bytes"} <= disk_status_fields
    assert "format" not in disk_status_fields  # a DiskSpec-only field

    nic_status_fields = {f.name for f in dataclasses.fields(NicStatus)}
    assert "ip" in nic_status_fields  # observed-only; never on NicSpec

    attached_fields = {f.name for f in dataclasses.fields(AttachedDevice)}
    assert {"lease", "address"} <= attached_fields
    assert "mode" not in attached_fields  # a DeviceSpec-only field


def test_domaininfo_carries_observed_lists_additively() -> None:
    minimal = DomainInfo(
        domain_id=DomainId("d-1"),
        name="vm1",
        cpu=1,
        memory_bytes=1024,
        is_running=True,
    )
    assert minimal.disks == ()
    assert minimal.nics == ()
    assert minimal.devices_attached == ()
    rich = DomainInfo(
        domain_id=DomainId("d-2"),
        name="vm2",
        cpu=2,
        memory_bytes=2048,
        is_running=True,
        disks=(DiskStatus(target="vda", size_bytes=2048),),
        nics=(NicStatus(mac="52:54:00:00:00:02", ip="10.0.0.6"),),
        devices_attached=(AttachedDevice(kind="gpu", selector={"vendor": "10de"}),),
    )
    assert rich.disks[0].target == "vda"
    assert rich.nics[0].ip == "10.0.0.6"
    assert rich.devices_attached[0].kind == "gpu"


# ---------------------------------------------------------------------------
# schema_version + migrate scaffold (D-G8)
# ---------------------------------------------------------------------------


def test_current_schema_version_is_one() -> None:
    assert CURRENT_VMSPEC_SCHEMA_VERSION == 1


def test_migrate_v1_is_identity_passthrough() -> None:
    raw = {"name": "vm", "cpu": 2, "memory_bytes": 1024, "schema_version": 1}
    migrated = migrate_vmspec(raw, from_version=1)
    assert migrated == raw
    # Does not mutate the input mapping.
    assert migrated is not raw


def test_migrate_stamps_current_schema_version() -> None:
    raw = {"name": "vm", "cpu": 1, "memory_bytes": 1}
    migrated = migrate_vmspec(raw, from_version=1)
    assert migrated["schema_version"] == CURRENT_VMSPEC_SCHEMA_VERSION


def test_migrate_rejects_future_version() -> None:
    with pytest.raises(ValueError, match="upgrade required"):
        migrate_vmspec({"name": "vm"}, from_version=CURRENT_VMSPEC_SCHEMA_VERSION + 1)


def test_migrated_mapping_reconstructs_a_valid_vmspec() -> None:
    raw = {"name": "vm", "cpu": 2, "memory_bytes": 2048, "schema_version": 1}
    migrated = migrate_vmspec(raw, from_version=1)
    spec = VmSpec(
        name=str(migrated["name"]),
        cpu=int(migrated["cpu"]),
        memory_bytes=int(migrated["memory_bytes"]),
        schema_version=int(migrated["schema_version"]),
    )
    assert spec.name == "vm"
    assert spec.schema_version == 1


# ---------------------------------------------------------------------------
# Public re-exports
# ---------------------------------------------------------------------------


def test_new_symbols_are_re_exported_from_package() -> None:
    for name in (
        "DiskSpec",
        "NicSpec",
        "Vm",
        "VmStatus",
        "DiskStatus",
        "NicStatus",
        "AttachedDevice",
        "ProvisioningSpec",
        "CURRENT_VMSPEC_SCHEMA_VERSION",
        "migrate_vmspec",
    ):
        assert name in core.__all__, f"{name} missing from package __all__"
        assert hasattr(core, name)
