from __future__ import annotations

import os
from pathlib import Path
from typing import Any
from unittest.mock import Mock

import pytest
from packaging.requirements import Requirement

from tox.pytest import CaptureFixture, SubRequest, ToxProject, ToxProjectCreator
from tox.tox_env.errors import Fail


@pytest.mark.parametrize("arg", [object, [object]])
def test_pip_install_bad_type(tox_project: ToxProjectCreator, capfd: CaptureFixture, arg: Any) -> None:
    proj = tox_project({"tox.ini": ""})
    result = proj.run("l")
    result.assert_success()
    pip = result.state.envs["py"].installer

    with pytest.raises(SystemExit, match="1"):
        pip.install(arg, "section", "type")
    out, err = capfd.readouterr()
    assert not err
    assert f"pip cannot install {object!r}" in out


def test_pip_install_empty_list(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": ""})
    result = proj.run("l")
    result.assert_success()

    pip = result.state.envs["py"].installer
    execute_calls = proj.patch_execute(Mock())
    pip.install([], "section", "type")
    assert execute_calls.call_count == 0


def test_pip_install_empty_command_error(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv]\ninstall_command="})
    result = proj.run("l")
    pip = result.state.envs["py"].installer

    with pytest.raises(Fail, match="unable to determine pip install command"):
        pip.install([Requirement("name")], "section", "type")


def test_pip_install_flags_only_error(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv:py]\ndeps=-i a"})
    result = proj.run("r")
    result.assert_failed()
    assert "no dependencies for tox env py within deps" in result.out


def test_pip_install_new_flag_recreates(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv:py]\ndeps=a\nskip_install=true"})
    proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)

    result = proj.run("r")
    result.assert_success()

    (proj.path / "tox.ini").write_text("[testenv:py]\ndeps=a\n -i i\nskip_install=true")
    result_second = proj.run("r")
    result_second.assert_success()
    assert "recreate env because changed install flag(s) added index_url=['i']" in result_second.out
    assert "install_deps> python -I -m pip install a -i i" in result_second.out


def test_pip_install_path(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv:py]\ndeps=.{/}a\nskip_install=true"})
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)

    result = proj.run("r")
    result.assert_success()
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", f".{os.sep}a"]


@pytest.mark.parametrize(
    ("content", "args"),
    [
        pytest.param("-e .", ["-e", "."], id="short editable"),
        pytest.param("--editable .", ["-e", "."], id="long editable"),
        pytest.param(
            "git+ssh://git.example.com/MyProject\\#egg=MyProject",
            ["git+ssh://git.example.com/MyProject#egg=MyProject"],
            id="vcs with ssh",
        ),
        pytest.param(
            "git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709\\#egg=MyProject",
            ["git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject"],
            id="vcs with commit hash pin",
        ),
    ],
)
def test_pip_install_req_file_req_like(tox_project: ToxProjectCreator, content: str, args: list[str]) -> None:
    proj = tox_project({"tox.ini": f"[testenv:py]\ndeps={content}\nskip_install=true"})
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)

    result = proj.run("r")
    result.assert_success()

    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install"] + args

    # check that adding a new dependency correctly finds the previous one
    (proj.path / "tox.ini").write_text(f"[testenv:py]\ndeps={content}\n a\nskip_install=true")
    execute_calls.reset_mock()

    result_second = proj.run("r")
    result_second.assert_success()
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a"] + args


def test_pip_req_path(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv:py]\ndeps=.\nskip_install=true"})
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)

    result = proj.run("r")
    result.assert_success()

    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "."]


def test_deps_remove_recreate(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv]\npackage=skip\ndeps=wheel\n setuptools"})
    execute_calls = proj.patch_execute(lambda request: 0)  # noqa: U100
    result_first = proj.run("r")
    result_first.assert_success()
    assert execute_calls.call_count == 1

    (proj.path / "tox.ini").write_text("[testenv]\npackage=skip\ndeps=setuptools\n")
    result_second = proj.run("r")
    result_second.assert_success()
    assert "py: recreate env because requirements removed: wheel" in result_second.out, result_second.out
    assert execute_calls.call_count == 2


