"""Tests for :mod:`mgf.vm.core.security.isolation.fixes` (MA-A4, Wave 1).

Per-executor unit tests for the pure XML-mutating fixes. Each fix's
contract is verified at three points: failing input → fixes,
already-satisfied input → no-op, and dry_run → no mutation.

The ``network.swap_to_quarantine`` fix IS ported (pure — see
:class:`TestNetworkSwapToQuarantine`). The full ``analyze_static``
apply-pipeline integration test is NOT ported — it needs
``analyze_static`` / ``synthesize_xml_from_spec``, which parse /
synthesise via defusedxml (DEFERRED to the adapter).
"""

from __future__ import annotations

import xml.etree.ElementTree as ET

import pytest

from mgf.vm.core.common.progress import RecordingReporter
from mgf.vm.core.security.isolation import all_fixes, get_fix
from mgf.vm.core.security.isolation.context import QUARANTINE_NETWORK_NAME


def _domain(*body: str) -> ET.ElementTree:
    """Build a domain XML tree wrapping ``<devices>{body}</devices>``."""
    devices_inner = "\n    ".join(body)
    text = (
        f"<domain type='kvm'>\n"
        f"  <name>t</name>\n"
        f"  <memory unit='MiB'>2048</memory>\n"
        f"  <vcpu>2</vcpu>\n"
        f"  <devices>\n"
        f"    {devices_inner}\n"
        f"  </devices>\n"
        f"</domain>\n"
    )
    return ET.ElementTree(ET.fromstring(text))


def _devices(tree: ET.ElementTree) -> ET.Element:
    devices = tree.getroot().find("./devices")
    assert devices is not None
    return devices


class TestRegistryCount:
    def test_21_fixes_registered(self) -> None:
        # Wave 1 ports every fix, including network.swap_to_quarantine
        # (pure once re-pointed at context.QUARANTINE_NETWORK_NAME).
        assert len(all_fixes()) == 21


