"""F3 unit tests — XML generate / read / compare (>=95% target).

Covers: spec→XML→parse roundtrip, byte-identity of generate, the
per-subsystem field matrix, the defused-parse path on hostile XML, and
a property-based idempotency fuzz on the semantic comparator.
"""

from __future__ import annotations

from xml.etree import ElementTree as ET

import pytest
from hypothesis import given
from hypothesis import strategies as st
from mgf.vm.core.contracts import VmSpec
from mgf.vm.core.domain import IsolationLevel
from mgf.vm.core.exceptions import SpecInvalid

from mgf.vm.libvirt._spec import DiskSpec, LibvirtVmSpec, NicSpec
from mgf.vm.libvirt._xml.compare import canonicalize, semantic_equal
from mgf.vm.libvirt._xml.generate import vmspec_to_xml
from mgf.vm.libvirt._xml.read import infer_is_running, xml_to_domaininfo

GIB = 1024 * 1024 * 1024


def _spec(**overrides: object) -> LibvirtVmSpec:
    base = LibvirtVmSpec(
        name="vm-test",
        vcpus=4,
        memory_bytes=4 * GIB,
        uuid="11111111-2222-3333-4444-555555555555",
        disks=(DiskSpec(size_bytes=20 * GIB),),
    )
    return base if not overrides else base.evolve(**overrides)


# --- generate: determinism / byte identity ----------------------------------


def test_generate_is_byte_identical_across_calls() -> None:
    spec = _spec()
    assert vmspec_to_xml(spec) == vmspec_to_xml(spec)


def test_generate_byte_identical_for_equal_specs() -> None:
    assert vmspec_to_xml(_spec()) == vmspec_to_xml(_spec())


def test_generate_is_wellformed_xml() -> None:
    # If ET can parse it back, it is well-formed.
    ET.fromstring(vmspec_to_xml(_spec()))


# --- generate: per-subsystem field matrix -----------------------------------


def test_identity_fields_present() -> None:
    spec = _spec(description="a box")
    root = ET.fromstring(vmspec_to_xml(spec))
    assert root.findtext("name") == "vm-test"
    assert root.findtext("uuid") == "11111111-2222-3333-4444-555555555555"
    assert root.findtext("description") == "a box"


def test_memory_uses_bytes_unit() -> None:
    root = ET.fromstring(vmspec_to_xml(_spec()))
    mem = root.find("memory")
    assert mem is not None
    assert mem.get("unit") == "bytes"
    assert mem.text == str(4 * GIB)


def test_memory_max_distinct_from_current() -> None:
    spec = _spec(memory_max_bytes=8 * GIB)
    root = ET.fromstring(vmspec_to_xml(spec))
    assert root.findtext("memory") == str(8 * GIB)
    assert root.findtext("currentMemory") == str(4 * GIB)


def test_vcpu_current_attr_when_max_set() -> None:
    spec = _spec(vcpus_max=8)
    root = ET.fromstring(vmspec_to_xml(spec))
    vcpu = root.find("vcpu")
    assert vcpu is not None
    assert vcpu.text == "8"
    assert vcpu.get("current") == "4"


def test_hugepages_backing() -> None:
    spec = _spec(hugepages=True)
    root = ET.fromstring(vmspec_to_xml(spec))
    assert root.find("memoryBacking/hugepages") is not None


def test_cpu_host_passthrough_default() -> None:
    root = ET.fromstring(vmspec_to_xml(_spec()))
    cpu = root.find("cpu")
    assert cpu is not None
    assert cpu.get("mode") == "host-passthrough"


def test_cpu_custom_model() -> None:
    spec = _spec(cpu_model="Nehalem")
    root = ET.fromstring(vmspec_to_xml(spec))
    cpu = root.find("cpu")
    assert cpu is not None
    assert cpu.get("mode") == "custom"
    assert cpu.findtext("model") == "Nehalem"


def test_cpu_pinning_emits_cputune() -> None:
    spec = _spec(cpu_pinning="0-3")
    root = ET.fromstring(vmspec_to_xml(spec))
    pin = root.find("cputune/vcpupin")
    assert pin is not None
    assert pin.get("cpuset") == "0-3"


