"""Tests for :mod:`mgf.vm.interchange.importer` — OVA -> libvirt domain."""

from __future__ import annotations

import tarfile
from io import BytesIO
from typing import TYPE_CHECKING

import pytest
from mgf.vm.core.exceptions import ImportExportError
from tests.support.mock_provider import MockProvider

from mgf.vm.interchange.importer import build_domain_xml, import_ova
from mgf.vm.interchange.ovf import (
    OVFNIC,
    OVFDescriptor,
    OVFDisk,
    serialize_ovf,
)

if TYPE_CHECKING:
    from collections.abc import Callable
    from pathlib import Path

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


def _build_ova(
    tmp_path: Path,
    *,
    ovf_name: str = "imported.ovf",
    disk_name: str = "imported-vmdisk1.vmdk",
    vm_name: str = "imported",
    disk_bytes: bytes = b"\x00" * 1024,
) -> Path:
    """Write a minimal valid OVA tarball to ``tmp_path`` and return its path."""
    descriptor = OVFDescriptor(
        name=vm_name,
        description="imported test",
        vcpus=2,
        memory_mb=2048,
        disks=[
            OVFDisk(
                disk_id="vmdisk1",
                file_ref="file1",
                file_href=disk_name,
                capacity_bytes=len(disk_bytes),
            ),
        ],
        nics=[OVFNIC(name="eth0", model="virtio", connection="NAT")],
        networks=["NAT"],
    )
    ovf_text = serialize_ovf(descriptor)

    ova_path = tmp_path / "input.ova"
    with tarfile.open(ova_path, mode="w") as tar:
        # OVF descriptor
        ovf_bytes = ovf_text.encode("utf-8")
        ovf_info = tarfile.TarInfo(name=ovf_name)
        ovf_info.size = len(ovf_bytes)
        tar.addfile(ovf_info, BytesIO(ovf_bytes))
        # Disk blob
        disk_info = tarfile.TarInfo(name=disk_name)
        disk_info.size = len(disk_bytes)
        tar.addfile(disk_info, BytesIO(disk_bytes))

    return ova_path


def _fake_convert_factory() -> tuple[
    Callable[..., None],
    list[tuple[Path, Path, str]],
]:
    """Return ``(convert_fn, calls_list)`` — the fn creates the dest file."""
    calls: list[tuple[Path, Path, str]] = []

    def _fake_convert(
        *,
        source: Path,
        destination: Path,
        destination_format: str,
        reporter: object | None = None,
    ) -> None:
        calls.append((source, destination, destination_format))
        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_bytes(b"fake qcow2 data")

    return _fake_convert, calls


# ---------------------------------------------------------------------------
# build_domain_xml unit coverage
# ---------------------------------------------------------------------------


class TestBuildDomainXml:
    def test_builds_xml_with_name_and_resources(self, tmp_path: Path) -> None:
        descriptor = OVFDescriptor(
            name="demo",
            description="hello",
            vcpus=4,
            memory_mb=4096,
        )
        disk = tmp_path / "demo.qcow2"
        xml = build_domain_xml(descriptor, [disk])
        assert "<name>demo</name>" in xml
        assert "<description>hello</description>" in xml
        assert "<vcpu placement='static'>4</vcpu>" in xml
        assert "<memory unit='MiB'>4096</memory>" in xml
        assert str(disk) in xml
        assert "target dev='vda'" in xml

    def test_every_disk_gets_a_sequential_target_dev(self, tmp_path: Path) -> None:
        descriptor = OVFDescriptor(name="multi", memory_mb=1024)
        disks = [tmp_path / f"d{i}.qcow2" for i in range(3)]
        xml = build_domain_xml(descriptor, disks)
        assert "dev='vda'" in xml
        assert "dev='vdb'" in xml
        assert "dev='vdc'" in xml

    def test_inserts_default_nic_when_descriptor_has_none(self, tmp_path: Path) -> None:
        descriptor = OVFDescriptor(name="nonic", memory_mb=1024, nics=[])
        xml = build_domain_xml(descriptor, [tmp_path / "d.qcow2"])
        assert "<interface type='network'>" in xml
        assert "<source network='default'/>" in xml

    def test_minimum_memory_floor_is_64_mib(self, tmp_path: Path) -> None:
        descriptor = OVFDescriptor(name="tiny", memory_mb=16)
        xml = build_domain_xml(descriptor, [tmp_path / "d.qcow2"])
        # Floor is 64 MiB so imported VMs always have enough RAM to boot.
        assert "<memory unit='MiB'>64</memory>" in xml


