"""PEP 723 inline script metadata support — shared logic for any venv backend."""

from __future__ import annotations

import logging
import re
import sys
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Final, cast

from packaging.specifiers import SpecifierSet
from packaging.version import Version

from tox.config.types import Command
from tox.tox_env.errors import Fail
from tox.tox_env.python.pip.req_file import PythonDeps
from tox.tox_env.python.runner import add_skip_missing_interpreters_to_core, add_skip_missing_interpreters_to_env
from tox.tox_env.runner import RunToxEnv

from .api import Python

if sys.version_info >= (3, 11):  # pragma: >=3.11 cover
    import tomllib
else:  # pragma: <3.11 cover
    import tomli as tomllib

if TYPE_CHECKING:
    from pathlib import Path

    from tox.config.main import Config
    from tox.config.of_type import ConfigDynamicDefinition
    from tox.tox_env.api import ToxEnvCreateArgs

_SCRIPT_METADATA_RE: Final = re.compile(
    r"""
    (?m)
    ^[#][ ]///[ ](?P<type>[a-zA-Z0-9-]+)$  # opening: # /// <type>
    \s                                      # blank line or whitespace
    (?P<content>                            # TOML content lines:
        (?:^[#](?:| .*)$\s)+               #   each line starts with # (optionally followed by space + text)
    )
    ^[#][ ]///$                             # closing: # ///
    """,
    re.VERBOSE,
)
_MAX_SCRIPT_BYTES: Final[int] = 5 * 1024 * 1024  # 5 MiB; PEP 723 metadata blocks are tiny in practice


@dataclass(frozen=True)
class ScriptMetadata:
    requires_python: str | None = None
    dependencies: list[str] = field(default_factory=list)


class Pep723Mixin(Python, RunToxEnv):
    """Mixin providing PEP 723 script metadata support for any venv-backed runner.

    Concrete runners compose this with a venv backend (VirtualEnv, UvVenv, etc.) and RunToxEnv.

    """

    _script_metadata: ScriptMetadata | None

    def __init__(self, create_args: ToxEnvCreateArgs) -> None:
        self._script_metadata = None
        super().__init__(create_args)

    def register_config(self) -> None:
        super().register_config()
        self.conf.add_config(
            keys=["script"],
            of_type=str,
            default="",
            desc="path to Python script with PEP 723 inline metadata (relative to tox_root)",
        )

        def default_commands(conf: Config, env_name: str | None) -> list[Command]:  # ruff:ignore[unused-function-argument]
            if script := self.conf["script"]:
                tox_root: Path = self.core["tox_root"]
                args = ["python", str(tox_root / script)]
                if (pos_args := conf.pos_args(None)) is not None:
                    args.extend(pos_args)
                return [Command(args)]
            return []

        commands_def = cast("ConfigDynamicDefinition[list[Command]]", self.conf._defined["commands"])  # ruff:ignore[private-member-access]
        commands_def.default = default_commands
        add_skip_missing_interpreters_to_core(self.core, self.options)
        add_skip_missing_interpreters_to_env(self.conf, self.core, self.options)

    def _setup_env(self) -> None:
        super()._setup_env()
        if self._base_python_explicitly_set:
            msg = "cannot set base_python with virtualenv-pep-723 runner; use requires-python in the script"
            raise Fail(msg)
        if self.conf["script"]:
            self._resolve_script_path()  # validates containment and existence, raises Fail on issue
        metadata = self._get_script_metadata()
        if metadata.requires_python:
            info = self.base_python
            py_version = Version(f"{info.version_info.major}.{info.version_info.minor}.{info.version_info.micro}")
            if py_version not in SpecifierSet(metadata.requires_python):
                msg = f"python {py_version} does not satisfy requires-python {metadata.requires_python!r}"
                raise Fail(msg)
        if getattr(self.options, "skip_env_install", False):
            logging.warning("skip installing dependencies")
            return
        if metadata.dependencies:
            root: Path = self.core["tox_root"]
            requirements = PythonDeps(metadata.dependencies, root)
            self._install(requirements, type(self).__name__, "deps")

    def _get_script_metadata(self) -> ScriptMetadata:
        if self._script_metadata is None:
            full_path = self._resolve_script_path()
            if full_path is None:
                self._script_metadata = ScriptMetadata()
                return self._script_metadata
            if (size := full_path.stat().st_size) > _MAX_SCRIPT_BYTES:
                msg = f"script file {full_path} is {size} bytes, exceeds the {_MAX_SCRIPT_BYTES} byte limit"
                raise Fail(msg)
            try:
                # utf-8-sig: a BOM would otherwise hide a metadata block starting on the first line
                self._script_metadata = _parse_script_metadata(full_path.read_text(encoding="utf-8-sig"))
            except ValueError as exc:  # config error, not a tox defect - includes TOMLDecodeError
                msg = f"invalid inline script metadata in {full_path}: {exc}"
                raise Fail(msg) from exc
        return self._script_metadata

    def _resolve_script_path(self) -> Path | None:
        """Resolve the configured script path and verify it stays inside ``tox_root``.

        :returns: the resolved absolute path if the script exists, ``None`` if no script is configured.

        :raises Fail: if the script escapes ``tox_root`` or does not exist when configured.

        """
        script: str = self.conf["script"]
        if not script:
            return None
        tox_root: Path = self.core["tox_root"]
        root_resolved = tox_root.resolve()
        full_path = (tox_root / script).resolve()
        try:
            full_path.relative_to(root_resolved)
        except ValueError as exc:
            msg = f"script path {script!r} escapes tox_root {tox_root}"
            raise Fail(msg) from exc
        if not full_path.is_file():
            msg = f"script file not found: {tox_root / script}"
            raise Fail(msg)
        return full_path


def _parse_script_metadata(script: str) -> ScriptMetadata:
    blocks = [(m.group("type"), m.group("content")) for m in _SCRIPT_METADATA_RE.finditer(script)]
    script_blocks = [(t, c) for t, c in blocks if t == "script"]
    if len(script_blocks) > 1:
        msg = "multiple [script] metadata blocks found in script"
        raise ValueError(msg)
    if not script_blocks:
        return ScriptMetadata()
    content = script_blocks[0][1]
    stripped = "".join(
        line[2:] if len(line) > 1 and line[1] == " " else line[1:] for line in content.splitlines(keepends=True)
    )
    metadata = tomllib.loads(stripped)
    return ScriptMetadata(
        requires_python=metadata.get("requires-python"),
        dependencies=metadata.get("dependencies", []),
    )


__all__ = [
    "Pep723Mixin",
    "ScriptMetadata",
]
