"""Fernet-based encryption for credential blobs.

The key is a single 32-byte value (urlsafe-base64-encoded; what
Fernet.generate_key produces) stored at /images/secrets/credential_key.b64
(mode 0600, parent dir 0700). The 2026-06-02 audit caught a silent
key-regeneration path: on a partial disaster restore where the DB was
restored but the secrets dir was lost, the old `_load_or_create_key`
would mint a fresh key with no log, producing a working-looking system
where every authenticated download failed AUTH_ERROR until the operator
re-uploaded every credential by hand. Now the constructor refuses to
auto-generate unless either:

  * the caller explicitly passes `bootstrap_ok=True` (tests, scripts), or
  * the env var `CURATOR_BOOTSTRAP_NEW_KEY=1` is set (operator opt-in
    during first-time setup).

Otherwise it raises `MissingCredentialKey` so the app fails fast at
startup and the operator can restore the key file from backup.

Operator backup procedure must include /images/secrets/ alongside the
rest of /images/ — losing the key file makes existing encrypted_blob
rows undecryptable (recovery = delete the rows and re-upload).
"""

import logging
import os
from pathlib import Path

from cryptography.fernet import Fernet, InvalidToken

log = logging.getLogger(__name__)

_BOOTSTRAP_ENV_VAR = "CURATOR_BOOTSTRAP_NEW_KEY"


class InvalidCredentialBlob(Exception):
    """Raised when decryption fails (wrong key, tampered blob, …)."""


class MissingCredentialKey(Exception):
    """The Fernet key file is missing AND the caller hasn't opted in to
    generating a new one. Audit 2026-06-02: prevents silent key
    regeneration on partial DB-restored / secrets-lost deployments.
    Set CURATOR_BOOTSTRAP_NEW_KEY=1 for first-time setup, or restore the
    key file from backup."""


class CredentialCrypto:
    """Fernet encrypt/decrypt with an on-disk key file.

    Instantiate with a path; the file is loaded if present, or created
    if absent AND the caller has opted in (bootstrap_ok=True or
    CURATOR_BOOTSTRAP_NEW_KEY=1 env var). Production sites:
    `IMAGES_ROOT / "secrets" / "credential_key.b64"`.
    """

    def __init__(self, key_path: Path, *, bootstrap_ok: bool | None = None):
        self._key_path = Path(key_path)
        if bootstrap_ok is None:
            bootstrap_ok = os.environ.get(_BOOTSTRAP_ENV_VAR) == "1"
        self._fernet = Fernet(self._load_or_create_key(bootstrap_ok))

    def _load_or_create_key(self, bootstrap_ok: bool) -> bytes:
        if self._key_path.exists():
            return self._key_path.read_bytes()
        if not bootstrap_ok:
            raise MissingCredentialKey(
                f"Fernet key file not found at {self._key_path}. "
                f"For first-time setup, set {_BOOTSTRAP_ENV_VAR}=1. "
                f"If this is a restored instance, restore the key file "
                f"from backup — generating a new one would make every "
                f"existing Credential row undecryptable."
            )
        log.warning(
            "Generating NEW Fernet credential key at %s. Any existing "
            "encrypted_blob rows in the DB will be undecryptable — "
            "re-upload each credential after this completes.",
            self._key_path,
        )
        parent = self._key_path.parent
        parent.mkdir(parents=True, exist_ok=True)
        os.chmod(parent, 0o700)
        key = Fernet.generate_key()
        self._key_path.write_bytes(key)
        os.chmod(self._key_path, 0o600)
        return key

    def encrypt(self, plaintext: str) -> bytes:
        return self._fernet.encrypt(plaintext.encode("utf-8"))

    def decrypt(self, ciphertext: bytes) -> str:
        try:
            return self._fernet.decrypt(ciphertext).decode("utf-8")
        except InvalidToken as exc:
            raise InvalidCredentialBlob(
                "credential blob is corrupt or encrypted with a different key"
            ) from exc
