"""Customize argparse logic for tox (also contains the base options)."""

from __future__ import annotations

import argparse
import logging
import os
import random
import sys
from argparse import SUPPRESS, Action, ArgumentDefaultsHelpFormatter, ArgumentError, ArgumentParser, Namespace
from pathlib import Path
from types import GenericAlias, UnionType
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload
from weakref import WeakKeyDictionary

from colorama import Fore

from tox.plugin import NAME
from tox.util.ci import is_ci

from .env_var import get_env_var
from .ini import IniConfig

if sys.version_info >= (3, 11):  # pragma: >=3.11 cover
    from typing import Self
else:  # pragma: <3.11 cover
    from typing_extensions import Self

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable, MutableMapping, Sequence

    from packaging.requirements import Requirement

    from tox.config.loader.api import Override
    from tox.session.env_select import CliEnv
    from tox.session.state import State

_N = TypeVar("_N")  # unbounded to match typeshed, argparse accepts any object as namespace

_ACTION_OF_TYPE: MutableMapping[Action, type[Any] | UnionType] = WeakKeyDictionary()
"""The value type an action's option was declared with (actions come from argparse, so no attribute can be added)."""

_ACTION_DEFAULT_SOURCE: MutableMapping[Action, str] = WeakKeyDictionary()
"""Where an action's default was overridden from (environment variable or user level configuration file)."""


