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

Every rule gets at least one positive and one negative test case.
Live-state rules (name uniqueness, network existence) use the pure
``FakeProvider`` (from ``conftest``) that satisfies the validator's
structural ``_NameNetworkProvider`` Protocol.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from mgf.vm.core.creator.spec import (
    ExtraDiskSpec,
    NICSpec,
    ProvisioningSpec,
    SourceSpec,
    VMSpec,
)
from mgf.vm.core.creator.validator import (
    ALLOWED_DISK_BUSES,
    ALLOWED_DISK_CACHES,
    ALLOWED_DISK_FORMATS,
    MAC_REGEX,
    MIN_RAM_MB,
    VM_NAME_REGEX,
    validate,
)
from mgf.vm.core.domain import NetworkInfo
from mgf.vm.core.exceptions import SpecValidationError

if TYPE_CHECKING:
    from tests.conftest import FakeProvider

# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------


def _valid_spec(**overrides: object) -> VMSpec:
    """Return a minimal valid spec with optional field overrides."""
    base = VMSpec(
        name="web-01",
        ram_mb=2048,
        disk_gb=20,
        vcpus=2,
        source=SourceSpec(type="cloud_image", url="https://example/u.img"),
    )
    if overrides:
        return base.merged_with(dict(overrides))
    return base


class TestHappyPath:
    def test_minimal_valid_spec_without_provider(self) -> None:
        # No raise.
        validate(_valid_spec())

    def test_minimal_valid_spec_with_fresh_provider(
        self, fake_provider_cls: type[FakeProvider]
    ) -> None:
        provider = fake_provider_cls()
        validate(_valid_spec(), provider)

    def test_source_less_spec_is_valid(self) -> None:
        # Adopt / import paths produce source-less specs; the builder
        # rejects them later if creation is actually requested.
        spec = VMSpec(name="web", ram_mb=2048, disk_gb=20)
        validate(spec)

    def test_multiple_nics_ok(self) -> None:
        spec = _valid_spec(
            nics=[
                {"network": "default", "model": "virtio"},
                {"network": "default", "model": "e1000", "mac": "52:54:00:aa:bb:cc"},
            ]
        )
        validate(spec)


# ---------------------------------------------------------------------------
# Name rules
# ---------------------------------------------------------------------------


class TestNameRules:
    @pytest.mark.parametrize(
        "name",
        [
            "web",
            "web-01",
            "web_01",
            "web.01",
            "Web",
            "a",
            "1",
            "a" + "b" * 62,  # exactly 63 chars, the upper bound
        ],
    )
    def test_accepts_valid_name(self, name: str) -> None:
        validate(_valid_spec(name=name))

    @pytest.mark.parametrize(
        "name",
        [
            "",  # empty
            "-web",  # leading hyphen
            ".web",  # leading dot
            "web 01",  # space
            "web/01",  # slash
            "web@01",  # @
            "web$01",  # $
            "web;rm -rf /",  # command injection attempt
            "a" * 64,  # too long
        ],
    )
    def test_rejects_invalid_name(self, name: str) -> None:
        with pytest.raises(SpecValidationError) as exc_info:
            validate(_valid_spec(name=name))
        assert any("name" in e for e in exc_info.value.errors)

    def test_regex_matches_documented_shape(self) -> None:
        """Lock-in: the shared regex must match the documented shape."""
        assert VM_NAME_REGEX.pattern == r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$"


# ---------------------------------------------------------------------------
# Resource rules
# ---------------------------------------------------------------------------