def test_pkg_dep_remove_recreate(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None:
    build = (demo_pkg_inline / "build.py").read_text()
    build_with_dep = build.replace("Summary: UNKNOWN\n", "Summary: UNKNOWN\n        Requires-Dist: wheel\n")
    proj = tox_project(
        {
            "tox.ini": "[testenv]\npackage=wheel",
            "pyproject.toml": (demo_pkg_inline / "pyproject.toml").read_text(),
            "build.py": build_with_dep,
        },
    )
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)

    result_first = proj.run("r")
    result_first.assert_success()
    run_ids = [i[0][3].run_id for i in execute_calls.call_args_list]
    assert run_ids == [
        "_optional_hooks",
        "get_requires_for_build_wheel",
        "build_wheel",
        "install_package_deps",
        "install_package",
        "_exit",
    ]
    execute_calls.reset_mock()

    (proj.path / "build.py").write_text(build)
    result_second = proj.run("r")
    result_second.assert_success()
    assert "py: recreate env because dependencies removed: wheel" in result_second.out, result_second.out
    run_ids = [i[0][3].run_id for i in execute_calls.call_args_list]
    assert run_ids == ["_optional_hooks", "get_requires_for_build_wheel", "build_wheel", "install_package", "_exit"]


def test_pkg_env_dep_remove_recreate(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None:
    toml = (demo_pkg_inline / "pyproject.toml").read_text()
    proj = tox_project(
        {
            "tox.ini": "[testenv]\npackage=wheel",
            "pyproject.toml": toml.replace("requires = []", 'requires = ["setuptools"]'),
            "build.py": (demo_pkg_inline / "build.py").read_text(),
        },
    )
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)
    result_first = proj.run("r")
    result_first.assert_success()
    run_ids = [i[0][3].run_id for i in execute_calls.call_args_list]
    assert run_ids == [
        "install_requires",
        "_optional_hooks",
        "get_requires_for_build_wheel",
        "build_wheel",
        "install_package",
        "_exit",
    ]
    execute_calls.reset_mock()

    (proj.path / "pyproject.toml").write_text(toml)
    result_second = proj.run("r")
    result_second.assert_success()
    assert ".pkg: recreate env because dependencies removed: setuptools" in result_second.out, result_second.out
    run_ids = [i[0][3].run_id for i in execute_calls.call_args_list]
    assert run_ids == ["_optional_hooks", "get_requires_for_build_wheel", "build_wheel", "install_package", "_exit"]


def test_pip_install_requirements_file_deps(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv]\ndeps=-r r.txt\nskip_install=true", "r.txt": "a"})
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)
    result = proj.run("r")
    result.assert_success()
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-r", "r.txt"]

    # check that adding a new dependency correctly finds the previous one
    (proj.path / "tox.ini").write_text("[testenv]\ndeps=-r r.txt\n b\nskip_install=true")
    execute_calls.reset_mock()
    result_second = proj.run("r")
    result_second.assert_success()
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "b", "-r", "r.txt"]

    # if the requirement file changes recreate
    (proj.path / "r.txt").write_text("c\nd")
    execute_calls.reset_mock()
    result_third = proj.run("r")
    result_third.assert_success()
    assert "py: recreate env because requirements removed: a" in result_third.out, result_third.out
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "b", "-r", "r.txt"]


def test_pip_install_constraint_file_create_change(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv]\ndeps=-c c.txt\n a\nskip_install=true", "c.txt": "b"})
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)
    result = proj.run("r")
    result.assert_success()
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "-c", "c.txt"]

    # a new dependency triggers an install
    (proj.path / "tox.ini").write_text("[testenv]\ndeps=-c c.txt\n a\n d\nskip_install=true")
    execute_calls.reset_mock()
    result_second = proj.run("r")
    result_second.assert_success()
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "d", "-c", "c.txt"]

    # a new constraints triggers a recreate
    (proj.path / "c.txt").write_text("")
    execute_calls.reset_mock()
    result_third = proj.run("r")
    result_third.assert_success()
    assert "py: recreate env because changed constraint(s) removed b" in result_third.out, result_third.out
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "d", "-c", "c.txt"]


def test_pip_install_constraint_file_new(tox_project: ToxProjectCreator) -> None:
    proj = tox_project({"tox.ini": "[testenv]\ndeps=a\nskip_install=true"})
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)
    result = proj.run("r")
    result.assert_success()
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a"]

    (proj.path / "c.txt").write_text("a")
    (proj.path / "tox.ini").write_text("[testenv]\ndeps=a\n -c c.txt\nskip_install=true")
    execute_calls.reset_mock()
    result_second = proj.run("r")
    result_second.assert_success()
    assert "py: recreate env because changed constraint(s) added a" in result_second.out, result_second.out
    assert execute_calls.call_count == 1
    assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "-c", "c.txt"]


@pytest.fixture(params=[True, False])
def constrain_package_deps(request: SubRequest) -> bool:
    return bool(request.param)


@pytest.fixture(params=[True, False])
def use_frozen_constraints(request: SubRequest) -> bool:
    return bool(request.param)


