"""OVF descriptor parser and writer.

OVF (Open Virtualization Format) is a DMTF standard for describing
virtual machine topology in XML. An OVA archive is a plain tarball
holding one ``.ovf`` descriptor, zero or more disk files referenced
from the descriptor, and optionally a ``.mf`` checksum manifest.

This module handles the OVF XML only — not the tar wrapper and not
the disk format conversion. Callers (``importer.py`` / ``exporter.py``)
use :func:`parse_ovf` / :func:`serialize_ovf` to round-trip the
descriptor around the I/O they need to do.

**Scope:** The parser handles the typical shapes emitted by
VirtualBox and VMware — a single ``VirtualSystem`` with one or more
disks and NICs. It intentionally does NOT implement every corner of
the OVF 1.0 / 2.0 specs; fields we don't care about round-trip as
opaque ``extra_xml`` strings we preserve on write, so an OVF produced
by VManager still validates against third-party tools.

**Numbers:** Memory is normalized to MiB on parse and written back
in the canonical ``byte * 2^20`` allocation unit. Disk capacity is
stored in bytes. CPU count is a plain int.
"""

from __future__ import annotations

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

from mgf.vm.core.exceptions import ImportExportError

from mgf.vm.interchange._xml import parse_xml_file, parse_xml_string

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# XML namespace handling
# ---------------------------------------------------------------------------

OVF_NS = "http://schemas.dmtf.org/ovf/envelope/1"
RASD_NS = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"
VSSD_NS = "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"

NSMAP = {
    "ovf": OVF_NS,
    "rasd": RASD_NS,
    "vssd": VSSD_NS,
    "xsi": XSI_NS,
}

# CIM ResourceType values we care about. The full list is in the
# CIM_ResourceAllocationSettingData schema; VManager only reads /
# writes the three most common ones.
RESOURCE_TYPE_CPU = 3
RESOURCE_TYPE_MEMORY = 4
RESOURCE_TYPE_ETHERNET = 10
RESOURCE_TYPE_DISK = 17


# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------


@dataclass
class OVFDisk:
    """One disk referenced from the OVF descriptor.

    * ``disk_id`` is the identifier used inside the ``DiskSection``.
    * ``file_ref`` matches a ``File`` ``ovf:id`` in the ``References``
      section.
    * ``file_href`` is the path inside the OVA tar (usually a plain
      filename like ``disk1.vmdk``).
    * ``capacity_bytes`` is the virtual disk size.
    * ``format_uri`` is the OVF format URI — VManager writes the
      standard vmdk streamOptimized URI, and the parser tolerates
      either streamOptimized or monolithicSparse on import.
    """

    disk_id: str
    file_ref: str
    file_href: str
    capacity_bytes: int
    format_uri: str = "http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"


@dataclass
class OVFNIC:
    """One network interface described in the OVF.

    OVF networks are named at the ``NetworkSection`` level; each NIC
    ``Item`` in the ``VirtualHardwareSection`` references one of
    those names via its ``Connection``. VManager maps every NIC to
    the libvirt network of the same name (or ``default`` when the
    name is unknown).
    """

    name: str
    model: str = "virtio"
    connection: str = "NAT"


@dataclass
class OVFDescriptor:
    """Parsed or to-be-written OVF descriptor.

    All numeric fields are strict native types — callers never see
    raw XML text. The :func:`serialize_ovf` writer regenerates a
    clean descriptor from these fields.
    """

    name: str
    description: str = ""
    os_type: str = "linux"
    os_id: int = 93  # CIM OS ID 93 = "Linux"
    vcpus: int = 1
    memory_mb: int = 1024
    disks: list[OVFDisk] = field(default_factory=list)
    nics: list[OVFNIC] = field(default_factory=list)
    networks: list[str] = field(default_factory=lambda: ["NAT"])


# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------