class TestResourceRules:
    def test_vcpus_too_low(self) -> None:
        with pytest.raises(SpecValidationError, match="vcpus"):
            validate(_valid_spec(vcpus=0))

    def test_vcpus_max_below_vcpus(self) -> None:
        with pytest.raises(SpecValidationError, match=">= vcpus"):
            validate(_valid_spec(vcpus=4, vcpus_max=2))

    def test_ram_below_minimum(self) -> None:
        with pytest.raises(SpecValidationError, match="ram_mb"):
            validate(_valid_spec(ram_mb=64))

    def test_ram_max_below_ram(self) -> None:
        with pytest.raises(SpecValidationError, match=">= ram_mb"):
            validate(_valid_spec(ram_mb=4096, ram_max_mb=2048))

    def test_disk_gb_zero(self) -> None:
        with pytest.raises(SpecValidationError, match="disk_gb"):
            validate(_valid_spec(disk_gb=0))

    def test_extra_disk_too_small(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="cloud_image", url="https://e/u.img"),
            extra_disks=[ExtraDiskSpec(size_gb=0)],
        )
        with pytest.raises(SpecValidationError, match=r"extra_disks\[0\]\.size_gb"):
            validate(base)

    def test_extra_disk_bad_format(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="cloud_image", url="https://e/u.img"),
            extra_disks=[ExtraDiskSpec(size_gb=10, format="vmdk")],  # type: ignore[arg-type]
        )
        with pytest.raises(SpecValidationError, match="extra_disks"):
            validate(base)

    def test_extra_disk_bad_bus(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="cloud_image", url="https://e/u.img"),
            extra_disks=[ExtraDiskSpec(size_gb=10, bus="usb")],  # type: ignore[arg-type]
        )
        with pytest.raises(SpecValidationError, match=r"extra_disks\[0\]\.bus"):
            validate(base)

    def test_extra_disk_bad_cache(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="cloud_image", url="https://e/u.img"),
            extra_disks=[ExtraDiskSpec(size_gb=10, cache="turbo")],  # type: ignore[arg-type]
        )
        with pytest.raises(SpecValidationError, match=r"extra_disks\[0\]\.cache"):
            validate(base)

    def test_vcpus_max_invalid_value(self) -> None:
        with pytest.raises(SpecValidationError, match="vcpus_max"):
            validate(_valid_spec(vcpus_max=0))

    def test_ram_max_below_minimum(self) -> None:
        with pytest.raises(SpecValidationError, match="ram_max_mb"):
            validate(_valid_spec(ram_max_mb=64))

    def test_min_ram_constant(self) -> None:
        assert MIN_RAM_MB == 128


# ---------------------------------------------------------------------------
# Literal / enum fields
# ---------------------------------------------------------------------------


class TestLiteralFields:
    def test_bad_disk_format(self) -> None:
        with pytest.raises(SpecValidationError, match="disk_format"):
            validate(_valid_spec(disk_format="vmdk"))

    def test_bad_disk_bus(self) -> None:
        with pytest.raises(SpecValidationError, match="disk_bus"):
            validate(_valid_spec(disk_bus="usb"))

    def test_bad_disk_cache(self) -> None:
        with pytest.raises(SpecValidationError, match="disk_cache"):
            validate(_valid_spec(disk_cache="turbo"))

    def test_bad_firmware(self) -> None:
        with pytest.raises(SpecValidationError, match="firmware"):
            validate(_valid_spec(firmware="coreboot"))

    def test_bad_display(self) -> None:
        with pytest.raises(SpecValidationError, match="display"):
            validate(_valid_spec(display="x11"))

    def test_ide_with_uefi_rejected(self) -> None:
        with pytest.raises(SpecValidationError, match="ide"):
            validate(_valid_spec(firmware="uefi", disk_bus="ide"))

    def test_bad_display_port_low(self) -> None:
        with pytest.raises(SpecValidationError, match="display_port"):
            validate(_valid_spec(display_port=0))

    def test_bad_display_port_high(self) -> None:
        with pytest.raises(SpecValidationError, match="display_port"):
            validate(_valid_spec(display_port=70000))

    def test_good_display_port_accepted(self) -> None:
        validate(_valid_spec(display_port=5901))

    def test_bad_provisioning_backend(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="cloud_image", url="https://e/u.img"),
            provisioning=ProvisioningSpec(backend="ansible"),  # type: ignore[arg-type]
        )
        with pytest.raises(SpecValidationError, match=r"provisioning\.backend"):
            validate(base)