class TestNetworkSwapToQuarantine:
    def test_swaps_default_to_quarantine(self) -> None:
        fix = get_fix("network.swap_to_quarantine")
        assert fix is not None
        tree = _domain(
            "<interface type='network'>"
            "<source network='default'/>"
            "<model type='virtio'/>"
            "</interface>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        source = tree.find("./devices/interface/source")
        assert source is not None
        assert source.get("network") == QUARANTINE_NETWORK_NAME

    def test_idempotent_on_already_quarantine(self) -> None:
        fix = get_fix("network.swap_to_quarantine")
        assert fix is not None
        tree = _domain(
            f"<interface type='network'><source network='{QUARANTINE_NETWORK_NAME}'/></interface>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)

    def test_dry_run_does_not_mutate(self) -> None:
        fix = get_fix("network.swap_to_quarantine")
        assert fix is not None
        tree = _domain(
            "<interface type='network'><source network='default'/></interface>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep, dry_run=True)
        source = tree.find("./devices/interface/source")
        assert source is not None
        assert source.get("network") == "default"  # unchanged

    def test_ignores_non_network_interfaces(self) -> None:
        # A type='direct' interface is left alone; strip.iface.macvtap
        # is the right tool for those.
        fix = get_fix("network.swap_to_quarantine")
        assert fix is not None
        tree = _domain(
            "<interface type='direct'><source dev='eth0'/></interface>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        # The macvtap interface still exists with its dev attr.
        iface = tree.find("./devices/interface")
        assert iface is not None
        assert iface.get("type") == "direct"

    def test_creates_missing_source_element(self) -> None:
        # A network interface with no <source> child gets one added,
        # pointing at the quarantine network.
        fix = get_fix("network.swap_to_quarantine")
        assert fix is not None
        tree = _domain("<interface type='network'/>")
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        source = tree.find("./devices/interface/source")
        assert source is not None
        assert source.get("network") == QUARANTINE_NETWORK_NAME

    def test_no_devices_element_is_noop(self) -> None:
        fix = get_fix("network.swap_to_quarantine")
        assert fix is not None
        tree = ET.ElementTree(ET.fromstring("<domain type='kvm'/>"))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("no <devices> element" in m[0] for m in rep.messages)


_STRIPPER_FIXTURES: list[tuple[str, str, str]] = [
    (
        "strip.filesystem",
        "<filesystem type='mount'><source dir='/share'/></filesystem>",
        "filesystem",
    ),
    (
        "strip.channel.guest_agent",
        "<channel type='unix'><target type='virtio' name='org.qemu.guest_agent.0'/></channel>",
        "channel",
    ),
    (
        "strip.channel.spice_clipboard",
        "<channel type='spicevmc'><target type='virtio' name='com.redhat.spice.0'/></channel>",
        "channel",
    ),
    ("strip.redirdev", "<redirdev bus='usb' type='spicevmc'/>", "redirdev"),
    ("strip.smartcard", "<smartcard mode='passthrough' type='spicevmc'/>", "smartcard"),
    ("strip.vsock", "<vsock model='virtio'><cid auto='yes'/></vsock>", "vsock"),
    ("strip.hostdev.pci", "<hostdev mode='subsystem' type='pci'><source/></hostdev>", "hostdev"),
    ("strip.hostdev.usb", "<hostdev mode='subsystem' type='usb'><source/></hostdev>", "hostdev"),
    ("strip.hostdev.mdev", "<hostdev mode='subsystem' type='mdev'><source/></hostdev>", "hostdev"),
    (
        "strip.iface.macvtap",
        "<interface type='direct'><source dev='eth0'/></interface>",
        "interface",
    ),
    (
        "strip.iface.passthrough",
        "<hostdev mode='subsystem' type='pci'><alias name='net0'/></hostdev>",
        "hostdev",
    ),
]


@pytest.mark.parametrize(("fix_name", "body", "_tag"), _STRIPPER_FIXTURES)
class TestStrippers:
    def test_strips_offending_element(
        self,
        fix_name: str,
        body: str,
        _tag: str,
    ) -> None:
        fix = get_fix(fix_name)
        assert fix is not None
        tree = _domain(body)
        rep = RecordingReporter()
        before = len(_devices(tree).findall("*"))
        fix(target=tree, reporter=rep)
        after = len(_devices(tree).findall("*"))
        assert after < before, f"{fix_name} did not remove anything"

    def test_idempotent_on_clean_input(
        self,
        fix_name: str,
        body: str,
        _tag: str,
    ) -> None:
        del body
        fix = get_fix(fix_name)
        assert fix is not None
        tree = _domain()  # no offending body
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages), rep.messages

    def test_dry_run_does_not_mutate(
        self,
        fix_name: str,
        body: str,
        _tag: str,
    ) -> None:
        fix = get_fix(fix_name)
        assert fix is not None
        tree = _domain(body)
        before = ET.tostring(tree.getroot(), encoding="unicode")
        rep = RecordingReporter()
        fix(target=tree, reporter=rep, dry_run=True)
        after = ET.tostring(tree.getroot(), encoding="unicode")
        assert before == after
        assert any("(dry-run)" in m[0] for m in rep.messages)


def _disk(*, cache: str = "none", discard: str = "ignore") -> str:
    return (
        f"<disk type='file' device='disk'>"
        f"<driver name='qemu' type='qcow2' cache='{cache}' discard='{discard}'/>"
        f"<source file='/x.qcow2'/>"
        f"<target dev='vda' bus='virtio'/>"
        f"</disk>"
    )


class TestDiskAttrFixes:
    def test_set_cache_none_rewrites(self) -> None:
        fix = get_fix("disk.set_cache_none")
        assert fix is not None
        tree = _domain(_disk(cache="writeback"))
        fix(target=tree, reporter=RecordingReporter())
        driver = tree.find("./devices/disk/driver")
        assert driver is not None
        assert driver.get("cache") == "none"

    def test_set_cache_none_idempotent(self) -> None:
        fix = get_fix("disk.set_cache_none")
        assert fix is not None
        tree = _domain(_disk(cache="none"))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)

    def test_set_cache_none_adds_driver_when_missing(self) -> None:
        fix = get_fix("disk.set_cache_none")
        assert fix is not None
        tree = _domain(
            "<disk type='file' device='disk'>"
            "<source file='/x.qcow2'/>"
            "<target dev='vda' bus='virtio'/>"
            "</disk>",
        )
        fix(target=tree, reporter=RecordingReporter())
        driver = tree.find("./devices/disk/driver")
        assert driver is not None
        assert driver.get("cache") == "none"

    def test_set_discard_ignore_rewrites(self) -> None:
        fix = get_fix("disk.set_discard_ignore")
        assert fix is not None
        tree = _domain(_disk(discard="unmap"))
        fix(target=tree, reporter=RecordingReporter())
        driver = tree.find("./devices/disk/driver")
        assert driver is not None
        assert driver.get("discard") == "ignore"

    def test_dry_run_does_not_mutate(self) -> None:
        fix = get_fix("disk.set_cache_none")
        assert fix is not None
        tree = _domain(_disk(cache="writeback"))
        before = ET.tostring(tree.getroot(), encoding="unicode")
        fix(target=tree, reporter=RecordingReporter(), dry_run=True)
        assert ET.tostring(tree.getroot(), encoding="unicode") == before


class TestGraphicsFixes:
    def test_localhost_listen_attr_form(self) -> None:
        fix = get_fix("graphics.localhost_listen")
        assert fix is not None
        tree = _domain("<graphics type='spice' listen='0.0.0.0'/>")
        fix(target=tree, reporter=RecordingReporter())
        graphics = tree.find("./devices/graphics")
        assert graphics is not None
        assert graphics.get("listen") == "127.0.0.1"

    def test_localhost_listen_child_form(self) -> None:
        fix = get_fix("graphics.localhost_listen")
        assert fix is not None
        tree = _domain(
            "<graphics type='spice'><listen type='address' address='192.168.1.1'/></graphics>",
        )
        fix(target=tree, reporter=RecordingReporter())
        graphics = tree.find("./devices/graphics")
        assert graphics is not None
        assert graphics.get("listen") == "127.0.0.1"
        assert graphics.find("listen") is None

    def test_localhost_listen_idempotent(self) -> None:
        fix = get_fix("graphics.localhost_listen")
        assert fix is not None
        tree = _domain("<graphics type='spice' listen='127.0.0.1'/>")
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)

    def test_set_password_generates_passwd(self) -> None:
        fix = get_fix("graphics.set_password")
        assert fix is not None
        tree = _domain("<graphics type='spice' listen='127.0.0.1'/>")
        fix(target=tree, reporter=RecordingReporter())
        graphics = tree.find("./devices/graphics")
        assert graphics is not None
        passwd = graphics.get("passwd")
        assert passwd is not None
        assert len(passwd) >= 20

    def test_set_password_idempotent_on_existing(self) -> None:
        fix = get_fix("graphics.set_password")
        assert fix is not None
        tree = _domain(
            "<graphics type='spice' listen='127.0.0.1' passwd='preset123'/>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        graphics = tree.find("./devices/graphics")
        assert graphics is not None
        assert graphics.get("passwd") == "preset123"
        assert any("already satisfied" in m[0] for m in rep.messages)


class TestMemoryFixes:
    def test_add_balloon_when_missing(self) -> None:
        fix = get_fix("memory.add_balloon_none")
        assert fix is not None
        tree = _domain()  # no balloon element
        fix(target=tree, reporter=RecordingReporter())
        balloon = tree.find("./devices/memballoon")
        assert balloon is not None
        assert balloon.get("model") == "none"

    def test_rewrites_existing_balloon(self) -> None:
        fix = get_fix("memory.add_balloon_none")
        assert fix is not None
        tree = _domain("<memballoon model='virtio'/>")
        fix(target=tree, reporter=RecordingReporter())
        balloon = tree.find("./devices/memballoon")
        assert balloon is not None
        assert balloon.get("model") == "none"

    def test_idempotent_when_already_none(self) -> None:
        fix = get_fix("memory.add_balloon_none")
        assert fix is not None
        tree = _domain("<memballoon model='none'/>")
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)


class TestCpuFixes:
    def test_swap_to_host_model_rewrites(self) -> None:
        fix = get_fix("cpu.swap_to_host_model")
        assert fix is not None
        xml = "<domain><name>t</name><cpu mode='host-passthrough'/><devices/></domain>"
        tree = ET.ElementTree(ET.fromstring(xml))
        fix(target=tree, reporter=RecordingReporter())
        cpu = tree.find("cpu")
        assert cpu is not None
        assert cpu.get("mode") == "host-model"

    def test_swap_to_host_model_idempotent(self) -> None:
        fix = get_fix("cpu.swap_to_host_model")
        assert fix is not None
        xml = "<domain><name>t</name><cpu mode='host-model'/><devices/></domain>"
        tree = ET.ElementTree(ET.fromstring(xml))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)

    def test_generic_topology_strips(self) -> None:
        fix = get_fix("cpu.generic_topology")
        assert fix is not None
        xml = (
            "<domain><name>t</name>"
            "<cpu mode='host-model'>"
            "<topology sockets='1' cores='4' threads='2'/>"
            "</cpu>"
            "<devices/></domain>"
        )
        tree = ET.ElementTree(ET.fromstring(xml))
        fix(target=tree, reporter=RecordingReporter())
        cpu = tree.find("cpu")
        assert cpu is not None
        assert cpu.find("topology") is None


