import os
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Union

from ape._pydantic_compat import root_validator
from ape.api import ConfigDict, DependencyAPI, PluginConfig
from ape.exceptions import ConfigError
from ape.logging import logger
from ape.utils import BaseInterfaceModel, load_config

if TYPE_CHECKING:
    from .project import ProjectManager

from ethpm_types import BaseModel, PackageMeta

CONFIG_FILE_NAME = "ape-config.yaml"


class DeploymentConfig(PluginConfig):
    address: Union[str, bytes]
    contract_type: str


class CompilerConfig(PluginConfig):
    ignore_files: List[str] = ["*package.json", "*package-lock.json", "*tsconfig.json"]
    """List of globular files to ignore"""


class DeploymentConfigCollection(BaseModel):
    __root__: Dict

    @root_validator(pre=True)
    def validate_deployments(cls, data: Dict):
        root_data = data.get("__root__", data)
        valid_ecosystems = root_data.pop("valid_ecosystems", {})
        valid_networks = root_data.pop("valid_networks", {})
        valid_data: Dict = {}
        for ecosystem_name, networks in root_data.items():
            if ecosystem_name not in valid_ecosystems:
                logger.warning(f"Invalid ecosystem '{ecosystem_name}' in deployments config.")
                continue

            ecosystem = valid_ecosystems[ecosystem_name]
            for network_name, contract_deployments in networks.items():
                if network_name not in valid_networks:
                    logger.warning(f"Invalid network '{network_name}' in deployments config.")
                    continue

                valid_deployments = []
                for deployment in [d for d in contract_deployments]:
                    if not (address := deployment.get("address")):
                        logger.warning(
                            f"Missing 'address' field in deployment "
                            f"(ecosystem={ecosystem_name}, network={network_name})"
                        )
                        continue

                    valid_deployment = {**deployment}
                    try:
                        valid_deployment["address"] = ecosystem.decode_address(address)
                    except ValueError as err:
                        logger.warning(str(err))

                    valid_deployments.append(valid_deployment)

                valid_data[ecosystem_name] = {
                    **valid_data.get(ecosystem_name, {}),
                    network_name: valid_deployments,
                }

        return {"__root__": valid_data}


