"""Tests for ``mgf.vm.core.creator.spec``.

Covers construction, defaults, YAML + JSON round-trip, nested sub-type
handling, strict unknown-key rejection, and the ``merged_with``
override semantics used by profile / template application.
"""

from __future__ import annotations

from dataclasses import fields
from pathlib import Path

import pytest
import yaml

from mgf.vm.core.creator.spec import (
    CURRENT_SPEC_VERSION,
    ExtraDiskSpec,
    NICSpec,
    ProvisioningSpec,
    SourceSpec,
    VMSpec,
)
from mgf.vm.core.exceptions import SpecValidationError

# ---------------------------------------------------------------------------
# Construction & defaults
# ---------------------------------------------------------------------------


class TestDefaults:
    def test_minimal_spec(self) -> None:
        spec = VMSpec(name="web")
        assert spec.name == "web"
        assert spec.version == CURRENT_SPEC_VERSION
        assert spec.os_variant == "generic"
        assert spec.vcpus == 2
        assert spec.ram_mb == 2048
        assert spec.disk_gb == 20
        assert spec.firmware == "bios"
        assert spec.display == "spice"
        assert spec.display_listen == "127.0.0.1"
        assert spec.source is None
        assert spec.description == ""

    def test_default_nic_list_has_one_virtio(self) -> None:
        spec = VMSpec(name="web")
        assert len(spec.nics) == 1
        assert spec.nics[0].network == "default"
        assert spec.nics[0].model == "virtio"

    def test_default_nics_are_independent_between_instances(self) -> None:
        """Regression guard: ``nics=[NICSpec()]`` default must not
        accidentally share state between VMSpec instances."""
        a = VMSpec(name="a")
        b = VMSpec(name="b")
        a.nics.append(NICSpec(network="lab"))
        assert len(b.nics) == 1
        assert b.nics[0].network == "default"

    def test_default_provisioning_is_none_backend(self) -> None:
        spec = VMSpec(name="web")
        assert spec.provisioning.backend == "none"
        assert spec.provisioning.hostname is None
        assert spec.provisioning.ssh_authorized_keys == []
        assert spec.provisioning.packages == []

    def test_default_extra_disks_empty(self) -> None:
        spec = VMSpec(name="web")
        assert spec.extra_disks == []


# ---------------------------------------------------------------------------
# Sub-type construction
# ---------------------------------------------------------------------------


def test_source_spec_cloud_image_with_url() -> None:
    source = SourceSpec(type="cloud_image", url="https://example/ubuntu.img")
    assert source.url == "https://example/ubuntu.img"
    assert source.path is None


def test_source_spec_iso_with_path() -> None:
    source = SourceSpec(type="iso", path=Path("/data/images/ubuntu.iso"))
    assert source.path == Path("/data/images/ubuntu.iso")
    assert source.url is None


def test_extra_disk_defaults() -> None:
    disk = ExtraDiskSpec()
    assert disk.size_gb == 20
    assert disk.format == "qcow2"
    assert disk.bus == "virtio"
    assert disk.cache == "none"
    assert disk.pool is None


def test_provisioning_spec_defaults() -> None:
    prov = ProvisioningSpec()
    assert prov.backend == "none"
    assert prov.username is None
    assert prov.password is None


# ---------------------------------------------------------------------------
# to_dict / to_yaml / to_json
# ---------------------------------------------------------------------------


class TestSerialize:
    def test_to_dict_contains_every_field(self) -> None:
        spec = VMSpec(name="web")
        payload = spec.to_dict()
        for f in fields(spec):
            assert f.name in payload

    def test_to_dict_serialises_nested_dataclasses(self) -> None:
        spec = VMSpec(
            name="web",
            source=SourceSpec(type="iso", path=Path("/data/u.iso")),
            nics=[NICSpec(network="lab", mac="52:54:00:aa:bb:cc")],
            extra_disks=[ExtraDiskSpec(size_gb=50, bus="scsi")],
            provisioning=ProvisioningSpec(
                backend="cloud_init",
                hostname="web-01",
                username="admin",
                ssh_authorized_keys=["ssh-ed25519 AAA..."],
            ),
        )
        payload = spec.to_dict()
        assert payload["source"]["type"] == "iso"
        assert payload["source"]["path"] == "/data/u.iso"  # Path → str
        assert payload["nics"][0]["network"] == "lab"
        assert payload["extra_disks"][0]["bus"] == "scsi"
        assert payload["provisioning"]["backend"] == "cloud_init"
        assert payload["provisioning"]["ssh_authorized_keys"] == ["ssh-ed25519 AAA..."]

    def test_to_dict_serialises_isolation_enum_value(self) -> None:
        from mgf.vm.core.domain import IsolationLevel

        spec = VMSpec(name="web", isolation=IsolationLevel.STRICT)
        payload = spec.to_dict()
        # The StrEnum is coerced to its plain string value so safe_dump
        # accepts it.
        assert payload["isolation"] == "strict"

    def test_to_yaml_is_safe_loadable(self) -> None:
        spec = VMSpec(name="web", description="the webserver")
        text = spec.to_yaml()
        reloaded = yaml.safe_load(text)
        assert reloaded["name"] == "web"
        assert reloaded["description"] == "the webserver"
        assert reloaded["version"] == CURRENT_SPEC_VERSION

    def test_to_json_round_trip(self) -> None:
        import json

        spec = VMSpec(name="web", ram_mb=4096)
        payload = json.loads(spec.to_json())
        assert payload["name"] == "web"
        assert payload["ram_mb"] == 4096


