from __future__ import annotations

import re
from copy import deepcopy
from functools import cached_property
from itertools import product
from os import environ
from typing import TYPE_CHECKING, Any

from hatch.env.utils import ensure_valid_environment
from hatch.project.constants import DEFAULT_BUILD_DIRECTORY, BuildEnvVars
from hatch.project.env import apply_overrides
from hatch.project.utils import format_script_commands, parse_script_command

if TYPE_CHECKING:
    from hatch.dep.core import Dependency


class ProjectConfig:
    def __init__(self, root, config, plugin_manager=None):
        self.root = root
        self.config = config
        self.plugin_manager = plugin_manager

        self._matrices = None
        self._env = None
        self._env_requires_complex = None
        self._env_requires = None
        self._env_collectors = None
        self._envs = None
        self._internal_envs = None
        self._internal_matrices = None
        self._matrix_variables = None
        self._publish = None
        self._scripts = None
        self._cached_env_overrides = {}

    @cached_property
    def build(self):
        config = self.config.get("build", {})
        if not isinstance(config, dict):
            message = "Field `tool.hatch.build` must be a table"
            raise TypeError(message)

        return BuildConfig(config)

    @property
    def env(self):
        if self._env is None:
            config = self.config.get("env", {})
            if not isinstance(config, dict):
                message = "Field `tool.hatch.env` must be a table"
                raise TypeError(message)

            self._env = config

        return self._env

    @property
    def env_requires_complex(self) -> list[Dependency]:
        if self._env_requires_complex is None:
            from hatch.dep.core import Dependency, InvalidDependencyError

            requires = self.env.get("requires", [])
            if not isinstance(requires, list):
                message = "Field `tool.hatch.env.requires` must be an array"
                raise TypeError(message)

            requires_complex = []

            for i, entry in enumerate(requires, 1):
                if not isinstance(entry, str):
                    message = f"Requirement #{i} in `tool.hatch.env.requires` must be a string"
                    raise TypeError(message)

                try:
                    requires_complex.append(Dependency(entry))
                except InvalidDependencyError as e:
                    message = f"Requirement #{i} in `tool.hatch.env.requires` is invalid: {e}"
                    raise ValueError(message) from None

            self._env_requires_complex = requires_complex

        return self._env_requires_complex

    @property
    def env_requires(self):
        if self._env_requires is None:
            self._env_requires = [str(r) for r in self.env_requires_complex]

        return self._env_requires

    @property
    def env_collectors(self):
        if self._env_collectors is None:
            collectors = self.env.get("collectors", {})
            if not isinstance(collectors, dict):
                message = "Field `tool.hatch.env.collectors` must be a table"
                raise TypeError(message)

            final_config = {"default": {}}
            for collector, config in collectors.items():
                if not isinstance(config, dict):
                    message = f"Field `tool.hatch.env.collectors.{collector}` must be a table"
                    raise TypeError(message)

                final_config[collector] = config

            self._env_collectors = final_config

        return self._env_collectors

    @property
    def matrices(self):
        if self._matrices is None:
            _ = self.envs

        return self._matrices

    @property
    def matrix_variables(self):
        if self._matrix_variables is None:
            _ = self.envs

        return self._matrix_variables

    @property
    def internal_envs(self):
        if self._internal_envs is None:
            _ = self.envs

        return self._internal_envs

    @property
    def internal_matrices(self):
        if self._internal_matrices is None:
            _ = self.envs

        return self._internal_matrices

    @property
    def envs(self):
        from hatch.env.internal import get_internal_env_config
        from hatch.utils.platform import get_platform_name

        if self._envs is None:
            env_config = self.config.get("envs", {})
            if not isinstance(env_config, dict):
                message = "Field `tool.hatch.envs` must be a table"
                raise TypeError(message)

            config = {}
            environment_collectors = []

            for collector, collector_config in self.env_collectors.items():
                collector_class = self.plugin_manager.environment_collector.get(collector)
                if collector_class is None:
                    message = f"Unknown environment collector: {collector}"
                    raise ValueError(message)

                environment_collector = collector_class(self.root, collector_config)
                environment_collectors.append(environment_collector)

                for env_name, data in environment_collector.get_initial_config().items():
                    config.setdefault(env_name, data)

            for env_name, data in env_config.items():
                if not isinstance(data, dict):
                    message = f"Field `tool.hatch.envs.{env_name}` must be a table"
                    raise TypeError(message)

                config.setdefault(env_name, {}).update(data)

            for environment_collector in environment_collectors:
                environment_collector.finalize_config(config)

            # Prevent plugins from removing the default environment
            ensure_valid_environment(config.setdefault("default", {}))

            seen = set()
            active = []
            for env_name, data in config.items():
                _populate_default_env_values(env_name, data, config, seen, active)

            current_platform = get_platform_name()
            all_matrices = {}
            generated_envs = {}
            final_config = {}
            cached_overrides = {}
            for env_name, raw_initial_config in config.items():
                current_cached_overrides = cached_overrides[env_name] = {
                    "platform": [],
                    "env": [],
                    "matrix": [],
                    "name": [],
                }

                # Only shallow copying is necessary since we just want to modify keys
                initial_config = raw_initial_config.copy()

                matrix_name_format = initial_config.pop("matrix-name-format", "{value}")
                if not isinstance(matrix_name_format, str):
                    message = f"Field `tool.hatch.envs.{env_name}.matrix-name-format` must be a string"
                    raise TypeError(message)

                if "{value}" not in matrix_name_format:
                    message = (
                        f"Field `tool.hatch.envs.{env_name}.matrix-name-format` must "
                        f"contain at least the `{{value}}` placeholder"
                    )
                    raise ValueError(message)

                overrides = initial_config.pop("overrides", {})
                if not isinstance(overrides, dict):
                    message = f"Field `tool.hatch.envs.{env_name}.overrides` must be a table"
                    raise TypeError(message)

                # Apply any configuration based on the current platform
                platform_overrides = overrides.get("platform", {})
                if not isinstance(platform_overrides, dict):
                    message = f"Field `tool.hatch.envs.{env_name}.overrides.platform` must be a table"
                    raise TypeError(message)

                for platform, options in platform_overrides.items():
                    if not isinstance(options, dict):
                        message = f"Field `tool.hatch.envs.{env_name}.overrides.platform.{platform}` must be a table"
                        raise TypeError(message)

                    if platform != current_platform:
                        continue

                    apply_overrides(env_name, "platform", platform, current_platform, options, initial_config)
                    current_cached_overrides["platform"].append((platform, current_platform, options))

                # Apply any configuration based on environment variables
                env_var_overrides = overrides.get("env", {})
                if not isinstance(env_var_overrides, dict):
                    message = f"Field `tool.hatch.envs.{env_name}.overrides.env` must be a table"
                    raise TypeError(message)

                for env_var, options in env_var_overrides.items():
                    if not isinstance(options, dict):
                        message = f"Field `tool.hatch.envs.{env_name}.overrides.env.{env_var}` must be a table"
                        raise TypeError(message)

                    if env_var not in environ:
                        continue

                    apply_overrides(env_name, "env", env_var, environ[env_var], options, initial_config)
                    current_cached_overrides["env"].append((env_var, environ[env_var], options))

                if "matrix" not in initial_config:
                    final_config[env_name] = initial_config
                    continue

                matrices = initial_config.pop("matrix")
                if not isinstance(matrices, list):
                    message = f"Field `tool.hatch.envs.{env_name}.matrix` must be an array"
                    raise TypeError(message)

                matrix_overrides = overrides.get("matrix", {})
                if not isinstance(matrix_overrides, dict):
                    message = f"Field `tool.hatch.envs.{env_name}.overrides.matrix` must be a table"
                    raise TypeError(message)

                name_overrides = overrides.get("name", {})
                if not isinstance(name_overrides, dict):
                    message = f"Field `tool.hatch.envs.{env_name}.overrides.name` must be a table"
                    raise TypeError(message)

                matrix_data = all_matrices[env_name] = {"config": deepcopy(initial_config)}
                all_envs = matrix_data["envs"] = {}
                for i, raw_matrix in enumerate(matrices, 1):
                    matrix = raw_matrix
                    if not isinstance(matrix, dict):
                        message = f"Entry #{i} in field `tool.hatch.envs.{env_name}.matrix` must be a table"
                        raise TypeError(message)

                    if not matrix:
                        message = f"Matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` cannot be empty"
                        raise ValueError(message)

                    for j, (variable, values) in enumerate(matrix.items(), 1):
                        if not variable:
                            message = (
                                f"Variable #{j} in matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
                                f"cannot be an empty string"
                            )
                            raise ValueError(message)

                        if not isinstance(values, list):
                            message = (
                                f"Variable `{variable}` in matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
                                f"must be an array"
                            )
                            raise TypeError(message)

                        if not values:
                            message = (
                                f"Variable `{variable}` in matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
                                f"cannot be empty"
                            )
                            raise ValueError(message)

                        existing_values = set()
                        for k, value in enumerate(values, 1):
                            if not isinstance(value, str):
                                message = (
                                    f"Value #{k} of variable `{variable}` in matrix #{i} in field "
                                    f"`tool.hatch.envs.{env_name}.matrix` must be a string"
                                )
                                raise TypeError(message)

                            if not value:
                                message = (
                                    f"Value #{k} of variable `{variable}` in matrix #{i} in field "
                                    f"`tool.hatch.envs.{env_name}.matrix` cannot be an empty string"
                                )
                                raise ValueError(message)

                            if value in existing_values:
                                message = (
                                    f"Value #{k} of variable `{variable}` in matrix #{i} in field "
                                    f"`tool.hatch.envs.{env_name}.matrix` is a duplicate"
                                )
                                raise ValueError(message)

                            existing_values.add(value)

                    variables = {}

                    # Ensure that any Python variable comes first
                    python_selected = False
                    for variable in ("py", "python"):
                        if variable in matrix:
                            if python_selected:
                                message = (
                                    f"Matrix #{i} in field `tool.hatch.envs.{env_name}.matrix` "
                                    f"cannot contain both `py` and `python` variables"
                                )
                                raise ValueError(message)
                            python_selected = True

                            # Only shallow copying is necessary since we just want to remove a key
                            matrix = matrix.copy()
                            variables[variable] = matrix.pop(variable)

                    variables.update(matrix)

                    for result in product(*variables.values()):
                        # Make a value mapping for easy referencing
                        variable_values = dict(zip(variables, result, strict=False))

                        # Create the environment's initial configuration
                        new_config = deepcopy(initial_config)

                        cached_matrix_overrides = []

                        # Apply any configuration based on matrix variables
                        for variable, options in matrix_overrides.items():
                            if not isinstance(options, dict):
                                message = (
                                    f"Field `tool.hatch.envs.{env_name}.overrides.matrix.{variable}` must be a table"
                                )
                                raise TypeError(message)

                            if variable not in variables:
                                continue

                            apply_overrides(
                                env_name, "matrix", variable, variable_values[variable], options, new_config
                            )
                            cached_matrix_overrides.append((variable, variable_values[variable], options))

                        # Construct the environment name
                        final_matrix_name_format = new_config.pop("matrix-name-format", matrix_name_format)
                        env_name_parts = []
                        for j, (variable, value) in enumerate(variable_values.items()):
                            if j == 0 and python_selected:
                                new_config["python"] = value
                                env_name_parts.append(value if value.startswith("py") else f"py{value}")
                            else:
                                env_name_parts.append(final_matrix_name_format.format(variable=variable, value=value))

                        new_env_name = "-".join(env_name_parts)

                        cached_name_overrides = []

                        # Apply any configuration based on the final name, minus the prefix for non-default environments
                        for pattern, options in name_overrides.items():
                            if not isinstance(options, dict):
                                message = f"Field `tool.hatch.envs.{env_name}.overrides.name.{pattern}` must be a table"
                                raise TypeError(message)

                            if not re.search(pattern, new_env_name):
                                continue

                            apply_overrides(env_name, "name", pattern, new_env_name, options, new_config)
                            cached_name_overrides.append((pattern, new_env_name, options))

                        if env_name != "default":
                            new_env_name = f"{env_name}.{new_env_name}"

                        # Save the generated environment
                        final_config[new_env_name] = new_config
                        cached_overrides[new_env_name] = {
                            "platform": current_cached_overrides["platform"],
                            "env": current_cached_overrides["env"],
                            "matrix": cached_matrix_overrides,
                            "name": cached_name_overrides,
                        }
                        all_envs[new_env_name] = variable_values
                        if "py" in variable_values:
                            all_envs[new_env_name] = {"python": variable_values.pop("py"), **variable_values}

                # Remove the root matrix generator
                del cached_overrides[env_name]

                # Save the variables used to generate the environments
                generated_envs.update(all_envs)

            for environment_collector in environment_collectors:
                environment_collector.finalize_environments(final_config)

            self._matrices = all_matrices
            self._internal_matrices = {}
            self._envs = final_config
            self._matrix_variables = generated_envs
            self._cached_env_overrides.update(cached_overrides)

            # Extract the internal environments
            self._internal_envs = {}
            for internal_name in get_internal_env_config():
                try:
                    self._internal_envs[internal_name] = self._envs.pop(internal_name)
                # Matrix
                except KeyError:
                    self._internal_matrices[internal_name] = self._matrices.pop(internal_name)
                    for env_name in [env_name for env_name in self._envs if env_name.startswith(f"{internal_name}.")]:
                        self._internal_envs[env_name] = self._envs.pop(env_name)

        return self._envs

    @property
    def publish(self):
        if self._publish is None:
            config = self.config.get("publish", {})
            if not isinstance(config, dict):
                message = "Field `tool.hatch.publish` must be a table"
                raise TypeError(message)

            for publisher, data in config.items():
                if not isinstance(data, dict):
                    message = f"Field `tool.hatch.publish.{publisher}` must be a table"
                    raise TypeError(message)

            self._publish = config

        return self._publish

    @property
    def scripts(self):
        if self._scripts is None:
            script_config = self.config.get("scripts", {})
            if not isinstance(script_config, dict):
                message = "Field `tool.hatch.scripts` must be a table"
                raise TypeError(message)

            config = {}

            for name, data in script_config.items():
                if " " in name:
                    message = f"Script name `{name}` in field `tool.hatch.scripts` must not contain spaces"
                    raise ValueError(message)

                commands = []

                if isinstance(data, str):
                    commands.append(data)
                elif isinstance(data, list):
                    for i, command in enumerate(data, 1):
                        if not isinstance(command, str):
                            message = f"Command #{i} in field `tool.hatch.scripts.{name}` must be a string"
                            raise TypeError(message)

                        commands.append(command)
                else:
                    message = f"Field `tool.hatch.scripts.{name}` must be a string or an array of strings"
                    raise TypeError(message)

                config[name] = commands

            seen = {}
            active = []
            for script_name, commands in config.items():
                commands[:] = expand_script_commands(script_name, commands, config, seen, active)

            self._scripts = config

        return self._scripts

    def finalize_env_overrides(self, option_types):
        # We lazily apply overrides because we need type information potentially defined by
        # environment plugins for their options
        if not self._cached_env_overrides:
            return

        for environments in (self.envs, self.internal_envs):
            for env_name, config in environments.items():
                for override_name, data in self._cached_env_overrides.get(env_name, {}).items():
                    for condition, condition_value, options in data:
                        apply_overrides(
                            env_name, override_name, condition, condition_value, options, config, option_types
                        )

        self._cached_env_overrides.clear()


