"""Configuration File Loaders.

These loaders must implement the `typer_config.__typing.ConfigLoader` interface.
"""

from __future__ import annotations

import json
from configparser import ConfigParser
from pathlib import Path
from typing import TYPE_CHECKING, Any, Mapping, Optional

from .__optional_imports import try_import

if TYPE_CHECKING:  # pragma: no cover
    from .__typing import (
        ConfigDict,
        ConfigDictTransformer,
        ConfigLoader,
        ConfigLoaderConditional,
        TyperParameterValue,
        TyperParameterValueTransformer,
    )


def loader_transformer(
    loader: ConfigLoader,
    loader_conditional: Optional[ConfigLoaderConditional] = None,
    param_transformer: Optional[TyperParameterValueTransformer] = None,
    config_transformer: Optional[ConfigDictTransformer] = None,
) -> ConfigLoader:
    """Configuration loader transformer.

    This allows to transform the input and output of a configuration loader.

    Examples:
        Set a default file to open when none is given:
        ```py
        default_file_loader = loader_transformer(
            yaml_loader,
            param_transformer=lambda param: param if param else "config.yml",
        )
        ```

        Use a subsection of a file:
        ```py
        subsection_loader = loader_transformer(
            yaml_loader,
            config_transformer = lambda config: config["subsection"],
        )
        ```

        Use both transformers to use the `[tool.my_tool]` section from `pyproject.toml`
        by default:
        ```py
        pyproject_loader = loader_transformer(
            toml_loader,
            param_transformer = lambda param: param if param else "pyproject.toml"
            config_transformer = lambda config: config["tool"]["my_tool"],
        )
        ```

    Args:
        loader (ConfigLoader): Loader to transform.
        loader_conditional (Optional[ConfigLoaderConditional], optional): Function
            to determine whether to execute loader. Defaults to None (no-op).
        param_transformer (Optional[TyperParameterValueTransformer], optional): Typer
            parameter transformer. Defaults to None (no-op).
        config_transformer (Optional[ConfigDictTransformer], optional): Config
            dictionary transformer. Defaults to None (no-op).

    Returns:
        ConfigLoader: Transformed config loader.
    """

    def _loader(param_value: TyperParameterValue) -> ConfigDict:
        # Transform input
        if param_transformer is not None:
            param_value = param_transformer(param_value)

        # Decide whether to execute loader
        # NOTE: bad things can happen when `param_value=''`
        # such as `--help` not working
        conf: ConfigDict = {}
        if loader_conditional is None or loader_conditional(param_value):
            conf = loader(param_value)

        # Transform output
        if config_transformer is not None:
            conf = config_transformer(conf)

        return conf

    return _loader


def yaml_loader(param_value: TyperParameterValue) -> ConfigDict:
    """YAML file loader.

    Args:
        param_value (TyperParameterValue): path of YAML file

    Raises:
        ModuleNotFoundError: pyyaml library is not installed

    Returns:
        ConfigDict: dictionary loaded from file
    """

    yaml = try_import("yaml")

    if yaml is None:  # pragma: no cover
        message = "Please install the pyyaml library."
        raise ModuleNotFoundError(message)

    with open(param_value, "r", encoding="utf-8") as _file:
        conf: ConfigDict = yaml.safe_load(_file)

    return conf


def json_loader(param_value: TyperParameterValue) -> ConfigDict:
    """JSON file loader.

    Args:
        param_value (TyperParameterValue): path of JSON file

    Returns:
        ConfigDict: dictionary loaded from file
    """

    with open(param_value, "r", encoding="utf-8") as _file:
        conf: ConfigDict = json.load(_file)

    return conf


def toml_loader(param_value: TyperParameterValue) -> ConfigDict:
    """TOML file loader.

    Args:
        param_value (TyperParameterValue): path of TOML file

    Raises:
        ModuleNotFoundError: toml library is not installed

    Returns:
        ConfigDict: dictionary loaded from file
    """

    # try `tomllib` first
    tomllib = try_import("tomllib")

    if tomllib is not None:
        with open(param_value, "rb") as _file:
            return tomllib.load(_file)

    # couldn't find `tommllib`, so try `toml`
    toml = try_import("toml")

    if toml is None:  # pragma: no cover
        message = "Please install the toml library."
        raise ModuleNotFoundError(message)

    with open(param_value, "r", encoding="utf-8") as _file:
        return toml.load(_file)