# ---------------------------------------------------------------------------
# import_ova end-to-end against MockProvider
# ---------------------------------------------------------------------------


class TestImportOva:
    def test_happy_path_defines_domain_with_converted_disk(self, tmp_path: Path) -> None:
        ova_path = _build_ova(tmp_path)
        target_dir = tmp_path / "converted"

        convert_fn, calls = _fake_convert_factory()
        provider = MockProvider()

        result = import_ova(
            ova_path,
            provider,
            target_dir=target_dir,
            convert_fn=convert_fn,
        )

        # Provider saw a define_domain call
        domains = provider.list_domains()
        assert any(h.name == "imported" for h in domains)

        # Exactly one disk was converted into the target_dir
        assert len(calls) == 1
        src, dest, fmt = calls[0]
        assert src.parent == target_dir
        assert dest.parent == target_dir
        assert dest.suffix == ".qcow2"
        assert fmt == "qcow2"

        # ImportResult reports both the handle and the converted path
        assert result.handle.name == "imported"
        assert len(result.converted_disks) == 1
        assert result.converted_disks[0] == dest

        # The raw extracted source was cleaned up (importer unlinks it)
        assert not src.exists()

    def test_name_override_is_applied_to_descriptor_and_domain(self, tmp_path: Path) -> None:
        ova_path = _build_ova(tmp_path)
        target_dir = tmp_path / "converted"
        convert_fn, _calls = _fake_convert_factory()
        provider = MockProvider()

        result = import_ova(
            ova_path,
            provider,
            target_dir=target_dir,
            name_override="web-prod-01",
            convert_fn=convert_fn,
        )

        assert result.descriptor.name == "web-prod-01"
        assert result.handle.name == "web-prod-01"

    def test_missing_ova_file_raises(self, tmp_path: Path) -> None:
        with pytest.raises(ImportExportError, match="not found"):
            import_ova(
                tmp_path / "ghost.ova",
                MockProvider(),
                target_dir=tmp_path / "converted",
                convert_fn=_fake_convert_factory()[0],
            )

    def test_ova_without_ovf_descriptor_raises(self, tmp_path: Path) -> None:
        ova_path = tmp_path / "empty.ova"
        with tarfile.open(ova_path, mode="w") as tar:
            blob = b"hello"
            info = tarfile.TarInfo(name="README.txt")
            info.size = len(blob)
            tar.addfile(info, BytesIO(blob))

        with pytest.raises(ImportExportError, match=r"does not contain a \.ovf"):
            import_ova(
                ova_path,
                MockProvider(),
                target_dir=tmp_path / "converted",
                convert_fn=_fake_convert_factory()[0],
            )

    def test_missing_disk_file_inside_ova_raises(self, tmp_path: Path) -> None:
        # OVF references disk1.vmdk but we only pack the OVF.
        descriptor = OVFDescriptor(
            name="broken",
            disks=[
                OVFDisk(
                    disk_id="vmdisk1",
                    file_ref="file1",
                    file_href="disk1.vmdk",
                    capacity_bytes=1024,
                ),
            ],
        )
        ovf_text = serialize_ovf(descriptor)
        ova_path = tmp_path / "broken.ova"
        with tarfile.open(ova_path, mode="w") as tar:
            ovf_bytes = ovf_text.encode("utf-8")
            info = tarfile.TarInfo(name="broken.ovf")
            info.size = len(ovf_bytes)
            tar.addfile(info, BytesIO(ovf_bytes))

        with pytest.raises(ImportExportError, match="missing disk file"):
            import_ova(
                ova_path,
                MockProvider(),
                target_dir=tmp_path / "converted",
                convert_fn=_fake_convert_factory()[0],
            )

    def test_define_failure_rolls_back_converted_disks(self, tmp_path: Path) -> None:
        ova_path = _build_ova(tmp_path)
        target_dir = tmp_path / "converted"
        convert_fn, _calls = _fake_convert_factory()

        provider = MockProvider()
        # Pre-seed the target name so define_domain raises
        # DomainAlreadyExistsError; importer must clean up the
        # converted disk before propagating.
        provider.seed_domain(name="imported")

        with pytest.raises(Exception):  # noqa: B017
            import_ova(
                ova_path,
                provider,
                target_dir=target_dir,
                convert_fn=convert_fn,
            )

        # No dangling qcow2 file should remain in target_dir after the
        # rollback path fires.
        leftover_qcow2 = list(target_dir.glob("*.qcow2"))
        assert leftover_qcow2 == []


