"""
Core SafeConfig class.

This module provides the primary public interface of the library.  It ties
together loaders, the cryptographic engine, environment management, and
validation into a single, easy-to-use object.

Typical usage::

    from pysafeconfigx import SafeConfig

    config = SafeConfig(
        environment="production",
        required_keys=["DATABASE_URL", "SECRET_KEY"],
    )
    config.load()

    db_url  = config.get("DATABASE_URL")
    secret  = config.get("SECRET_KEY", decrypt=True)
"""

from __future__ import annotations

import logging
import os
import warnings
from pathlib import Path
from typing import Any

from pysafeconfigx.core.crypto import (
    decrypt_value,
    encrypt_value,
    generate_master_key,
    is_encrypted_blob,
    load_master_key,
)
from pysafeconfigx.core.exceptions import (
    ConfigurationError,
    ExposedKeyWarning,
    InvalidEnvironmentError,
    KeyNotFoundError,
    MasterKeyError,
    MissingRequiredKeyError,
)
from pysafeconfigx.core.loaders import (
    BaseLoader,
    DotEnvLoader,
    EnvironmentLoader,
    JsonLoader,
    YamlLoader,
)

logger = logging.getLogger(__name__)

# Environments that SafeConfig recognises out of the box.
VALID_ENVIRONMENTS = ["development", "staging", "production", "test", "local"]

# Environment variable that holds the master encryption key.
MASTER_KEY_ENV_VAR = "SAFECONFIG_MASTER_KEY"

# Prefix used to mark encrypted values stored in plain-text sources.
ENCRYPTED_PREFIX = "enc:"