class ArgumentParserWithEnvAndConfig(ArgumentParser):
    """Argument parser which updates its defaults by checking the configuration files and environmental variables."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # sub-parsers also construct an instance of the parser, but they don't get their own file config, but inherit
        self.file_config = kwargs.pop("file_config") if "file_config" in kwargs else IniConfig()
        kwargs["epilog"] = self.file_config.epilog
        super().__init__(*args, **kwargs)

    def fix_defaults(self) -> None:
        for action in self._actions:
            self.fix_default(action)

    def fix_default(self, action: Action) -> None:
        if hasattr(action, "default") and hasattr(action, "dest") and action.default != SUPPRESS:
            of_type = self.get_type(action)
            key = action.dest
            outcome = get_env_var(key, of_type=of_type)
            if outcome is None and self.file_config:
                outcome = self.file_config.get(key, of_type=of_type)
            if outcome is not None:
                action.default, default_value = outcome
                _ACTION_DEFAULT_SOURCE[action] = default_value
        if isinstance(action, argparse._SubParsersAction):  # ruff:ignore[private-member-access]
            for values in action.choices.values():
                if not isinstance(values, ToxParser):  # pragma: no cover
                    msg = "detected sub-parser added without using our own add command"
                    raise RuntimeError(msg)  # ruff:ignore[type-check-without-type-error]
                values.fix_defaults()

    @staticmethod
    def get_type(action: Action) -> type[Any] | UnionType:
        of_type = _ACTION_OF_TYPE.get(action)
        if of_type is None:
            if isinstance(action, argparse._AppendAction):  # ruff:ignore[private-member-access]
                if action.nargs in {"+", "*"} or (isinstance(action.nargs, int) and action.nargs > 1):
                    of_type = cast("type[Any]", GenericAlias(list, (GenericAlias(list, (action.type,)),)))
                else:
                    of_type = cast("type[Any]", GenericAlias(list, (action.type,)))
            elif isinstance(action, argparse._StoreAction) and action.choices:  # ruff:ignore[private-member-access]
                of_type = cast("type[Any]", Literal[tuple(action.choices)])  # ty: ignore[invalid-type-form] # pyrefly: ignore[invalid-literal] # dynamic Literal from choices
            elif action.default is not None:
                of_type = type(action.default)
            elif isinstance(action, argparse._StoreConstAction) and action.const is not None:  # ruff:ignore[private-member-access]
                of_type = type(action.const)
            else:
                raise TypeError(action)
        return of_type

    @overload
    def parse_args(self, args: Iterable[str] | None = None, namespace: None = None) -> Namespace: ...

    @overload
    def parse_args(self, args: Iterable[str] | None, namespace: _N) -> _N: ...

    @overload
    def parse_args(self, *, namespace: _N) -> _N: ...

    def parse_args(
        self,
        args: Iterable[str] | None = None,
        namespace: _N | None = None,
    ) -> _N:
        res, argv = self.parse_known_args(list(args) if args is not None else None, namespace)
        if argv:
            self.error(
                f"unrecognized arguments: {' '.join(argv)}\n"
                "hint: if you tried to pass arguments to a command use -- to separate them from tox ones",
            )
        if getattr(res, "no_capture", False) and getattr(res, "result_json", None):
            self.error("argument -i/--no-capture: not allowed with argument --result-json")
        return cast("_N", res)


class HelpFormatter(ArgumentDefaultsHelpFormatter):
    """A help formatter that provides the default value and the source it comes from."""

    def __init__(self, prog: str, **kwargs: Any) -> None:
        super().__init__(prog, max_help_position=30, width=240, **kwargs)

    def _get_help_string(self, action: Action) -> str | None:
        text: str = super()._get_help_string(action) or ""
        if (source := _ACTION_DEFAULT_SOURCE.get(action)) is not None:
            default = " (default: %(default)s)"
            if text.endswith(default):  # pragma: no branch
                # escape % as the result goes through one more %-formatting pass with the action variables
                text = f"{text[: -len(default)]} (default: %(default)s -> from {source.replace('%', '%%')})"
        return text

    def add_raw_text(self, text: str | None) -> None:
        def keep(content: str) -> str:
            return content

        if text is not SUPPRESS and text is not None:
            self._add_item(keep, [text])


ToxParserT = TypeVar("ToxParserT", bound="ToxParser")
DEFAULT_VERBOSITY = 2

CORE = "core"
ENV = "env"
_INHERIT_ALL: frozenset[str] = frozenset({CORE, ENV})


class Parsed(Namespace):
    """CLI options.

    The annotations below declare the type each option has once its command registered it; an option belonging to
    another command is absent at runtime, so shared code paths read it via ``getattr`` with a fallback.

    """

    verbose: int
    quiet: int
    colored: Literal["yes", "no"]
    stderr_color: str
    exit_and_dump_after: int
    config_file: Path | None
    work_dir: Path | None
    root_dir: Path | None
    override: list[Override]
    command: str
    remainder: list[str]

    env: CliEnv
    labels: list[str]
    factors: list[str]
    skip_env: str
    default_runner: str

    result_json: Path | None
    no_capture: bool
    hash_seed: int | None
    discover: list[str]
    list_dependencies: bool

    recreate: bool
    no_test: bool
    package_only: bool
    install_pkg: Path | None
    fail_fast: bool
    develop: bool
    no_recreate_pkg: bool
    skip_pkg_install: bool
    skip_env_install: bool
    skip_missing_interpreters: Literal["config", "true", "false"] | bool  # devenv forces False after parsing
    parallel: int
    parallel_live: bool
    parallel_no_spinner: bool

    no_provision: bool | str
    no_recreate_provision: bool
    devenv_path: Path

    force_dep: list[Requirement]
    site_packages: bool
    always_copy: bool
    pre: bool

    @property
    def verbosity(self) -> int:
        """:returns: reporting verbosity"""
        result: int = max(self.verbose - self.quiet, 0)
        return result

    @property
    def is_colored(self) -> bool:
        """:returns: flag indicating if the output is colored or not"""
        return self.colored == "yes"


ArgumentArgs = tuple[tuple[str, ...], type[Any] | UnionType | None, dict[str, Any]]


class ToxParser(ArgumentParserWithEnvAndConfig):
    """Argument parser for tox."""

    def __init__(self, *args: Any, root: bool = False, add_cmd: bool = False, **kwargs: Any) -> None:
        self.of_cmd: str | None = None
        self.inherit: frozenset[str] = _INHERIT_ALL
        self.handlers: dict[str, tuple[ToxParser, Callable[[State], int]]] = {}
        self._arguments: list[ArgumentArgs] = []
        self._groups: list[
            tuple[tuple[str | None, ...], dict[str, Any], list[tuple[dict[str, Any], list[ArgumentArgs]]]]
        ] = []
        super().__init__(*args, **kwargs)
        if root is True:
            self._add_base_options()
        if add_cmd is True:
            msg = "tox command to execute (by default legacy)"
            # the subparsers action instantiates parser_class, which defaults to type(self), hence the cast holds
            self._cmd: argparse._SubParsersAction[ToxParser] | None = cast(
                "argparse._SubParsersAction[ToxParser]",  # ruff:ignore[private-member-access]
                self.add_subparsers(title="subcommands", description=msg, dest="command"),
            )
            self._cmd.required = False
            self._cmd.default = "legacy"
        else:
            self._cmd = None

    def add_command(
        self,
        cmd: str,
        aliases: Sequence[str],
        help_msg: str,
        handler: Callable[[State], int],
        *,
        inherit: frozenset[str] = _INHERIT_ALL,
    ) -> ArgumentParser:
        if self._cmd is None:
            msg = "no sub-command group allowed"
            raise RuntimeError(msg)
        sub_parser: ToxParser = self._cmd.add_parser(
            cmd,
            help=help_msg,
            aliases=aliases,
            formatter_class=HelpFormatter,
            file_config=self.file_config,
        )
        sub_parser.of_cmd = cmd
        sub_parser.inherit = inherit
        content = sub_parser, handler
        self.handlers[cmd] = content
        for alias in aliases:
            self.handlers[alias] = content
        defaults: dict[str, Any] = {}
        self._copy_arguments(sub_parser, defaults)
        self._copy_groups(sub_parser, defaults)
        self._add_env_arguments(sub_parser, defaults)
        if defaults:
            sub_parser.set_defaults(**defaults)
        return sub_parser

    def _copy_arguments(self, sub_parser: ToxParser, defaults: dict[str, Any]) -> None:
        for args, of_type, kwargs in self._arguments:
            if CORE in sub_parser.inherit:
                sub_parser.add_argument(*args, of_type=of_type, **kwargs)
            else:
                defaults[self._dest_from(args, kwargs)] = kwargs.get("default")

    def _copy_groups(self, sub_parser: ToxParser, defaults: dict[str, Any]) -> None:
        for args, kwargs, excl in self._groups:
            if CORE in sub_parser.inherit:
                group = sub_parser.add_argument_group(*args, **kwargs)
                for e_kwargs, arguments in excl:
                    excl_group = group.add_mutually_exclusive_group(**e_kwargs)
                    for a_args, _, a_kwargs in arguments:
                        excl_group.add_argument(*a_args, **a_kwargs)
            else:
                for _, arguments in excl:
                    for a_args, _, a_kwargs in arguments:
                        defaults[self._dest_from(a_args, a_kwargs)] = a_kwargs.get("default")

    def _add_env_arguments(self, sub_parser: ToxParser, defaults: dict[str, Any]) -> None:  # ruff:ignore[no-self-use]
        if os.environ.get("PYTHONHASHSEED", "random") != "random":
            hashseed_default = int(os.environ["PYTHONHASHSEED"])
        else:
            hashseed_default = random.randint(1, 1024 if sys.platform == "win32" else 4294967295)  # ruff:ignore[suspicious-non-cryptographic-random-usage]

        if ENV not in sub_parser.inherit:
            defaults.update(
                result_json=None,
                hash_seed=hashseed_default,
                discover=[],
                list_dependencies=is_ci(),
            )
            return

        sub_parser.add_argument(
            "--result-json",
            dest="result_json",
            metavar="path",
            of_type=Path,
            default=None,
            help="write a JSON file with detailed information about all commands and results involved",
        )
        if sub_parser.of_cmd != "exec":
            sub_parser.add_argument(
                "-i",
                "--no-capture",
                dest="no_capture",
                action="store_true",
                default=False,
                help="disable output capture (mutually exclusive with --result-json and parallel mode)",
            )
        else:
            defaults["no_capture"] = False

        class SeedAction(Action):
            def __call__(
                self,
                parser: ArgumentParser,  # ruff:ignore[unused-method-argument]
                namespace: Namespace,
                values: str | Sequence[Any] | None,
                option_string: str | None = None,  # ruff:ignore[unused-method-argument]
            ) -> None:
                if values == "notset":
                    result = None
                else:
                    try:
                        result = int(cast("str", values))
                        if result <= 0:
                            msg = "must be greater than zero"
                            raise ValueError(msg)  # ruff:ignore[raise-within-try]
                    except ValueError as exc:
                        raise ArgumentError(self, str(exc)) from exc
                setattr(namespace, self.dest, result)

        sub_parser.add_argument(
            "--hashseed",
            metavar="SEED",
            help="set PYTHONHASHSEED to SEED before running commands. Defaults to a random integer in the range "
            "[1, 4294967295] ([1, 1024] on Windows). Passing 'notset' suppresses this behavior.",
            action=SeedAction,
            of_type=int | None,
            default=hashseed_default,
            dest="hash_seed",
        )
        sub_parser.add_argument(
            "--discover",
            dest="discover",
            nargs="+",
            metavar="path",
            of_type=list[str],
            help="for Python discovery first try these Python executables",
            default=[],
        )
        list_deps = sub_parser.add_mutually_exclusive_group()
        list_deps.add_argument(
            "--list-dependencies",
            action="store_true",
            default=is_ci(),
            help="list the dependencies installed during environment setup",
        )
        list_deps.add_argument(
            "--no-list-dependencies",
            action="store_false",
            dest="list_dependencies",
            help="never list the dependencies installed during environment setup",
        )

    @staticmethod
    def _dest_from(args: tuple[str, ...], kwargs: dict[str, Any]) -> str:
        if dest := cast("str | None", kwargs.get("dest")):
            return dest
        args_list = list(args)
        args_list.sort(key=len, reverse=True)
        for arg in args_list:
            if arg.startswith("--"):
                return arg.lstrip("-").replace("-", "_")
        return args[0].lstrip("-").replace("-", "_")

    def add_argument_group(self, *args: Any, **kwargs: Any) -> argparse._ArgumentGroup:
        if self.of_cmd is not None or args in {("positional arguments",), ("optional arguments",)}:
            return super().add_argument_group(*args, **kwargs)
        excl: list[tuple[dict[str, Any], list[ArgumentArgs]]] = []
        group = _RecordingArgumentGroup(self, *args, excl=excl, **kwargs)
        self._action_groups.append(group)  # the same bookkeeping argparse add_argument_group performs
        self._groups.append((args, kwargs, excl))
        return group

    def add_argument(self, *args: str, of_type: type[Any] | UnionType | None = None, **kwargs: Any) -> Action:
        result = super().add_argument(*args, **kwargs)
        if self.of_cmd is None and result.dest != "help":
            self._arguments.append((args, of_type, kwargs))
            if hasattr(self, "_cmd") and self._cmd is not None and hasattr(self._cmd, "choices"):
                for parser in {id(v): v for v in self._cmd.choices.values()}.values():
                    if CORE in parser.inherit:
                        parser.add_argument(*args, of_type=of_type, **kwargs)
                    else:
                        parser.set_defaults(**{result.dest: result.default})
        if of_type is not None:
            _ACTION_OF_TYPE[result] = of_type
        return result

    @classmethod
    def base(cls) -> Self:
        return cls(add_help=False, root=True)

    @classmethod
    def core(cls) -> Self:
        return cls(
            prog=NAME,
            formatter_class=HelpFormatter,
            add_cmd=True,
            root=True,
            description="create and set up environments to run command(s) in them",
        )

    def _add_base_options(self) -> None:
        """Argument options that always make sense."""
        add_core_arguments(self)
        self.fix_defaults()

    @overload
    def parse_known_args(
        self, args: Iterable[str] | None = None, namespace: None = None
    ) -> tuple[Parsed, list[str]]: ...

    @overload
    def parse_known_args(self, args: Iterable[str] | None, namespace: _N) -> tuple[_N, list[str]]: ...

    @overload
    def parse_known_args(self, *, namespace: _N) -> tuple[_N, list[str]]: ...

    def parse_known_args(
        self,
        args: Iterable[str] | None = None,
        namespace: _N | None = None,
    ) -> tuple[_N, list[str]]:
        args_list: list[str] = list(args) if args is not None else sys.argv[1:]
        cmd_at: int | None = None
        if self._cmd is not None and args_list:
            # options that consume a value; a subcommand name appearing as such a value (e.g. ``-e list``) must not
            # be mistaken for the command. Options live on the subparsers (``-e`` on legacy), so consult those too.
            value_opts = {
                name
                for parser in (self, self._cmd.choices.get("legacy"))
                if parser is not None
                for name, action in parser._option_string_actions.items()  # ruff:ignore[private-member-access]
                if action.nargs != 0
            }
            skip_next = False
            for at, arg in enumerate(args_list):
                if skip_next:
                    skip_next = False
                elif arg in value_opts:
                    skip_next = True
                elif arg in self._cmd.choices:
                    cmd_at = at
                    break
        if cmd_at is not None:  # if we found a command move it to the start
            args_list = [args_list[cmd_at], *args_list[:cmd_at], *args_list[cmd_at + 1 :]]
        elif tuple(args_list) not in {("--help",), ("-h",)} and (
            self._cmd is not None and "legacy" in self._cmd.choices
        ):
            # on help no mangling needed, and we also want to insert once we have legacy to insert
            args_list = ["legacy", *args_list]
        result = Parsed() if namespace is None else namespace
        _, remainder = super().parse_known_args(args_list, namespace=result)
        return cast("tuple[_N, list[str]]", (result, remainder))


class _RecordingArgumentGroup(argparse._ArgumentGroup):  # ruff:ignore[private-member-access]
    """Argument group that records its exclusive sub-groups so they replay onto later added sub-parsers."""

    def __init__(
        self, container: ToxParser, *args: Any, excl: list[tuple[dict[str, Any], list[ArgumentArgs]]], **kwargs: Any
    ) -> None:
        super().__init__(container, *args, **kwargs)
        self._excl = excl

    def add_mutually_exclusive_group(self, **kwargs: Any) -> _RecordingMutuallyExclusiveGroup:
        arguments: list[ArgumentArgs] = []
        self._excl.append((kwargs, arguments))
        group = _RecordingMutuallyExclusiveGroup(self, recorded=arguments, **kwargs)
        self._mutually_exclusive_groups.append(group)  # the same bookkeeping argparse performs
        return group


class _RecordingMutuallyExclusiveGroup(argparse._MutuallyExclusiveGroup):  # ruff:ignore[private-member-access]
    """Exclusive group that records added arguments so they replay onto later added sub-parsers."""

    def __init__(self, container: _RecordingArgumentGroup, *, recorded: list[ArgumentArgs], **kwargs: Any) -> None:
        super().__init__(container, **kwargs)
        self._recorded = recorded

    def add_argument(self, *args: str, of_type: type[Any] | UnionType | None = None, **kwargs: Any) -> Action:
        action = super().add_argument(*args, **kwargs)
        self._recorded.append((args, of_type, kwargs))
        return action


def add_core_arguments(parser: ArgumentParser) -> None:
    add_color_flags(parser)
    add_verbosity_flags(parser)
    add_exit_and_dump_after(parser)
    parser.add_argument(
        "-c",
        "--conf",
        dest="config_file",
        metavar="file",
        default=None,
        type=Path,
        of_type=Path | None,
        help="configuration file/folder for tox (if not specified will discover one)",
    )
    parser.add_argument(
        "--workdir",
        dest="work_dir",
        metavar="dir",
        default=None,
        type=Path,
        of_type=Path | None,
        help="tox working directory (if not specified will be the folder of the config file)",
    )
    parser.add_argument(
        "--root",
        dest="root_dir",
        metavar="dir",
        default=None,
        type=Path,
        of_type=Path | None,
        help="project root directory (if not specified will be the folder of the config file)",
    )


def add_color_flags(parser: ArgumentParser) -> None:
    if os.environ.get("NO_COLOR", ""):
        color = "no"
    elif os.environ.get("FORCE_COLOR", ""):
        color = "yes"
    elif (tty_compat := os.environ.get("TTY_COMPATIBLE", "")) in {"0", "1"}:
        color = "yes" if tty_compat == "1" else "no"
    elif os.environ.get("TERM", "") == "dumb":
        color = "no"
    else:
        color = "yes" if sys.stdout.isatty() else "no"

    parser.add_argument(
        "--colored",
        default=color,
        choices=["yes", "no"],
        help="should output be enriched with colors, default is yes unless TERM=dumb or NO_COLOR is defined.",
    )
    parser.add_argument(
        "--stderr-color",
        default="RED",
        choices=[*Fore.__dict__.keys()],
        help="color for stderr output, use RESET for terminal defaults.",
    )


def add_verbosity_flags(parser: ArgumentParser) -> None:
    from tox.report import LEVELS  # ruff:ignore[import-outside-top-level]

    level_map = "|".join(f"{c}={logging.getLevelName(level)}" for c, level in sorted(LEVELS.items()))
    verbosity_group = parser.add_argument_group("verbosity")
    verbosity_group.description = (
        f"every -v increases, every -q decreases verbosity level, "
        f"default {logging.getLevelName(LEVELS[DEFAULT_VERBOSITY])}, map {level_map}"
    )
    verbosity = verbosity_group.add_mutually_exclusive_group()
    verbosity.add_argument(
        "-v",
        "--verbose",
        action="count",
        dest="verbose",
        help="increase verbosity",
        default=DEFAULT_VERBOSITY,
    )
    verbosity.add_argument("-q", "--quiet", action="count", dest="quiet", help="decrease verbosity", default=0)


def add_exit_and_dump_after(parser: ArgumentParser) -> None:
    parser.add_argument(
        "--exit-and-dump-after",
        dest="exit_and_dump_after",
        metavar="seconds",
        default=0,
        type=int,
        help="dump tox threads after n seconds and exit the app - useful to debug when tox hangs, 0 means disabled",
    )


__all__ = (
    "CORE",
    "DEFAULT_VERBOSITY",
    "ENV",
    "HelpFormatter",
    "Parsed",
    "ToxParser",
)