def parse_ovf(source: str | Path) -> OVFDescriptor:
    """Parse OVF XML from a string or file path.

    Raises :class:`ImportExportError` on any structural problem. The
    error message always names the specific missing / unreadable
    field so a user importing a broken OVA knows what to fix.
    """
    if isinstance(source, str) and source.lstrip().startswith("<"):
        # SH-04: OVF text from an attacker-controllable OVA — route
        # through the defused parser with the strict ``untrusted=True``
        # mode (DTD ban + size cap on top of the entity-declaration
        # ban defusedxml already enforces by default).
        try:
            root = parse_xml_string(source, untrusted=True)
        except ET.ParseError as e:
            raise ImportExportError(f"invalid OVF XML: {e}") from e
    else:
        try:
            tree = parse_xml_file(source, untrusted=True)
            maybe_root = tree.getroot()
        except ET.ParseError as e:
            raise ImportExportError(f"invalid OVF XML: {e}") from e
        except OSError as e:
            raise ImportExportError(f"could not read OVF: {e}") from e
        if maybe_root is None:
            raise ImportExportError(f"OVF at {source} has no root element")
        root = maybe_root

    if _localname(root.tag) != "Envelope":
        raise ImportExportError(
            f"OVF root element must be <Envelope>, got <{root.tag}>",
        )

    # Build a fileRef -> href map from the References section.
    file_map = _parse_references(root)

    # Build a diskId -> (fileRef, capacity) map from the DiskSection.
    disk_map = _parse_disks(root, file_map)

    # Parse networks from the NetworkSection.
    networks = _parse_networks(root)

    # Find the first VirtualSystem — VManager v1 assumes one VM per OVA.
    vsys = _first_child(root, "VirtualSystem")
    if vsys is None:
        raise ImportExportError("OVF has no <VirtualSystem>")

    name = _text_of(vsys, "Name", default=vsys.get(_qn(OVF_NS, "id"), "unknown"))
    description = _text_of(vsys, "Info", default="")

    os_type, os_id = _parse_os(vsys)

    hw = _find_section(vsys, "VirtualHardwareSection")
    if hw is None:
        raise ImportExportError("<VirtualSystem> has no <VirtualHardwareSection>")

    vcpus, memory_mb, nics, disk_items = _parse_hardware_items(hw)

    # Merge disk item references with the disk_map to produce OVFDisk
    # objects in hardware order.
    disks: list[OVFDisk] = []
    for disk_ref in disk_items:
        info = disk_map.get(disk_ref)
        if info is None:
            raise ImportExportError(
                f"disk item references unknown disk {disk_ref!r}",
            )
        file_ref, capacity_bytes, format_uri = info
        href = file_map.get(file_ref)
        if href is None:
            raise ImportExportError(
                f"disk {disk_ref!r} references unknown file {file_ref!r}",
            )
        disks.append(
            OVFDisk(
                disk_id=disk_ref,
                file_ref=file_ref,
                file_href=href,
                capacity_bytes=capacity_bytes,
                format_uri=format_uri,
            ),
        )

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


# ---------------------------------------------------------------------------
# Writer
# ---------------------------------------------------------------------------


