"""Fernet-encrypted JSON-on-disk vault.

Backend of last resort for desktop / portable deployments where
:class:`mgf.common.vault.KeyringVault` is not available (headless
Linux without a keyring daemon, embedded CI runners that need
persistence, etc.).

Crypto profile (per rule SC-05 — OWASP 2023 baseline):

  - **Algorithm**: Fernet — AES-128-CBC + HMAC-SHA256, the
    high-level recipe from the :mod:`cryptography` library.
  - **KDF**: PBKDF2-HMAC-SHA256, 600 000 iterations, 16-byte salt.
  - **Salt**: per-vault, persisted in the file header (NOT a
    config value; never reused across vaults).
  - **At-rest permissions**: ``0o600`` (read/write owner only).

File layout::

    +--------+-------+--------+----------------------------+
    | "MGFV" | u8 v  | salt16 | Fernet token (the payload) |
    +--------+-------+--------+----------------------------+
       4         1       16              N

The payload is a Fernet-encrypted UTF-8 JSON object: ``{"key":
"value", ...}``. The whole map is decrypted, mutated, and
re-encrypted on every :meth:`set` / :meth:`delete`. This is fine
for the vault's intended scale (tens to hundreds of entries per
consumer); not for a general-purpose KV store.

Audit logging: every ``get`` / ``set`` / ``delete`` emits an
``audit=true`` log record per rule SC-17. The KEY is logged; the
VALUE is not.

Threading: NOT thread-safe across writers. The pattern's
intended usage (single-process desktop / CI) avoids the race.
"""

from __future__ import annotations

import base64
import json
import os
import struct
from typing import TYPE_CHECKING, Any

from mgf.common.exceptions import VaultError
from mgf.common.observability import get_logger

if TYPE_CHECKING:
    from pathlib import Path

    from cryptography.fernet import Fernet

_LOG = get_logger(__name__)

# File-format constants. Versioning the header lets us migrate the
# crypto profile (e.g., move to AES-GCM) without breaking existing
# vaults — bump _FILE_VERSION + handle both versions in `_decrypt`.
_MAGIC = b"MGFV"
_FILE_VERSION = 1
_SALT_BYTES = 16
_HEADER_LEN = len(_MAGIC) + 1 + _SALT_BYTES  # 4 + 1 + 16 = 21

# OWASP 2023 baseline for PBKDF2-HMAC-SHA256 (rule SC-05).
# Tests pass `_kdf_iterations=1000` so the suite stays fast; do
# NOT lower this default.
_DEFAULT_KDF_ITERATIONS = 600_000

_IMPORT_HINT = (
    "the [vault] extra is not installed. Install with "
    "`pip install mgf-common[vault]` (or add `mgf-common[vault]` "
    "to your project's dependencies)."
)


def _load_fernet() -> type[Fernet]:
    """Lazy import — see :mod:`mgf.common.vault._keyring_vault` for rationale."""
    try:
        from cryptography.fernet import Fernet
    except ImportError as e:
        raise VaultError(
            f"EncryptedFileVault requires the cryptography library, but {_IMPORT_HINT}"
        ) from e
    return Fernet


def _load_pbkdf2() -> Any:
    """Lazy import of the PBKDF2 KDF class.

    Returns an opaque box exposing ``kdf_cls`` (the PBKDF2HMAC class)
    and ``sha256`` (the hash class) so callers do not re-import the
    same names. Typed as ``Any`` because the lazy import escapes
    static type analysis — callers consume both attributes at the
    only call site.
    """
    try:
        from cryptography.hazmat.primitives import hashes
        from cryptography.hazmat.primitives.kdf.pbkdf2 import (
            PBKDF2HMAC,
        )
    except ImportError as e:
        raise VaultError(
            f"EncryptedFileVault requires the cryptography library, but {_IMPORT_HINT}"
        ) from e

    class _Boxed:
        kdf_cls = PBKDF2HMAC
        sha256 = hashes.SHA256

    return _Boxed


