"""Import a VM from an OVA archive.

Pipeline:

1. Open the OVA tarball.
2. Find the ``.ovf`` member and parse it via
   :mod:`mgf.vm.interchange.ovf`.
3. Extract each referenced disk into a staging directory.
4. Convert non-qcow2 disks via the injected ``convert_fn``.
5. Build a minimal libvirt domain XML pointing at the converted
   disks, with vCPUs / memory / NICs / OS type taken from the OVF.
6. Call ``provider.define_domain(xml)`` and return the resulting
   :class:`~mgf.vm.domain.VMHandle`.

The importer does NOT invoke ``virt-install`` — it talks straight
to the provider, because we already have the disk and just need to
register the VM with libvirt. That keeps this path independent of
the creator pipeline and testable with :class:`MockProvider` without
mocking ``virt-install``.

**Scope:** v1 handles one VirtualSystem per OVA (the common shape
from VirtualBox and VMware exports). Multi-VM OVAs raise
:class:`ImportExportError` — a follow-on slice can grow the pipeline
into a for-loop when the need is real.
"""

from __future__ import annotations

import contextlib
import tarfile
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol

from mgf.common.observability import operation_span
from mgf.vm.core.common.safe_tar import (
    TarMemberUnsafeError,
    reject_unsafe_tar_member,
    safe_destination,
)
from mgf.vm.core.exceptions import ImportExportError, VManagerError

from mgf.vm.interchange.ovf import OVFDescriptor, parse_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 _DomainDefiner(Protocol):
    """The minimal backend surface OVA *import* needs (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 importer
    no longer pins the adapter-bound ``mgf.vm.providers`` package.
    """

    def define_domain(self, xml: str) -> VMHandle:
        """Persistently define a domain from raw XML; return its handle."""


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


@dataclass
class ImportResult:
    """Summary of a successful OVA import.

    * ``handle`` is the freshly defined domain.
    * ``descriptor`` is the parsed OVF so the caller can surface
      metadata (description, disk count) without re-parsing.
    * ``converted_disks`` lists the on-disk qcow2 paths the domain
      now references — useful for CLI progress output and for tests.
    """

    handle: VMHandle
    descriptor: OVFDescriptor
    converted_disks: list[Path] = field(default_factory=list)


def import_ova(
    source: Path,
    provider: _DomainDefiner,
    *,
    target_dir: Path,
    name_override: str | None = None,
    reporter: ProgressReporter | None = None,
    convert_fn: Callable[..., None],
) -> ImportResult:
    """Import the OVA at ``source`` and define a new VM.

    Args:
        source: path to the ``.ova`` archive on disk.
        provider: :class:`VMProvider` to define the new domain on.
        target_dir: directory that will hold the converted qcow2
            disks. Created on demand; the OVA's original disk files
            are NOT kept.
        name_override: override the VM name declared in the OVF so
            users can avoid collisions when importing the same OVA
            twice.
        reporter: optional progress reporter; streams the extract +
            convert + define steps.
        convert_fn: REQUIRED disk-format converter (caller-injected, e.g. a
            qemu-img wrapper; production);
            tests pass a stub that records the call and creates a
            fake qcow2 file.

    Raises:
        ImportExportError: on any structural problem — bad tar,
            missing OVF, multi-VM OVA, disk file not found inside
            the archive, convert failure.
        VManagerError: on any underlying provider failure from
            :meth:`VMProvider.define_domain`.
    """
    with operation_span(
        "ova.import",
        source=str(source),
        target_dir=str(target_dir),
        name_override=name_override,
    ):
        return _import_ova_impl(
            source,
            provider,
            target_dir=target_dir,
            name_override=name_override,
            reporter=reporter,
            convert_fn=convert_fn,
        )