# ---------------------------------------------------------------------------
# Source rules
# ---------------------------------------------------------------------------


class TestSourceRules:
    def test_bad_source_type(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="docker", url="https://e/u.img"),  # type: ignore[arg-type]
        )
        with pytest.raises(SpecValidationError, match=r"source\.type"):
            validate(base)

    def test_both_url_and_path_rejected(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="iso", url="https://e/u.iso", path=Path("/x.iso")),
        )
        with pytest.raises(SpecValidationError, match="mutually exclusive"):
            validate(base)

    def test_neither_url_nor_path_rejected(self) -> None:
        base = VMSpec(name="web", source=SourceSpec(type="iso"))
        with pytest.raises(SpecValidationError, match="exactly one"):
            validate(base)

    def test_bad_url_scheme(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="cloud_image", url="ftp://e/u.img"),
        )
        with pytest.raises(SpecValidationError, match="scheme"):
            validate(base)

    def test_url_missing_host(self) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="cloud_image", url="http:///u.img"),
        )
        with pytest.raises(SpecValidationError, match="host"):
            validate(base)

    def test_missing_path_rejected_by_default(self, tmp_path: Path) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="iso", path=tmp_path / "nope.iso"),
        )
        with pytest.raises(SpecValidationError, match="does not exist"):
            validate(base)

    def test_existing_path_accepted(self, tmp_path: Path) -> None:
        iso_file = tmp_path / "u.iso"
        iso_file.write_bytes(b"placeholder")
        base = VMSpec(
            name="web",
            source=SourceSpec(type="iso", path=iso_file),
        )
        validate(base)

    def test_lenient_source_skips_path_existence(self, tmp_path: Path) -> None:
        base = VMSpec(
            name="web",
            source=SourceSpec(type="iso", path=tmp_path / "ghost.iso"),
        )
        # No raise in lenient mode — the OVA importer uses this.
        validate(base, lenient_source=True)


# ---------------------------------------------------------------------------
# NIC rules
# ---------------------------------------------------------------------------