# ---------------------------------------------------------------------------
# from_dict / from_yaml — the strict-parse path used by every loader
# ---------------------------------------------------------------------------


class TestFromDict:
    def test_minimal_dict(self) -> None:
        spec = VMSpec.from_dict({"name": "web"})
        assert spec == VMSpec(name="web")

    def test_unknown_key_raises_with_all_errors(self) -> None:
        with pytest.raises(SpecValidationError) as exc_info:
            VMSpec.from_dict({"name": "web", "typo1": 1, "typo2": 2})
        # Both typos surface in a single exception.
        assert len(exc_info.value.errors) == 2
        combined = " ".join(exc_info.value.errors)
        assert "typo1" in combined
        assert "typo2" in combined

    def test_missing_name_raises(self) -> None:
        with pytest.raises(SpecValidationError, match="missing required"):
            VMSpec.from_dict({"vcpus": 4})

    def test_nested_source_dict(self) -> None:
        spec = VMSpec.from_dict(
            {
                "name": "web",
                "source": {
                    "type": "iso",
                    "path": "/data/ubuntu.iso",
                },
            }
        )
        assert isinstance(spec.source, SourceSpec)
        assert spec.source.type == "iso"
        assert spec.source.path == Path("/data/ubuntu.iso")

    def test_nested_nic_list(self) -> None:
        spec = VMSpec.from_dict(
            {
                "name": "web",
                "nics": [
                    {"network": "lab", "model": "e1000"},
                    {"network": "default"},
                ],
            }
        )
        assert len(spec.nics) == 2
        assert spec.nics[0].network == "lab"
        assert spec.nics[0].model == "e1000"
        assert spec.nics[1].network == "default"
        assert spec.nics[1].model == "virtio"  # default applied

    def test_nested_extra_disks_list(self) -> None:
        spec = VMSpec.from_dict(
            {
                "name": "web",
                "extra_disks": [
                    {"size_gb": 10},
                    {"size_gb": 40, "format": "raw"},
                ],
            }
        )
        assert len(spec.extra_disks) == 2
        assert spec.extra_disks[0].size_gb == 10
        assert spec.extra_disks[1].format == "raw"

    def test_isolation_string_coerced_to_enum(self) -> None:
        from mgf.vm.core.domain import IsolationLevel

        spec = VMSpec.from_dict({"name": "web", "isolation": "airgap"})
        assert spec.isolation is IsolationLevel.AIRGAP

    def test_bad_isolation_string_raises(self) -> None:
        with pytest.raises(SpecValidationError, match="invalid isolation"):
            VMSpec.from_dict({"name": "web", "isolation": "nonsense"})

    def test_unknown_nested_key_raises(self) -> None:
        with pytest.raises(SpecValidationError, match="bogus"):
            VMSpec.from_dict(
                {
                    "name": "web",
                    "source": {"type": "iso", "path": "/x", "bogus": True},
                }
            )

    def test_type_mismatch_at_construction_accepted_for_validator(self) -> None:
        """Python dataclasses do not enforce type hints at runtime, so
        ``ram_mb="four gigs"`` constructs cleanly here — the validator
        is the single gate that rejects malformed values.
        """
        spec = VMSpec.from_dict({"name": "web", "ram_mb": "four gigs"})
        assert spec.ram_mb == "four gigs"  # type: ignore[comparison-overlap]


class TestFromYAML:
    def test_round_trip_minimal(self) -> None:
        original = VMSpec(name="web")
        reloaded = VMSpec.from_yaml(original.to_yaml())
        assert reloaded == original

    def test_round_trip_full(self) -> None:
        original = VMSpec(
            name="web-01",
            description="production web tier",
            os_variant="ubuntu24.04",
            vcpus=4,
            vcpus_max=8,
            cpu_model="Westmere",
            ram_mb=8192,
            ram_max_mb=16384,
            hugepages=True,
            balloon=False,
            disk_gb=100,
            disk_format="qcow2",
            disk_bus="virtio",
            disk_cache="writeback",
            storage_pool="fast-ssd",
            firmware="uefi",
            display="vnc",
            display_listen="0.0.0.0",
            display_port=5901,
            source=SourceSpec(
                type="cloud_image",
                url="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
            ),
            nics=[
                NICSpec(network="default"),
                NICSpec(network="lab", mac="52:54:00:11:22:33", model="e1000"),
            ],
            extra_disks=[
                ExtraDiskSpec(size_gb=200, format="qcow2", bus="virtio"),
            ],
            provisioning=ProvisioningSpec(
                backend="cloud_init",
                hostname="web-01",
                username="admin",
                ssh_authorized_keys=[
                    "ssh-ed25519 AAAAC3Nza test@host",
                ],
                packages=["nginx", "htop"],
                run_commands=["systemctl enable nginx"],
            ),
        )
        reloaded = VMSpec.from_yaml(original.to_yaml())
        assert reloaded == original

    def test_empty_yaml_raises(self) -> None:
        with pytest.raises(SpecValidationError, match="empty"):
            VMSpec.from_yaml("")

    def test_non_mapping_top_level_raises(self) -> None:
        with pytest.raises(SpecValidationError, match="mapping"):
            VMSpec.from_yaml("- 1\n- 2\n")

    def test_malformed_yaml_raises(self) -> None:
        with pytest.raises(SpecValidationError, match="invalid YAML"):
            VMSpec.from_yaml("{{{{{")


