"""The ``backup-manifest.json`` schema and its pure (de)serialization.

Extracted from ``mgf.vm.operations.backup`` (Wave 1). This module holds
only the *format* — the dataclasses and their JSON round-trip. The
archive-writing / disk-hashing / tar-extraction bodies stay in the
adapter because they touch :mod:`tarfile`, :mod:`hashlib` streaming over
real files, and the filesystem.

Backup format
=============

A backup is a single ``.tar.gz`` archive containing, at the top level, a
``backup-manifest.json`` and one or more disk image files named after
their guest target device (``vda.qcow2``, ``vdb.raw``, ...).

``backup-manifest.json`` is the source of truth for every restore:

.. code-block:: json

    {
      "format_version": 1,
      "vm_name": "web",
      "uuid": "...",
      "created_at": "2026-04-12T10:00:00+00:00",
      "domain_xml": "<domain>...</domain>",
      "disks": [
        {"target": "vda", "file": "vda.qcow2", "sha256": "..."}
      ]
    }

Threat model (RA-14)
--------------------

The SHA-256 list in ``backup-manifest.json`` is an **integrity check
against accidental corruption** — bit rot, a truncated transfer, a
half-copied archive. It is **NOT** a defence against an attacker with
write access to the archive: the manifest is unsigned, so an attacker who
edits BOTH a disk file AND its ``sha256`` row passes a rehash check
cleanly. A backup archive is not chain-of-custody evidence; if the
deployment needs that bar, ship a detached signature stored off-box.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import datetime

from mgf.vm.core.exceptions import BackupError

FORMAT_VERSION = 1
MANIFEST_NAME = "backup-manifest.json"


@dataclass(frozen=True)
class DiskEntry:
    """One disk in a backup manifest."""

    target: str  # guest target dev, e.g. 'vda'
    file: str  # archive-relative filename
    sha256: str


@dataclass(frozen=True)
class BackupManifest:
    """Deserialized ``backup-manifest.json``."""

    format_version: int
    vm_name: str
    uuid: str
    created_at: datetime
    domain_xml: str
    disks: tuple[DiskEntry, ...]

    def to_json(self) -> str:
        payload = {
            "format_version": self.format_version,
            "vm_name": self.vm_name,
            "uuid": self.uuid,
            "created_at": self.created_at.isoformat(),
            "domain_xml": self.domain_xml,
            "disks": [{"target": d.target, "file": d.file, "sha256": d.sha256} for d in self.disks],
        }
        return json.dumps(payload, indent=2, sort_keys=False)

    @classmethod
    def from_json(cls, raw: str) -> BackupManifest:
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as e:
            raise BackupError(f"backup manifest is not valid JSON: {e}") from e
        try:
            disks = tuple(
                DiskEntry(
                    target=str(d["target"]),
                    file=str(d["file"]),
                    sha256=str(d["sha256"]),
                )
                for d in data["disks"]
            )
            return cls(
                format_version=int(data["format_version"]),
                vm_name=str(data["vm_name"]),
                uuid=str(data["uuid"]),
                created_at=datetime.fromisoformat(data["created_at"]),
                domain_xml=str(data["domain_xml"]),
                disks=disks,
            )
        except (KeyError, ValueError, TypeError) as e:
            raise BackupError(f"backup manifest is malformed: {e}") from e


__all__ = [
    "FORMAT_VERSION",
    "MANIFEST_NAME",
    "BackupManifest",
    "DiskEntry",
]
