from __future__ import annotations

import inspect
from abc import abstractmethod
from argparse import ArgumentTypeError
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, TypeVar, cast

from tox.plugin import impl
from tox.tox_env.python.pip.req_file import PythonDeps

from .convert import Convert, Factory
from .str_convert import StrConvert

if TYPE_CHECKING:
    from types import UnionType

    from tox.config.cli.parser import ToxParser
    from tox.config.main import Config

    from .section import Section


class Override:  # ruff:ignore[eq-without-hash]
    """An override for config definitions."""

    def __init__(self, value: str) -> None:
        key, equal, self.value = value.partition("=")
        if not equal:
            msg = f"override {value} has no = sign in it"
            raise ArgumentTypeError(msg)

        self.append = False
        if key.endswith("+"):  # key += value appends to a list
            key = key[:-1]
            self.append = True

        parts = self._split_on_unescaped_dot(key)
        self._namespace_parts = parts[:-1]
        self.namespace = ".".join(self._namespace_parts)
        self.key = parts[-1]

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}('{self}')"

    def __str__(self) -> str:
        escaped_ns = ".".join(part.replace(".", "\\.") for part in self._namespace_parts)
        return f"{escaped_ns}{'.' if escaped_ns else ''}{self.key}{'+' if self.append else ''}={self.value}"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Override):
            return False
        return (self.namespace, self.key, self.value) == (
            other.namespace,
            other.key,
            other.value,
        )

    def __ne__(self, other: object) -> bool:
        return not (self == other)

    @staticmethod
    def _split_on_unescaped_dot(value: str) -> list[str]:
        parts: list[str] = []
        current: list[str] = []
        pos = 0
        while pos < len(value):
            if value[pos] == "\\" and pos + 1 < len(value) and value[pos + 1] == ".":
                current.append(".")
                pos += 2
            elif value[pos] == ".":
                parts.append("".join(current))
                current = []
                pos += 1
            else:
                current.append(value[pos])
                pos += 1
        parts.append("".join(current))
        return parts


class ConfigLoadArgs:
    """Arguments that help loading a configuration value."""

    def __init__(self, chain: list[str] | None, name: str | None, env_name: str | None) -> None:
        """:param chain: the configuration chain (useful to detect circular references)
        :param name: the name of the configuration
        :param env_name: the tox environment this load is for

        """
        self.chain: list[str] = chain or []
        self.name = name
        self.env_name = env_name

    def copy(self) -> ConfigLoadArgs:
        """:returns: create a copy of the object"""
        return ConfigLoadArgs(self.chain.copy(), self.name, self.env_name)


OverrideMap = Mapping[str, list[Override]]

T = TypeVar("T")
V = TypeVar("V")