def test_uefi_firmware_loader() -> None:
    spec = _spec(firmware="uefi", secure_boot=True)
    root = ET.fromstring(vmspec_to_xml(spec))
    loader = root.find("os/loader")
    assert loader is not None
    assert loader.get("type") == "pflash"
    assert loader.get("secure") == "yes"


def test_bios_firmware_has_no_loader() -> None:
    root = ET.fromstring(vmspec_to_xml(_spec(firmware="bios")))
    assert root.find("os/loader") is None


def test_disk_subsystem() -> None:
    spec = _spec(
        disks=(
            DiskSpec(
                size_bytes=10 * GIB,
                format="raw",
                bus="sata",
                cache="writeback",
                target="sda",
                source_path="/var/lib/libvirt/images/d.raw",
                readonly=True,
            ),
        )
    )
    root = ET.fromstring(vmspec_to_xml(spec))
    disk = root.find("devices/disk")
    assert disk is not None
    driver = disk.find("driver")
    assert driver is not None
    assert driver.get("type") == "raw"
    assert driver.get("cache") == "writeback"
    assert disk.find("source").get("file") == "/var/lib/libvirt/images/d.raw"  # type: ignore[union-attr]
    target = disk.find("target")
    assert target is not None
    assert target.get("dev") == "sda"
    assert target.get("bus") == "sata"
    assert disk.find("readonly") is not None


def test_nic_subsystem_with_mac() -> None:
    spec = _spec(nics=(NicSpec(network="br0", model="e1000", mac="52:54:00:ab:cd:ef"),))
    root = ET.fromstring(vmspec_to_xml(spec))
    iface = root.find("devices/interface")
    assert iface is not None
    assert iface.find("mac").get("address") == "52:54:00:ab:cd:ef"  # type: ignore[union-attr]
    assert iface.find("source").get("network") == "br0"  # type: ignore[union-attr]
    assert iface.find("model").get("type") == "e1000"  # type: ignore[union-attr]


def test_display_none_omits_graphics() -> None:
    root = ET.fromstring(vmspec_to_xml(_spec(display="none")))
    assert root.find("devices/graphics") is None


def test_display_with_explicit_port() -> None:
    spec = _spec(display="vnc", display_port=5905)
    root = ET.fromstring(vmspec_to_xml(spec))
    g = root.find("devices/graphics")
    assert g is not None
    assert g.get("type") == "vnc"
    assert g.get("port") == "5905"
    assert g.get("autoport") == "no"


def test_balloon_toggle() -> None:
    assert ET.fromstring(vmspec_to_xml(_spec())).find("devices/memballoon") is not None
    assert ET.fromstring(vmspec_to_xml(_spec(balloon=False))).find("devices/memballoon") is None


def test_isolation_field_round_trips_in_spec() -> None:
    # The isolation posture lives on the spec; this slice does not yet
    # project it into XML, but the field must survive on the bridge.
    spec = _spec(isolation=IsolationLevel.STRICT)
    assert spec.isolation is IsolationLevel.STRICT


# --- read: roundtrip + state inference ---------------------------------------


def test_spec_to_xml_to_domaininfo_roundtrip() -> None:
    spec = _spec()
    info = xml_to_domaininfo(vmspec_to_xml(spec))
    assert info.name == "vm-test"
    assert info.cpu == 4
    assert info.memory_bytes == 4 * GIB
    assert info.domain_id.value == spec.uuid
    assert info.is_running is False


def test_read_memory_unit_conversions() -> None:
    xml = '<domain><name>x</name><currentMemory unit="MiB">2048</currentMemory></domain>'
    info = xml_to_domaininfo(xml)
    assert info.memory_bytes == 2048 * 1024 * 1024


def test_read_falls_back_name_as_domain_id_when_no_uuid() -> None:
    info = xml_to_domaininfo("<domain><name>noid</name><vcpu>1</vcpu></domain>")
    assert info.domain_id.value == "noid"


