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

from __future__ import annotations

import tarfile
from typing import TYPE_CHECKING

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

from mgf.vm.interchange.exporter import export_ova
from mgf.vm.interchange.ovf import parse_ovf

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

_SAMPLE_DOMAIN_XML = """\
<domain type='kvm'>
  <name>exported</name>
  <description>sample export fixture</description>
  <memory unit='KiB'>2097152</memory>
  <currentMemory unit='KiB'>2097152</currentMemory>
  <vcpu placement='static'>2</vcpu>
  <os>
    <type arch='x86_64' machine='q35'>hvm</type>
    <boot dev='hd'/>
  </os>
  <devices>
    <disk type='file' device='disk'>
      <driver name='qemu' type='qcow2'/>
      <source file='/var/lib/libvirt/images/exported.qcow2'/>
      <target dev='vda' bus='virtio'/>
    </disk>
    <interface type='network'>
      <source network='default'/>
      <model type='virtio'/>
    </interface>
  </devices>
</domain>
"""


def _fake_convert_factory() -> tuple[
    Callable[..., None],
    list[tuple[Path, Path, str]],
]:
    calls: list[tuple[Path, Path, str]] = []

    def _fake(
        *,
        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 vmdk bytes")

    return _fake, calls


def _seeded_provider_with_disk(tmp_path: Path) -> tuple[MockProvider, Path]:
    """Return a provider with one domain whose disk lives on disk."""
    disk_path = tmp_path / "exported.qcow2"
    disk_path.write_bytes(b"source qcow2 placeholder")
    provider = MockProvider()
    provider.seed_domain(
        name="exported",
        status=VMStatus.STOPPED,
        disks=[disk_path],
        xml=_SAMPLE_DOMAIN_XML,
    )
    return provider, disk_path


# ---------------------------------------------------------------------------
# export_ova happy path
# ---------------------------------------------------------------------------


class TestExportHappyPath:
    def test_writes_ova_at_destination(self, tmp_path: Path) -> None:
        provider, _disk = _seeded_provider_with_disk(tmp_path)
        handle = provider.get_domain("exported")
        destination = tmp_path / "exported.ova"
        convert_fn, _calls = _fake_convert_factory()

        result = export_ova(
            provider,
            handle,
            destination,
            convert_fn=convert_fn,
        )

        assert destination.exists()
        assert result.destination == destination
        # Archive contains both the OVF and the vmdk.
        with tarfile.open(destination, mode="r") as tar:
            names = tar.getnames()
        assert any(n.endswith(".ovf") for n in names)
        assert any(n.endswith(".vmdk") for n in names)

    def test_ovf_inside_archive_captures_cpu_and_memory(self, tmp_path: Path) -> None:
        provider, _disk = _seeded_provider_with_disk(tmp_path)
        handle = provider.get_domain("exported")
        destination = tmp_path / "exported.ova"
        convert_fn, _calls = _fake_convert_factory()

        export_ova(provider, handle, destination, convert_fn=convert_fn)

        with tarfile.open(destination, mode="r") as tar:
            ovf_name = next(n for n in tar.getnames() if n.endswith(".ovf"))
            member = tar.extractfile(ovf_name)
            assert member is not None
            ovf_text = member.read().decode("utf-8")

        parsed = parse_ovf(ovf_text)
        assert parsed.name == "exported"
        # Libvirt XML used KiB; exporter must convert to MiB.
        assert parsed.memory_mb == 2048
        assert parsed.vcpus == 2
        assert len(parsed.disks) == 1
        assert parsed.disks[0].file_href.endswith(".vmdk")

    def test_convert_is_called_once_per_disk(self, tmp_path: Path) -> None:
        provider, disk = _seeded_provider_with_disk(tmp_path)
        handle = provider.get_domain("exported")
        destination = tmp_path / "exported.ova"
        convert_fn, calls = _fake_convert_factory()

        export_ova(provider, handle, destination, convert_fn=convert_fn)

        assert len(calls) == 1
        source, _dest, fmt = calls[0]
        assert source == disk
        assert fmt == "vmdk"

    def test_staging_directory_is_cleaned_up_on_success(self, tmp_path: Path) -> None:
        provider, _disk = _seeded_provider_with_disk(tmp_path)
        handle = provider.get_domain("exported")
        destination = tmp_path / "exported.ova"
        convert_fn, _calls = _fake_convert_factory()

        export_ova(provider, handle, destination, convert_fn=convert_fn)

        # Default staging dir is <dest>.staging — must be gone.
        staging = destination.with_suffix(destination.suffix + ".staging")
        assert not staging.exists()


# ---------------------------------------------------------------------------
# Error paths
# ---------------------------------------------------------------------------


class TestExportErrors:
    def test_refuses_to_overwrite_existing_destination(self, tmp_path: Path) -> None:
        provider, _disk = _seeded_provider_with_disk(tmp_path)
        handle = provider.get_domain("exported")
        destination = tmp_path / "existing.ova"
        destination.write_bytes(b"not empty")
        convert_fn, _calls = _fake_convert_factory()

        with pytest.raises(ImportExportError, match="already exists"):
            export_ova(provider, handle, destination, convert_fn=convert_fn)

    def test_missing_source_disk_raises(self, tmp_path: Path) -> None:
        # Seed a provider whose disk path is a ghost on disk.
        provider = MockProvider()
        provider.seed_domain(
            name="phantom",
            status=VMStatus.STOPPED,
            disks=[tmp_path / "ghost.qcow2"],
            xml=_SAMPLE_DOMAIN_XML.replace("exported", "phantom"),
        )
        handle = provider.get_domain("phantom")
        convert_fn, _calls = _fake_convert_factory()

        with pytest.raises(ImportExportError, match="missing"):
            export_ova(
                provider,
                handle,
                tmp_path / "out.ova",
                convert_fn=convert_fn,
            )


class TestDomainXmlUntrustedHardening:
    """F-VI-001: the export path parses domain XML with untrusted=True
    (DTD-ban + size-cap), matching the import path — a DTD/entity bomb in
    domain XML (e.g. from a restored backup or `xml --edit`) is rejected."""

    def test_billion_laughs_domain_xml_rejected(self) -> None:
        from mgf.vm.interchange.exporter import _descriptor_from_domain_xml

        bomb = (
            '<?xml version="1.0"?>\n'
            "<!DOCTYPE domain [\n"
            '  <!ENTITY a "aaaaaaaaaa">\n'
            '  <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">\n'
            "]>\n"
            "<domain type='kvm'><name>&b;</name></domain>"
        )
        with pytest.raises(ImportExportError):
            _descriptor_from_domain_xml(bomb, fallback_name="vm")
