"""Encrypted credential store using Fernet (AES-128-CBC + HMAC).

Credentials are encrypted at rest. The encryption key is automatically
derived from your machine identity (hostname + username + install path).
No configuration needed — works out of the box.

No plaintext passwords ever touch disk.
"""

from __future__ import annotations

import base64
import contextlib
import hashlib
import json
import logging
import os
import platform
import stat
from pathlib import Path

from cryptography.fernet import Fernet, InvalidToken

log = logging.getLogger(__name__)


def _derive_key(master: str | None = None, home_dir: Path | None = None) -> bytes:
    """Derive a Fernet key automatically from machine identity.

    Uses hostname + OS username + install path to create a unique key
    per machine/user. No user configuration needed.

    The optional master param exists only for testing.
    """
    if master:
        seed = master
    else:
        parts = [
            platform.node(),
            os.getenv("USERNAME", os.getenv("USER", "agntspace")),
            str(home_dir or Path.home() / ".agntspace"),
            "agntspace-credential-store-v1",
        ]
        seed = "|".join(parts)

    # SHA-256 → 32 bytes → base64-encode first 32 bytes for Fernet (needs url-safe base64)
    raw = hashlib.sha256(seed.encode("utf-8")).digest()
    return base64.urlsafe_b64encode(raw)


class CredentialStore:
    """Encrypted credential storage using Fernet symmetric encryption.

    All data is encrypted before writing to disk.
    Decrypted only in memory when needed.
    """

    def __init__(self, path: Path, master_key: str | None = None) -> None:
        self._path = path
        self._key = _derive_key(master=master_key, home_dir=path.parent.parent)
        self._fernet = Fernet(self._key)
        self._data: dict[str, dict] = {}
        self._load()

    def get(self, service: str) -> dict | None:
        """Get decrypted credentials for a service."""
        return self._data.get(service)

    def set(self, service: str, credentials: dict) -> None:
        """Encrypt and store credentials for a service."""
        self._data[service] = credentials
        self._save()
        log.info("Credentials saved (encrypted) for: %s", service)

    def delete(self, service: str) -> None:
        """Remove credentials for a service."""
        if service in self._data:
            del self._data[service]
            self._save()
            log.info("Credentials removed for: %s", service)

    def list_services(self) -> list[str]:
        """List services with stored credentials."""
        return list(self._data.keys())

    def has(self, service: str) -> bool:
        return service in self._data

    def _load(self) -> None:
        """Load and decrypt credentials from disk."""
        if not self._path.exists():
            self._data = {}
            return

        try:
            encrypted = self._path.read_bytes()
            decrypted = self._fernet.decrypt(encrypted)
            self._data = json.loads(decrypted.decode("utf-8"))
        except InvalidToken:
            log.error(
                "Failed to decrypt credentials — machine identity may have changed. "
                "Re-enter your integration credentials in the control panel, "
                "or delete %s to reset.",
                self._path,
            )
            self._data = {}
        except (json.JSONDecodeError, OSError):
            log.warning("Failed to load credentials from %s", self._path)
            self._data = {}

    def _save(self) -> None:
        """Encrypt and write credentials to disk."""
        self._path.parent.mkdir(parents=True, exist_ok=True)

        plaintext = json.dumps(self._data).encode("utf-8")
        encrypted = self._fernet.encrypt(plaintext)
        self._path.write_bytes(encrypted)

        # Restrict file permissions (owner only read/write)
        with contextlib.suppress(OSError):
            os.chmod(self._path, stat.S_IRUSR | stat.S_IWUSR)


# ─────────────────────────────────────────────────────────────────────────────
# Channel credential helpers
#
# Channels (Telegram, Discord, Slack) historically persisted their bot tokens
# to ``config.toml`` in plaintext via ``[channels.<name>] bot_token = "..."``.
# That's a real security issue — anyone with read access to the user's home
# directory could lift an active bot token. These helpers move channel
# credentials into the encrypted Fernet store under service key
# ``channel_<name>``, with backward-compat fallback to config.toml so existing
# deployments don't break before the boot-time migration runs.
# ─────────────────────────────────────────────────────────────────────────────

# Channels whose credentials we recognize as token-style secrets and migrate.
# Adding a new bot-token channel (e.g. "matrix") means adding it here AND
# implementing the channel class — they're paired by construction. This is
# deterministic security infrastructure, not user-tunable strategy.
_TOKEN_CHANNELS: tuple[str, ...] = ("telegram", "discord", "slack")  # arch:deterministic — security vocabulary, mirrors the bot-token channel implementations in channels/{telegram,discord,slack}.py and is paired with their constructors; no skill or YAML can change which channel produces secret material
_SECRET_KEYS: frozenset[str] = frozenset({"bot_token", "token", "access_token", "api_key"})  # arch:deterministic — security vocabulary; the canonical set of frontmatter keys treated as secrets across the encrypted-credentials guard, the migration scanner, and the CI plaintext-credential test; tuning this would silently widen or narrow what counts as a secret


