"""Symmetric encryption helper for at-rest secrets.

Uses AES-256-GCM. The master key is generated once and stored at
``data/.secret_key`` with permissions tightened to the current user.

Envelope format (single base64 string)::

    ENC:v1:<base64(nonce(12) || ciphertext || tag(16))>
"""

from __future__ import annotations

import base64
import os
import secrets
import sys
from pathlib import Path

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

from ledgrab.utils import get_logger

logger = get_logger(__name__)

_ENVELOPE_PREFIX = "ENC:v1:"
_NONCE_BYTES = 12
_KEY_BYTES = 32  # AES-256

_DEFAULT_KEY_PATH = Path("data/.secret_key")

_cached_key: bytes | None = None
_cached_path: Path | None = None


def _key_path() -> Path:
    """Resolve the secret-key file path (overridable via env var for tests)."""
    override = os.getenv("LEDGRAB_SECRET_KEY_PATH")
    if override:
        return Path(override)
    return _DEFAULT_KEY_PATH


def _tighten_perms(path: Path) -> None:
    """Set 0600 on POSIX. Best-effort ACL on Windows (no-op fallback)."""
    if sys.platform == "win32":
        # NTFS ACL hardening is best-effort; skip if pywin32 not available.
        try:
            import ntsecuritycon as con
            import win32security  # type: ignore

            user, _, _ = win32security.LookupAccountName("", os.getlogin())
            sd = win32security.SECURITY_DESCRIPTOR()
            dacl = win32security.ACL()
            dacl.AddAccessAllowedAce(win32security.ACL_REVISION, con.FILE_ALL_ACCESS, user)
            sd.SetSecurityDescriptorDacl(1, dacl, 0)
            win32security.SetFileSecurity(str(path), win32security.DACL_SECURITY_INFORMATION, sd)
        except Exception:
            # Fallback: leave default perms (user folder is already user-owned).
            pass
    else:
        try:
            os.chmod(path, 0o600)
        except OSError:
            pass


def _load_or_create_key() -> bytes:
    """Load the master key from disk, creating it on first use."""
    global _cached_key, _cached_path
    path = _key_path()
    if _cached_key is not None and _cached_path == path:
        return _cached_key
    if path.exists():
        key = path.read_bytes()
        if len(key) != _KEY_BYTES:
            raise RuntimeError(
                f"Corrupt secret key at {path}: expected {_KEY_BYTES} bytes, " f"got {len(key)}"
            )
    else:
        path.parent.mkdir(parents=True, exist_ok=True)
        key = secrets.token_bytes(_KEY_BYTES)
        path.write_bytes(key)
        _tighten_perms(path)
        logger.info("Generated new secret key at %s", path)
    _cached_key = key
    _cached_path = path
    return key


def is_encrypted(value: str) -> bool:
    """Return True when *value* matches the encryption envelope format."""
    return isinstance(value, str) and value.startswith(_ENVELOPE_PREFIX)


def encrypt(plaintext: str) -> str:
    """Encrypt *plaintext* and return an envelope string."""
    if not isinstance(plaintext, str):
        raise TypeError("plaintext must be str")
    if is_encrypted(plaintext):
        return plaintext  # already encrypted — no-op
    key = _load_or_create_key()
    aes = AESGCM(key)
    nonce = secrets.token_bytes(_NONCE_BYTES)
    ct = aes.encrypt(nonce, plaintext.encode("utf-8"), None)
    blob = nonce + ct
    return _ENVELOPE_PREFIX + base64.b64encode(blob).decode("ascii")


def decrypt(ciphertext: str) -> str:
    """Decrypt an envelope string produced by :func:`encrypt`.

    Returns the input unchanged if it is not in envelope format (so callers
    can pass through legacy plaintext during migrations).
    """
    if not is_encrypted(ciphertext):
        return ciphertext
    key = _load_or_create_key()
    blob = base64.b64decode(ciphertext[len(_ENVELOPE_PREFIX) :])
    if len(blob) < _NONCE_BYTES + 16:
        raise ValueError("Ciphertext envelope too short")
    nonce, ct = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
    aes = AESGCM(key)
    return aes.decrypt(nonce, ct, None).decode("utf-8")


def reset_for_tests() -> None:
    """Clear the in-memory key cache (used by unit tests)."""
    global _cached_key, _cached_path
    _cached_key = None
    _cached_path = None