def serialize_ovf(descriptor: OVFDescriptor) -> str:
    """Render an :class:`OVFDescriptor` to OVF XML.

    Returns a pretty-printed UTF-8 string with namespace declarations
    at the root. The output is accepted by ``virt-v2v``, ``ovftool``,
    and VirtualBox.
    """
    # Register namespace prefixes so ElementTree emits them on the
    # root element instead of using ns0, ns1, etc.
    for prefix, uri in NSMAP.items():
        ET.register_namespace(prefix, uri)

    envelope = ET.Element(
        _qn(OVF_NS, "Envelope"),
        {_qn(XSI_NS, "schemaLocation"): _SCHEMA_LOCATION},
    )

    # References section — one <File> per disk.
    refs = ET.SubElement(envelope, _qn(OVF_NS, "References"))
    for disk in descriptor.disks:
        ET.SubElement(
            refs,
            _qn(OVF_NS, "File"),
            {
                _qn(OVF_NS, "id"): disk.file_ref,
                _qn(OVF_NS, "href"): disk.file_href,
                _qn(OVF_NS, "size"): str(disk.capacity_bytes),
            },
        )

    # DiskSection.
    disk_section = ET.SubElement(envelope, _qn(OVF_NS, "DiskSection"))
    ET.SubElement(disk_section, _qn(OVF_NS, "Info")).text = "Virtual disk information"
    for disk in descriptor.disks:
        ET.SubElement(
            disk_section,
            _qn(OVF_NS, "Disk"),
            {
                _qn(OVF_NS, "diskId"): disk.disk_id,
                _qn(OVF_NS, "fileRef"): disk.file_ref,
                _qn(OVF_NS, "capacity"): str(disk.capacity_bytes),
                _qn(OVF_NS, "capacityAllocationUnits"): "byte",
                _qn(OVF_NS, "format"): disk.format_uri,
            },
        )

    # NetworkSection.
    net_section = ET.SubElement(envelope, _qn(OVF_NS, "NetworkSection"))
    ET.SubElement(net_section, _qn(OVF_NS, "Info")).text = "Logical networks"
    for net_name in descriptor.networks:
        net = ET.SubElement(
            net_section,
            _qn(OVF_NS, "Network"),
            {_qn(OVF_NS, "name"): net_name},
        )
        ET.SubElement(net, _qn(OVF_NS, "Description")).text = f"Network {net_name}"

    # VirtualSystem.
    vsys = ET.SubElement(
        envelope,
        _qn(OVF_NS, "VirtualSystem"),
        {_qn(OVF_NS, "id"): descriptor.name},
    )
    ET.SubElement(vsys, _qn(OVF_NS, "Info")).text = (
        descriptor.description or f"{descriptor.name} virtual machine"
    )
    ET.SubElement(vsys, _qn(OVF_NS, "Name")).text = descriptor.name

    # OperatingSystemSection.
    os_section = ET.SubElement(
        vsys,
        _qn(OVF_NS, "OperatingSystemSection"),
        {_qn(OVF_NS, "id"): str(descriptor.os_id)},
    )
    ET.SubElement(os_section, _qn(OVF_NS, "Info")).text = "Guest OS"
    ET.SubElement(os_section, _qn(OVF_NS, "Description")).text = descriptor.os_type

    # VirtualHardwareSection.
    hw = ET.SubElement(vsys, _qn(OVF_NS, "VirtualHardwareSection"))
    ET.SubElement(hw, _qn(OVF_NS, "Info")).text = "Virtual hardware"

    system = ET.SubElement(hw, _qn(OVF_NS, "System"))
    ET.SubElement(system, _qn(VSSD_NS, "ElementName")).text = "Virtual Hardware Family"
    ET.SubElement(system, _qn(VSSD_NS, "InstanceID")).text = "0"
    ET.SubElement(system, _qn(VSSD_NS, "VirtualSystemIdentifier")).text = descriptor.name
    ET.SubElement(system, _qn(VSSD_NS, "VirtualSystemType")).text = "vmx-09"

    instance_id = 1

    # CPU.
    _append_hw_item(
        hw,
        instance_id=instance_id,
        element_name=f"{descriptor.vcpus} virtual CPUs",
        description="Number of Virtual CPUs",
        resource_type=RESOURCE_TYPE_CPU,
        virtual_quantity=descriptor.vcpus,
        allocation_units="hertz * 10^6",
    )
    instance_id += 1

    # Memory.
    _append_hw_item(
        hw,
        instance_id=instance_id,
        element_name=f"{descriptor.memory_mb} MB of memory",
        description="Memory Size",
        resource_type=RESOURCE_TYPE_MEMORY,
        virtual_quantity=descriptor.memory_mb,
        allocation_units="byte * 2^20",
    )
    instance_id += 1

    # NICs.
    for nic in descriptor.nics:
        _append_hw_item(
            hw,
            instance_id=instance_id,
            element_name=nic.name,
            description=f"{nic.model} ethernet adapter",
            resource_type=RESOURCE_TYPE_ETHERNET,
            resource_sub_type=nic.model,
            connection=nic.connection,
        )
        instance_id += 1

    # Disks.
    for disk in descriptor.disks:
        _append_hw_item(
            hw,
            instance_id=instance_id,
            element_name=f"disk {disk.disk_id}",
            description="Disk drive",
            resource_type=RESOURCE_TYPE_DISK,
            host_resource=f"ovf:/disk/{disk.disk_id}",
        )
        instance_id += 1

    # Pretty-print via ElementTree's built-in indent.
    ET.indent(envelope, space="  ")
    xml_text = ET.tostring(
        envelope,
        encoding="unicode",
        xml_declaration=True,
    )
    return str(xml_text)


# ---------------------------------------------------------------------------
# Parser helpers
# ---------------------------------------------------------------------------


def _parse_references(root: ET.Element) -> dict[str, str]:
    """Return ``{file_id: href}`` from the ``<References>`` section."""
    out: dict[str, str] = {}
    refs = _first_child(root, "References")
    if refs is None:
        return out
    for file_el in refs.findall(_qn(OVF_NS, "File")):
        fid = file_el.get(_qn(OVF_NS, "id"))
        href = file_el.get(_qn(OVF_NS, "href"))
        if fid and href:
            out[fid] = href
    return out