def channel_service_name(channel: str) -> str:
    """Canonical credential-store key for a channel ('telegram' → 'channel_telegram')."""
    return f"channel_{channel}"


def _has_secret(creds: dict | None) -> bool:
    if not isinstance(creds, dict):
        return False
    return any(creds.get(k) for k in _SECRET_KEYS)


def load_channel_credentials(
    channel: str,
    *,
    credential_store: CredentialStore | None,
    settings_service: object | None,
) -> dict:
    """Read channel credentials.

    Encrypted store wins; config.toml is a backward-compat fallback for
    deployments where the boot-time migration hasn't run yet (e.g. an
    older vault opened by a newer build before lifespan startup completes).
    """
    if credential_store is not None:
        existing = credential_store.get(channel_service_name(channel))
        if isinstance(existing, dict) and existing:
            return dict(existing)
    if settings_service is not None:
        try:
            section = settings_service.get_section("channels") or {}
            return dict(section.get(channel, {}) or {})
        except Exception:
            return {}
    return {}


def save_channel_credentials(
    channel: str,
    creds: dict,
    *,
    credential_store: CredentialStore | None,
    settings_service: object | None,
) -> None:
    """Persist channel credentials to the encrypted store and strip any
    legacy plaintext copy from config.toml."""
    if credential_store is not None:
        credential_store.set(channel_service_name(channel), creds)
    _strip_plaintext_channel(channel, settings_service)


def clear_channel_credentials(
    channel: str,
    *,
    credential_store: CredentialStore | None,
    settings_service: object | None,
) -> None:
    """Remove channel credentials from BOTH stores (idempotent)."""
    if credential_store is not None:
        with contextlib.suppress(Exception):
            credential_store.delete(channel_service_name(channel))
    _strip_plaintext_channel(channel, settings_service)


def _strip_plaintext_channel(channel: str, settings_service: object | None) -> None:
    """Remove ``[channels.<name>]`` from config.toml if present. Idempotent."""
    if settings_service is None:
        return
    try:
        all_cfg = settings_service.get_all()
    except Exception:
        return
    channels = all_cfg.get("channels")
    if not isinstance(channels, dict) or channel not in channels:
        return
    del channels[channel]
    all_cfg["channels"] = channels
    try:
        settings_service._write(all_cfg)  # noqa: SLF001 — internal write API
    except Exception:
        log.exception("Failed to strip plaintext credentials for channel %s", channel)


def migrate_plaintext_channel_credentials(
    *,
    credential_store: CredentialStore | None,
    settings_service: object | None,
    channels: tuple[str, ...] = _TOKEN_CHANNELS,
    activity_logger: object | None = None,
) -> list[str]:
    """One-time idempotent migration: move plaintext channel tokens out of
    ``config.toml`` and into the encrypted credential store.

    Safe to call on every boot. Returns the list of channels migrated this run
    (empty when nothing needed migrating). Emits a high-importance ``security``
    activity event per channel so the user has an audit trail.

    Conflict policy: when both stores hold credentials for the same channel,
    the encrypted copy wins. The plaintext copy is always stripped from
    config.toml — that's the load-bearing security guarantee.
    """
    if credential_store is None or settings_service is None:
        return []
    try:
        all_cfg = settings_service.get_all()
    except Exception:
        return []
    section = all_cfg.get("channels")
    if not isinstance(section, dict):
        return []
    section = dict(section)  # don't mutate the live reference until we're done

    migrated: list[str] = []
    for channel in channels:
        creds = section.get(channel)
        if not _has_secret(creds):
            continue
        existing = credential_store.get(channel_service_name(channel))
        if _has_secret(existing):
            # Encrypted store already owns this channel — strip plaintext only.
            del section[channel]
            migrated.append(channel)
        else:
            credential_store.set(channel_service_name(channel), dict(creds))
            del section[channel]
            migrated.append(channel)
        if activity_logger is not None:
            with contextlib.suppress(Exception):
                activity_logger.log(
                    category="security",
                    action="channel_creds_migrated",
                    summary=f"Moved {channel} credentials from plaintext config.toml to encrypted store",
                    importance="high",
                    details={"channel": channel},
                )

    if not migrated:
        return []
    all_cfg["channels"] = section
    try:
        settings_service._write(all_cfg)  # noqa: SLF001 — internal write API
    except Exception:
        log.exception("Failed to write config.toml after migrating channel credentials")
        # Don't roll back the credential_store — encrypted copy is authoritative.
    return migrated