def test_read_missing_name_raises_spec_invalid() -> None:
    with pytest.raises(SpecInvalid, match="missing required <name>"):
        xml_to_domaininfo("<domain><vcpu>1</vcpu></domain>")


def test_read_malformed_xml_raises_spec_invalid() -> None:
    with pytest.raises(SpecInvalid, match="malformed"):
        xml_to_domaininfo("<domain><name>oops")


@pytest.mark.parametrize(
    ("code", "running"),
    [(None, False), (1, True), (2, True), (3, False), (5, False)],
)
def test_state_inference(code: int | None, running: bool) -> None:
    assert infer_is_running(code) is running
    info = xml_to_domaininfo("<domain><name>s</name></domain>", state_code=code)
    assert info.is_running is running


# --- read: defused parse (hostile XML) ---------------------------------------


def test_defused_billion_laughs_is_rejected() -> None:
    # Classic XML-bomb. defusedxml must refuse to expand entities; the
    # seam surfaces it as a typed SpecInvalid rather than exhausting RAM.
    bomb = (
        '<?xml version="1.0"?>'
        "<!DOCTYPE lolz ["
        '<!ENTITY lol "lol">'
        '<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">'
        '<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">'
        "]>"
        "<domain><name>&lol3;</name></domain>"
    )
    with pytest.raises(SpecInvalid):
        xml_to_domaininfo(bomb)


def test_defused_external_entity_ovf_is_rejected() -> None:
    # An OVF-style doc with an external entity referencing a local file.
    # defusedxml blocks the external fetch → SpecInvalid.
    ovf = (
        '<?xml version="1.0"?>'
        '<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>'
        "<domain><name>&xxe;</name></domain>"
    )
    with pytest.raises(SpecInvalid):
        xml_to_domaininfo(ovf)


def test_benign_ovf_like_doc_parses() -> None:
    # A namespaced, comment-laden OVF-ish doc with no entities parses fine
    # and the comparator's localname-stripping handles the namespace.
    doc = (
        "<!-- exported by tool -->"
        '<domain xmlns:vm="urn:x"><name>imported</name>'
        "<vcpu>2</vcpu>"
        '<currentMemory unit="GiB">1</currentMemory></domain>'
    )
    info = xml_to_domaininfo(doc)
    assert info.name == "imported"
    assert info.cpu == 2
    assert info.memory_bytes == GIB


# --- compare: semantic equality ----------------------------------------------


def test_semantic_equal_ignores_child_order() -> None:
    a = "<domain><name>v</name><vcpu>2</vcpu></domain>"
    b = "<domain><vcpu>2</vcpu><name>v</name></domain>"
    assert semantic_equal(a, b)


def test_semantic_equal_ignores_attr_order() -> None:
    a = '<domain><disk a="1" b="2"/></domain>'
    b = '<domain><disk b="2" a="1"/></domain>'
    assert semantic_equal(a, b)


def test_semantic_equal_ignores_whitespace_and_comments() -> None:
    a = "<domain>\n  <name>v</name>\n  <!-- note -->\n</domain>"
    b = "<domain><name>v</name></domain>"
    assert semantic_equal(a, b)


def test_semantic_equal_strips_runtime_elements() -> None:
    spec_xml = "<domain><name>v</name><vcpu>2</vcpu></domain>"
    live_xml = (
        "<domain><name>v</name><vcpu>2</vcpu>"
        "<uuid>auto-assigned</uuid>"
        '<seclabel model="apparmor"/>'
        "<clock offset='utc'/></domain>"
    )
    assert semantic_equal(spec_xml, live_xml)


def test_semantic_not_equal_on_real_difference() -> None:
    a = "<domain><name>v</name><vcpu>2</vcpu></domain>"
    b = "<domain><name>v</name><vcpu>4</vcpu></domain>"
    assert not semantic_equal(a, b)


def test_semantic_not_equal_on_attr_value_difference() -> None:
    a = '<domain><memory unit="bytes">100</memory></domain>'
    b = '<domain><memory unit="KiB">100</memory></domain>'
    assert not semantic_equal(a, b)