def dotenv_loader(param_value: TyperParameterValue) -> ConfigDict:
    """Dotenv file loader.

    Args:
        param_value (TyperParameterValue): path of Dotenv file

    Raises:
        ModuleNotFoundError: python-dotenv library is not installed

    Returns:
        ConfigDict: dictionary loaded from file
    """

    dotenv = try_import("dotenv")

    if dotenv is None:  # pragma: no cover
        message = "Please install the python-dotenv library."
        raise ModuleNotFoundError(message)

    with open(param_value, "r", encoding="utf-8") as _file:
        # NOTE: I'm using a stream here so that the loader
        # will raise an exception when the file doesn't exist.
        conf: ConfigDict = dotenv.dotenv_values(stream=_file)

    return conf


def ini_loader(param_value: TyperParameterValue) -> ConfigDict:
    """INI file loader.

    Note:
        INI files must have sections at the top level.
        You probably want to combine this with `loader_transformer`
        to extract the correct section.
        For example:
        ```py
        ini_section_loader = loader_transformer(
            ini_loader,
            config_transformer=lambda config: config["section"],
        )
        ```

    Args:
        param_value (TyperParameterValue): path of INI file

    Returns:
        ConfigDict: dictionary loaded from file
    """

    ini_parser = ConfigParser()
    with open(param_value, "r", encoding="utf-8") as _file:
        ini_parser.read_file(_file)

    conf: ConfigDict = {
        sect: dict(ini_parser.items(sect)) for sect in ini_parser.sections()
    }

    return conf


def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
    """Deep merge two dictionaries.

    Values from `override` take precedence over `base`.
    Nested dictionaries are merged recursively.

    Args:
        base (dict[str, Any]): Base dictionary.
        override (Mapping[str, Any]): Dictionary with values to override.

    Returns:
        dict[str, Any]: Merged dictionary.
    """
    result = dict(base)
    for key, value in override.items():
        if (
            key in result
            and isinstance(result[key], dict)
            and isinstance(value, Mapping)
        ):
            result[key] = _deep_merge(result[key], value)
        else:
            result[key] = value
    return result


def _get_loader_for_file(file_path: str) -> ConfigLoader:  # pragma: no cover
    """Get the appropriate loader based on file extension.

    Args:
        file_path (str): Path to the configuration file.

    Returns:
        ConfigLoader: Loader function for the file type.

    Raises:
        ValueError: If file format is not supported.
    """
    if file_path.endswith(".json"):
        return json_loader
    if file_path.endswith((".yaml", ".yml")):
        return yaml_loader
    if file_path.endswith(".toml"):
        return toml_loader
    if file_path.endswith(".ini"):
        return ini_loader
    if file_path.endswith(".env"):
        return dotenv_loader
    msg = f"Unsupported file format for '{file_path}'."
    raise ValueError(msg)


def multifile_loader(
    files: list[TyperParameterValue],
    *,
    skip_missing: bool = True,
    deep_merge: bool = True,
) -> ConfigDict:
    """Loader that merges multiple configuration files into one dictionary.

    Files are processed in order, with later files overriding earlier ones.
    Missing files are skipped by default.

    Args:
        files (list[TyperParameterValue]): List of paths to configuration files.
        skip_missing (bool, optional): Skip files that don't exist.
            Defaults to True.
        deep_merge (bool, optional): Deep merge nested dictionaries.
            Defaults to True.

    Returns:
        ConfigDict: Merged dictionary loaded from all files.
    """
    merged_config: ConfigDict = {}

    for file_path in files:
        if not file_path:
            continue

        if skip_missing and not Path(file_path).is_file():
            continue

        loader = _get_loader_for_file(file_path)
        config = loader(file_path)

        if deep_merge:
            merged_config = _deep_merge(merged_config, config)
        else:
            merged_config.update(config)

    return merged_config


def multifile_fallback_loader(files: list[TyperParameterValue]) -> ConfigDict:
    """Loader that uses the first existing configuration file from a list.

    Files are checked in order. The first file that exists is loaded and returned.
    This is useful for fallback configurations (e.g., local -> user -> system).

    Args:
        files (list[TyperParameterValue]): List of paths to configuration files,
            in order of priority (first has highest priority).

    Returns:
        ConfigDict: Dictionary loaded from the first existing file,
            or empty dict if no files exist.
    """
    for file_path in files:

        if Path(file_path).is_file():
            loader = _get_loader_for_file(file_path)
            return loader(file_path)

    return {}