class BuildConfig:
    def __init__(self, config: dict[str, Any]) -> None:
        self.__config = config
        self.__targets: dict[str, BuildTargetConfig] = config

    @cached_property
    def directory(self) -> str:
        directory = self.__config.get("directory", DEFAULT_BUILD_DIRECTORY)
        if not isinstance(directory, str):
            message = "Field `tool.hatch.build.directory` must be a string"
            raise TypeError(message)

        return directory

    @cached_property
    def dependencies(self) -> list[str]:
        dependencies: list[str] = self.__config.get("dependencies", [])
        if not isinstance(dependencies, list):
            message = "Field `tool.hatch.build.dependencies` must be an array"
            raise TypeError(message)

        for i, dependency in enumerate(dependencies, 1):
            if not isinstance(dependency, str):
                message = f"Dependency #{i} in field `tool.hatch.build.dependencies` must be a string"
                raise TypeError(message)

        return list(dependencies)

    @cached_property
    def hook_config(self) -> dict[str, dict[str, Any]]:
        hook_config: dict[str, dict[str, Any]] = self.__config.get("hooks", {})
        if not isinstance(hook_config, dict):
            message = "Field `tool.hatch.build.hooks` must be a table"
            raise TypeError(message)

        for hook_name, config in hook_config.items():
            if not isinstance(config, dict):
                message = f"Field `tool.hatch.build.hooks.{hook_name}` must be a table"
                raise TypeError(message)

        return finalize_hook_config(hook_config)

    @cached_property
    def __target_config(self) -> dict[str, Any]:
        config = self.__config.get("targets", {})
        if not isinstance(config, dict):
            message = "Field `tool.hatch.build.targets` must be a table"
            raise TypeError(message)

        return config

    def target(self, target: str) -> BuildTargetConfig:
        if target in self.__targets:
            return self.__targets[target]

        config = self.__target_config.get(target, {})
        if not isinstance(config, dict):
            message = f"Field `tool.hatch.build.targets.{target}` must be a table"
            raise TypeError(message)

        target_config = BuildTargetConfig(target, config, self)
        self.__targets[target] = target_config
        return target_config