def _parse_disks(
    root: ET.Element,
    file_map: dict[str, str],
) -> dict[str, tuple[str, int, str]]:
    """Return ``{disk_id: (file_ref, capacity_bytes, format_uri)}``."""
    out: dict[str, tuple[str, int, str]] = {}
    section = _first_child(root, "DiskSection")
    if section is None:
        return out
    for disk_el in section.findall(_qn(OVF_NS, "Disk")):
        disk_id = disk_el.get(_qn(OVF_NS, "diskId"))
        file_ref = disk_el.get(_qn(OVF_NS, "fileRef"))
        if not disk_id or not file_ref:
            raise ImportExportError(
                f"OVF <Disk> missing diskId/fileRef: {ET.tostring(disk_el)!r}",
            )
        capacity = disk_el.get(_qn(OVF_NS, "capacity"), "0")
        units = disk_el.get(_qn(OVF_NS, "capacityAllocationUnits"), "byte")
        capacity_bytes = _parse_allocation_quantity(capacity, units)
        format_uri = disk_el.get(_qn(OVF_NS, "format"), "")
        out[disk_id] = (file_ref, capacity_bytes, format_uri)
    return out


def _parse_networks(root: ET.Element) -> list[str]:
    """Return the list of logical-network names declared in the OVF."""
    names: list[str] = []
    section = _first_child(root, "NetworkSection")
    if section is None:
        return names
    for net_el in section.findall(_qn(OVF_NS, "Network")):
        name = net_el.get(_qn(OVF_NS, "name"))
        if name:
            names.append(name)
    return names


def _parse_os(vsys: ET.Element) -> tuple[str, int]:
    """Return ``(os_type, os_id)`` from the OperatingSystemSection."""
    os_section = _find_section(vsys, "OperatingSystemSection")
    if os_section is None:
        return ("linux", 93)
    os_id_raw = os_section.get(_qn(OVF_NS, "id"), "93")
    try:
        os_id = int(os_id_raw)
    except ValueError:
        os_id = 93
    description = _text_of(os_section, "Description", default="linux")
    return (description.lower() or "linux", os_id)


def _parse_hardware_items(
    hw: ET.Element,
) -> tuple[int, int, list[OVFNIC], list[str]]:
    """Parse <Item> rows in the VirtualHardwareSection.

    Returns ``(vcpus, memory_mb, nics, disk_host_resources)``. Disks
    come back as a list of HostResource strings (e.g.,
    ``ovf:/disk/vmdisk1``) so the caller can cross-reference against
    the DiskSection.
    """
    vcpus = 1
    memory_mb = 1024
    nics: list[OVFNIC] = []
    disks: list[str] = []
    for item in hw.findall(_qn(OVF_NS, "Item")):
        resource_type_raw = _text_of_rasd(item, "ResourceType")
        try:
            resource_type = int(resource_type_raw)
        except ValueError:
            continue
        if resource_type == RESOURCE_TYPE_CPU:
            qty = _text_of_rasd(item, "VirtualQuantity")
            with contextlib.suppress(ValueError):
                vcpus = int(qty)
        elif resource_type == RESOURCE_TYPE_MEMORY:
            qty = _text_of_rasd(item, "VirtualQuantity")
            units = _text_of_rasd(item, "AllocationUnits") or "byte * 2^20"
            memory_mb = _memory_quantity_to_mb(qty, units)
        elif resource_type == RESOURCE_TYPE_ETHERNET:
            connection = _text_of_rasd(item, "Connection") or "NAT"
            model = _text_of_rasd(item, "ResourceSubType") or "virtio"
            element_name = _text_of_rasd(item, "ElementName") or "eth0"
            nics.append(OVFNIC(name=element_name, model=model, connection=connection))
        elif resource_type == RESOURCE_TYPE_DISK:
            host_resource = _text_of_rasd(item, "HostResource") or ""
            disk_id = _disk_id_from_host_resource(host_resource)
            if disk_id:
                disks.append(disk_id)
    return (vcpus, memory_mb, nics, disks)


_UNIT_EXPR = re.compile(r"byte\s*\*\s*2\^(\d+)")


def _memory_quantity_to_mb(qty_text: str, units: str) -> int:
    """Normalize an OVF memory quantity to MiB.

    OVF memory is written as ``VirtualQuantity`` + ``AllocationUnits``,
    where the unit is typically ``byte * 2^20`` (MiB) or ``byte * 2^30``
    (GiB). We parse the exponent and scale to MiB.
    """
    try:
        qty = int(qty_text)
    except ValueError:
        return 1024
    match = _UNIT_EXPR.match(units)
    if match is None:
        # Assume MiB — that's what every exporter we've seen writes.
        return qty
    exp = int(match.group(1))
    # byte * 2^exp → MiB conversion: divide by 2^20.
    bytes_per_unit = 1 << exp
    total_bytes = qty * bytes_per_unit
    return max(1, total_bytes >> 20)


