"""Hypothesis property tests for :mod:`mgf.vm.core.creator.spec`.

Enforces two design rules:

1. **YAML parsers never leak raw exceptions.** Any malformed input
   must either parse cleanly into a :class:`VMSpec` or raise the
   typed :class:`SpecValidationError` — never ``KeyError``,
   ``TypeError``, ``ValueError``, or a raw ``yaml.YAMLError``.
2. **Round-trip is lossless for reachable specs.** ``spec.to_yaml()``
   followed by ``VMSpec.from_yaml(...)`` must produce an equal spec.
"""

from __future__ import annotations

from pathlib import Path

from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st

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

# Keep the budget small so the property suite does not dominate CI time.
SETTINGS = settings(
    max_examples=60,
    deadline=None,
    suppress_health_check=[HealthCheck.function_scoped_fixture],
)

# ASCII-printable names (YAML-safe). VM names have a strict regex
# the validator will enforce; for the spec loader round-trip we only
# need the text to survive YAML.
safe_name = st.text(
    alphabet=st.characters(min_codepoint=0x21, max_codepoint=0x7E),
    min_size=1,
    max_size=40,
)
safe_short = st.text(
    alphabet=st.characters(min_codepoint=0x21, max_codepoint=0x7E),
    min_size=0,
    max_size=20,
)

safe_description = st.text(
    alphabet=st.characters(exclude_categories=["Cs", "Cc"], exclude_characters="\x00"),
    min_size=0,
    max_size=80,
)


def _source_strategy() -> st.SearchStrategy[SourceSpec | None]:
    url_source = st.builds(
        SourceSpec,
        type=st.just("cloud_image"),
        url=st.text(
            alphabet=st.characters(min_codepoint=0x21, max_codepoint=0x7E),
            min_size=10,
            max_size=80,
        ).map(lambda s: f"https://example/{s}.img"),
        path=st.none(),
    )
    iso_source = st.builds(
        SourceSpec,
        type=st.just("iso"),
        url=st.none(),
        path=safe_name.map(lambda s: Path(f"/data/images/{s}.iso")),
    )
    return st.one_of(st.none(), url_source, iso_source)


def _provisioning_strategy() -> st.SearchStrategy[ProvisioningSpec]:
    return st.builds(
        ProvisioningSpec,
        backend=st.sampled_from(["none", "cloud_init", "preseed", "kickstart"]),
        hostname=st.one_of(st.none(), safe_name),
        username=st.one_of(st.none(), safe_short),
        password=st.one_of(st.none(), safe_short),
        ssh_authorized_keys=st.lists(safe_name, max_size=3),
        packages=st.lists(safe_short, max_size=5),
        run_commands=st.lists(safe_description, max_size=3),
    )


def _nic_strategy() -> st.SearchStrategy[NICSpec]:
    return st.builds(
        NICSpec,
        network=safe_name,
        model=st.sampled_from(["virtio", "e1000", "rtl8139"]),
        mac=st.one_of(st.none(), safe_name),
    )


def _extra_disk_strategy() -> st.SearchStrategy[ExtraDiskSpec]:
    return st.builds(
        ExtraDiskSpec,
        size_gb=st.integers(min_value=1, max_value=2000),
        format=st.sampled_from(["qcow2", "raw"]),
        bus=st.sampled_from(["virtio", "sata", "scsi", "ide"]),
        cache=st.sampled_from(["none", "writeback", "writethrough", "directsync", "unsafe"]),
        pool=st.one_of(st.none(), safe_name),
    )


def _vmspec_strategy() -> st.SearchStrategy[VMSpec]:
    return st.builds(
        VMSpec,
        name=safe_name,
        description=safe_description,
        os_variant=safe_short,
        source=_source_strategy(),
        vcpus=st.integers(min_value=1, max_value=64),
        vcpus_max=st.one_of(st.none(), st.integers(min_value=1, max_value=256)),
        cpu_model=safe_short,
        cpu_pinning=st.one_of(st.none(), safe_short),
        ram_mb=st.integers(min_value=128, max_value=64 * 1024),
        ram_max_mb=st.one_of(st.none(), st.integers(min_value=128, max_value=256 * 1024)),
        hugepages=st.booleans(),
        balloon=st.booleans(),
        disk_gb=st.integers(min_value=1, max_value=10_000),
        disk_format=st.sampled_from(["qcow2", "raw"]),
        disk_bus=st.sampled_from(["virtio", "sata", "scsi", "ide"]),
        disk_cache=st.sampled_from(["none", "writeback", "writethrough", "directsync", "unsafe"]),
        storage_pool=st.one_of(st.none(), safe_name),
        extra_disks=st.lists(_extra_disk_strategy(), max_size=4),
        nics=st.lists(_nic_strategy(), min_size=1, max_size=4),
        firmware=st.sampled_from(["bios", "uefi"]),
        display=st.sampled_from(["spice", "vnc", "none"]),
        display_listen=st.sampled_from(["127.0.0.1", "0.0.0.0", "::1", "192.168.122.1"]),
        display_port=st.one_of(st.none(), st.integers(min_value=5900, max_value=6000)),
        provisioning=_provisioning_strategy(),
    )


# ---------------------------------------------------------------------------
# Round-trip property
# ---------------------------------------------------------------------------


@SETTINGS
@given(_vmspec_strategy())
def test_yaml_round_trip_is_lossless(spec: VMSpec) -> None:
    text = spec.to_yaml()
    reloaded = VMSpec.from_yaml(text)
    assert reloaded == spec


@SETTINGS
@given(_vmspec_strategy())
def test_json_round_trip_is_lossless(spec: VMSpec) -> None:
    import json

    payload = json.loads(spec.to_json())
    reloaded = VMSpec.from_dict(payload)
    assert reloaded == spec


# ---------------------------------------------------------------------------
# Loader robustness: arbitrary text → SpecValidationError or success
# ---------------------------------------------------------------------------


yaml_garbage = st.text(
    alphabet=st.characters(exclude_categories=["Cs"], exclude_characters="\x00"),
    min_size=0,
    max_size=400,
)


@SETTINGS
@given(yaml_garbage)
def test_loader_only_raises_typed_errors_for_text_garbage(text: str) -> None:
    try:
        VMSpec.from_yaml(text)
    except SpecValidationError:
        pass
    except Exception as e:
        msg = f"loader leaked {type(e).__name__}({e!r}) on input: {text!r}"
        raise AssertionError(msg) from e


# Arbitrary structured mappings: scalars only, no deeply nested junk.
arbitrary_scalar = st.one_of(
    st.none(),
    st.booleans(),
    st.integers(min_value=-1_000, max_value=1_000),
    st.floats(allow_nan=False, allow_infinity=False, width=32),
    st.text(max_size=30),
)
arbitrary_mapping = st.dictionaries(
    keys=st.text(min_size=1, max_size=30),
    values=arbitrary_scalar,
    max_size=8,
)


@SETTINGS
@given(arbitrary_mapping)
def test_loader_only_raises_typed_errors_for_structured_garbage(
    payload: dict[str, object],
) -> None:
    try:
        VMSpec.from_dict(payload)
    except SpecValidationError:
        pass
    except Exception as e:
        msg = f"loader leaked {type(e).__name__}({e!r}) on structured: {payload!r}"
        raise AssertionError(msg) from e