class BuildTargetConfig:
    def __init__(self, name: str, config: dict[str, Any], global_config: BuildConfig) -> None:
        self.__name = name
        self.__config = config
        self.__global_config = global_config

    @cached_property
    def directory(self) -> str:
        directory = self.__config.get("directory", self.__global_config.directory)
        if not isinstance(directory, str):
            message = f"Field `tool.hatch.build.targets.{self.__name}.directory` must be a string"
            raise TypeError(message)

        return directory

    @cached_property
    def dependencies(self) -> list[str]:
        dependencies: list[str] = self.__config.get("dependencies", [])
        if not isinstance(dependencies, list):
            message = f"Field `tool.hatch.build.targets.{self.__name}.dependencies` must be an array"
            raise TypeError(message)

        for i, dependency in enumerate(dependencies, 1):
            if not isinstance(dependency, str):
                message = (
                    f"Dependency #{i} in field `tool.hatch.build.targets.{self.__name}.dependencies` must be a string"
                )
                raise TypeError(message)

        all_dependencies = list(self.__global_config.dependencies)
        all_dependencies.extend(dependencies)
        return all_dependencies

    @cached_property
    def hook_config(self) -> dict[str, dict[str, Any]]:
        hook_config: dict[str, dict[str, Any]] = self.__config.get("hooks", {})
        if not isinstance(hook_config, dict):
            message = f"Field `tool.hatch.build.targets.{self.__name}.hooks` must be a table"
            raise TypeError(message)

        for hook_name, config in hook_config.items():
            if not isinstance(config, dict):
                message = f"Field `tool.hatch.build.targets.{self.__name}.hooks.{hook_name}` must be a table"
                raise TypeError(message)

        config = self.__global_config.hook_config.copy()
        config.update(hook_config)
        return finalize_hook_config(config)


