"""
Configuration loaders for SafeConfig.

Each loader is responsible for reading key-value pairs from a specific source
format (.env, JSON, YAML) and returning them as a plain ``dict[str, str]``.

All loaders are *lazy* by design: they do nothing until :meth:`load` is called.
"""

from __future__ import annotations

import json
import logging
import os
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any

import yaml
from dotenv import dotenv_values

from pysafeconfigx.core.exceptions import ConfigurationError

logger = logging.getLogger(__name__)


# ─── Abstract base ───────────────────────────────────────────────────────────


class BaseLoader(ABC):
    """Abstract base class for all configuration loaders.

    Attributes:
        path: Path to the configuration file.
        _data: Internal cache populated on first :meth:`load` call.
    """

    def __init__(self, path: str | Path) -> None:
        self.path = Path(path)
        self._data: dict[str, str] | None = None

    @abstractmethod
    def _read(self) -> dict[str, str]:
        """Read raw data from the source.

        Returns:
            Flat mapping of string keys to string values.

        Raises:
            ConfigurationError: If the source cannot be read or parsed.
        """
        ...

    def load(self, *, force: bool = False) -> dict[str, str]:
        """Load configuration data, caching the result.

        Subsequent calls return the cached result unless *force* is ``True``.

        Args:
            force: If ``True``, re-read the file and refresh the cache.

        Returns:
            Flat mapping of string keys to string values.
        """
        if self._data is None or force:
            logger.debug("Loading configuration from '%s'.", self.path)
            self._data = self._read()
        return self._data

    def exists(self) -> bool:
        """Return *True* if the source file exists on disk."""
        return self.path.exists()


# ─── .env loader ─────────────────────────────────────────────────────────────


class DotEnvLoader(BaseLoader):
    """Load configuration from a ``.env`` file.

    Uses :mod:`python-dotenv` for robust parsing, including multi-line values,
    quoted strings, and inline comments.

    Args:
        path: Path to the ``.env`` file (default: ``".env"``).
        override: If ``True``, values from this file override the OS
            environment variables.

    Example::

        loader = DotEnvLoader(".env.production")
        data = loader.load()
    """

    def __init__(self, path: str | Path = ".env", *, override: bool = False) -> None:
        super().__init__(path)
        self._override = override

    def _read(self) -> dict[str, str]:
        if not self.path.exists():
            logger.warning(".env file not found at '%s'; skipping.", self.path)
            return {}

        raw: dict[str, str | None] = dotenv_values(self.path)  # type: ignore[assignment]
        # Filter out keys with None values (unset variables in .env)
        return {k: v for k, v in raw.items() if v is not None}


# ─── JSON loader ─────────────────────────────────────────────────────────────


class JsonLoader(BaseLoader):
    """Load configuration from a JSON file.

    The JSON document must be a flat or nested object.  Nested structures are
    flattened using dot notation, e.g. ``{"database": {"url": "..."}}`` becomes
    ``{"database.url": "..."}``.

    Args:
        path: Path to the JSON file.
        flatten_separator: Separator used when flattening nested keys.

    Example::

        loader = JsonLoader("config/settings.json")
        data = loader.load()
    """

    def __init__(
        self,
        path: str | Path,
        *,
        flatten_separator: str = ".",
    ) -> None:
        super().__init__(path)
        self._sep = flatten_separator

    def _read(self) -> dict[str, str]:
        if not self.path.exists():
            logger.warning("JSON config not found at '%s'; skipping.", self.path)
            return {}

        try:
            with self.path.open(encoding="utf-8") as fh:
                raw: Any = json.load(fh)
        except json.JSONDecodeError as exc:
            raise ConfigurationError(
                f"JSON config at '{self.path}' is not valid JSON: {exc}"
            ) from exc

        if not isinstance(raw, dict):
            raise ConfigurationError(
                f"JSON config at '{self.path}' must be a JSON object, got {type(raw).__name__}."
            )

        return self._flatten(raw)

    def _flatten(self, obj: dict[str, Any], prefix: str = "") -> dict[str, str]:
        """Recursively flatten a nested dict into dot-separated keys."""
        result: dict[str, str] = {}
        for key, value in obj.items():
            full_key = f"{prefix}{self._sep}{key}" if prefix else key
            if isinstance(value, dict):
                result.update(self._flatten(value, prefix=full_key))
            else:
                result[full_key] = str(value)
        return result


# ─── YAML loader ─────────────────────────────────────────────────────────────


class YamlLoader(BaseLoader):
    """Load configuration from a YAML file.

    Like :class:`JsonLoader`, nested mappings are flattened with dot notation.

    Args:
        path: Path to the YAML file.
        flatten_separator: Separator used when flattening nested keys.

    Example::

        loader = YamlLoader("config/settings.yaml")
        data = loader.load()
    """

    def __init__(
        self,
        path: str | Path,
        *,
        flatten_separator: str = ".",
    ) -> None:
        super().__init__(path)
        self._sep = flatten_separator

    def _read(self) -> dict[str, str]:
        if not self.path.exists():
            logger.warning("YAML config not found at '%s'; skipping.", self.path)
            return {}

        try:
            with self.path.open(encoding="utf-8") as fh:
                raw: Any = yaml.safe_load(fh)
        except yaml.YAMLError as exc:
            raise ConfigurationError(
                f"YAML config at '{self.path}' is not valid YAML: {exc}"
            ) from exc

        if raw is None:
            return {}

        if not isinstance(raw, dict):
            raise ConfigurationError(
                f"YAML config at '{self.path}' must be a YAML mapping, got {type(raw).__name__}."
            )

        return self._flatten(raw)

    def _flatten(self, obj: dict[str, Any], prefix: str = "") -> dict[str, str]:
        """Recursively flatten nested YAML mapping into dot-separated keys."""
        result: dict[str, str] = {}
        for key, value in obj.items():
            full_key = f"{prefix}{self._sep}{key}" if prefix else str(key)
            if isinstance(value, dict):
                result.update(self._flatten(value, prefix=full_key))
            else:
                result[full_key] = str(value) if value is not None else ""
        return result


# ─── Environment variable loader ─────────────────────────────────────────────


class EnvironmentLoader(BaseLoader):
    """Load configuration from OS environment variables.

    Optionally filters to only keys matching a given prefix.

    Args:
        prefix: If provided, only environment variables starting with this
            prefix are included, and the prefix is stripped from the key name.
            E.g. prefix ``"APP_"`` maps ``APP_DATABASE_URL`` → ``DATABASE_URL``.

    Example::

        loader = EnvironmentLoader(prefix="MYAPP_")
        data = loader.load()
    """

    def __init__(self, prefix: str = "") -> None:
        super().__init__(path="<environment>")
        self._prefix = prefix

    def exists(self) -> bool:
        return True  # The OS environment is always available.

    def _read(self) -> dict[str, str]:
        env = os.environ
        if not self._prefix:
            return dict(env)

        stripped: dict[str, str] = {}
        for key, value in env.items():
            if key.startswith(self._prefix):
                stripped[key[len(self._prefix) :]] = value

        logger.debug(
            "Loaded %d keys from environment with prefix '%s'.",
            len(stripped),
            self._prefix,
        )
        return stripped