class TestFromYAMLFile:
    def test_reads_file(self, tmp_path: Path) -> None:
        spec_path = tmp_path / "web.yaml"
        spec_path.write_text("name: web\nram_mb: 4096\n")
        spec = VMSpec.from_yaml_file(spec_path)
        assert spec.name == "web"
        assert spec.ram_mb == 4096

    def test_missing_file_raises(self, tmp_path: Path) -> None:
        with pytest.raises(SpecValidationError, match="could not read"):
            VMSpec.from_yaml_file(tmp_path / "nope.yaml")


# ---------------------------------------------------------------------------
# merged_with — the profile + template override path
# ---------------------------------------------------------------------------


class TestMergedWith:
    def test_override_simple_field(self) -> None:
        base = VMSpec(name="base", ram_mb=2048)
        merged = base.merged_with({"ram_mb": 8192})
        assert merged.ram_mb == 8192
        # Other fields untouched.
        assert merged.name == "base"
        assert merged.vcpus == 2

    def test_override_nested_sub_dict(self) -> None:
        base = VMSpec(
            name="base",
            provisioning=ProvisioningSpec(backend="cloud_init", hostname="base", username="admin"),
        )
        merged = base.merged_with(
            {
                "provisioning": {
                    "hostname": "web-01",
                    "packages": ["nginx"],
                }
            }
        )
        # hostname replaced; other provisioning fields preserved.
        assert merged.provisioning.hostname == "web-01"
        assert merged.provisioning.username == "admin"
        assert merged.provisioning.backend == "cloud_init"
        assert merged.provisioning.packages == ["nginx"]

    def test_override_list_replaces_entirely(self) -> None:
        base = VMSpec(name="base", nics=[NICSpec(network="default")])
        merged = base.merged_with({"nics": [{"network": "lab"}, {"network": "mgmt"}]})
        assert len(merged.nics) == 2
        assert [n.network for n in merged.nics] == ["lab", "mgmt"]

    def test_unknown_override_raises(self) -> None:
        base = VMSpec(name="base")
        with pytest.raises(SpecValidationError, match="bogus"):
            base.merged_with({"bogus": "nope"})


# ---------------------------------------------------------------------------
# Version handling (future-proof)
# ---------------------------------------------------------------------------


def test_current_spec_version_is_int() -> None:
    assert isinstance(CURRENT_SPEC_VERSION, int)
    assert CURRENT_SPEC_VERSION >= 1


def test_version_field_round_trips() -> None:
    spec = VMSpec(name="web")
    reloaded = VMSpec.from_yaml(spec.to_yaml())
    assert reloaded.version == CURRENT_SPEC_VERSION


# ---------------------------------------------------------------------------
# Structural lock-in — every documented field must exist.
# If this test fails, the YAML schema has silently drifted from the doc.
# ---------------------------------------------------------------------------


EXPECTED_TOP_LEVEL_FIELDS: set[str] = {
    "name",
    "version",
    "description",
    "isolation",
    "os_variant",
    "source",
    "vcpus",
    "vcpus_max",
    "cpu_model",
    "cpu_pinning",
    "ram_mb",
    "ram_max_mb",
    "hugepages",
    "balloon",
    "disk_gb",
    "disk_format",
    "disk_bus",
    "disk_cache",
    "storage_pool",
    "extra_disks",
    "nics",
    "firmware",
    "display",
    "display_listen",
    "display_port",
    "provisioning",
}


def test_vmspec_field_set_matches_docs() -> None:
    actual = {f.name for f in fields(VMSpec)}
    missing = EXPECTED_TOP_LEVEL_FIELDS - actual
    extra = actual - EXPECTED_TOP_LEVEL_FIELDS
    assert not missing, f"VMSpec is missing documented fields: {sorted(missing)}"
    assert not extra, (
        f"VMSpec has fields not in the doc: {sorted(extra)}. "
        f"Update EXPECTED_TOP_LEVEL_FIELDS in lockstep."
    )
