"""Export a VM to an OVA archive.

Pipeline:

1. Read the domain XML via ``provider.domain_xml(handle)``.
2. Parse the XML for vCPUs / memory / disks / NICs / description.
3. Convert each qcow2 disk to a streamOptimized vmdk via
   the injected ``convert_fn`` into a staging dir.
4. Render an OVF descriptor via
   :func:`mgf.vm.interchange.ovf.serialize_ovf`.
5. Pack the OVF + disks into a POSIX tar archive at the destination.

The exporter is tolerant of both provider-generated XML (from
``virt-install`` runs) and hand-built XML (from
:mod:`mgf.vm.interchange.importer`). Fields we can't extract fall
back to conservative defaults so an exported OVA always round-trips
back through ``import_ova`` without error.
"""

from __future__ import annotations

import contextlib
import tarfile
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol
from xml.etree import ElementTree as ET

from mgf.common.observability import operation_span
from mgf.vm.core.exceptions import ImportExportError, VManagerError

from mgf.vm.interchange._xml import parse_xml_string
from mgf.vm.interchange.ovf import (
    OVFNIC,
    OVFDescriptor,
    OVFDisk,
    serialize_ovf,
)

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

    from mgf.vm.core.common.progress import ProgressReporter
    from mgf.vm.core.domain import VMHandle


class _DomainReader(Protocol):
    """The minimal backend surface OVA *export* reads (D-G24).

    Structural by design — the legacy ``VMProvider`` and a thin adapter over the
    SDK client + libvirt raw-XML escape hatch both satisfy it, so the exporter
    no longer pins the adapter-bound ``mgf.vm.providers`` package.
    """

    def domain_xml(self, handle: VMHandle) -> str:
        """Return the VM's raw domain XML."""

    def domain_disk_paths(self, handle: VMHandle) -> list[Path]:
        """Return the host paths of the VM's disk images."""


@dataclass
class ExportResult:
    """Summary of a successful OVA export.

    * ``destination`` is the final OVA path on disk.
    * ``descriptor`` is the OVF we produced — useful for tests and
      for user-facing summaries.
    * ``exported_disks`` is the list of vmdk files packed into the
      archive.
    """

    destination: Path
    descriptor: OVFDescriptor
    exported_disks: list[Path] = field(default_factory=list)


def export_ova(
    provider: _DomainReader,
    handle: VMHandle,
    destination: Path,
    *,
    staging_dir: Path | None = None,
    reporter: ProgressReporter | None = None,
    convert_fn: Callable[..., None],
) -> ExportResult:
    """Pack the VM named by ``handle`` into an OVA at ``destination``.

    Args:
        provider: the injected :class:`VMProvider`.
        handle: VM to export.
        destination: final ``.ova`` path. The function refuses to
            overwrite an existing file (matching
            the app's backup flow).
        staging_dir: optional directory for intermediate vmdk files.
            Defaults to ``<destination>.staging``. Removed on success;
            left in place on failure so the user can inspect.
        reporter: optional progress reporter.
        convert_fn: REQUIRED disk-format converter (the interchange no
            longer bundles one — the caller injects e.g. a qemu-img wrapper).

    Raises:
        ImportExportError: on any problem reading the domain XML,
            accessing a disk file, converting a disk, or writing
            the output archive.
    """
    with operation_span(
        "ova.export",
        vm_name=handle.name,
        vm_uuid=handle.uuid,
        destination=str(destination),
    ):
        return _export_ova_impl(
            provider,
            handle,
            destination,
            staging_dir=staging_dir,
            reporter=reporter,
            convert_fn=convert_fn,
        )