@pytest.fixture(
    params=[
        "explicit",
        "requirements",
        "constraints",
        "explicit+requirements",
        "requirements_indirect",
        "requirements_constraints_indirect",
    ],
)
def constrained_mock_project(
    request: SubRequest,
    tox_project: ToxProjectCreator,
    demo_pkg_inline: Path,
    constrain_package_deps: bool,
    use_frozen_constraints: bool,
) -> tuple[ToxProject, list[str]]:
    toml = (demo_pkg_inline / "pyproject.toml").read_text()
    files = {
        "pyproject.toml": toml.replace("requires = []", 'requires = ["setuptools"]')
        + '\n[project]\nname = "demo"\nversion = "0.1"\ndependencies = ["foo > 2"]',
        "build.py": (demo_pkg_inline / "build.py").read_text(),
    }
    exp_constraints: list[str] = []
    requirement = "foo==1.2.3"
    constraint = "foo<2"
    if request.param == "explicit":
        deps = requirement
        exp_constraints.append(requirement)
    elif request.param == "requirements":
        files["requirements.txt"] = f"--pre\n{requirement}"
        deps = "-rrequirements.txt"
        exp_constraints.append(requirement)
    elif request.param == "constraints":
        files["constraints.txt"] = constraint
        deps = "-cconstraints.txt"
        exp_constraints.append(constraint)
    elif request.param == "explicit+requirements":
        files["requirements.txt"] = f"--pre\n{requirement}"
        deps = "\n\t-rrequirements.txt\n\tfoo"
        exp_constraints.extend(["foo", requirement])
    elif request.param == "requirements_indirect":
        files["foo.requirements.txt"] = f"--pre\n{requirement}"
        files["requirements.txt"] = "-r foo.requirements.txt"
        deps = "-rrequirements.txt"
        exp_constraints.append(requirement)
    elif request.param == "requirements_constraints_indirect":
        files["foo.requirements.txt"] = f"--pre\n{requirement}"
        files["foo.constraints.txt"] = f"{constraint}"
        files["requirements.txt"] = "-r foo.requirements.txt\n-c foo.constraints.txt"
        deps = "-rrequirements.txt"
        exp_constraints.extend([requirement, constraint])
    else:  # pragma: no cover
        pytest.fail(f"Missing case: {request.param}")
    files["tox.ini"] = (
        "[testenv]\npackage=wheel\n"
        f"constrain_package_deps = {constrain_package_deps}\n"
        f"use_frozen_constraints = {use_frozen_constraints}\n"
        f"deps = {deps}"
    )
    return tox_project(files), exp_constraints if constrain_package_deps else []


def test_constrain_package_deps(
    constrained_mock_project: tuple[ToxProject, list[str]],
    constrain_package_deps: bool,
    use_frozen_constraints: bool,
) -> None:
    proj, exp_constraints = constrained_mock_project
    execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)
    result_first = proj.run("r")
    result_first.assert_success()
    exp_run_ids = ["install_deps"]
    if constrain_package_deps and use_frozen_constraints:
        exp_run_ids.append("freeze")
    exp_run_ids.extend(
        [
            "install_requires",
            "_optional_hooks",
            "get_requires_for_build_wheel",
            "build_wheel",
            "install_package_deps",
            "install_package",
            "_exit",
        ],
    )
    run_ids = [i[0][3].run_id for i in execute_calls.call_args_list]
    assert run_ids == exp_run_ids
    constraints_file = proj.path / ".tox" / "py" / "constraints.txt"
    if constrain_package_deps:
        constraints = constraints_file.read_text().splitlines()
        for call in execute_calls.call_args_list:
            if call[0][3].run_id == "install_package_deps":
                assert f"-c{constraints_file}" in call[0][3].cmd
        if use_frozen_constraints:
            for c in exp_constraints:
                # when using frozen constraints with this mock, the mock package does NOT
                # actually end up in the constraints, so assert it's not there
                assert c not in constraints
            for c in constraints:
                assert c.partition("==")[0] in ["pip", "setuptools", "wheel"]
        else:
            for c in constraints:
                assert c in exp_constraints
            for c in exp_constraints:
                assert c in constraints
    else:
        assert not constraints_file.exists()


@pytest.mark.parametrize("conf_key", ["constrain_package_deps", "use_frozen_constraints"])
def test_change_constraint_options_recreates(tox_project: ToxProjectCreator, conf_key: str) -> None:
    tox_ini_content = "[testenv:py]\ndeps=a\nskip_install=true"
    proj = tox_project({"tox.ini": f"{tox_ini_content}\n{conf_key} = true"})
    proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)

    result = proj.run("r")
    result.assert_success()

    (proj.path / "tox.ini").write_text(f"{tox_ini_content}\n{conf_key} = false")
    result_second = proj.run("r")
    result_second.assert_success()
    assert "recreate env because constraint options changed" in result_second.out
    assert conf_key in result_second.out
