"""Schluesselverwaltung und Feldverschluesselung (AES-256-GCM, rotierbar).

Der Vorgaenger nutzte Fernet mit einem einzigen, nie rotierbaren Schluessel in
``fernet.key``. Fernet ist in Ordnung, aber:

* Ein Schluesselwechsel war nicht vorgesehen -- ein kompromittierter Schluessel
  haette bedeutet, alle Zugangsdaten von Hand neu einzugeben.
* Es gab keine zugeordneten Daten (AAD), also keine kryptographische Bindung
  eines Chiffrats an den Ort, an dem es steht. Ein Chiffrat aus Tabelle A liess
  sich in Tabelle B einspielen.

Hier: ein versionierter Schluesselbund (``data.keys``, Modus 0600) mit
AES-256-GCM. Jedes Chiffrat traegt die Schluessel-ID im Klartext-Praefix, damit
alte Werte nach einer Rotation weiter lesbar bleiben. ``aad`` bindet ein
Chiffrat an seinen Verwendungszweck (z. B. ``credentials.password:42``).

Format: ``v1.<key_id>.<base64url(nonce)>.<base64url(ciphertext)>``
"""

from __future__ import annotations

import base64
import json
import os
import secrets
import stat
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

_PREFIX = "v1"
_NONCE_BYTES = 12
_KEY_BYTES = 32


class DecryptionError(Exception):
    """Chiffrat gehoert nicht zu diesem Schluesselbund oder wurde manipuliert."""


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


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


def write_private_file(path: Path, content: str) -> None:
    """Schreibt eine Datei mit Modus 0600, atomar (erst tmp, dann ersetzen)."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
    handle = os.open(tmp, flags, 0o600)
    try:
        with os.fdopen(handle, "w", encoding="utf-8") as stream:
            stream.write(content)
            stream.flush()
            os.fsync(stream.fileno())
    except BaseException:
        tmp.unlink(missing_ok=True)
        raise
    os.replace(tmp, path)
    try:
        os.chmod(path, 0o600)
    except OSError:  # pragma: no cover - Windows
        pass


def check_private_file(path: Path) -> list[str]:
    """Meldet zu weite Dateirechte -- die Diagnoseseite zeigt das an."""
    problems: list[str] = []
    try:
        mode = path.stat().st_mode
    except OSError:
        return problems
    if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
        problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
    return problems


@dataclass(frozen=True, slots=True)
class _Key:
    key_id: str
    material: bytes
    created_at: str


class Keystore:
    """Rotierbarer Schluesselbund fuer Feldverschluesselung."""

    def __init__(self, path: Path) -> None:
        self.path = Path(path)
        self._keys: dict[str, _Key] = {}
        self._active: str = ""
        self._load_or_create()

    # ------------------------------------------------------------------ #

    def _load_or_create(self) -> None:
        if self.path.exists():
            raw = json.loads(self.path.read_text(encoding="utf-8"))
            for entry in raw.get("keys", []):
                key = _Key(
                    key_id=str(entry["id"]),
                    material=_b64d(str(entry["key"])),
                    created_at=str(entry.get("created_at", "")),
                )
                if len(key.material) != _KEY_BYTES:
                    raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
                self._keys[key.key_id] = key
            self._active = str(raw.get("active") or "")
            if self._active not in self._keys:
                raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
            return
        self._active = "1"
        self._keys = {
            "1": _Key(
                key_id="1",
                material=secrets.token_bytes(_KEY_BYTES),
                created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
            )
        }
        self._persist()

    def _persist(self) -> None:
        payload = {
            "active": self._active,
            "keys": [
                {"id": key.key_id, "key": _b64e(key.material), "created_at": key.created_at}
                for key in sorted(self._keys.values(), key=lambda k: int(k.key_id))
            ],
        }
        write_private_file(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n")

    # ------------------------------------------------------------------ #

    @property
    def active_key_id(self) -> str:
        return self._active

    def key_ids(self) -> list[str]:
        return sorted(self._keys, key=int)

    def rotate(self) -> str:
        """Legt einen neuen aktiven Schluessel an. Alte bleiben zum Lesen erhalten."""
        new_id = str(max(int(k) for k in self._keys) + 1)
        self._keys[new_id] = _Key(
            key_id=new_id,
            material=secrets.token_bytes(_KEY_BYTES),
            created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
        )
        self._active = new_id
        self._persist()
        return new_id

    def drop_key(self, key_id: str) -> None:
        """Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
        if key_id == self._active:
            raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
        self._keys.pop(key_id, None)
        self._persist()

    # ------------------------------------------------------------------ #

    def encrypt(self, plaintext: str, *, aad: str) -> str:
        key = self._keys[self._active]
        nonce = secrets.token_bytes(_NONCE_BYTES)
        ciphertext = AESGCM(key.material).encrypt(
            nonce, plaintext.encode("utf-8"), aad.encode("utf-8")
        )
        return f"{_PREFIX}.{key.key_id}.{_b64e(nonce)}.{_b64e(ciphertext)}"

    def decrypt(self, token: str, *, aad: str) -> str:
        try:
            prefix, key_id, nonce_b64, ct_b64 = token.split(".", 3)
        except ValueError as exc:
            raise DecryptionError("Chiffrat hat ein unbekanntes Format") from exc
        if prefix != _PREFIX:
            raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
        key = self._keys.get(key_id)
        if key is None:
            raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
        try:
            plain = AESGCM(key.material).decrypt(
                _b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
            )
        except (InvalidTag, ValueError) as exc:
            raise DecryptionError(
                "Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
                "manipulierte Daten oder falscher Verwendungszweck)"
            ) from exc
        return plain.decode("utf-8")

    def needs_reencrypt(self, token: str) -> bool:
        """True, wenn das Chiffrat noch unter einem alten Schluessel liegt."""
        parts = token.split(".", 3)
        return len(parts) == 4 and parts[1] != self._active


def load_or_create_secret_key(path: Path) -> bytes:
    """Flask-Secret-Key: 64 Byte, Datei mit Modus 0600, beim ersten Start erzeugt."""
    if path.exists():
        raw = path.read_text(encoding="utf-8").strip()
        if raw:
            return _b64d(raw)
    material = secrets.token_bytes(64)
    write_private_file(path, _b64e(material) + "\n")
    return material
