"""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 UnionType
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast

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, Sequence

    from tox.session.state import State


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 = default_value  # ty: ignore[unresolved-attribute] # dynamic attr for HelpFormatter
        if isinstance(action, argparse._SubParsersAction):  # noqa: SLF001
            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)  # noqa: TRY004
                values.fix_defaults()

    @staticmethod
    def get_type(action: Action) -> type[Any]:
        of_type: type[Any] | None = getattr(action, "of_type", None)
        if of_type is None:
            if isinstance(action, argparse._AppendAction):  # noqa: SLF001
                if action.nargs in {"+", "*"} or (isinstance(action.nargs, int) and action.nargs > 1):
                    of_type = list[list[action.type]]  # ty: ignore[invalid-type-form] # nargs produces list per invocation
                else:
                    of_type = list[action.type]  # ty: ignore[invalid-type-form] # runtime generic from argparse action type
            elif isinstance(action, argparse._StoreAction) and action.choices:  # noqa: SLF001
                loc = locals()
                loc["Literal"] = Literal
                as_literal = f"Literal[{', '.join(repr(i) for i in action.choices)}]"
                of_type = eval(as_literal, globals(), loc)  # noqa: S307
            elif action.default is not None:
                of_type = type(action.default)
            elif isinstance(action, argparse._StoreConstAction) and action.const is not None:  # noqa: SLF001
                of_type = type(action.const)
            else:
                raise TypeError(action)
        return of_type

    def parse_args(  # avoid defining all overloads
        self,
        args: Sequence[str] | None = None,
        namespace: Namespace | None = None,
    ) -> Namespace:
        res, argv = self.parse_known_args(args, 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("Namespace", 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 hasattr(action, "default_source"):
            default = " (default: %(default)s)"
            if text.endswith(default):  # pragma: no branch
                text = f"{text[: -len(default)]} (default: %(default)s -> from %(default_source)s)"
        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."""

    @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 cast("bool", self.colored == "yes")

    exit_and_dump_after: int


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[Any, Callable[[State], int]]] = {}
        self._arguments: list[ArgumentArgs] = []
        self._groups: list[tuple[Any, 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)"
            self._cmd: Any | None = 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:  # noqa: PLR6301
        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)  # noqa: S311

        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,  # noqa: ARG002
                namespace: Namespace,
                values: str | Sequence[Any] | None,
                option_string: str | None = None,  # noqa: ARG002
            ) -> 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)  # noqa: TRY301
                    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 := 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) -> Any:
        result = super().add_argument_group(*args, **kwargs)
        if self.of_cmd is None and args not in {("positional arguments",), ("optional arguments",)}:

            def add_mutually_exclusive_group(**e_kwargs: Any) -> Any:
                def add_argument(*a_args: str, of_type: type[Any] | None = None, **a_kwargs: Any) -> Action:
                    res_args: Action = prev_add_arg(*a_args, **a_kwargs)
                    arguments.append((a_args, of_type, a_kwargs))
                    return res_args

                arguments: list[ArgumentArgs] = []
                excl.append((e_kwargs, arguments))
                res_excl = prev_excl(**kwargs)
                prev_add_arg = res_excl.add_argument
                res_excl.add_argument = add_argument  # ty: ignore[invalid-assignment] # wrapping to record args
                return res_excl

            prev_excl = result.add_mutually_exclusive_group
            result.add_mutually_exclusive_group = add_mutually_exclusive_group  # ty: ignore[invalid-assignment] # wrapping to record exclusions
            excl: list[tuple[dict[str, Any], list[ArgumentArgs]]] = []
            self._groups.append((args, kwargs, excl))
        return result

    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 k, v in self._cmd.choices.items()}.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:
            result.of_type = of_type  # ty: ignore[unresolved-attribute] # dynamic attr read by get_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()

    def parse_known_args(
        self,
        args: Sequence[str] | None = None,
        namespace: Parsed | None = None,
    ) -> tuple[Parsed, list[str]]:
        if args is None:
            args = sys.argv[1:]
        cmd_at: int | None = None
        if self._cmd is not None and args:
            for at, arg in enumerate(args):
                if arg in self._cmd.choices:
                    cmd_at = at
                    break
            else:
                cmd_at = None
        if cmd_at is not None:  # if we found a command move it to the start
            args = args[cmd_at], *args[:cmd_at], *args[cmd_at + 1 :]
        elif tuple(args) 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 = "legacy", *args
        result = Parsed() if namespace is None else namespace
        _, args = super().parse_known_args(args, namespace=result)
        return result, args


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  # noqa: PLC0415

    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",
)
