"""Declare the abstract base class for tox environments that handle the Python language."""

from __future__ import annotations

import logging
import re
import sys
import sysconfig
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, NamedTuple

from virtualenv.discovery.py_spec import PythonSpec

from tox.tox_env.api import ToxEnv, ToxEnvCreateArgs
from tox.tox_env.errors import Fail, Recreate, Skip

if TYPE_CHECKING:
    from tox.config.main import Config
    from tox.util.json_types import JsonValue


class VersionInfo(NamedTuple):
    major: int
    minor: int
    micro: int
    releaselevel: str
    serial: int


@dataclass(frozen=True)
class PythonInfo:
    implementation: str
    version_info: VersionInfo
    version: str
    is_64: bool
    platform: str
    extra: dict[str, Any]
    free_threaded: bool = False
    debug: bool = False
    machine: str | None = None

    @property
    def version_no_dot(self) -> str:
        return f"{self.version_info.major}{self.version_info.minor}"

    @property
    def impl_lower(self) -> str:
        return self.implementation.lower()

    @property
    def version_dot(self) -> str:
        return f"{self.version_info.major}.{self.version_info.minor}"


PY_FACTORS_RE = re.compile(
    r"""
    ^(?!py$)                                               # don't match 'py' as it doesn't provide any info
    (?P<impl>py|pypy|cpython|jython|graalpy|rustpython|ironpython) # the interpreter; most users will simply use 'py'
    (?:
    (?P<version>[2-9]\.?[0-9]?[0-9]?)                      # the version; one of: MAJORMINOR, MAJOR.MINOR
    (?P<threaded>t?)                                       # version followed by t for free-threading
    (?P<debug>d?)                                          # version followed by d for a debug build
    )?$
    """,
    re.VERBOSE,
)
PY_FACTORS_RE_EXPLICIT_VERSION = re.compile(
    r"""
    ^
    ( (?P<impl> cpython | pypy ) - )?   # optional interpreter prefix with dash
    (?P<version> [23] \. [0-9]+ )        # explicit major.minor version (Python 2.x or 3.x)
    (?P<threaded> t? )                   # optional free-threaded suffix
    (?P<debug> d? )                      # optional debug-build suffix
    $
    """,
    re.VERBOSE,
)