class TestDeviceFixes:
    def test_swap_vhost_to_qemu(self) -> None:
        fix = get_fix("device.swap_vhost_to_qemu")
        assert fix is not None
        tree = _domain(
            "<interface type='network'>"
            "<source network='vmanager-quarantine'/>"
            "<driver name='vhost'/>"
            "</interface>",
        )
        fix(target=tree, reporter=RecordingReporter())
        driver = tree.find("./devices/interface/driver")
        assert driver is not None
        assert driver.get("name") == "qemu"

    def test_swap_vhost_idempotent(self) -> None:
        fix = get_fix("device.swap_vhost_to_qemu")
        assert fix is not None
        tree = _domain(
            "<interface type='network'>"
            "<source network='vmanager-quarantine'/>"
            "<driver name='qemu'/>"
            "</interface>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)

    def test_strip_tpm_passthrough_only(self) -> None:
        fix = get_fix("device.strip_tpm_passthrough")
        assert fix is not None
        tree = _domain(
            "<tpm model='tpm-tis'><backend type='passthrough'/></tpm>",
            "<tpm model='tpm-crb'><backend type='emulator'/></tpm>",
        )
        fix(target=tree, reporter=RecordingReporter())
        tpms = tree.findall("./devices/tpm")
        assert len(tpms) == 1
        backend = tpms[0].find("backend")
        assert backend is not None
        assert backend.get("type") == "emulator"

    def test_strip_tpm_idempotent_when_emulated(self) -> None:
        fix = get_fix("device.strip_tpm_passthrough")
        assert fix is not None
        tree = _domain(
            "<tpm model='tpm-crb'><backend type='emulator'/></tpm>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)


class TestCpuFixNoElementAndDryRun:
    def test_swap_to_host_model_no_cpu_element(self) -> None:
        fix = get_fix("cpu.swap_to_host_model")
        assert fix is not None
        tree = ET.ElementTree(ET.fromstring("<domain><name>t</name><devices/></domain>"))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("no <cpu> element" in m[0] for m in rep.messages)

    def test_swap_to_host_model_dry_run(self) -> None:
        fix = get_fix("cpu.swap_to_host_model")
        assert fix is not None
        xml = "<domain><name>t</name><cpu mode='host-passthrough'/><devices/></domain>"
        tree = ET.ElementTree(ET.fromstring(xml))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep, dry_run=True)
        cpu = tree.find("cpu")
        assert cpu is not None
        assert cpu.get("mode") == "host-passthrough"  # unchanged
        assert any("(dry-run)" in m[0] for m in rep.messages)

    def test_generic_topology_no_cpu_element(self) -> None:
        fix = get_fix("cpu.generic_topology")
        assert fix is not None
        tree = ET.ElementTree(ET.fromstring("<domain><name>t</name><devices/></domain>"))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("no <cpu> element" in m[0] for m in rep.messages)

    def test_generic_topology_idempotent_when_absent(self) -> None:
        fix = get_fix("cpu.generic_topology")
        assert fix is not None
        xml = "<domain><name>t</name><cpu mode='host-model'/><devices/></domain>"
        tree = ET.ElementTree(ET.fromstring(xml))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("already satisfied" in m[0] for m in rep.messages)

    def test_generic_topology_dry_run(self) -> None:
        fix = get_fix("cpu.generic_topology")
        assert fix is not None
        xml = (
            "<domain><name>t</name>"
            "<cpu mode='host-model'><topology sockets='1' cores='2' threads='1'/></cpu>"
            "<devices/></domain>"
        )
        tree = ET.ElementTree(ET.fromstring(xml))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep, dry_run=True)
        cpu = tree.find("cpu")
        assert cpu is not None
        assert cpu.find("topology") is not None  # unchanged
        assert any("(dry-run)" in m[0] for m in rep.messages)


