# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Credential encryption and unified keyring for Familiar.

Two layers:

  1. **encrypt_password / decrypt_password** — Fernet wrapper for individual
     password fields in JSON registries (email_accounts, calendar_accounts).
     This is the original API and remains unchanged.

  2. **Keyring** — a centralized, Fernet-encrypted JSON store at
     ``~/.familiar/.keys/keyring.json`` (chmod 600). Any subsystem can
     ``keyring_set(namespace, key, value)`` / ``keyring_get(namespace, key)``
     instead of scattering plaintext secrets across multiple files.

A dedicated Fernet key is auto-generated at ~/.familiar/.keys/credentials.key
on first use. This is intentionally separate from FAMILIAR_ENCRYPTION_KEY
(which encrypts sessions and memory) so credential rotation is independent.

Graceful degradation: if the cryptography package is not installed, passwords
are stored as plaintext with a warning logged. The keyring falls back to
chmod-600 plaintext JSON.

Migration: Fernet tokens always start with 'gAAAAA' (urlsafe-b64 of the
version byte). Anything else is treated as plaintext and transparently returned
as-is, so existing unencrypted registries work without a migration step.
"""

import json
import logging
import os
import threading
from pathlib import Path

logger = logging.getLogger(__name__)

# Fernet tokens always begin with this prefix (version byte 0x80 → urlsafe-b64).
# Used to detect plaintext passwords during migration.
_FERNET_PREFIX = "gAAAAA"

# Override this in tests via monkeypatch to redirect the key file.
_key_file_override: "Path | None" = None


def _key_file() -> Path:
    return _key_file_override or (Path.home() / ".familiar" / ".keys" / "credentials.key")


def _get_or_create_key() -> "bytes | None":
    """Load or auto-generate a Fernet key. Returns None if cryptography unavailable."""
    try:
        from cryptography.fernet import Fernet
    except ImportError:
        return None

    kf = _key_file()
    if kf.exists():
        return kf.read_bytes()

    # Generate and persist a new key.
    key = Fernet.generate_key()
    kf.parent.mkdir(parents=True, exist_ok=True)
    kf.write_bytes(key)
    kf.chmod(0o600)
    logger.info("Generated credential encryption key: %s", kf)
    return key


def encrypt_password(plaintext: str) -> str:
    """Encrypt a credential password for at-rest storage.

    Returns the plaintext unchanged (with a warning) if the cryptography
    package is not installed.
    """
    if not plaintext:
        return plaintext

    key = _get_or_create_key()
    if key is None:
        logger.warning(
            "cryptography package not installed — credential stored without encryption. "
            "Install it with: pip install 'familiar-agent[encryption]'"
        )
        return plaintext

    from cryptography.fernet import Fernet

    return Fernet(key).encrypt(plaintext.encode()).decode()


def decrypt_password(stored: str) -> str:
    """Decrypt a stored credential password.

    Falls back to returning the stored value unchanged when:
    - The value is not a Fernet token (plaintext migration case).
    - The cryptography package is not installed.
    - Decryption fails (wrong key or corrupted data — logs a warning).
    """
    if not stored:
        return stored

    # Fast-path: not a Fernet token → plaintext (migration or no-encryption case).
    if not stored.startswith(_FERNET_PREFIX):
        return stored

    key = _get_or_create_key()
    if key is None:
        return stored

    try:
        from cryptography.fernet import Fernet

        return Fernet(key).decrypt(stored.encode()).decode()
    except Exception:
        logger.warning(
            "Failed to decrypt stored credential — returning stored value as-is. "
            "If this persists, re-enter the password via /connect."
        )
        return stored


# ═══════════════════════════════════════════════════════════════════════════════
# UNIFIED KEYRING
# ═══════════════════════════════════════════════════════════════════════════════

_keyring_lock = threading.Lock()
_KEYRING_FILE = Path.home() / ".familiar" / ".keys" / "keyring.json"


def _load_keyring() -> dict:
    """Load the keyring from disk. Values are Fernet-encrypted strings."""
    if not _KEYRING_FILE.exists():
        return {}
    try:
        return json.loads(_KEYRING_FILE.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as e:
        logger.warning("Failed to load keyring: %s", e)
        return {}


def _save_keyring(data: dict):
    """Save the keyring to disk with restrictive permissions."""
    _KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
    _KEYRING_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
    try:
        os.chmod(_KEYRING_FILE, 0o600)
    except OSError:
        pass


def keyring_set(namespace: str, key: str, value: str) -> None:
    """Store a secret in the keyring (encrypted if cryptography available).

    Args:
        namespace: Subsystem identifier (e.g. ``"dashboard"``, ``"service.gitea"``,
                   ``"google.calendar"``, ``"llm.anthropic"``).
        key: Credential key within the namespace (e.g. ``"api_key"``, ``"password"``).
        value: The secret value to store.
    """
    with _keyring_lock:
        data = _load_keyring()
        ns = data.setdefault(namespace, {})
        ns[key] = encrypt_password(value)
        _save_keyring(data)


def keyring_get(namespace: str, key: str) -> str | None:
    """Retrieve a secret from the keyring.

    Returns None if the namespace/key doesn't exist.
    """
    with _keyring_lock:
        data = _load_keyring()
    ns = data.get(namespace, {})
    stored = ns.get(key)
    if stored is None:
        return None
    return decrypt_password(stored)


def keyring_delete(namespace: str, key: str | None = None) -> bool:
    """Delete a key or entire namespace from the keyring.

    Args:
        namespace: The namespace to target.
        key: Specific key to delete. If None, deletes the entire namespace.

    Returns True if anything was deleted.
    """
    with _keyring_lock:
        data = _load_keyring()
        if key is None:
            if namespace in data:
                del data[namespace]
                _save_keyring(data)
                return True
        else:
            ns = data.get(namespace, {})
            if key in ns:
                del ns[key]
                if not ns:
                    del data[namespace]
                else:
                    data[namespace] = ns
                _save_keyring(data)
                return True
    return False


def keyring_list(namespace: str | None = None) -> dict:
    """List keyring contents (keys only, no values).

    Args:
        namespace: If given, lists keys within that namespace.
                   If None, lists all namespaces with their key counts.
    """
    with _keyring_lock:
        data = _load_keyring()
    if namespace is not None:
        return {k: "***" for k in data.get(namespace, {})}
    return {ns: len(keys) for ns, keys in data.items()}


def keyring_migrate_env(namespace: str, env_var: str, key: str = "api_key") -> bool:
    """Migrate a secret from an environment variable into the keyring.

    If the env var is set and the keyring doesn't already have this key,
    stores it encrypted and returns True. Does NOT clear the env var
    (callers can do that if they want).
    """
    value = os.environ.get(env_var, "")
    if not value:
        return False
    existing = keyring_get(namespace, key)
    if existing:
        return False  # already migrated
    keyring_set(namespace, key, value)
    logger.info("Migrated %s → keyring[%s.%s]", env_var, namespace, key)
    return True