class Loader(Convert[T]):
    """Loader loads configuration values and converts it.

    :param overrides: A list of overrides to be applied.

    """

    def __init__(self, section: Section, overrides: list[Override]) -> None:
        self._section = section
        self.overrides: dict[str, list[Override]] = {}
        for override in overrides:
            self.overrides.setdefault(override.key, []).append(override)
        self.parent: Loader[Any] | None = None

    @property
    def section(self) -> Section:
        """Return the section of the configuration from where the values are extracted."""
        return self._section

    @abstractmethod
    def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> T:
        """Load the raw object from the config store.

        :param key: the key under what we want the configuration
        :param env_name: load for env name
        :param conf: the global config object

        """
        raise NotImplementedError

    @abstractmethod
    def found_keys(self) -> set[str]:
        """A list of configuration keys found within the configuration."""
        raise NotImplementedError

    def __repr__(self) -> str:
        return f"{type(self).__name__}"

    def __contains__(self, item: str) -> bool:
        return item in self.found_keys()

    def load(  # ruff:ignore[too-many-arguments]
        self,
        key: str,
        of_type: type[V] | UnionType,
        factory: Factory[V],
        conf: Config | None,
        args: ConfigLoadArgs,
        all_keys: Iterable[str] = (),
    ) -> V:
        """Load a value (raw and then convert).

        :param key: the key under it lives
        :param of_type: the type to convert to
        :param factory: factory method to build the object
        :param conf: the configuration object of this tox session (needed to manifest the value)
        :param args: the config load arguments
        :param all_keys: all alias keys for this config entry (to collect overrides from any alias)

        :returns: the converted type

        """
        from tox.config.set_env import SetEnv  # ruff:ignore[import-outside-top-level]

        overrides = [o for alias in (key, *all_keys) for o in self.overrides.get(alias, [])]
        converted: Any = None  # merged across loader/overrides, only the final cast can name the type
        for alias in (key, *all_keys):
            try:
                raw = self.load_raw(alias, conf, args.env_name)
            except KeyError:
                continue
            converted = self.build(alias, of_type, factory, conf, raw, args)
            break
        else:
            if not overrides:
                raise KeyError(key)

        delay_replace = inspect.isclass(of_type) and issubclass(of_type, SetEnv)
        for override in overrides:
            # an override arrives as a raw CLI string, so it has not been through the loader's substitution pass yet
            raw_override = (
                override.value
                if delay_replace or conf is None  # set_env expands later, the CLI config file never does
                else self.substitute(override.value, conf, args)
            )
            converted_override = _STR_CONVERT.to(raw_override, of_type, factory)
            if override.append and converted is not None:
                if isinstance(converted, list) and isinstance(converted_override, list):
                    converted += converted_override
                elif isinstance(converted, dict) and isinstance(converted_override, dict):
                    converted.update(converted_override)
                elif isinstance(converted, SetEnv) and isinstance(converted_override, SetEnv):
                    converted.update(converted_override, override=True)
                elif isinstance(converted, PythonDeps) and isinstance(converted_override, PythonDeps):
                    converted += converted_override
                else:
                    msg = "Only able to append to lists and dicts"
                    raise ValueError(msg)
            else:
                converted = converted_override

        return cast("V", converted)  # guaranteed non-None: either build() succeeded or overrides set it

    def build(  # ruff:ignore[too-many-arguments]
        self,
        key: str,  # ruff:ignore[unused-method-argument]
        of_type: type[V] | UnionType,
        factory: Factory[V],
        conf: Config | None,  # ruff:ignore[unused-method-argument]
        raw: T,
        args: ConfigLoadArgs,  # ruff:ignore[unused-method-argument]
    ) -> V:
        """Materialize the raw configuration value from the loader.

        :param future: a future which when called will provide the converted config value
        :param key: the config key
        :param of_type: the config type
        :param conf: the global config
        :param raw: the raw value
        :param args: env args

        """
        return self.to(raw, of_type, factory)

    def substitute(self, value: str, conf: Config, args: ConfigLoadArgs) -> str:
        """Apply this loader's replacements to a raw string.

        :param value: the raw string
        :param conf: the global config
        :param args: env args

        :returns: the string with every substitution this loader understands resolved

        """
        raise NotImplementedError


def apply_overrides_to_raw(overrides: Iterable[Override], key: str, value: T) -> T:
    """Fold the overrides targeting ``key`` onto a raw (pre-conversion) value.

    Reference resolution reads values straight from the parsed config, sidestepping :meth:`Loader.load` where overrides
    are normally applied. This re-applies them so that ``TOX_OVERRIDE`` propagates through ``{[section]key}`` (ini) and
    ``{replace = "ref", of = [...]}`` (toml) references.

    """
    for override in overrides:
        if override.key == key:
            value = _apply_override_to_raw(value, override)
    return value


def _apply_override_to_raw(value: T, override: Override) -> T:
    converted: Any
    if override.append:
        if isinstance(value, list):
            converted = [*value, *_STR_CONVERT.to_list(override.value, str)]
        elif isinstance(value, dict):
            converted = {**value, **dict(_STR_CONVERT.to_dict(override.value, (str, str)))}
        elif isinstance(value, str):
            converted = f"{value}\n{override.value}"
        else:
            msg = "Only able to append to lists, dicts and strings"
            raise ValueError(msg)
    elif isinstance(value, list):
        converted = list(_STR_CONVERT.to_list(override.value, str))
    elif isinstance(value, dict):
        converted = dict(_STR_CONVERT.to_dict(override.value, (str, str)))
    else:
        converted = override.value
    return cast("T", converted)


@impl
def tox_add_option(parser: ToxParser) -> None:
    override_short_option = "-x"
    parser.add_argument(
        override_short_option,
        "--override",
        action="append",
        type=Override,
        default=[],
        dest="override",
        help=f"configuration override(s), e.g., {override_short_option} testenv:pypy3.ignore_errors=True",
    )


_STR_CONVERT = StrConvert()