class SafeConfig:
    """Secure, multi-source configuration manager.

    SafeConfig aggregates configuration values from multiple sources (OS
    environment, .env files, JSON, YAML) in a defined priority order, provides
    transparent AES-256-GCM decryption for sensitive values, and validates that
    all required keys are present before the application can start.

    Priority order (highest to lowest):
        1. OS environment variables
        2. Loaders added via :meth:`add_loader` (in order added)
        3. Auto-detected environment-specific file (e.g. ``.env.production``)
        4. Base ``.env`` file

    Args:
        environment: Target environment name (``"development"``,
            ``"production"``, etc.).  Defaults to the value of the
            ``APP_ENV`` environment variable, falling back to
            ``"development"``.
        required_keys: Keys that *must* be present when :meth:`load` is
            called.  A :class:`~safeconfig.core.exceptions.MissingRequiredKeyError`
            is raised if any are absent.
        config_dir: Directory that contains .env / JSON / YAML files.
            Defaults to the current working directory.
        master_key: URL-safe base64-encoded AES master key.  If *None*,
            SafeConfig reads the key from the ``SAFECONFIG_MASTER_KEY``
            environment variable.
        auto_load_env: If *True* (default), automatically add a
            :class:`~safeconfig.core.loaders.DotEnvLoader` for both the
            base ``.env`` file and the environment-specific variant.
        strict: If *True*, raise :class:`~safeconfig.core.exceptions.KeyNotFoundError`
            when a key is not found instead of returning *default*.

    Raises:
        InvalidEnvironmentError: If *environment* is not in
            :data:`VALID_ENVIRONMENTS`.
    """

    def __init__(
        self,
        *,
        environment: str | None = None,
        required_keys: list[str] | None = None,
        config_dir: str | Path = ".",
        master_key: str | None = None,
        auto_load_env: bool = True,
        strict: bool = False,
    ) -> None:
        self._environment = self._resolve_environment(environment)
        self._required_keys: set[str] = set(required_keys or [])
        self._config_dir = Path(config_dir)
        self._master_key: str | None = master_key
        self._strict = strict
        self._auto_load_env = auto_load_env

        self._loaders: list[BaseLoader] = []
        self._data: dict[str, str] = {}
        self._loaded = False

        if self._auto_load_env:
            self._register_default_loaders()

        logger.info(
            "SafeConfig initialised [env=%s, required=%d key(s)].",
            self._environment,
            len(self._required_keys),
        )

    # ── Public properties ────────────────────────────────────────────────────

    @property
    def environment(self) -> str:
        """Current environment name."""
        return self._environment

    @property
    def is_loaded(self) -> bool:
        """Return *True* after :meth:`load` has been called."""
        return self._loaded

    @property
    def master_key(self) -> str:
        """Return the master encryption key, loading it on demand.

        Raises:
            MasterKeyError: If no key is configured.
        """
        if self._master_key is not None:
            return self._master_key

        env_key = os.environ.get(MASTER_KEY_ENV_VAR)
        if env_key:
            # Validate format early for a helpful error message.
            load_master_key(env_key)
            self._master_key = env_key
            return self._master_key

        raise MasterKeyError(
            f"No master key found.  Set the '{MASTER_KEY_ENV_VAR}' environment variable "
            "or pass 'master_key' to SafeConfig()."
        )

    # ── Loader management ────────────────────────────────────────────────────

    def add_loader(self, loader: BaseLoader) -> "SafeConfig":
        """Register an additional configuration loader.

        Loaders registered here are consulted *after* the OS environment but
        before auto-detected .env files.

        Args:
            loader: Any :class:`~safeconfig.core.loaders.BaseLoader` subclass
                instance.

        Returns:
            *self* for method chaining.

        Example::

            config.add_loader(JsonLoader("config/database.json"))
        """
        self._loaders.append(loader)
        logger.debug("Registered loader: %s('%s').", type(loader).__name__, loader.path)
        return self

    def add_json(self, path: str | Path) -> "SafeConfig":
        """Convenience method to add a :class:`~safeconfig.core.loaders.JsonLoader`.

        Args:
            path: Path to the JSON file.

        Returns:
            *self* for method chaining.
        """
        return self.add_loader(JsonLoader(path))

    def add_yaml(self, path: str | Path) -> "SafeConfig":
        """Convenience method to add a :class:`~safeconfig.core.loaders.YamlLoader`.

        Args:
            path: Path to the YAML file.

        Returns:
            *self* for method chaining.
        """
        return self.add_loader(YamlLoader(path))

    # ── Loading ──────────────────────────────────────────────────────────────

    def load(self, *, force: bool = False) -> "SafeConfig":
        """Load configuration from all registered sources and validate.

        Sources are merged in priority order (OS env overrides everything).
        After merging, :meth:`_validate_required_keys` is called.

        Args:
            force: Re-load even if already loaded.

        Returns:
            *self* for method chaining.

        Raises:
            MissingRequiredKeyError: If any required key is absent after
                merging all sources.
        """
        if self._loaded and not force:
            logger.debug("Configuration already loaded; use force=True to reload.")
            return self

        merged: dict[str, str] = {}

        # 1. Load from registered loaders (lowest priority — applied first).
        for loader in reversed(self._loaders):
            try:
                merged.update(loader.load(force=force))
            except ConfigurationError:
                raise
            except Exception as exc:
                raise ConfigurationError(
                    f"Loader {type(loader).__name__}('{loader.path}') failed: {exc}"
                ) from exc

        # 2. OS environment has highest priority — applied last.
        merged.update(EnvironmentLoader().load())

        self._data = merged
        self._loaded = True

        logger.info("Configuration loaded from %d source(s).", len(self._loaders) + 1)

        # 3. Validate required keys.
        self._validate_required_keys()

        return self

    def reload(self) -> "SafeConfig":
        """Force a full reload of all configuration sources.

        Returns:
            *self* for method chaining.
        """
        return self.load(force=True)

    # ── Value access ─────────────────────────────────────────────────────────

    def get(
        self,
        key: str,
        default: str | None = None,
        *,
        decrypt: bool = False,
    ) -> str | None:
        """Retrieve a configuration value by key.

        Args:
            key: Configuration key to look up.
            default: Value to return if *key* is not found and *strict* mode
                is off.
            decrypt: If ``True``, attempt to decrypt the value using
                :func:`~safeconfig.core.crypto.decrypt_value` before returning.

        Returns:
            The configuration value, or *default* if not found.

        Raises:
            KeyNotFoundError: If *key* is not found and ``strict=True`` or
                *default* is ``None`` in strict mode.
            RuntimeError: If :meth:`load` has not been called yet.

        Example::

            value = config.get("API_KEY", decrypt=True)
        """
        self._ensure_loaded()
        raw = self._data.get(key)

        if raw is None:
            if self._strict:
                raise KeyNotFoundError(key)
            return default

        if decrypt or (raw.startswith(ENCRYPTED_PREFIX) or is_encrypted_blob(raw)):
            actual_blob = raw[len(ENCRYPTED_PREFIX):] if raw.startswith(ENCRYPTED_PREFIX) else raw
            raw = decrypt_value(actual_blob, self.master_key, config_key=key)

        return raw

    def require(self, key: str, *, decrypt: bool = False) -> str:
        """Retrieve a required configuration value, raising if absent.

        Equivalent to :meth:`get` but always raises on missing keys and never
        returns ``None``.

        Args:
            key: Configuration key to look up.
            decrypt: If ``True``, decrypt the value before returning.

        Returns:
            The configuration value as a non-``None`` string.

        Raises:
            KeyNotFoundError: If *key* is not found.
        """
        self._ensure_loaded()
        value = self.get(key, decrypt=decrypt)
        if value is None:
            raise KeyNotFoundError(key)
        return value

    def get_int(self, key: str, default: int | None = None) -> int | None:
        """Retrieve a configuration value as an integer.

        Args:
            key: Configuration key.
            default: Fallback integer value.

        Returns:
            Integer value or *default*.

        Raises:
            ConfigurationError: If the value cannot be converted to int.
        """
        raw = self.get(key)
        if raw is None:
            return default
        try:
            return int(raw)
        except ValueError as exc:
            raise ConfigurationError(
                f"Cannot convert '{key}' value '{raw}' to int."
            ) from exc

    def get_bool(self, key: str, default: bool | None = None) -> bool | None:
        """Retrieve a configuration value as a boolean.

        Truthy strings: ``"1"``, ``"true"``, ``"yes"``, ``"on"`` (case-insensitive).
        Falsy strings: ``"0"``, ``"false"``, ``"no"``, ``"off"`` (case-insensitive).

        Args:
            key: Configuration key.
            default: Fallback boolean value.

        Returns:
            Boolean value or *default*.

        Raises:
            ConfigurationError: If the value is not a recognised boolean string.
        """
        raw = self.get(key)
        if raw is None:
            return default
        if raw.lower() in {"1", "true", "yes", "on"}:
            return True
        if raw.lower() in {"0", "false", "no", "off"}:
            return False
        raise ConfigurationError(
            f"Cannot convert '{key}' value '{raw}' to bool. "
            "Use one of: true/false, yes/no, on/off, 1/0."
        )

    def get_list(
        self,
        key: str,
        default: list[str] | None = None,
        *,
        separator: str = ",",
    ) -> list[str] | None:
        """Retrieve a comma-separated configuration value as a list.

        Args:
            key: Configuration key.
            default: Fallback list.
            separator: String separator between values.

        Returns:
            List of stripped strings, or *default*.
        """
        raw = self.get(key)
        if raw is None:
            return default
        return [item.strip() for item in raw.split(separator) if item.strip()]

    def all(self) -> dict[str, str]:
        """Return a copy of all loaded configuration data.

        Returns:
            Shallow copy of the internal data dict.

        Warning:
            Encrypted blobs are returned as-is; call :meth:`get` with
            ``decrypt=True`` to decrypt individual values.
        """
        self._ensure_loaded()
        return dict(self._data)

    def keys(self) -> list[str]:
        """Return sorted list of all configuration keys.

        Returns:
            Sorted list of key strings.
        """
        self._ensure_loaded()
        return sorted(self._data.keys())

    # ── Encryption helpers ───────────────────────────────────────────────────

    def encrypt(self, key: str) -> str:
        """Encrypt a plaintext value and store the blob back in the config.

        The original plaintext is read from the current config data, encrypted,
        and stored with the ``enc:`` prefix so future reads auto-decrypt.

        Args:
            key: Configuration key whose value should be encrypted.

        Returns:
            The encrypted blob string (with ``enc:`` prefix stripped).

        Raises:
            KeyNotFoundError: If *key* does not exist.
            EncryptionError: If encryption fails.
        """
        self._ensure_loaded()
        plaintext = self.require(key)
        blob = encrypt_value(plaintext, self.master_key, config_key=key)
        self._data[key] = f"{ENCRYPTED_PREFIX}{blob}"
        logger.info("Value for key '%s' encrypted in-memory.", key)
        return blob

    def set(self, key: str, value: str, *, encrypt: bool = False) -> None:
        """Set a configuration value in-memory.

        This does NOT persist the value to any file.  Use the CLI command
        ``safeconfig set`` or write to your .env manually.

        Args:
            key: Configuration key.
            value: Value to store.
            encrypt: If ``True``, encrypt *value* before storing.
        """
        self._ensure_loaded()
        if encrypt:
            blob = encrypt_value(value, self.master_key, config_key=key)
            self._data[key] = f"{ENCRYPTED_PREFIX}{blob}"
        else:
            self._data[key] = value
        logger.debug("Set key '%s' in-memory (encrypted=%s).", key, encrypt)

    # ── Key generation ───────────────────────────────────────────────────────

    @staticmethod
    def generate_master_key() -> str:
        """Generate a new random master encryption key.

        Returns:
            URL-safe base64-encoded 256-bit key.

        Example::

            key = SafeConfig.generate_master_key()
            # Store in SAFECONFIG_MASTER_KEY env var or .env
        """
        return generate_master_key()

    # ── Private helpers ──────────────────────────────────────────────────────

    def _resolve_environment(self, environment: str | None) -> str:
        """Determine the current environment name."""
        env = environment or os.environ.get("APP_ENV") or os.environ.get("ENVIRONMENT") or "development"
        if env not in VALID_ENVIRONMENTS:
            raise InvalidEnvironmentError(env, VALID_ENVIRONMENTS)
        return env

    def _register_default_loaders(self) -> None:
        """Register the base and environment-specific .env loaders."""
        base_env = self._config_dir / ".env"
        env_specific = self._config_dir / f".env.{self._environment}"

        # Registered in reverse priority (first loaded = lowest priority).
        if base_env.exists() or True:  # Always register; load() handles missing files.
            self._loaders.append(DotEnvLoader(base_env))

        if str(env_specific) != str(base_env):
            self._loaders.append(DotEnvLoader(env_specific))

    def _validate_required_keys(self) -> None:
        """Raise if any required key is missing from the merged config."""
        missing = {
            key for key in self._required_keys if key not in self._data
        }
        if missing:
            raise MissingRequiredKeyError(missing)

    def _ensure_loaded(self) -> None:
        """Raise if load() has not been called yet."""
        if not self._loaded:
            raise RuntimeError(
                "Configuration has not been loaded.  Call .load() first, "
                "or use lazy=True on __init__ (not yet supported — planned for v2)."
            )

    # ── Dunder helpers ───────────────────────────────────────────────────────

    def __repr__(self) -> str:
        return (
            f"SafeConfig(environment={self._environment!r}, "
            f"loaded={self._loaded}, "
            f"keys={len(self._data)})"
        )

    def __contains__(self, key: object) -> bool:
        self._ensure_loaded()
        return key in self._data

    def __getitem__(self, key: str) -> str:
        value = self.get(key)
        if value is None:
            raise KeyNotFoundError(key)
        return value

    def _warn_exposed_keys(self, source_code: str) -> None:
        """Scan *source_code* for suspicious hard-coded secrets and emit warnings.

        This is a best-effort heuristic; it will produce false positives.

        Args:
            source_code: Python source code to scan.
        """
        import re

        patterns = [
            r'(?:api_key|secret|password|token|passwd)\s*=\s*["\']([^"\']{8,})["\']',
            r'(?:AWS|AZURE|GCP|GOOGLE)_SECRET\s*=\s*["\']([^"\']{8,})["\']',
        ]
        for pattern in patterns:
            for match in re.finditer(pattern, source_code, re.IGNORECASE):
                suspect = match.group(0)
                warnings.warn(
                    f"Possible hard-coded secret detected: {suspect!r}. "
                    "Consider moving it to a .env file and using SafeConfig.",
                    ExposedKeyWarning,
                    stacklevel=3,
                )
