r"""
    ____  ____  __  ___
   / __ \/ __ \/  |/  /
  / /_/ / / / / /|_/ /
 / ____/ /_/ / /  / /
/_/   /_____/_/  /_/
"""

from __future__ import annotations

import argparse
import contextlib
import dataclasses as dc
import importlib
import importlib.metadata
import itertools
import os
import pkgutil
import sys
from datetime import datetime
from functools import cached_property
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, cast

import tomlkit.exceptions

from pdm import termui
from pdm.__version__ import __version__
from pdm.cli.options import ignore_python_option, no_cache_option, non_interactive_option, pep582_option, verbose_option
from pdm.cli.utils import ArgumentParser, ErrorArgumentParser, format_similar_command
from pdm.exceptions import PdmArgumentError, PdmUsageError
from pdm.installers import InstallManager
from pdm.models.repositories import BaseRepository, PyPIRepository
from pdm.project import Project
from pdm.project.config import Config
from pdm.utils import convert_to_datetime, is_in_zipapp

if TYPE_CHECKING:
    from collections.abc import Iterable
    from typing import Any

    from pdm.cli.commands.base import BaseCommand
    from pdm.project.config import ConfigItem

COMMANDS_MODULE_PATH = importlib.import_module("pdm.cli.commands").__path__
WORKSPACE_ROOT_ONLY_COMMANDS = frozenset({"info", "install", "lock", "outdated", "sync"})


@dc.dataclass
class State:
    """State of the core object."""

    config_settings: dict[str, Any] | None = None
    """The config settings map shared by all packages"""
    exclude_newer: datetime | None = None
    """The exclude newer than datetime for the lockfile"""
    build_isolation: bool = True
    """Whether to make an isolated environment and install requirements for build"""
    enable_cache: bool = True
    """Whether to enable the cache"""
    overrides: list[str] = dc.field(default_factory=list)
    """The requirement overrides for the resolver"""