def _export_ova_impl(
    provider: _DomainReader,
    handle: VMHandle,
    destination: Path,
    *,
    staging_dir: Path | None,
    reporter: ProgressReporter | None,
    convert_fn: Callable[..., None],
) -> ExportResult:
    """Body of :func:`export_ova` — kept separate so the public
    function is purely the OTel span wrapper."""
    convert = convert_fn

    if destination.exists():
        raise ImportExportError(
            f"destination {destination} already exists; refusing to overwrite",
        )
    destination.parent.mkdir(parents=True, exist_ok=True)

    staging = staging_dir or destination.with_suffix(destination.suffix + ".staging")
    staging.mkdir(parents=True, exist_ok=True)

    _announce(reporter, f"reading domain XML for {handle.name}")
    try:
        xml_text = provider.domain_xml(handle)
        disk_sources = provider.domain_disk_paths(handle)
    except VManagerError as e:
        raise ImportExportError(
            f"could not read source domain {handle.name!r}: {e}",
        ) from e

    if not disk_sources:
        raise ImportExportError(
            f"{handle.name!r} has no disks; nothing to export",
        )

    descriptor = _descriptor_from_domain_xml(xml_text, handle.name)

    # Convert each qcow2 disk to vmdk in the staging directory.
    vmdk_paths: list[Path] = []
    descriptor.disks = []
    for index, source_path in enumerate(disk_sources):
        if not source_path.exists():
            raise ImportExportError(
                f"disk {source_path} referenced by {handle.name!r} is missing",
            )
        disk_id = f"vmdisk{index + 1}"
        file_ref = f"file{index + 1}"
        vmdk_name = f"{descriptor.name}-{disk_id}.vmdk"
        vmdk_path = staging / vmdk_name
        _announce(
            reporter,
            f"converting {source_path.name} -> {vmdk_name}",
        )
        try:
            convert(
                source=source_path,
                destination=vmdk_path,
                destination_format="vmdk",
                reporter=reporter,
            )
        except VManagerError as e:
            raise ImportExportError(
                f"converting {source_path} failed: {e}",
            ) from e
        capacity = _file_size_bytes(vmdk_path)
        descriptor.disks.append(
            OVFDisk(
                disk_id=disk_id,
                file_ref=file_ref,
                file_href=vmdk_name,
                capacity_bytes=capacity,
            ),
        )
        vmdk_paths.append(vmdk_path)

    # Ensure every NIC ends up under a single logical network.
    if not descriptor.nics:
        descriptor.nics = [OVFNIC(name="eth0", model="virtio", connection="NAT")]
    if not descriptor.networks:
        descriptor.networks = [nic.connection for nic in descriptor.nics]

    ovf_text = serialize_ovf(descriptor)
    ovf_name = f"{descriptor.name}.ovf"
    ovf_path = staging / ovf_name
    ovf_path.write_text(ovf_text, encoding="utf-8")

    _announce(reporter, f"packing {destination}")
    try:
        with tarfile.open(destination, mode="w") as tar:
            tar.add(ovf_path, arcname=ovf_name)
            for vmdk_path in vmdk_paths:
                tar.add(vmdk_path, arcname=vmdk_path.name)
    except (tarfile.TarError, OSError) as e:
        raise ImportExportError(
            f"could not write {destination}: {e}",
        ) from e

    # Tidy up staging on success. On failure the staging dir is left
    # in place so the user can inspect it — matches the backup
    # module's "error path leaves evidence" convention.
    for vmdk_path in vmdk_paths:
        with contextlib.suppress(OSError):
            vmdk_path.unlink()
    with contextlib.suppress(OSError):
        ovf_path.unlink()
    with contextlib.suppress(OSError):
        staging.rmdir()

    _announce(reporter, f"exported {handle.name} -> {destination}")

    return ExportResult(
        destination=destination,
        descriptor=descriptor,
        exported_disks=vmdk_paths,
    )


# ---------------------------------------------------------------------------
# Domain XML -> OVFDescriptor
# ---------------------------------------------------------------------------


def _descriptor_from_domain_xml(xml_text: str, fallback_name: str) -> OVFDescriptor:
    """Parse libvirt domain XML into a partial :class:`OVFDescriptor`.

    Disks are NOT populated here — the caller fills them in after
    running the injected ``convert_fn`` so the descriptor always references
    the post-conversion vmdk files. NICs and CPU / memory / name /
    description come straight from the XML.
    """
    try:
        # untrusted=True for defence-in-depth + parity with the import
        # path (ovf.py): domain XML is normally trusted libvirt output,
        # but it can come from a restored backup or a user `xml --edit`,
        # so apply the DTD-ban + size-cap too. Legit libvirt domain XML
        # carries no DOCTYPE, so this never rejects valid input (F-VI-001).
        root = parse_xml_string(xml_text, untrusted=True)
    except ET.ParseError as e:
        raise ImportExportError(f"invalid domain XML: {e}") from e

    name_el = root.find("name")
    name = (name_el.text or "").strip() if name_el is not None else fallback_name
    if not name:
        name = fallback_name

    desc_el = root.find("description")
    description = (desc_el.text or "").strip() if desc_el is not None else ""

    vcpu_el = root.find("vcpu")
    vcpus = 1
    if vcpu_el is not None and vcpu_el.text:
        try:
            vcpus = int(vcpu_el.text.strip())
        except ValueError:
            vcpus = 1

    memory_mb = _memory_to_mb(root)

    nics: list[OVFNIC] = []
    for nic_el in root.findall("devices/interface"):
        model_el = nic_el.find("model")
        model = model_el.get("type", "virtio") if model_el is not None else "virtio"
        source_el = nic_el.find("source")
        connection = "NAT"
        if source_el is not None:
            connection = source_el.get("network") or source_el.get("bridge") or "NAT"
        nics.append(
            OVFNIC(
                name=f"eth{len(nics)}",
                model=model,
                connection=connection,
            ),
        )

    networks = sorted({nic.connection for nic in nics}) or ["NAT"]

    return OVFDescriptor(
        name=name,
        description=description,
        vcpus=vcpus,
        memory_mb=memory_mb,
        nics=nics,
        networks=networks,
    )


def _memory_to_mb(root: ET.Element) -> int:
    """Read ``<memory>`` and normalize to MiB.

    Libvirt memory elements carry a unit attribute (``KiB``, ``MiB``,
    ``GiB``, ``bytes``). The default when unit is missing is KiB
    per libvirt documentation.
    """
    mem_el = root.find("memory")
    if mem_el is None or not mem_el.text:
        return 1024
    try:
        value = int(mem_el.text.strip())
    except ValueError:
        return 1024
    unit = (mem_el.get("unit") or "KiB").lower()
    if unit in ("b", "bytes"):
        return max(1, value >> 20)
    if unit == "kib":
        return max(1, value // 1024)
    if unit == "mib":
        return max(1, value)
    if unit == "gib":
        return max(1, value * 1024)
    return value


def _file_size_bytes(path: Path) -> int:
    try:
        return path.stat().st_size
    except OSError:
        return 0


def _announce(reporter: ProgressReporter | None, message: str) -> None:
    if reporter is not None:
        reporter.report(message)


__all__ = ["ExportResult", "export_ova"]
