"""Verschluesselter Umschlag fuer Sicherungen -- gemeinsam von beiden Anwendungen.

Format 3. Was hier liegt, ist der kryptographische Teil: Passphrase ableiten,
verschluesseln, entschluesseln, Zwischenstand halten. *Welche* Kategorien es
gibt und wie sie gelesen/geschrieben werden, entscheidet jede Anwendung selbst.

Argon2id statt PBKDF2 (der Vorgaenger nutzte 390.000 PBKDF2-Iterationen --
ordentlich, aber nicht speicherhart) und AES-256-GCM statt Fernet, mit dem
Umschlagkopf als zugeordneten Daten: wer Salz oder Kategorienliste manipuliert,
bricht die Entschluesselung.
"""

from __future__ import annotations

import base64
import json
import secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any

from argon2.low_level import Type, hash_secret_raw
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

from .db import Database

FORMAT_VERSION = 3

KDF_TIME_COST = 4
KDF_MEMORY_COST = 131072  # 128 MiB
KDF_PARALLELISM = 2
KDF_SALT_BYTES = 16
KEY_BYTES = 32
NONCE_BYTES = 12

MIN_PASSPHRASE_LENGTH = 12
PREVIEW_JOB_KIND = "backup.preview"
PREVIEW_TTL_MINUTES = 30


class TransferError(Exception):
    """Fehler mit Klartextmeldung fuer die Oberflaeche."""


@dataclass(frozen=True, slots=True)
class Category:
    """Eine waehlbare Kategorie einer Sicherung."""

    key: str
    label: str
    hint: str = ""
    #: Enthaelt Geheimnisse im Klartext (verlangt ``backup.secrets``).
    sensitive: bool = False
    #: Nur echte Administratoren duerfen sie sichern/einspielen.
    admin_only: bool = False


def _b64e(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _b64d(text: str) -> bytes:
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes:
    return hash_secret_raw(
        secret=passphrase.encode("utf-8"),
        salt=salt,
        time_cost=int(params["t"]),
        memory_cost=int(params["m"]),
        parallelism=int(params["p"]),
        hash_len=KEY_BYTES,
        type=Type.ID,
    )


def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
    if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
        raise TransferError(
            f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist "
            "der einzige Schutz dieser Datei."
        )
    salt = secrets.token_bytes(KDF_SALT_BYTES)
    params = {"t": KDF_TIME_COST, "m": KDF_MEMORY_COST, "p": KDF_PARALLELISM}
    header = {
        "app": app_tag,
        "format": FORMAT_VERSION,
        "created_at": _now().isoformat(timespec="seconds"),
        "categories": sorted(payload.keys()),
        "kdf": {"name": "argon2id", "salt": _b64e(salt), **params},
    }
    key = _derive_key(passphrase, salt, params)
    nonce = secrets.token_bytes(NONCE_BYTES)
    aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ciphertext = AESGCM(key).encrypt(
        nonce, json.dumps(payload, ensure_ascii=False).encode("utf-8"), aad
    )
    return json.dumps(
        {**header, "nonce": _b64e(nonce), "payload": _b64e(ciphertext)},
        ensure_ascii=False,
        indent=2,
    )


def unseal(
    raw: bytes | str, passphrase: str, *, app_tag: str
) -> tuple[dict[str, Any], dict[str, Any]]:
    text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
    try:
        envelope = json.loads(text)
    except ValueError as exc:
        raise TransferError("Die Datei ist kein gueltiges JSON.") from exc
    if not isinstance(envelope, dict):
        raise TransferError("Die Datei enthaelt kein Objekt.")
    if envelope.get("app") != app_tag:
        raise TransferError(
            f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})."
        )
    if int(envelope.get("format") or 0) != FORMAT_VERSION:
        raise TransferError(
            f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, "
            f"erwartet {FORMAT_VERSION})."
        )
    kdf = envelope.get("kdf") or {}
    if kdf.get("name") != "argon2id":
        raise TransferError("Unbekanntes Schluesselableitungsverfahren.")

    header = {
        key: envelope[key]
        for key in ("app", "format", "created_at", "categories", "kdf")
        if key in envelope
    }
    aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8")
    try:
        key = _derive_key(
            passphrase,
            _b64d(str(kdf["salt"])),
            {
                "t": int(kdf.get("t") or KDF_TIME_COST),
                "m": int(kdf.get("m") or KDF_MEMORY_COST),
                "p": int(kdf.get("p") or KDF_PARALLELISM),
            },
        )
        plain = AESGCM(key).decrypt(
            _b64d(str(envelope["nonce"])), _b64d(str(envelope["payload"])), aad
        )
    except (InvalidTag, KeyError, ValueError) as exc:
        raise TransferError(
            "Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt."
        ) from exc
    return json.loads(plain.decode("utf-8")), header


# --------------------------------------------------------------------------- #
# Zwischenstand des Imports
# --------------------------------------------------------------------------- #


def store_preview(conn: sqlite3.Connection, payload: dict[str, Any], *, actor: str) -> str:
    """Legt die geoeffnete Sicherung in der Datenbank ab.

    Der Vorgaenger hielt diesen Zwischenstand in einer Modulvariable -- einer der
    Gruende, warum die Anwendung mit genau einem Worker laufen *musste*.
    """
    token = secrets.token_urlsafe(18)
    conn.execute("DELETE FROM jobs WHERE kind=? AND started_by=?", (PREVIEW_JOB_KIND, actor))
    conn.execute(
        "INSERT INTO jobs (id, kind, subject, state, message, output, started_by, created_at, "
        "updated_at) VALUES (?,?,?,'pending','',?,?,?,?)",
        (
            token,
            PREVIEW_JOB_KIND,
            "Import-Vorschau",
            json.dumps(payload, ensure_ascii=False),
            actor,
            _now().isoformat(timespec="seconds"),
            _now().isoformat(timespec="seconds"),
        ),
    )
    return token


def load_preview(conn: sqlite3.Connection, token: str, *, actor: str) -> dict[str, Any]:
    row = Database.one(
        conn,
        "SELECT output, created_at FROM jobs WHERE id=? AND kind=? AND started_by=?",
        (token, PREVIEW_JOB_KIND, actor),
    )
    if row is None:
        raise TransferError("Der Zwischenstand ist nicht mehr vorhanden. Bitte neu hochladen.")
    created = datetime.fromisoformat(str(row["created_at"]))
    if created.tzinfo is None:
        created = created.replace(tzinfo=timezone.utc)
    if _now() - created > timedelta(minutes=PREVIEW_TTL_MINUTES):
        conn.execute("DELETE FROM jobs WHERE id=?", (token,))
        raise TransferError("Der Zwischenstand ist abgelaufen. Bitte neu hochladen.")
    return json.loads(str(row["output"]))


def drop_preview(conn: sqlite3.Connection, token: str) -> None:
    conn.execute("DELETE FROM jobs WHERE id=? AND kind=?", (token, PREVIEW_JOB_KIND))


def summarize(payload: dict[str, Any], categories: tuple[Category, ...]) -> list[dict[str, Any]]:
    summary: list[dict[str, Any]] = []
    for category in categories:
        if category.key not in payload:
            continue
        data = payload[category.key]
        if isinstance(data, list):
            count = len(data)
        elif isinstance(data, dict):
            count = sum(len(value) if isinstance(value, list) else 1 for value in data.values())
        else:
            count = 1
        summary.append(
            {
                "key": category.key,
                "label": category.label,
                "hint": category.hint,
                "count": count,
                "sensitive": category.sensitive,
                "admin_only": category.admin_only,
            }
        )
    return summary