class TestDeviceFixNoDevicesAndDryRun:
    def test_swap_vhost_no_devices_element(self) -> None:
        fix = get_fix("device.swap_vhost_to_qemu")
        assert fix is not None
        tree = ET.ElementTree(ET.fromstring("<domain><name>t</name></domain>"))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("no <devices> element" in m[0] for m in rep.messages)

    def test_swap_vhost_dry_run(self) -> None:
        fix = get_fix("device.swap_vhost_to_qemu")
        assert fix is not None
        tree = _domain(
            "<interface type='network'>"
            "<source network='vmanager-quarantine'/>"
            "<driver name='vhost'/>"
            "</interface>",
        )
        rep = RecordingReporter()
        fix(target=tree, reporter=rep, dry_run=True)
        driver = tree.find("./devices/interface/driver")
        assert driver is not None
        assert driver.get("name") == "vhost"  # unchanged
        assert any("(dry-run)" in m[0] for m in rep.messages)

    def test_strip_tpm_no_devices_element(self) -> None:
        fix = get_fix("device.strip_tpm_passthrough")
        assert fix is not None
        tree = ET.ElementTree(ET.fromstring("<domain><name>t</name></domain>"))
        rep = RecordingReporter()
        fix(target=tree, reporter=rep)
        assert any("no <devices> element" in m[0] for m in rep.messages)

    def test_strip_tpm_dry_run(self) -> None:
        fix = get_fix("device.strip_tpm_passthrough")
        assert fix is not None
        tree = _domain("<tpm model='tpm-tis'><backend type='passthrough'/></tpm>")
        rep = RecordingReporter()
        fix(target=tree, reporter=rep, dry_run=True)
        assert len(tree.findall("./devices/tpm")) == 1  # unchanged
        assert any("(dry-run)" in m[0] for m in rep.messages)
