from __future__ import annotations

import json
import os
import sys
import time
from contextlib import contextmanager
from pathlib import Path
from subprocess import check_call
from typing import TYPE_CHECKING
from unittest import mock
from zipfile import ZipFile

import pytest
from filelock import FileLock
from packaging.requirements import Requirement

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator, Sequence

    from build import DistributionType

    from tox.pytest import MonkeyPatch, TempPathFactory, ToxProjectCreator

from importlib.metadata import Distribution

ROOT = Path(__file__).parents[1]


@contextmanager
def elapsed(msg: str) -> Iterator[None]:
    start = time.monotonic()
    try:
        yield
    finally:
        print(f"done in {time.monotonic() - start}s {msg}")  # noqa: T201


@pytest.fixture(scope="session")
def tox_wheel(
    tmp_path_factory: TempPathFactory,
    worker_id: str,
    pkg_builder: Callable[[Path, Path, list[str], bool], Path],
) -> Path:
    if worker_id == "master":  # if not running under xdist we can just return
        return _make_tox_wheel(tmp_path_factory, pkg_builder)  # pragma: no cover
    # otherwise we need to ensure only one worker creates the wheel, and the rest reuses
    root_tmp_dir = tmp_path_factory.getbasetemp().parent
    cache_file = root_tmp_dir / "tox_wheel.json"
    with FileLock(f"{cache_file}.lock"):
        if cache_file.is_file():
            data = Path(json.loads(cache_file.read_text()))  # pragma: no cover
        else:
            data = _make_tox_wheel(tmp_path_factory, pkg_builder)
            cache_file.write_text(json.dumps(str(data)))
    return data


def _make_tox_wheel(
    tmp_path_factory: TempPathFactory,
    pkg_builder: Callable[[Path, Path, list[str], bool], Path],
) -> Path:
    with elapsed("acquire current tox wheel"):  # takes around 3.2s on build
        into = tmp_path_factory.mktemp("dist")  # pragma: no cover
        from tox.version import version_tuple  # noqa: PLC0415

        patch_version = version_tuple[2]
        if isinstance(patch_version, str) and patch_version[:3] == "dev":
            # Version is in the form of 1.23.dev456, we need to increment the 456 part
            version = f"{version_tuple[0]}.{version_tuple[1]}.dev{int(patch_version[3:]) + 1}"
        else:
            version = f"{version_tuple[0]}.{version_tuple[1]}.{int(patch_version) + 1}"

        with mock.patch.dict(os.environ, {"SETUPTOOLS_SCM_PRETEND_VERSION": version}):
            return pkg_builder(into, Path(__file__).parents[1], ["wheel"], False)  # pragma: no cover


@pytest.fixture(scope="session")
def tox_wheels(tox_wheel: Path, tmp_path_factory: TempPathFactory) -> list[Path]:
    with elapsed("acquire dependencies for current tox"):  # takes around 1.5s if already cached
        result: list[Path] = [tox_wheel]
        info = tmp_path_factory.mktemp("info")
        with ZipFile(str(tox_wheel), "r") as zip_file:
            zip_file.extractall(path=info)
        dist_info = next((i for i in info.iterdir() if i.suffix == ".dist-info"), None)
        if dist_info is None:  # pragma: no cover
            msg = f"no tox.dist-info inside {tox_wheel}"
            raise RuntimeError(msg)
        distribution = Distribution.at(dist_info)
        wheel_cache = ROOT / ".wheel_cache" / f"{sys.version_info.major}.{sys.version_info.minor}"
        wheel_cache.mkdir(parents=True, exist_ok=True)
        cmd = [sys.executable, "-m", "pip", "download", "-d", str(wheel_cache)]
        assert distribution.requires is not None
        for req in distribution.requires:
            requirement = Requirement(req)
            if not requirement.extras:  # pragma: no branch # we don't need to install any extras (tests/docs/etc)
                cmd.append(req)
        check_call(cmd)
        result.extend(wheel_cache.iterdir())
        res = "\n".join(str(i) for i in result)
        with elapsed(f"acquired dependencies for current tox: {res}"):
            pass
        return result


def test_provision_requires_nok(tox_project: ToxProjectCreator) -> None:
    ini = "[tox]\nrequires = pkg-does-not-exist\n setuptools==1\nskipsdist=true\n"
    outcome = tox_project({"tox.ini": ini}).run("c", "-e", "py")
    outcome.assert_failed()
    outcome.assert_out_err(
        r".*will run in automatically provisioned tox, host .* is missing \[requires \(has\)\]:"
        r" pkg-does-not-exist, setuptools==1 \(.*\).*",
        r".*",
        regex=True,
    )


@pytest.mark.integration
def test_provision_plugin_runner_in_provision(tox_project: ToxProjectCreator, tmp_path: Path) -> None:
    """Ensure that provision environment can be explicitly configured."""
    log = tmp_path / "out.log"
    proj = tox_project({"tox.ini": "[tox]\nrequires=somepkg123xyz\n[testenv:.tox]\nrunner=example"})
    with pytest.raises(KeyError, match="example"):
        proj.run("r", "-e", "py", "--result-json", str(log))


@pytest.mark.parametrize("subcommand", ["r", "p", "de", "l", "d", "c", "q", "e", "le"])
def test_provision_default_arguments_exists(tox_project: ToxProjectCreator, subcommand: str) -> None:
    ini = r"""
    [tox]
    requires =
        tox<4.14
    [testenv]
    package = skip
    """
    project = tox_project({"tox.ini": ini})
    project.patch_execute(lambda r: 0 if "install" in r.run_id else None)
    outcome = project.run(subcommand)
    for argument in ["result_json", "hash_seed", "discover", "list_dependencies"]:
        assert hasattr(outcome.state.conf.options, argument)