def _import_ova_impl(
    source: Path,
    provider: _DomainDefiner,
    *,
    target_dir: Path,
    name_override: str | None,
    reporter: ProgressReporter | None,
    convert_fn: Callable[..., None],
) -> ImportResult:
    """Body of :func:`import_ova` — kept separate so the public
    function is purely the OTel span wrapper. Behaviour is identical
    to the prior shape; the wrapper adds the trace span only."""
    convert = convert_fn

    if not source.exists():
        raise ImportExportError(f"OVA not found: {source}")

    target_dir.mkdir(parents=True, exist_ok=True)

    _announce(reporter, f"opening {source}")
    try:
        with tarfile.open(source, mode="r:*") as tar:
            ovf_member = _find_ovf_member(tar)
            if ovf_member is None:
                raise ImportExportError(
                    f"OVA {source} does not contain a .ovf descriptor",
                )
            _announce(reporter, f"parsing {ovf_member.name}")
            ovf_bytes = _read_member(tar, ovf_member)
            descriptor = parse_ovf(ovf_bytes.decode("utf-8"))
            if name_override:
                descriptor.name = name_override
            if not descriptor.disks:
                raise ImportExportError(
                    "OVF declares no disks; refusing to import",
                )

            converted_paths: list[Path] = []
            for disk in descriptor.disks:
                disk_member = _find_member(tar, disk.file_href)
                if disk_member is None:
                    raise ImportExportError(
                        f"OVA is missing disk file {disk.file_href!r} referenced by OVF",
                    )
                # SH-01: validate ``disk.file_href`` (attacker-controlled —
                # comes from the OVF inside the OVA) does NOT escape
                # ``target_dir`` via ``..`` segments or absolute paths.
                # Also reject non-regular tarinfo types (symlinks /
                # devices) — the in-process equivalent of Python 3.12's
                # tarfile ``filter="data"``, applied uniformly across
                # supported Python versions. See ``common/safe_tar.py``.
                try:
                    raw_path = safe_destination(target_dir, disk.file_href)
                    reject_unsafe_tar_member(disk_member, target_dir)
                except TarMemberUnsafeError as e:
                    raise ImportExportError(str(e)) from e
                _announce(reporter, f"extracting {disk.file_href}")
                _extract_member_to(tar, disk_member, raw_path)

                qcow_path = target_dir / f"{descriptor.name}-{disk.disk_id}.qcow2"
                if qcow_path.exists():
                    raise ImportExportError(
                        f"target qcow2 {qcow_path} already exists; "
                        f"pick a different --target-dir or remove the file",
                    )
                _announce(
                    reporter,
                    f"converting {disk.file_href} -> {qcow_path.name} (qcow2)",
                )
                try:
                    convert(
                        source=raw_path,
                        destination=qcow_path,
                        destination_format="qcow2",
                        reporter=reporter,
                    )
                except VManagerError as e:
                    raise ImportExportError(
                        f"converting {raw_path} failed: {e}",
                    ) from e
                converted_paths.append(qcow_path)
                # Remove the raw extracted source to keep target_dir
                # tidy — the caller asked us to import, not stage.
                # Best-effort cleanup; convert already succeeded.
                with contextlib.suppress(OSError):
                    raw_path.unlink()
    except tarfile.TarError as e:
        raise ImportExportError(f"could not read OVA {source}: {e}") from e
    except OSError as e:
        raise ImportExportError(f"could not read OVA {source}: {e}") from e

    _announce(reporter, f"defining domain {descriptor.name}")
    domain_xml = build_domain_xml(descriptor, converted_paths)
    try:
        handle = provider.define_domain(domain_xml)
    except VManagerError:
        # Best-effort rollback — drop the converted disks so a
        # half-imported VM doesn't leave dead files behind.
        for path in converted_paths:
            with contextlib.suppress(OSError):
                path.unlink()
        raise
    _announce(reporter, f"imported {descriptor.name}")

    return ImportResult(
        handle=handle,
        descriptor=descriptor,
        converted_disks=converted_paths,
    )


# ---------------------------------------------------------------------------
# Domain XML builder
# ---------------------------------------------------------------------------