class TestNICRules:
    def test_no_nics_rejected(self) -> None:
        with pytest.raises(SpecValidationError, match="at least one NIC"):
            validate(_valid_spec(nics=[]))

    def test_bad_nic_model(self) -> None:
        spec = _valid_spec(nics=[{"model": "xen"}])
        with pytest.raises(SpecValidationError, match=r"nics\[\d+\]\.model"):
            validate(spec)

    def test_bad_mac_format(self) -> None:
        spec = _valid_spec(nics=[{"mac": "52-54-00-aa-bb-cc"}])
        with pytest.raises(SpecValidationError, match=r"nics\[\d+\]\.mac"):
            validate(spec)

    def test_good_mac_format(self) -> None:
        spec = _valid_spec(nics=[{"mac": "52:54:00:AA:BB:CC"}])
        validate(spec)

    def test_mac_regex_matches_colon_separated_hex(self) -> None:
        assert MAC_REGEX.match("52:54:00:aa:bb:cc")
        assert not MAC_REGEX.match("52-54-00-aa-bb-cc")
        assert not MAC_REGEX.match("garbage")

    def test_nic_on_unknown_network_rejected(self, fake_provider_cls: type[FakeProvider]) -> None:
        provider = fake_provider_cls()
        # Default FakeProvider only has "default"; asking for "missing-net"
        # should produce a validation error.
        spec = _valid_spec(nics=[{"network": "missing-net"}])
        with pytest.raises(SpecValidationError, match="missing-net"):
            validate(spec, provider)

    def test_nic_on_known_seeded_network_accepted(
        self, fake_provider_cls: type[FakeProvider]
    ) -> None:
        provider = fake_provider_cls()
        provider.seed_network(NetworkInfo(name="lab-net", uuid="u1", active=True, autostart=False))
        spec = _valid_spec(nics=[{"network": "lab-net"}])
        validate(spec, provider)

    def test_network_lookup_failure_does_not_block_validation(
        self,
        fake_provider_cls: type[FakeProvider],
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """If list_networks explodes for some reason, the validator should
        still surface other errors rather than crashing."""
        provider = fake_provider_cls()

        def explode() -> list[NetworkInfo]:
            from mgf.vm.core.exceptions import ProviderError

            raise ProviderError("libvirt disconnected")

        monkeypatch.setattr(provider, "list_networks", explode)
        # NIC check is skipped; name-unique check still runs. No raise.
        validate(_valid_spec(), provider)


# ---------------------------------------------------------------------------
# Name uniqueness (live state)
# ---------------------------------------------------------------------------


class TestNameUniqueness:
    def test_unique_name_ok(self, fake_provider_cls: type[FakeProvider]) -> None:
        provider = fake_provider_cls()
        validate(_valid_spec(name="free-name"), provider)

    def test_duplicate_name_rejected(self, fake_provider_cls: type[FakeProvider]) -> None:
        provider = fake_provider_cls()
        provider.seed_domain("web-01")
        with pytest.raises(SpecValidationError, match="already exists"):
            validate(_valid_spec(name="web-01"), provider)

    def test_no_provider_skips_uniqueness_check(self) -> None:
        # With no provider we should not attempt any live-state lookup.
        validate(_valid_spec(name="anything"))

    def test_provider_error_during_uniqueness_surfaced(
        self,
        fake_provider_cls: type[FakeProvider],
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """A non-DomainNotFound ProviderError becomes a validation error
        rather than crashing the caller."""
        from mgf.vm.core.exceptions import ProviderError

        provider = fake_provider_cls()

        def explode(name: str) -> object:
            raise ProviderError("connection reset")

        monkeypatch.setattr(provider, "get_domain", explode)
        with pytest.raises(SpecValidationError, match="could not verify name uniqueness"):
            validate(_valid_spec(name="web-01"), provider)


# ---------------------------------------------------------------------------
# Multi-error collection — the promise that one bad spec surfaces every
# problem at once.
# ---------------------------------------------------------------------------


def test_every_error_collected_into_one_exception(
    fake_provider_cls: type[FakeProvider],
) -> None:
    provider = fake_provider_cls()
    provider.seed_domain("existing")

    bad = VMSpec(
        name="-bad name",  # bad name
        vcpus=0,  # bad vcpus
        ram_mb=64,  # bad ram
        disk_gb=0,  # bad disk
        disk_format="vmdk",  # type: ignore[arg-type]
        firmware="coreboot",  # type: ignore[arg-type]
        display="x11",  # type: ignore[arg-type]
        nics=[NICSpec(network="default", model="xen", mac="not-a-mac")],  # type: ignore[arg-type]
        source=SourceSpec(type="cloud_image"),  # missing url/path
    )
    with pytest.raises(SpecValidationError) as exc_info:
        validate(bad, provider)

    errors = exc_info.value.errors
    # At least one error per category — no order assumption.
    assert any("name" in e for e in errors)
    assert any("vcpus" in e for e in errors)
    assert any("ram_mb" in e for e in errors)
    assert any("disk_gb" in e for e in errors)
    assert any("disk_format" in e for e in errors)
    assert any("firmware" in e for e in errors)
    assert any("display" in e for e in errors)
    assert any("nics" in e and "model" in e for e in errors)
    assert any("nics" in e and "mac" in e for e in errors)
    assert any("source" in e for e in errors)


# ---------------------------------------------------------------------------
# Allowed-set / Literal lock-in
# ---------------------------------------------------------------------------


def test_allowed_sets_match_literal_aliases() -> None:
    from typing import get_args

    from mgf.vm.core.creator.spec import DiskBus, DiskCache, DiskFormat

    assert set(get_args(DiskFormat)) == ALLOWED_DISK_FORMATS
    assert set(get_args(DiskBus)) == ALLOWED_DISK_BUSES
    assert set(get_args(DiskCache)) == ALLOWED_DISK_CACHES