# ---------------------------------------------------------------------------
# SH-01 — OVA path-traversal pin (docs/inprogress/SECURITY_HARDENING.md)
# ---------------------------------------------------------------------------


class TestImportOvaPathTraversal:
    """Pin the SH-01 contract: a malicious OVA whose OVF declares a
    ``file_href`` with ``..`` segments or an absolute path is rejected
    *before* any extraction touches the host filesystem.

    The historic bug: ``raw_path = target_dir / disk.file_href`` did not
    validate ``disk.file_href`` (attacker-controlled, comes from the OVF
    inside the tar). A crafted OVF with
    ``file_href="../../home/user/.ssh/authorized_keys"`` would write
    the attacker-chosen disk content there.
    """

    def test_parent_traversal_in_file_href_rejected(self, tmp_path: Path) -> None:
        ova_path = _build_ova(
            tmp_path,
            disk_name="../../escape.bin",
        )
        target_dir = tmp_path / "converted"
        convert_fn, _calls = _fake_convert_factory()

        with pytest.raises(ImportExportError, match="traversal path"):
            import_ova(
                ova_path,
                MockProvider(),
                target_dir=target_dir,
                convert_fn=convert_fn,
            )

        # And — critically — no file was written outside target_dir.
        # The escape destination would be ``tmp_path/escape.bin``; if the
        # vulnerability fired, this would be a 1024-byte file.
        escape_target = tmp_path / "escape.bin"
        assert not escape_target.exists(), (
            "SH-01 regressed: a traversal OVF wrote outside target_dir"
        )

    def test_absolute_file_href_rejected(self, tmp_path: Path) -> None:
        """``Path("/tmp/foo") / "/abs/file"`` absorbs the absolute and
        resolves to ``/abs/file`` — outside the target. Must be
        rejected at the boundary."""
        ova_path = _build_ova(
            tmp_path,
            disk_name="/tmp/sh01-absolute-escape.bin",
        )
        target_dir = tmp_path / "converted"
        convert_fn, _calls = _fake_convert_factory()

        with pytest.raises(ImportExportError, match="traversal path"):
            import_ova(
                ova_path,
                MockProvider(),
                target_dir=target_dir,
                convert_fn=convert_fn,
            )


# ---------------------------------------------------------------------------
# SH-04 — OVF billion-laughs pin (docs/inprogress/SECURITY_HARDENING.md)
# ---------------------------------------------------------------------------


class TestImportOvaMaliciousXml:
    """Pin SH-04: an OVA whose OVF text declares an entity-expansion
    bomb is rejected at the parse boundary — defusedxml's
    ``EntitiesForbidden`` raises before any expansion can OOM the
    process. Routed via ``mgf.vm.common.xml.parse_xml_string`` with
    ``untrusted=True`` (DTD ban + size cap on top of the
    entity-declaration ban).
    """

    def test_billion_laughs_ovf_rejected(self, tmp_path: Path) -> None:
        bomb_ovf = """<?xml version="1.0"?>
<!DOCTYPE Envelope [
  <!ENTITY lol "lol">
  <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
]>
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1">
  <VirtualSystem><Name>&lol1;</Name></VirtualSystem>
</Envelope>
"""
        ova_path = tmp_path / "bomb.ova"
        with tarfile.open(ova_path, mode="w") as tar:
            ovf_bytes = bomb_ovf.encode("utf-8")
            ovf_info = tarfile.TarInfo(name="bomb.ovf")
            ovf_info.size = len(ovf_bytes)
            tar.addfile(ovf_info, BytesIO(ovf_bytes))

        with pytest.raises(ImportExportError, match="invalid OVF XML"):
            import_ova(
                ova_path,
                MockProvider(),
                target_dir=tmp_path / "converted",
                convert_fn=_fake_convert_factory()[0],
            )