def build_domain_xml(
    descriptor: OVFDescriptor,
    disks: list[Path],
) -> str:
    """Build a minimal libvirt domain XML for an imported VM.

    The shape matches what ``virt-install`` produces for a
    ``--import`` run against a qcow2 disk with virtio everything,
    which is the modern KVM default. The output validates against
    libvirt 8+ and is accepted by :class:`MockProvider` as well.

    Not exhaustive: no UEFI / firmware, no USB controllers, no
    passthrough, no shared folders. Callers who need those edit
    the XML afterwards via ``vmanager vm xml <name> --edit``.
    """
    vcpu = max(1, descriptor.vcpus)
    memory_mb = max(64, descriptor.memory_mb)

    disk_blocks: list[str] = []
    for index, path in enumerate(disks):
        target_dev = f"vd{chr(ord('a') + index)}"
        disk_blocks.append(
            "    <disk type='file' device='disk'>\n"
            "      <driver name='qemu' type='qcow2'/>\n"
            f"      <source file='{path}'/>\n"
            f"      <target dev='{target_dev}' bus='virtio'/>\n"
            "    </disk>",
        )

    # NIC blocks — map each OVF NIC to libvirt default network. Users
    # can retarget via `vmanager vm config nic-remove` + `nic-add`.
    nic_blocks: list[str] = []
    for nic in descriptor.nics:
        nic_blocks.append(
            "    <interface type='network'>\n"
            "      <source network='default'/>\n"
            f"      <model type='{nic.model}'/>\n"
            "    </interface>",
        )
    if not nic_blocks:
        # Always give the VM at least one NIC so it can boot onto a
        # network — users who explicitly want no NIC can strip it.
        nic_blocks.append(
            "    <interface type='network'>\n"
            "      <source network='default'/>\n"
            "      <model type='virtio'/>\n"
            "    </interface>",
        )

    description_line = (
        f"  <description>{_xml_escape(descriptor.description)}</description>\n"
        if descriptor.description
        else ""
    )

    return (
        "<domain type='kvm'>\n"
        f"  <name>{_xml_escape(descriptor.name)}</name>\n"
        f"{description_line}"
        f"  <memory unit='MiB'>{memory_mb}</memory>\n"
        f"  <currentMemory unit='MiB'>{memory_mb}</currentMemory>\n"
        f"  <vcpu placement='static'>{vcpu}</vcpu>\n"
        "  <os>\n"
        "    <type arch='x86_64' machine='q35'>hvm</type>\n"
        "    <boot dev='hd'/>\n"
        "  </os>\n"
        "  <features><acpi/><apic/></features>\n"
        "  <cpu mode='host-model'/>\n"
        "  <clock offset='utc'/>\n"
        "  <on_poweroff>destroy</on_poweroff>\n"
        "  <on_reboot>restart</on_reboot>\n"
        "  <on_crash>destroy</on_crash>\n"
        "  <devices>\n" + "\n".join(disk_blocks) + "\n" + "\n".join(nic_blocks) + "\n"
        "    <console type='pty'><target type='serial' port='0'/></console>\n"
        "    <graphics type='spice' autoport='yes'/>\n"
        "  </devices>\n"
        "</domain>\n"
    )


# ---------------------------------------------------------------------------
# Tarfile helpers
# ---------------------------------------------------------------------------


def _find_ovf_member(tar: tarfile.TarFile) -> tarfile.TarInfo | None:
    for member in tar.getmembers():
        if member.isfile() and member.name.endswith(".ovf"):
            return member
    return None


def _find_member(tar: tarfile.TarFile, href: str) -> tarfile.TarInfo | None:
    """Find a tar member by name, tolerant of leading path components."""
    wanted = href.lstrip("./")
    for member in tar.getmembers():
        if not member.isfile():
            continue
        if member.name == wanted or member.name.endswith("/" + wanted):
            return member
    return None


def _read_member(tar: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
    f = tar.extractfile(member)
    if f is None:
        raise ImportExportError(f"could not open tar member {member.name}")
    try:
        return f.read()
    finally:
        f.close()


def _extract_member_to(
    tar: tarfile.TarFile,
    member: tarfile.TarInfo,
    destination: Path,
) -> None:
    """Stream a tar member to ``destination`` without using extractall.

    Avoiding ``extractall`` means we don't trigger tarfile path-
    traversal warnings in newer Python versions, AND the destination
    path is whatever the caller picked — not whatever was in the tar.
    """
    destination.parent.mkdir(parents=True, exist_ok=True)
    source = tar.extractfile(member)
    if source is None:
        raise ImportExportError(f"could not open tar member {member.name}")
    try:
        with destination.open("wb") as out:
            while True:
                chunk = source.read(1024 * 1024)
                if not chunk:
                    break
                out.write(chunk)
    finally:
        source.close()


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


def _xml_escape(value: str) -> str:
    """Minimal XML escaping for domain-XML template substitution."""
    return (
        value.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
        .replace("'", "&apos;")
    )


__all__ = ["ImportResult", "build_domain_xml", "import_ova"]