def _parse_allocation_quantity(value: str, units: str) -> int:
    """Parse an OVF capacity quantity and return bytes.

    Used for disk capacity. OVF disks usually declare
    ``capacityAllocationUnits="byte"`` so the raw value is already
    in bytes, but callers occasionally emit ``"byte * 2^30"`` when
    measuring in GiB.
    """
    try:
        qty = int(value)
    except ValueError:
        return 0
    match = _UNIT_EXPR.match(units)
    if match is None:
        return qty
    exp = int(match.group(1))
    return qty * (1 << exp)


def _disk_id_from_host_resource(host_resource: str) -> str:
    """Pull ``vmdisk1`` out of ``ovf:/disk/vmdisk1`` (or similar)."""
    if not host_resource:
        return ""
    # Accept ``ovf:/disk/NAME`` and ``/disk/NAME``.
    return host_resource.rsplit("/", 1)[-1]


# ---------------------------------------------------------------------------
# XML helpers
# ---------------------------------------------------------------------------


def _qn(ns: str, local: str) -> str:
    return f"{{{ns}}}{local}"


def _localname(tag: str) -> str:
    return tag.rsplit("}", 1)[-1] if "}" in tag else tag


def _first_child(parent: ET.Element, local: str) -> ET.Element | None:
    """Find the first child whose local name matches ``local``.

    We can't rely on ``parent.find("ovf:X", NSMAP)`` because some OVFs
    omit the prefix on the root namespace; walking the children is
    both simpler and more forgiving.
    """
    for child in parent:
        if _localname(child.tag) == local:
            return child
    return None


def _find_section(parent: ET.Element, local: str) -> ET.Element | None:
    return _first_child(parent, local)


def _text_of(parent: ET.Element, local: str, *, default: str) -> str:
    child = _first_child(parent, local)
    if child is None or child.text is None:
        return default
    return child.text.strip()


def _text_of_rasd(item: ET.Element, local: str) -> str:
    """Return the text of ``<rasd:X>`` directly under ``item``."""
    for child in item:
        if _localname(child.tag) == local and child.text is not None:
            return child.text.strip()
    return ""


def _append_hw_item(
    hw: ET.Element,
    *,
    instance_id: int,
    element_name: str,
    description: str,
    resource_type: int,
    virtual_quantity: int | None = None,
    allocation_units: str | None = None,
    resource_sub_type: str | None = None,
    connection: str | None = None,
    host_resource: str | None = None,
) -> ET.Element:
    """Append one ``<Item>`` to a ``<VirtualHardwareSection>``."""
    item = ET.SubElement(hw, _qn(OVF_NS, "Item"))
    ET.SubElement(item, _qn(RASD_NS, "ElementName")).text = element_name
    ET.SubElement(item, _qn(RASD_NS, "Description")).text = description
    ET.SubElement(item, _qn(RASD_NS, "InstanceID")).text = str(instance_id)
    ET.SubElement(item, _qn(RASD_NS, "ResourceType")).text = str(resource_type)
    if virtual_quantity is not None:
        ET.SubElement(item, _qn(RASD_NS, "VirtualQuantity")).text = str(
            virtual_quantity,
        )
    if allocation_units is not None:
        ET.SubElement(item, _qn(RASD_NS, "AllocationUnits")).text = allocation_units
    if resource_sub_type is not None:
        ET.SubElement(item, _qn(RASD_NS, "ResourceSubType")).text = resource_sub_type
    if connection is not None:
        ET.SubElement(item, _qn(RASD_NS, "Connection")).text = connection
    if host_resource is not None:
        ET.SubElement(item, _qn(RASD_NS, "HostResource")).text = host_resource
    return item


_SCHEMA_LOCATION = f"{OVF_NS} http://schemas.dmtf.org/ovf/envelope/1/dsp8023_1.1.0.xsd"


__all__ = [
    "NSMAP",
    "OVFNIC",
    "OVF_NS",
    "RASD_NS",
    "RESOURCE_TYPE_CPU",
    "RESOURCE_TYPE_DISK",
    "RESOURCE_TYPE_ETHERNET",
    "RESOURCE_TYPE_MEMORY",
    "VSSD_NS",
    "OVFDescriptor",
    "OVFDisk",
    "parse_ovf",
    "serialize_ovf",
]