def expand_script_commands(script_name, commands, config, seen, active):
    if script_name in seen:
        return seen[script_name]

    if script_name in active:
        active.append(script_name)

        message = f"Circular expansion detected for field `tool.hatch.scripts`: {' -> '.join(active)}"
        raise ValueError(message)

    active.append(script_name)

    expanded_commands = []

    for command in commands:
        possible_script, args, ignore_exit_code = parse_script_command(command)

        if possible_script in config:
            expanded_commands.extend(
                format_script_commands(
                    commands=expand_script_commands(possible_script, config[possible_script], config, seen, active),
                    args=args,
                    ignore_exit_code=ignore_exit_code,
                )
            )
        else:
            expanded_commands.append(command)

    seen[script_name] = expanded_commands
    active.pop()

    return expanded_commands


def _populate_default_env_values(env_name, data, config, seen, active):
    if env_name in seen:
        return

    if data.pop("detached", False):
        data["template"] = env_name
        data["skip-install"] = True

    template_name = data.pop("template", "default")
    if template_name not in config:
        message = f"Field `tool.hatch.envs.{env_name}.template` refers to an unknown environment `{template_name}`"
        raise ValueError(message)

    if env_name in active:
        active.append(env_name)

        message = f"Circular inheritance detected for field `tool.hatch.envs.*.template`: {' -> '.join(active)}"
        raise ValueError(message)

    if template_name == env_name:
        ensure_valid_environment(data)
        seen.add(env_name)
        return

    active.append(env_name)

    template_config = config[template_name]
    _populate_default_env_values(template_name, template_config, config, seen, active)

    for key, value in template_config.items():
        if key == "matrix":
            continue

        if key == "scripts":
            scripts = data["scripts"] if "scripts" in data else data.setdefault("scripts", {})
            for script, commands in value.items():
                scripts.setdefault(script, commands)
        else:
            data.setdefault(key, value)

    seen.add(env_name)
    active.pop()


def finalize_hook_config(hook_config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
    if env_var_enabled(BuildEnvVars.NO_HOOKS):
        return {}

    all_hooks_enabled = env_var_enabled(BuildEnvVars.HOOKS_ENABLE)
    final_hook_config: dict[str, dict[str, Any]] = {
        hook_name: config
        for hook_name, config in hook_config.items()
        if (
            all_hooks_enabled
            or config.get("enable-by-default", True)
            or env_var_enabled(f"{BuildEnvVars.HOOK_ENABLE_PREFIX}{hook_name.upper()}")
        )
    }
    return final_hook_config


def env_var_enabled(env_var: str, *, default: bool = False) -> bool:
    if env_var in environ:
        return environ[env_var] in {"1", "true"}

    return default