class Python(ToxEnv, ABC):
    def __init__(self, create_args: ToxEnvCreateArgs) -> None:
        self._base_python: PythonInfo | None = None
        self._base_python_searched: bool = False
        super().__init__(create_args)

    def register_config(self) -> None:
        super().register_config()

        def _ensure_list(value: list[str] | str) -> list[str]:
            return [value] if isinstance(value, str) else value

        self.conf.add_config(
            keys=["default_base_python"],
            of_type=list[str] | str,
            default=[sys.executable],
            desc="fallback python interpreter used when no factor or explicit base_python is defined",
            post_process=_ensure_list,
        )

        self.conf.add_config(
            keys=["base_python_file"],
            of_type=list[str] | str,
            default=[],
            desc="file(s) containing the Python version to use (e.g. .python-version), first one found wins; "
            "used when base_python is not explicitly set and the env name has no Python factor",
            post_process=_ensure_list,
        )

        self._base_python_explicitly_set = True  # track whether base_python was explicitly configured

        def validate_base_python(value: list[str] | str) -> list[str]:
            result = _ensure_list(value)
            if self._base_python_explicitly_set and self.conf["base_python_file"]:
                msg = "cannot set both base_python and base_python_file"
                raise Fail(msg)
            return self._validate_base_python(self.name, result, self.core["ignore_base_python_conflict"])

        self.conf.add_config(
            keys=["base_python", "basepython"],
            of_type=list[str] | str,
            default=self._base_python_default,
            desc="environment identifier for python, first one found wins",
            post_process=validate_base_python,
        )
        self.core.add_config(
            keys=["ignore_base_python_conflict", "ignore_basepython_conflict"],
            of_type=bool,
            default=False,
            desc="do not raise error if the environment name conflicts with base python",
        )
        self.conf.add_constant(
            keys=["env_site_packages_dir", "envsitepackagesdir"],
            desc="the python environments site package - pure python (purelib)",
            value=self.env_site_package_dir,
        )
        self.conf.add_constant(
            keys=["env_site_packages_dir_plat", "envsitepackagesdir_plat"],
            desc="the python environments platform-specific site package (platlib)",
            value=self.env_site_package_dir_plat,
        )
        self.conf.add_constant(
            keys=["env_bin_dir", "envbindir"],
            desc="the python environments binary folder",
            value=self.env_bin_dir,
        )
        self.conf.add_constant(
            ["env_python", "envpython"],
            desc="python executable from within the tox environment",
            value=self.env_python,
        )
        self.conf.add_constant("py_dot_ver", "<python major>.<python minor>", value=self.py_dot_ver)
        self.conf.add_constant("py_impl", "python implementation", value=self.py_impl)
        self.conf.add_constant("py_free_threaded", "is no-gil interpreted", value=self.py_free_threaded)
        self.conf.add_constant("py_debug", "is a debug build", value=self.py_debug)

    def _default_set_env(self) -> dict[str, str]:
        env = super()._default_set_env()
        hash_seed: int | None = getattr(self.options, "hash_seed", None)
        if hash_seed is not None:
            env["PYTHONHASHSEED"] = str(hash_seed)
        return env

    def py_dot_ver(self) -> str:
        return self.base_python.version_dot

    def py_free_threaded(self) -> bool:
        return self.base_python.free_threaded

    def py_debug(self) -> bool:
        return self.base_python.debug

    def py_impl(self) -> str:
        return self.base_python.impl_lower

    def _default_pass_env(self) -> list[str]:
        env = super()._default_pass_env()
        if sys.platform == "win32":  # pragma: win32 cover
            env.extend(
                [
                    "APPDATA",  # Needed for PIP platformsdirs.windows
                    "LOCALAPPDATA",  # Needed for pymanager
                    "PROGRAMDATA",  # needed for discovering the VS compiler
                    "PROGRAMFILES(x86)",  # needed for discovering the VS compiler
                    "PROGRAMFILES",  # needed for discovering the VS compiler
                    "SYSTEMDRIVE",
                    "SYSTEMROOT",  # needed for python's crypto module
                    "COMSPEC",  # needed for distutils cygwin compiler
                    "PROCESSOR_ARCHITECTURE",  # platform.machine()
                    "NUMBER_OF_PROCESSORS",  # multiprocessing.cpu_count()
                ],
            )
        binary_extension_build = ["PKG_CONFIG", "PKG_CONFIG_PATH", "PKG_CONFIG_SYSROOT_DIR"]
        env.extend(binary_extension_build)  # used by binary extensions during installation
        env.extend(["REQUESTS_CA_BUNDLE"])
        return env

    def _base_python_default(self, conf: Config, env_name: str | None) -> list[str]:  # ruff:ignore[unused-method-argument]
        self._base_python_explicitly_set = False
        try:
            base_python = None if env_name is None else self.extract_base_python(env_name)
        except ValueError:
            if self.core["ignore_base_python_conflict"]:
                base_python = None
            else:
                raise
        if base_python is not None:
            return [base_python]
        base_python_files: list[str] = self.conf["base_python_file"]
        if base_python_files:
            return self._read_python_version_file(base_python_files)
        return self.conf.get("default_base_python", list[str])

    def _read_python_version_file(self, file_paths: list[str]) -> list[str]:
        tox_root: Path = self.core["toxinidir"]
        for file_path in file_paths:
            path = tox_root / file_path
            if not path.exists():
                continue
            content = path.read_text(encoding="utf-8")
            for line in content.splitlines():
                stripped = line.strip()
                if stripped and not stripped.startswith("#"):
                    return [stripped]
        paths = ", ".join(repr(f) for f in file_paths)
        msg = f"base_python_file: no valid Python version found in {paths}"
        raise Fail(msg)

    @classmethod
    def extract_base_python(cls, env_name: str) -> str | None:
        candidates: list[str] = []
        if match := PY_FACTORS_RE_EXPLICIT_VERSION.match(env_name):
            candidates.append(cls._explicit_version_spec(match.groupdict()))
        else:
            for factor in env_name.split("-"):
                if match := PY_FACTORS_RE.match(factor):
                    candidates.append(factor)
                elif match := PY_FACTORS_RE_EXPLICIT_VERSION.match(factor):
                    candidates.append(cls._explicit_version_spec(match.groupdict()))
        if candidates:
            if len(candidates) > 1:
                msg = f"conflicting factors {', '.join(candidates)} in {env_name}"
                raise ValueError(msg)
            return next(iter(candidates))
        return None

    @staticmethod
    def _explicit_version_spec(found: dict[str, str | None]) -> str:
        prefix = "pypy" if found["impl"] == "pypy" else ""
        return f"{prefix}{found['version']}{found['threaded']}{found['debug']}"

    @classmethod
    def _python_spec_for_sys_executable(cls) -> PythonSpec:
        implementation = sys.implementation.name
        version = sys.version_info
        bits = "64" if sys.maxsize > 2**32 else "32"
        threaded = "t" if sysconfig.get_config_var("Py_GIL_DISABLED") == 1 else ""
        debug = "d" if sysconfig.get_config_var("Py_DEBUG") else ""
        parts = sysconfig.get_platform().rsplit("-", 1)
        machine_suffix = f"-{isa}" if len(parts) > 1 and (isa := parts[-1]) else ""
        string_spec = f"{implementation}{version.major}{version.minor}{threaded}{debug}-{bits}{machine_suffix}"
        return PythonSpec.from_string_spec(string_spec)

    @classmethod
    def _validate_base_python(
        cls,
        env_name: str,
        base_pythons: list[str],
        ignore_base_python_conflict: bool,  # ruff:ignore[boolean-type-hint-positional-argument]
    ) -> list[str]:
        try:
            env_base_python = cls.extract_base_python(env_name)
        except ValueError:
            if ignore_base_python_conflict:
                return base_pythons
            raise
        if env_base_python is not None:
            spec_name = PythonSpec.from_string_spec(env_base_python)
            for base_python in base_pythons:
                spec_base = PythonSpec.from_string_spec(base_python)
                if spec_base.path is not None:
                    path = Path(spec_base.path).absolute()
                    if str(spec_base.path) == sys.executable:
                        spec_base = cls._python_spec_for_sys_executable()
                    else:
                        spec_base = cls.python_spec_for_path(path)
                if any(
                    getattr(spec_base, key) != getattr(spec_name, key)
                    for key in (
                        "implementation",
                        "major",
                        "minor",
                        "micro",
                        "architecture",
                        "machine",
                        "free_threaded",
                        "debug",
                    )
                    if getattr(spec_name, key) is not None
                ):
                    msg = f"env name {env_name} conflicting with base python {base_python}"
                    if ignore_base_python_conflict:
                        # ignore the base python settings and return the thing that looks like a Python version
                        return [env_base_python]
                    raise Fail(msg)
        return base_pythons

    @classmethod
    @abstractmethod
    def python_spec_for_path(cls, path: Path) -> PythonSpec:
        """Get the spec for an absolute path to a Python executable.

        :param path: the path investigated

        :returns: the found spec

        """
        raise NotImplementedError

    @abstractmethod
    def env_site_package_dir(self) -> Path:
        """Return the pure-python site-packages directory (purelib) for this environment.

        Debian derivatives change site-packages to dist-packages, so we look at the last path under prefix.

        """
        raise NotImplementedError

    def env_site_package_dir_plat(self) -> Path:
        """Return the platform-specific site-packages directory (platlib) for this environment.

        On most platforms this is the same as purelib, but on some Linux distributions (Fedora, RHEL) platlib uses lib64
        instead of lib. Defaults to purelib so third-party runners don't need to override this.

        """
        return self.env_site_package_dir()

    @abstractmethod
    def env_python(self) -> Path:
        """The python executable within the tox environment."""
        raise NotImplementedError

    @abstractmethod
    def env_bin_dir(self) -> Path:
        """The binary folder within the tox environment."""
        raise NotImplementedError

    def _setup_env(self) -> None:
        """Setup a virtual python environment."""
        super()._setup_env()
        self.ensure_python_env()
        self._paths = self.prepend_env_var_path()  # now that the environment exist we can add them to the path

    def ensure_python_env(self) -> None:
        conf = self.python_cache()
        with self.cache.compare(conf, Python.__name__) as (eq, old):
            if old is None:  # does not exist -> create
                self.create_python_env()
            elif eq is False:  # pragma: no branch # exists but changed -> recreate
                raise Recreate(self._diff_msg(conf, old))

    @staticmethod
    def _diff_msg(conf: dict[str, Any], old: dict[str, Any]) -> str:
        result: list[str] = []
        added = [f"{k}={v!r}" for k, v in conf.items() if k not in old]
        if added:  # pragma: no branch
            result.append(f"added {' | '.join(added)}")
        removed = [f"{k}={v!r}" for k, v in old.items() if k not in conf]
        if removed:
            result.append(f"removed {' | '.join(removed)}")
        changed = [f"{k}={old[k]!r}->{v!r}" for k, v in conf.items() if k in old and v != old[k]]
        if changed:
            result.append(f"changed {' | '.join(changed)}")
        return f"python {', '.join(result)}"

    @abstractmethod
    def prepend_env_var_path(self) -> list[Path]:
        raise NotImplementedError

    def _done_with_setup(self) -> None:
        """Called when setup is done."""
        super()._done_with_setup()
        if self.journal or self.options.list_dependencies:
            outcome = self.installer.installed()
            if self.journal:
                self.journal["installed_packages"] = outcome
            if self.options.list_dependencies:
                logging.warning(",".join(outcome))

    def python_cache(self) -> dict[str, JsonValue]:
        return {
            "version_info": list(self.base_python.version_info),
        }

    @property
    def base_python(self) -> PythonInfo:
        """Resolve base python."""
        base_pythons: list[str] = self.conf["base_python"]

        if self._base_python_searched is False:
            self._base_python_searched = True
            self._base_python = self._get_python(base_pythons)
            if self._base_python is not None and self.journal:
                value = self._get_env_journal_python()
                self.journal["python"] = value

        if self._base_python is None:
            if self.conf["skip_missing_interpreters"]:
                msg = f"could not find python interpreter with spec(s): {', '.join(base_pythons)}"
                raise Skip(msg)
            raise NoInterpreter(base_pythons)

        return self._base_python

    def _get_env_journal_python(self) -> dict[str, JsonValue]:
        return {
            "implementation": self.base_python.implementation,
            "version_info": tuple(self.base_python.version_info),
            "version": self.base_python.version,
            "is_64": self.base_python.is_64,
            "sysplatform": self.base_python.platform,
            "extra_version_info": None,
            "free_threaded": self.base_python.free_threaded,
            "debug": self.base_python.debug,
            "machine": self.base_python.machine,
        }

    @abstractmethod
    def _get_python(self, base_python: list[str]) -> PythonInfo | None:
        raise NotImplementedError

    @abstractmethod
    def create_python_env(self) -> None:
        raise NotImplementedError


class NoInterpreter(Fail):
    """could not find interpreter."""

    def __init__(self, base_pythons: list[str]) -> None:
        self.base_pythons = base_pythons

    def __str__(self) -> str:
        return f"could not find python interpreter matching any of the specs {', '.join(self.base_pythons)}"
