"""Pull the CMS's backup artefacts to this machine, and prove each one arrived.

The contract with the CMS half (``BackupApiController``):

* ``GET api/agent/v1/backups`` — every artefact's name, size and sha256.
* ``GET api/agent/v1/backups/{name}`` — the bytes.

The digest in the listing is computed by the CMS from the file it will serve,
so a transfer that was truncated, transcoded or tampered with in flight fails
here — at pull time, loudly — rather than during the restore that mattered.
"""

from __future__ import annotations

import hashlib
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path

from app.security.signing import sign
from app.support.http import HttpTransport, UrllibTransport, user_agent

logger = logging.getLogger(__name__)

#: The artefact families the CMS serves, by filename shape — document archives
#: are ``files_<timestamp>.zip``, database dumps ``<database>_<timestamp>.sql.gz``.
#: Retention is per-family so a burst of database dumps can never age out the
#: last document archive, or the reverse.
_FAMILIES = {
    "document archive": lambda name: name.startswith("files_") and name.endswith(".zip"),
    "database dump": lambda name: name.endswith(".sql.gz"),
}


class PullError(RuntimeError):
    """A pull that must not be mistaken for a success."""


@dataclass(frozen=True, slots=True)
class PullResult:
    """What one run did, for the log and the exit code."""

    fetched: list[str] = field(default_factory=list)
    already_present: list[str] = field(default_factory=list)
    pruned: list[str] = field(default_factory=list)


@dataclass(slots=True)
class BackupPuller:
    """One CMS installation's backups, mirrored into one local directory."""

    cms_base_url: str
    api_key: str
    api_secret: str
    destination: Path
    keep_per_family: int = 7
    timeout: float = 300.0
    transport: HttpTransport = field(default_factory=UrllibTransport)

    def pull(self) -> PullResult:
        """Mirror every artefact the CMS lists, verify each, prune old copies.

        Idempotent: an artefact already present with the listed digest is not
        fetched again, so running this hourly costs one listing call, and
        re-running after a failure resumes rather than starts over.
        """
        self.destination.mkdir(parents=True, exist_ok=True)

        result = PullResult()

        for artefact in self._wanted(self._listing()):
            name = artefact["name"]
            expected = artefact["sha256"]
            local = self.destination / name

            if local.is_file() and _sha256(local) == expected:
                result.already_present.append(name)
                continue

            self._fetch(name, expected, local)
            result.fetched.append(name)
            logger.info("Pulled %s (%d bytes).", name, local.stat().st_size)

        result.pruned.extend(self._prune())

        return result

    # ── The two endpoints ─────────────────────────────────────────────────

    def _listing(self) -> list[dict]:
        payload = self._get("backups").json()

        if not payload.get("ok"):
            raise PullError(f"The CMS refused the listing: {payload.get('error', 'unknown')}.")

        artefacts = payload.get("data") or []

        # An empty listing is answered, not swallowed. On an installation that
        # has ever taken a backup it means the backup job died — exactly the
        # state an off-site copy exists to survive, so the operator hears it
        # from the exit code rather than from a quiet night.
        if not artefacts:
            raise PullError("The CMS lists no backup artefacts at all.")

        return artefacts

    def _wanted(self, artefacts: list[dict]) -> list[dict]:
        """The newest ``keep_per_family`` of each family, nothing older.

        The CMS may list more history than this mirror keeps — its dump
        retention is day-based, ours is count-based. Fetching everything and
        pruning afterwards re-downloaded the same ten stale dumps on every
        run, forever; measured live on the first real pull. What retention
        would delete is never worth transferring.

        Sorted by name, not listing order or mtime: the name carries the
        backup's own timestamp, which survives re-serving and re-pulling.
        """
        wanted: list[dict] = []

        for is_member in _FAMILIES.values():
            members = sorted(
                (a for a in artefacts if is_member(a["name"])),
                key=lambda a: a["name"],
            )
            wanted.extend(members[-self.keep_per_family:])

        # Anything matching no family is served deliberately (the endpoint has
        # its own allow-list), so a shape this code predates is mirrored rather
        # than silently dropped.
        known = {a["name"] for a in wanted}
        wanted.extend(
            a for a in artefacts
            if a["name"] not in known
            and not any(is_member(a["name"]) for is_member in _FAMILIES.values())
        )

        return wanted

    def _fetch(self, name: str, expected_sha256: str, local: Path) -> None:
        response = self._get(f"backups/{name}")
        digest = hashlib.sha256(response.body).hexdigest()

        if digest != expected_sha256:
            # Nothing partial is kept: a mirror holding a plausible-looking
            # archive that does not match its digest is worse than a gap,
            # because a gap gets noticed and a bad archive gets restored.
            raise PullError(
                f"{name} arrived with digest {digest[:12]}…, "
                f"the CMS promised {expected_sha256[:12]}…. Discarded."
            )

        # Write-then-rename, so a crash mid-write leaves a .partial to sweep
        # rather than a truncated file under the real name.
        partial = local.with_suffix(local.suffix + ".partial")
        partial.write_bytes(response.body)
        os.replace(partial, local)

    def _get(self, path: str):
        signed_path = f"api/agent/v1/{path}"
        headers = sign(self.api_secret, "GET", signed_path, "", api_key=self.api_key).as_dict()
        headers["Accept"] = "application/json"
        headers["User-Agent"] = user_agent()

        response = self.transport.request(
            "GET",
            f"{self.cms_base_url.rstrip('/')}/{signed_path}",
            headers=headers,
            timeout=self.timeout,
        )

        if not response.ok:
            raise PullError(f"GET {path} answered HTTP {response.status}.")

        return response

    # ── Retention ─────────────────────────────────────────────────────────

    def _prune(self) -> list[str]:
        """Keep the newest N of each family, by the timestamp in the name.

        Count-based for the same reason the CMS's own pruning is: these are
        large, and day-based retention is how one full disk has already
        happened. Sorted by name rather than mtime, because the name carries
        the backup's own date and a re-pulled old artefact gets a fresh mtime.
        """
        pruned: list[str] = []

        for is_member in _FAMILIES.values():
            members = sorted(p for p in self.destination.iterdir()
                             if p.is_file() and is_member(p.name))

            for old in members[: max(0, len(members) - self.keep_per_family)]:
                old.unlink()
                pruned.append(old.name)
                logger.info("Pruned %s.", old.name)

        # Wreckage from a crash mid-download. Older pulls' partials serve no
        # purpose — the next fetch of that name starts from the beginning.
        for partial in self.destination.glob("*.partial"):
            partial.unlink()

        return pruned


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()

    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1 << 20), b""):
            digest.update(chunk)

    return digest.hexdigest()