def test_keep_runtime_elements_distinguishes_them() -> None:
    a = "<domain><name>v</name></domain>"
    b = "<domain><name>v</name><uuid>x</uuid></domain>"
    # With stripping ON they look equal; with it OFF the uuid matters.
    assert semantic_equal(a, b)
    assert not semantic_equal(a, b, strip_runtime_elements=False)


def test_canonicalize_is_idempotent() -> None:
    xml = "<domain><vcpu>2</vcpu><name>v</name></domain>"
    once = canonicalize(xml)
    assert canonicalize(once) == once


def test_generated_xml_is_self_equal_after_libvirt_style_reorder() -> None:
    # Simulate libvirt reordering: parse our XML, shuffle top-level kids,
    # re-serialise — semantic_equal must still hold.
    xml = vmspec_to_xml(_spec())
    root = ET.fromstring(xml)
    children = list(root)
    root[:] = list(reversed(children))
    reordered = ET.tostring(root, encoding="unicode")
    assert semantic_equal(xml, reordered)


# --- compare: property-based idempotency fuzz --------------------------------

_TAGS = st.sampled_from(["name", "vcpu", "memory", "disk", "interface", "model", "os"])
_ATTR_KEYS = st.sampled_from(["type", "unit", "dev", "bus", "mode", "network"])
_VALUES = st.text(
    alphabet="abcdefghijklmnopqrstuvwxyz0123456789-_",
    min_size=0,
    max_size=8,
)


@st.composite
def _element(draw: st.DrawFn, depth: int = 0) -> ET.Element:
    el = ET.Element(draw(_TAGS))
    for _ in range(draw(st.integers(0, 3))):
        el.set(draw(_ATTR_KEYS), draw(_VALUES))
    if depth < 2:
        for _ in range(draw(st.integers(0, 3))):
            el.append(draw(_element(depth + 1)))
    else:
        text = draw(_VALUES)
        if text:
            el.text = text
    return el


@st.composite
def _domain_xml(draw: st.DrawFn) -> str:
    root = ET.Element("domain")
    root.append(_named("name", draw(_VALUES) or "v"))
    for _ in range(draw(st.integers(0, 4))):
        root.append(draw(_element()))
    return ET.tostring(root, encoding="unicode")


def _named(tag: str, text: str) -> ET.Element:
    el = ET.Element(tag)
    el.text = text
    return el


def test_read_vcpu_current_attribute() -> None:
    xml = '<domain><name>v</name><vcpu current="2">8</vcpu></domain>'
    assert xml_to_domaininfo(xml).cpu == 2


def test_read_vcpu_nonnumeric_text_defaults_to_one() -> None:
    xml = "<domain><name>v</name><vcpu>not-a-number</vcpu></domain>"
    assert xml_to_domaininfo(xml).cpu == 1


def test_canonicalize_malformed_raises_valueerror() -> None:
    with pytest.raises(ValueError, match="malformed XML for comparison"):
        canonicalize("<domain><name>oops")


def test_spec_from_core_neutral_fields_map_directly() -> None:
    core = VmSpec(name="b", cpu=3, memory_bytes=GIB)
    spec = LibvirtVmSpec.from_core(core)
    assert spec.name == "b"
    assert spec.vcpus == 3
    assert spec.memory_bytes == GIB


@given(_domain_xml())
def test_fuzz_canonicalize_idempotent(xml: str) -> None:
    once = canonicalize(xml)
    assert canonicalize(once) == once


@given(_domain_xml())
def test_fuzz_reflexive_equality(xml: str) -> None:
    assert semantic_equal(xml, xml)


@given(_domain_xml())
def test_fuzz_child_reorder_preserves_equality(xml: str) -> None:
    root = ET.fromstring(xml)
    if len(root) <= 1:
        return
    root[:] = list(reversed(list(root)))
    shuffled = ET.tostring(root, encoding="unicode")
    assert semantic_equal(xml, shuffled)
