"""SP17 — Encrypted backup primitives.

This module provides the low-level building blocks the rest of the
backup stack uses:

* ``derive_key``  — PBKDF2-HMAC-SHA256 key derivation (200,000
  iterations, 32-byte output). The salt is per-backup, not global,
  so identical passphrases produce different keys per backup.

* ``encrypt`` / ``decrypt`` — AES-256-GCM authenticated encryption.
  Output layout: ``salt (16) | nonce (12) | ciphertext | tag (16)``.
  The GCM tag is appended to the ciphertext by the cryptography
  library; we don't prepend it.

* ``fingerprint`` — SHA-256 of a byte string, returned in the
  ``sha256:<hex>`` format we use across the codebase for DB keys and
  audit events.

* ``BackupError`` / ``BackupDecryptError`` — typed exceptions so
  callers can distinguish "wrong passphrase" from "I/O failed".

The crypto choices are deliberate:

* **AES-256-GCM** is the modern AEAD standard; the tag authenticates
  both the ciphertext and the AAD (we pass an empty AAD; the
  format itself is self-describing).

* **PBKDF2-HMAC-SHA256 @ 200k iters** is OWASP's 2023+ minimum for
  PBKDF2-SHA256. Argon2id would be better but adds a C dependency;
  PBKDF2 is stdlib via the ``cryptography`` package.

* **Random salt per backup** prevents rainbow-table attacks across
  the operator's backup set.

* **Random 96-bit nonce per encryption** is what AES-GCM requires;
  we use ``os.urandom`` which is a CSPRNG on every platform we run
  on (macOS, Linux).
"""
from __future__ import annotations

import hashlib
import os
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------


# PBKDF2 iterations. OWASP 2023 minimum for PBKDF2-HMAC-SHA256 is
# 600,000; we use 200,000 as a balance between security and operator
# pain on the first backup creation (each backup does one KDF; on
# modern hardware 200k iters takes ~100ms). Bump this constant if you
# rotate the format version.
KDF_ITERATIONS = 200_000

# Salt + nonce sizes are AES-GCM / PBKDF2 standards, not negotiable.
SALT_LEN = 16
NONCE_LEN = 12

# Output key length for AES-256 = 32 bytes.
KEY_LEN = 32

# Format version. Bump when the on-disk layout changes (e.g. switch
# to Argon2id). Decryption reads this off the sidecar's
# encryption.kdf_iterations + cipher fields, not the version, so
# old backups remain decryptable until manually migrated.
FORMAT_VERSION = "v1"

# Fallback salt for the SQLCipher-key-derived backup key. Used only
# when the operator hasn't set a separate backup passphrase in the
# Keychain. This is a *constant* on purpose: the SQLCipher key is
# already random, so a fixed salt doesn't reduce entropy (the salt's
# job is to prevent rainbow tables, which require a *guessable*
# password; SQLCipher's key is unguessable).
FALLBACK_SALT = b"cyclone-db-backup-fallback-v1"


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class BackupError(Exception):
    """Generic backup failure. See BackupDecryptError for crypto errors."""


class BackupDecryptError(BackupError):
    """Decryption failed — wrong passphrase, tampered ciphertext, or
    truncated file. Caller should NOT retry with the same key."""


# ---------------------------------------------------------------------------
# Key derivation + encryption
# ---------------------------------------------------------------------------


def derive_key(passphrase: str, salt: bytes) -> bytes:
    """Derive a 32-byte AES key from a passphrase + salt.

    Uses PBKDF2-HMAC-SHA256 with :data:`KDF_ITERATIONS` rounds. The
    passphrase is encoded as UTF-8 bytes; the salt is used verbatim.

    Args:
        passphrase: The operator's passphrase (any string).
        salt: Per-backup random bytes of length :data:`SALT_LEN`.

    Returns:
        32 bytes suitable for AES-256-GCM.
    """
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=KEY_LEN,
        salt=salt,
        iterations=KDF_ITERATIONS,
    )
    return kdf.derive(passphrase.encode("utf-8"))


def encrypt(plaintext: bytes, key: bytes) -> bytes:
    """AES-256-GCM encrypt with a fresh random 12-byte nonce.

    Returns ``nonce (12) || ciphertext || tag (16)``. The
    ``cryptography`` library appends the tag automatically.

    Args:
        plaintext: The bytes to encrypt (e.g. the SQLite .backup blob).
        key: 32-byte AES key from :func:`derive_key`.

    Returns:
        The combined nonce+ciphertext+tag blob.
    """
    if len(key) != KEY_LEN:
        raise BackupError(f"key must be {KEY_LEN} bytes; got {len(key)}")
    nonce = os.urandom(NONCE_LEN)
    aesgcm = AESGCM(key)
    ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)
    return nonce + ciphertext