class EncryptedFileVault:
    """Fernet-encrypted JSON-on-disk vault.

    A vault is identified by its on-disk path + the passphrase.
    Lose the passphrase → the file is unrecoverable. The library
    does NOT escrow / store the passphrase anywhere; that is the
    consumer's problem (rule SC-12).

    Implements :class:`mgf.common.typing.VaultProtocol`.

    The Fernet instance is cached after first decryption — every
    subsequent operation on the same vault reuses it without
    re-running PBKDF2 (which is the expensive part).
    """

    __slots__ = ("_fernet", "_kdf_iterations", "_passphrase", "_path")

    def __init__(
        self,
        path: Path,
        *,
        passphrase: str,
        _kdf_iterations: int = _DEFAULT_KDF_ITERATIONS,
    ) -> None:
        """Create an encrypted-file vault.

        Parameters
        ----------
        path
            Absolute path to the vault file. The file is created
            on first :meth:`set` if it does not exist; the parent
            directory MUST already exist (we never create
            arbitrary directory trees, per rule SC-13).
        passphrase
            The vault passphrase. NOT escrowed by the library —
            consumer is responsible for prompt / retrieve flow.
        _kdf_iterations
            PBKDF2 iteration count. The default is 600 000 (OWASP
            2023 baseline for PBKDF2-HMAC-SHA256). Tests override
            to ``1000`` to keep the suite fast — do NOT lower in
            production. Underscore-prefixed because consumer code
            should never set this.
        """
        if not passphrase:
            raise VaultError("EncryptedFileVault: passphrase MUST NOT be empty")
        self._path = path
        self._passphrase = passphrase
        self._kdf_iterations = _kdf_iterations
        # Cached Fernet — populated on first read / write so the
        # PBKDF2 cost is paid once per vault instance.
        self._fernet: Fernet | None = None

    @property
    def backend(self) -> str:
        """Returns ``"file"`` — see rule DP-10 (no silent fallback)."""
        return "file"

    def get(self, key: str) -> str | None:
        """Return the secret for ``key``, or ``None`` if absent."""
        store = self._load_store()
        value = store.get(key)
        _LOG.info(
            "vault read",
            extra={
                "audit": True,
                "event": "vault.read",
                "vault_key": key,
                "vault_backend": "file",
                "found": value is not None,
            },
        )
        return value

    def set(self, key: str, value: str) -> None:
        """Store ``value`` under ``key``. Idempotent.

        Whole-file rewrite via temp+rename (atomic per CF-04) so
        a partial write cannot leave the vault corrupted.
        """
        store = self._load_store()
        store[key] = value
        self._save_store(store)
        _LOG.info(
            "vault write",
            extra={
                "audit": True,
                "event": "vault.write",
                "vault_key": key,
                "vault_backend": "file",
            },
        )

    def delete(self, key: str) -> None:
        """Remove the entry for ``key``. No-op if absent."""
        store = self._load_store()
        existed = key in store
        if existed:
            del store[key]
            self._save_store(store)
        _LOG.info(
            "vault delete",
            extra={
                "audit": True,
                "event": "vault.delete",
                "vault_key": key,
                "vault_backend": "file",
                "existed": existed,
            },
        )

    def list_keys(self) -> list[str]:
        """Return every stored key (sorted; deterministic)."""
        store = self._load_store()
        return sorted(store.keys())

    # -----------------------------------------------------------------
    # Internals
    # -----------------------------------------------------------------

    def _load_store(self) -> dict[str, str]:
        """Decrypt the on-disk vault → dict. Empty dict if file missing."""
        if not self._path.exists():
            return {}

        try:
            blob = self._path.read_bytes()
        except OSError as e:
            raise VaultError(f"EncryptedFileVault: failed to read {self._path}: {e}") from e

        if len(blob) < _HEADER_LEN:
            raise VaultError(
                f"EncryptedFileVault: {self._path} is too short to be a "
                f"valid vault file ({len(blob)} bytes; header is {_HEADER_LEN})"
            )

        magic = blob[:4]
        if magic != _MAGIC:
            raise VaultError(
                f"EncryptedFileVault: {self._path} is not a vault file "
                f"(magic {magic!r}; expected {_MAGIC!r})"
            )

        version = struct.unpack("B", blob[4:5])[0]
        if version != _FILE_VERSION:
            raise VaultError(
                f"EncryptedFileVault: {self._path} is version {version}; "
                f"this build supports {_FILE_VERSION}"
            )

        salt = blob[5 : 5 + _SALT_BYTES]
        token = blob[_HEADER_LEN:]

        fernet = self._get_or_build_fernet(salt)
        try:
            plaintext = fernet.decrypt(token)
        except Exception as e:
            raise VaultError(
                f"EncryptedFileVault: failed to decrypt {self._path} "
                f"(wrong passphrase or corrupted file)"
            ) from e

        try:
            store = json.loads(plaintext.decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as e:
            raise VaultError(
                f"EncryptedFileVault: decrypted payload of {self._path} is not valid JSON: {e}"
            ) from e

        if not isinstance(store, dict):
            raise VaultError(
                f"EncryptedFileVault: decrypted payload of {self._path} "
                f"is not a JSON object (got {type(store).__name__})"
            )

        # Defensive — the JSON must be {str: str}. Coerce + check.
        for k, v in store.items():
            if not isinstance(k, str) or not isinstance(v, str):
                raise VaultError(
                    f"EncryptedFileVault: payload of {self._path} contains "
                    f"a non-string entry ({type(k).__name__}: "
                    f"{type(v).__name__}); the file is corrupted"
                )

        return store

    def _save_store(self, store: dict[str, str]) -> None:
        """Encrypt + write the dict to disk atomically (temp+rename, 0o600).

        EncryptedFileVault is single-writer by contract; this is not safe
        against another process mutating the same file concurrently.
        """
        # Reuse the existing salt if the file already exists; otherwise
        # generate a fresh one. Salt is per-vault, never per-write.
        # F-011: read defensively — a concurrent delete between exists()
        # and read_bytes() would otherwise surface a raw FileNotFoundError
        # instead of the VaultError contract; treat a vanished file as
        # "no existing salt" (fresh salt) rather than failing.
        try:
            existing = self._path.read_bytes()
            salt = existing[5 : 5 + _SALT_BYTES]
        except FileNotFoundError:
            salt = os.urandom(_SALT_BYTES)
        except OSError as e:
            raise VaultError(
                f"EncryptedFileVault: reading existing salt from {self._path} failed: {e}"
            ) from e

        fernet = self._get_or_build_fernet(salt)
        plaintext = json.dumps(store, separators=(",", ":")).encode("utf-8")
        token = fernet.encrypt(plaintext)

        header = _MAGIC + struct.pack("B", _FILE_VERSION) + salt
        blob = header + token

        # Atomic write per CF-04 — fsync the body AND fsync the parent
        # directory so the rename is durable across power failure
        # (the vault is the last line of defence for credentials;
        # losing it on unclean shutdown is the failure class this
        # guards against). RA-01: shared helper.
        from mgf.common._io import atomic_write

        try:
            atomic_write(self._path, blob, mode=0o600, fsync_dir=True)
        except OSError as e:
            raise VaultError(f"EncryptedFileVault: atomic write to {self._path} failed: {e}") from e

    def _get_or_build_fernet(self, salt: bytes) -> Fernet:
        """Return the cached Fernet, building it on first call."""
        if self._fernet is not None:
            return self._fernet
        self._fernet = self._build_fernet(salt)
        return self._fernet

    def _build_fernet(self, salt: bytes) -> Fernet:
        """Derive a Fernet key from passphrase + salt via PBKDF2-HMAC-SHA256."""
        fernet_cls = _load_fernet()
        kdf_box = _load_pbkdf2()
        kdf = kdf_box.kdf_cls(
            algorithm=kdf_box.sha256(),
            length=32,  # Fernet keys are always 32 bytes.
            salt=salt,
            iterations=self._kdf_iterations,
        )
        # Fernet keys must be 32 url-safe-base64-encoded bytes.
        derived = kdf.derive(self._passphrase.encode("utf-8"))
        return fernet_cls(base64.urlsafe_b64encode(derived))

    def __repr__(self) -> str:
        return f"EncryptedFileVault(path={self._path!s})"


__all__ = ["EncryptedFileVault"]