class Core:
    """A high level object that manages all classes and configurations"""

    parser: argparse.ArgumentParser
    subparsers: argparse._SubParsersAction

    project_class = Project
    repository_class: type[BaseRepository] = PyPIRepository
    install_manager_class = InstallManager

    def __init__(self) -> None:
        self.version = __version__
        self.exit_stack = contextlib.ExitStack()
        self.ui = termui.UI(exit_stack=self.exit_stack)
        self.state = State()
        self.exit_stack.callback(setattr, self, "config_settings", None)
        self.commands: list[str] = []
        self.init_parser()
        self.load_plugins()

    def create_temp_dir(self, *args: Any, **kwargs: Any) -> str:
        return self.exit_stack.enter_context(TemporaryDirectory(*args, **kwargs))

    def init_parser(self) -> None:
        self.parser = ErrorArgumentParser(
            prog="pdm",
            description=__doc__,
        )
        self.parser.add_argument(
            "-V",
            "--version",
            action="version",
            version="{}, version {}".format(
                termui.style("PDM", style="bold"),
                termui.style(self.version, style="success"),
            ),
            help="Show the version and exit",
        )
        self.parser.add_argument(
            "-c",
            "--config",
            help="Specify another config file path [env var: PDM_CONFIG_FILE] ",
        )
        verbose_option.add_to_parser(self.parser)
        no_cache_option.add_to_parser(self.parser)
        ignore_python_option.add_to_parser(self.parser)
        pep582_option.add_to_parser(self.parser)
        non_interactive_option.add_to_parser(self.parser)

        self.subparsers = self.parser.add_subparsers(parser_class=ArgumentParser, title="commands", metavar="")
        for _, name, _ in pkgutil.iter_modules(COMMANDS_MODULE_PATH):
            module = importlib.import_module(f"pdm.cli.commands.{name}", __name__)
            try:
                klass = module.Command
            except AttributeError:
                continue
            self.register_command(klass, klass.name or name)

    def __call__(self, *args: Any, **kwargs: Any) -> None:
        return self.main(*args, **kwargs)

    def ensure_project(self, options: argparse.Namespace, obj: Project | None) -> Project:
        if obj is not None:
            project = obj
        else:
            global_project = bool(getattr(options, "global_project", None))

            default_root = None if global_project or getattr(options, "search_parent", True) else "."
            project = self.create_project(
                getattr(options, "project_path", None) or default_root,
                is_global=global_project,
                global_config=options.config or os.getenv("PDM_CONFIG_FILE"),
            )
        return project

    def create_project(
        self,
        root_path: str | Path | None = None,
        is_global: bool = False,
        global_config: str | None = None,
    ) -> Project:
        """Create a new project object

        Args:
            root_path (PathLike): The path to the project root directory
            is_global (bool): Whether the project is a global project
            global_config (str): The path to the global config file

        Returns:
            The project object
        """
        return self.project_class(self, root_path, is_global, global_config)

    def handle(self, project: Project, options: argparse.Namespace) -> None:
        """Called before command invocation"""
        from pdm.cli.commands.fix import Command as FixCommand
        from pdm.cli.hooks import HookManager

        self.ui.set_verbosity(options.verbose)
        self.ui.set_theme(project.global_config.load_theme())
        self.ui.log_dir = os.path.expanduser(cast(str, project.config["log_dir"]))

        command = cast("BaseCommand | None", getattr(options, "command", None))
        self.state.config_settings = getattr(options, "config_setting", None)

        if options.no_cache:
            self.state.enable_cache = False

        hooks = HookManager(project, getattr(options, "skip", None))
        hooks.try_emit("pre_invoke", command=command.name if command else None, options=options)

        if command and command.name in WORKSPACE_ROOT_ONLY_COMMANDS and project.workspace_project is not None:
            raise PdmUsageError(
                f"`pdm {command.name}` can only be run from the workspace root: {project.workspace_project.root}"
            )

        if not isinstance(command, FixCommand):
            FixCommand.check_problems(project)

        self.state.build_isolation = project.config["build_isolation"]

        if exclude_newer := project.config.get("strategy.exclude-newer"):
            self.state.exclude_newer = exclude_newer
        if exclude_newer := project.pyproject.resolution.get("exclude-newer"):
            self.state.exclude_newer = convert_to_datetime(exclude_newer)

        for callback in getattr(options, "callbacks", []):
            callback(project, options)

        if overrides := getattr(options, "override", None):
            self.state.overrides = overrides

        if command is None:
            self.parser.print_help()
            sys.exit(0)
        command.handle(project, options)

    @staticmethod
    def get_command(args: list[str]) -> tuple[int, str]:
        """Get the command name from the arguments"""
        options_with_values = ("-c", "--config")
        need_value = False
        for i, arg in enumerate(args):
            if arg.startswith("-"):
                if not arg.startswith(options_with_values):
                    continue
                if (arg.startswith("-c") and arg != "-c") or arg.startswith("--config="):
                    continue
                need_value = True
            elif need_value:
                need_value = False
                continue
            else:
                return i, arg
        return -1, ""

    def _inject_cli_args(self, project: Project, args: list[str]) -> bool:
        if project.is_global:
            return False
        try:
            config = project.pyproject.settings.get("options", {})
        except tomlkit.exceptions.TOMLKitError as e:  # pragma: no cover
            self.ui.error(f"Failed to parse pyproject.toml: {e}")
            config = {}
        (pos, command) = self.get_command(args)
        if command and command in config:
            # add args after the command
            args[pos + 1 : pos + 1] = list(config[command])
            return True
        return False

    def main(
        self,
        args: list[str] | None = None,
        prog_name: str | None = None,
        obj: Project | None = None,
        **extra: Any,
    ) -> None:
        """The main entry function"""
        if args is None:
            args = []
        # Keep it for after project parsing to check if its a defined script
        root_script = None
        try:
            options = self.parser.parse_args(args)
        except PdmArgumentError as e:
            # Failed to parse, try to give all to `run` command as shortcut
            # and keep to root script (first non-dashed param) to check existence
            # as soon as the project is parsed
            _, root_script = self.get_command(args)
            if not root_script:
                self.parser.error(str(e.__cause__))
            args = ["run", *args]
            try:
                options = self.parser.parse_args(args)
            except PdmArgumentError as e:
                self.parser.error(str(e.__cause__))

        project = self.ensure_project(options, obj)
        if root_script and root_script not in project.scripts:
            message = format_similar_command(root_script, self.commands, list(project.scripts.keys()))
            message = termui.style(message)
            self.parser.error(message)

        # Inject CLI args from pyproject.toml if exists
        if self._inject_cli_args(project, args):
            # Re-parse the arguments after injection
            options = self.parser.parse_args(args)

        try:
            self.handle(project, options)
        except Exception:
            etype, err, traceback = sys.exc_info()
            should_show_tb = not isinstance(err, PdmUsageError) or self.ui.verbosity > termui.Verbosity.DETAIL
            if self.ui.verbosity > termui.Verbosity.NORMAL and should_show_tb:
                raise cast(Exception, err).with_traceback(traceback) from None
            self.ui.echo(
                rf"[error]\[{etype.__name__}][/]: {err}",  # type: ignore[union-attr]
                err=True,
            )
            if should_show_tb:
                self.ui.warn("Add '-v' to see the detailed traceback", verbosity=termui.Verbosity.NORMAL)
            sys.exit(1)
        else:
            if project.config["check_update"] and not is_in_zipapp():
                from pdm.cli.actions import check_update

                check_update(project)

    def register_command(self, command: type[BaseCommand], name: str | None = None) -> None:
        """Register a subcommand to the subparsers,
        with an optional name of the subcommand.

        Args:
            command (Type[pdm.cli.commands.base.BaseCommand]):
                The command class to register
            name (str): The name of the subcommand, if not given, `command.name`
                is used
        """
        assert self.subparsers
        self.commands.append(name or command.name or "")
        command.register_to(self.subparsers, name)

    @staticmethod
    def add_config(name: str, config_item: ConfigItem) -> None:
        """Add a config item to the configuration class.

        Args:
            name (str): The name of the config item
            config_item (pdm.project.config.ConfigItem): The config item to add
        """
        Config.add_config(name, config_item)

    def _add_project_plugins_library(self) -> None:
        project = self.create_project(is_global=False, global_config=os.getenv("PDM_CONFIG_FILE"))
        plugin_root = project.project_plugins_dir
        if project.is_global or not plugin_root.exists():
            return

        import site
        import sysconfig

        base = str(plugin_root)
        replace_vars = {"base": base, "platbase": base}

        scheme_names = sysconfig.get_scheme_names()
        if (sys.platform == "darwin" and "osx_framework_library" in scheme_names) or sys.platform == "linux":
            scheme = "posix_prefix"
        else:
            scheme = sysconfig.get_default_scheme()
        purelib = sysconfig.get_path("purelib", scheme, replace_vars)
        scripts = sysconfig.get_path("scripts", scheme, replace_vars)
        site.addsitedir(purelib)
        if os.path.exists(scripts):
            os.environ["PATH"] = os.pathsep.join([scripts, os.getenv("PATH", "")])

    def load_plugins(self) -> None:
        """Import and load plugins under `pdm.plugin` namespace
        A plugin is a callable that accepts the core object as the only argument.

        Example:
            ```python
            def my_plugin(core: pdm.core.Core) -> None:
                ...
            ```
        """
        self._add_project_plugins_library()
        entry_points: Iterable[importlib.metadata.EntryPoint] = itertools.chain(
            importlib.metadata.entry_points(group="pdm"),
            importlib.metadata.entry_points(group="pdm.plugin"),
        )
        for plugin in entry_points:
            try:
                plugin.load()(self)
            except Exception as e:
                self.ui.error(
                    f"Failed to load plugin {plugin.name}={plugin.value}: {e}",
                )

    @cached_property
    def uv_cmd(self) -> list[str]:
        self.ui.info("Using uv is experimental and might break due to uv updates.")
        # First, try to find uv in Python modules
        try:
            importlib.metadata.distribution("uv")
        except ModuleNotFoundError:
            pass
        else:
            return [sys.executable, "-m", "uv"]
        # Try to find it in the typical place:
        for bin_dir in [".local/bin", ".cargo/bin"]:
            if (uv_path := Path.home() / bin_dir / "uv").exists():
                return [str(uv_path)]
        # If not found, try to find it in PATH
        import shutil

        path = shutil.which("uv")
        if path:
            return [path]
        # If not found, try to find in the bin dir:
        if (uv_path := Path(sys.argv[0]).with_name("uv")).exists():
            return [str(uv_path)]
        raise PdmUsageError(
            "use_uv is enabled but can't find uv, please install it first: "
            "https://docs.astral.sh/uv/getting-started/installation/"
        )


def main(args: list[str] | None = None) -> None:
    """The CLI entry function"""
    core = Core()
    with core.exit_stack:
        return core.main(args or sys.argv[1:])