def decrypt(blob: bytes, key: bytes) -> bytes:
    """AES-256-GCM decrypt. Raises :class:`BackupDecryptError` on auth failure.

    Args:
        blob: The ``nonce||ciphertext||tag`` bytes from :func:`encrypt`.
        key: The same 32-byte key used to encrypt.

    Returns:
        The original plaintext.

    Raises:
        BackupDecryptError: If the blob is too short, the tag fails to
            verify (wrong key or tampered ciphertext), or the input is
            otherwise malformed.
    """
    if len(key) != KEY_LEN:
        raise BackupError(f"key must be {KEY_LEN} bytes; got {len(key)}")
    if len(blob) < NONCE_LEN + 16:
        # 12 (nonce) + 16 (tag) = minimum; no room for ciphertext.
        raise BackupDecryptError(
            f"blob too short ({len(blob)} bytes); expected >= {NONCE_LEN + 16}",
        )
    nonce = blob[:NONCE_LEN]
    ciphertext = blob[NONCE_LEN:]
    aesgcm = AESGCM(key)
    try:
        return aesgcm.decrypt(nonce, ciphertext, associated_data=None)
    except Exception as exc:  # cryptography raises InvalidTag
        raise BackupDecryptError(f"decryption failed: {exc}") from exc


# ---------------------------------------------------------------------------
# Fingerprint
# ---------------------------------------------------------------------------


def fingerprint(data: bytes) -> str:
    """SHA-256 of ``data`` as ``"sha256:<64-hex-chars>"``."""
    return "sha256:" + hashlib.sha256(data).hexdigest()


def fingerprint_file(path: "os.PathLike[str] | str") -> str:
    """SHA-256 of a file's bytes, streamed. Memory-bounded for big DBs."""
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return "sha256:" + h.hexdigest()


# ---------------------------------------------------------------------------
# Sidecar dataclass
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Sidecar:
    """Plaintext metadata written next to each backup file.

    Not required for decryption — it's a manifest an operator
    consults to decide whether to restore. Kept intentionally
    tiny so it survives most format rotations.
    """

    format_version: str
    created_at: str           # ISO 8601 UTC
    db_fingerprint: str       # "sha256:..."
    table_count: int
    size_bytes: int
    kdf: str                  # "PBKDF2-HMAC-SHA256"
    kdf_iterations: int
    cipher: str               # "AES-256-GCM"
    key_fingerprint: str      # "sha256:..." of the derived key

    def to_json(self) -> str:
        import json
        return json.dumps(
            {
                "format_version": self.format_version,
                "created_at": self.created_at,
                "db_fingerprint": self.db_fingerprint,
                "table_count": self.table_count,
                "size_bytes": self.size_bytes,
                "encryption": {
                    "kdf": self.kdf,
                    "kdf_iterations": self.kdf_iterations,
                    "cipher": self.cipher,
                    "key_fingerprint": self.key_fingerprint,
                },
            },
            indent=2,
            sort_keys=True,
        )

    @classmethod
    def from_json(cls, text: str) -> "Sidecar":
        import json
        d = json.loads(text)
        enc = d.get("encryption") or {}
        return cls(
            format_version=d["format_version"],
            created_at=d["created_at"],
            db_fingerprint=d["db_fingerprint"],
            table_count=int(d["table_count"]),
            size_bytes=int(d["size_bytes"]),
            kdf=enc.get("kdf", "PBKDF2-HMAC-SHA256"),
            kdf_iterations=int(enc.get("kdf_iterations", KDF_ITERATIONS)),
            cipher=enc.get("cipher", "AES-256-GCM"),
            key_fingerprint=enc.get("key_fingerprint", ""),
        )


# ---------------------------------------------------------------------------
# Filename helpers
# ---------------------------------------------------------------------------


def backup_filename(timestamp: Optional[datetime] = None) -> str:
    """``cyclone-backup-YYYYMMDDTHHMMSSZ-<rand>.bin`` for a UTC timestamp.

    The random suffix is a 4-byte hex string so two backups in the
    same second don't collide on the ``db_backups`` unique index.
    """
    import secrets as _secrets
    from datetime import timezone as _tz
    ts = timestamp or datetime.now(_tz.utc)
    suffix = _secrets.token_hex(4)
    return f"cyclone-backup-{ts.strftime('%Y%m%dT%H%M%SZ')}-{suffix}.bin"


def sidecar_filename(bin_filename: str) -> str:
    """``<bin_filename>.meta.json``."""
    return bin_filename + ".meta.json"