class ConfigManager(BaseInterfaceModel):
    """
    The singleton responsible for managing the ``ape-config.yaml`` project file.
    The config manager is useful for loading plugin configurations which contain
    settings that determine how ``ape`` functions. When developing plugins,
    you may want to have settings that control how the plugin works. When developing
    scripts in a project, you may want to parametrize how it runs. The config manager
    is how you can access those settings at runtime.

    Access the ``ConfigManager`` from the ``ape`` namespace directly via:

    Usage example::

        from ape import config  # "config" is the ConfigManager singleton

        # Example: load the "ape-test" plugin and access the mnemonic
        test_mnemonic = config.get_config("test").mnemonic
    """

    DATA_FOLDER: Path
    """The path to the ``ape`` directory such as ``$HOME/.ape``."""

    REQUEST_HEADER: Dict

    PROJECT_FOLDER: Path
    """The path to the ``ape`` project."""

    name: str = ""
    """The name of the project."""

    version: str = ""
    """The project's version."""

    meta: PackageMeta = PackageMeta()
    """Metadata about the project."""

    compiler: CompilerConfig = CompilerConfig()
    """Global compiler information."""

    contracts_folder: Path = None  # type: ignore
    """
    The path to the project's ``contracts/`` directory
    (differs by project structure).
    """

    dependencies: List[DependencyAPI] = []
    """A list of project dependencies."""

    deployments: Optional[DeploymentConfigCollection] = None
    """A dict of contract deployments by address and contract type."""

    default_ecosystem: str = "ethereum"
    """The default ecosystem to use. Defaults to ``"ethereum"``."""

    _cached_configs: Dict[str, Dict[str, Any]] = {}

    @root_validator(pre=True)
    def check_config_for_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        extra = [key for key in values.keys() if key not in cls.__fields__]
        if extra:
            logger.warning(f"Unprocessed extra config fields not set '{extra}'.")

        return values

    @property
    def packages_folder(self) -> Path:
        self.dependency_manager.packages_folder.mkdir(parents=True, exist_ok=True)
        return self.dependency_manager.packages_folder

    @property
    def _plugin_configs(self) -> Dict[str, PluginConfig]:
        project_name = self.PROJECT_FOLDER.stem
        if project_name in self._cached_configs:
            cache = self._cached_configs[project_name]
            self.name = cache.get("name", "")
            self.version = cache.get("version", "")
            self.default_ecosystem = cache.get("default_ecosystem", "ethereum")
            self.meta = PackageMeta.parse_obj(cache.get("meta", {}))
            self.dependencies = cache.get("dependencies", [])
            self.deployments = cache.get("deployments", {})
            self.contracts_folder = cache.get("contracts_folder", self.PROJECT_FOLDER / "contracts")
            self.compiler = CompilerConfig.parse_obj(cache.get("compiler", {}))
            return cache

        # First, load top-level configs. Then, load all the plugin configs.
        # The configs are popped off the dict for checking if all configs were processed.

        configs = {}
        global_config_file = self.DATA_FOLDER / CONFIG_FILE_NAME
        global_config = load_config(global_config_file) if global_config_file.is_file() else {}
        config_file = self.PROJECT_FOLDER / CONFIG_FILE_NAME

        # NOTE: It is critical that we read in global config values first
        # so that project config values will override them as-needed.
        project_config = load_config(config_file) if config_file.is_file() else {}
        user_config = merge_configs(global_config, project_config)

        self.name = configs["name"] = user_config.pop("name", "")
        self.version = configs["version"] = user_config.pop("version", "")
        meta_dict = user_config.pop("meta", {})
        meta_obj = PackageMeta.parse_obj(meta_dict)
        configs["meta"] = meta_dict
        self.meta = meta_obj
        self.default_ecosystem = configs["default_ecosystem"] = user_config.pop(
            "default_ecosystem", "ethereum"
        )
        compiler_dict = user_config.pop("compiler", CompilerConfig().dict())
        configs["compiler"] = compiler_dict
        self.compiler = CompilerConfig(**compiler_dict)

        dependencies = user_config.pop("dependencies", []) or []
        if not isinstance(dependencies, list):
            raise ConfigError("'dependencies' config item must be a list of dicts.")

        decode = self.dependency_manager.decode_dependency
        configs["dependencies"] = [decode(dep) for dep in dependencies]
        self.dependencies = configs["dependencies"]

        # NOTE: It is okay for this directory not to exist at this point.
        contracts_folder = user_config.pop("contracts_folder", None)
        contracts_folder = (
            (self.project_manager.path / Path(contracts_folder)).expanduser().resolve()
            if contracts_folder
            else self.PROJECT_FOLDER / "contracts"
        )

        self.contracts_folder = configs["contracts_folder"] = contracts_folder
        deployments = user_config.pop("deployments", {})
        valid_ecosystems = dict(self.plugin_manager.ecosystems)
        valid_network_names = [n[1] for n in [e[1] for e in self.plugin_manager.networks]]
        self.deployments = configs["deployments"] = DeploymentConfigCollection(
            __root__={
                **deployments,
                "valid_ecosystems": valid_ecosystems,
                "valid_networks": valid_network_names,
            }
        )

        for plugin_name, config_class in self.plugin_manager.config_class:
            # `or {}` to handle the case when the empty config is `None`.
            user_override = user_config.pop(plugin_name, {}) or {}
            if config_class != ConfigDict:
                # NOTE: Will raise if improperly provided keys
                config = config_class.from_overrides(user_override)  # type: ignore
            else:
                # NOTE: Just use it directly as a dict if `ConfigDict` is passed
                config = user_override

            configs[plugin_name] = config

        remaining_keys = user_config.keys()
        if len(remaining_keys) > 0:
            remaining_keys_str = ", ".join(remaining_keys)
            logger.warning(
                f"Unprocessed plugin config(s): {remaining_keys_str}. "
                "Plugins may not be installed yet or keys may be mis-spelled."
            )

        self._cached_configs[project_name] = configs
        return configs

    def __repr__(self):
        return f"<{self.__class__.__name__} project={self.PROJECT_FOLDER.name}>"

    def load(self, force_reload: bool = False) -> "ConfigManager":
        """
        Load the user config file and return this class.
        """

        if force_reload:
            self._cached_configs = {}

        _ = self._plugin_configs
        return self

    def get_config(self, plugin_name: str) -> PluginConfig:
        """
        Get a plugin config.

        Args:
            plugin_name (str): The name of the plugin to get the config for.

        Returns:
            :class:`~ape.api.config.PluginConfig`
        """

        self.load()  # Only loads if it needs to.

        if plugin_name not in self._plugin_configs:
            # plugin has no registered config class, so return empty config
            return PluginConfig()

        return self._plugin_configs[plugin_name]

    @contextmanager
    def using_project(
        self, project_folder: Path, contracts_folder: Optional[Path] = None, **config
    ) -> Generator["ProjectManager", None, None]:
        """
        Temporarily change the project context.

        Usage example::

            from pathlib import Path
            from ape import config, Project

            project_path = Path("path/to/project")
            contracts_path = project_path / "contracts"

            with config.using_project(project_path):
                my_project = Project(project_path)

        Args:
            project_folder (pathlib.Path): The path of the context's project.
            contracts_folder (Optional[pathlib.Path]): The path to the context's source files.
              Defaults to ``<project_path>/contracts``.

        Returns:
            Generator
        """

        initial_project_folder = self.project_manager.path
        initial_contracts_folder = self.contracts_folder

        if initial_project_folder == project_folder and (
            not contracts_folder or initial_contracts_folder == contracts_folder
        ):
            # Already in project.
            yield self.project_manager
            return

        self.PROJECT_FOLDER = project_folder
        self.contracts_folder = (
            contracts_folder if contracts_folder else project_folder / "contracts"
        )
        self.project_manager.path = project_folder
        os.chdir(project_folder)
        clean_config = False

        try:
            # Process and reload the project's configuration
            project = self.project_manager.get_project(
                project_folder, contracts_folder=contracts_folder
            )
            clean_config = project.process_config_file(contracts_folder=contracts_folder, **config)
            self.load(force_reload=True)
            yield self.project_manager

        finally:
            temp_project_path = self.project_manager.path
            self.PROJECT_FOLDER = initial_project_folder
            self.contracts_folder = initial_contracts_folder
            self.project_manager.path = initial_project_folder

            if initial_project_folder.is_dir():
                os.chdir(initial_project_folder)

            config_file = temp_project_path / CONFIG_FILE_NAME
            if clean_config and config_file.is_file():
                config_file.unlink()


def merge_configs(base: Dict, secondary: Dict) -> Dict:
    result: Dict = {}

    # Short circuits
    if not base and not secondary:
        return result
    elif not base:
        return secondary
    elif not secondary:
        return base

    for key, value in base.items():
        if key not in secondary:
            result[key] = value

        elif not isinstance(value, dict):
            # Is a primitive value found in both configs.
            # Must use the second one.
            result[key] = secondary[key]

        else:
            # Merge the dictionaries.
            sub = merge_configs(base[key], secondary[key])
            result[key] = sub

    # Add missed keys from secondary.
    for key, value in secondary.items():
        if key not in base:
            result[key] = value

    return result
