import os
import sys
from unittest import mock

import pytest

from pipenv.exceptions import PipenvUsageError
from pipenv.utils import dependencies, indexes, internet, shell, toml, virtualenv

# Pipfile format <-> requirements.txt format.
DEP_PIP_PAIRS = [
    ({"django": ">1.10"}, {"django": "django>1.10"}),
    ({"Django": ">1.10"}, {"Django": "Django>1.10"}),
    (
        {"requests": {"extras": ["socks"], "version": ">1.10"}},
        {"requests": "requests[socks]>1.10"},
    ),
    (
        {"requests": {"extras": ["socks"], "version": "==1.10"}},
        {"requests": "requests[socks]==1.10"},
    ),
    (
        {
            "dataclasses-json": {
                "git": "https://github.com/lidatong/dataclasses-json.git",
                "ref": "v0.5.7",
                "editable": True,
            }
        },
        {
            "dataclasses-json": "dataclasses-json @ git+https://github.com/lidatong/dataclasses-json.git@v0.5.7"
        },
    ),
    (
        {
            "dataclasses-json": {
                "git": "https://github.com/lidatong/dataclasses-json.git",
                "ref": "v0.5.7",
            }
        },
        {
            "dataclasses-json": "dataclasses-json @ git+https://github.com/lidatong/dataclasses-json.git@v0.5.7"
        },
    ),
    (
        # Extras in url
        {
            "dparse": {
                "file": "https://github.com/oz123/dparse/archive/refs/heads/master.zip",
                "extras": ["pipenv"],
            }
        },
        {
            "dparse": "dparse[pipenv] @ https://github.com/oz123/dparse/archive/refs/heads/master.zip"
        },
    ),
    (
        {
            "requests": {
                "git": "https://github.com/requests/requests.git",
                "ref": "main",
                "extras": ["security"],
                "editable": False,
            }
        },
        {
            "requests": "requests[security] @ git+https://github.com/requests/requests.git@main"
        },
    ),
]


def mock_unpack(
    link,
    source_dir,
    download_dir,
    only_download=False,
    session=None,
    hashes=None,
    progress_bar="off",
):
    return


@pytest.mark.utils
@pytest.mark.parametrize("deps, expected", DEP_PIP_PAIRS)
@pytest.mark.needs_internet
def test_convert_deps_to_pip(deps, expected):
    assert dependencies.convert_deps_to_pip(deps) == expected


@pytest.mark.utils
@pytest.mark.needs_internet
def test_convert_deps_to_pip_star_specifier():
    deps = {"uvicorn": "*"}
    expected = {"uvicorn": "uvicorn"}
    assert dependencies.convert_deps_to_pip(deps) == expected


@pytest.mark.utils
@pytest.mark.needs_internet
def test_convert_deps_to_pip_extras_no_version():
    deps = {"uvicorn": {"extras": ["standard"], "version": "*"}}
    expected = {"uvicorn": "uvicorn[standard]"}
    assert dependencies.convert_deps_to_pip(deps) == expected


@pytest.mark.utils
@pytest.mark.parametrize(
    "deps, expected",
    [
        # Hash value should be passed into the result.
        (
            {
                "FooProject": {
                    "version": "==1.2",
                    "hashes": ["sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"],
                }
            },
            {
                "FooProject": "FooProject==1.2  --hash=sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
            },
        ),
        (
            {
                "FooProject": {
                    "version": "==1.2",
                    "extras": ["stuff"],
                    "hashes": ["sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"],
                }
            },
            {
                "FooProject": "FooProject[stuff]==1.2  --hash=sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
            },
        ),
        (
            {
                "uvicorn": {
                    "git": "https://github.com/encode/uvicorn.git",
                    "ref": "master",
                    "extras": ["standard"],
                }
            },
            {
                "uvicorn": "uvicorn[standard] @ git+https://github.com/encode/uvicorn.git@master"
            },
        ),
    ],
)
def test_convert_deps_to_pip_one_way(deps, expected):
    assert dependencies.convert_deps_to_pip(deps) == expected


@pytest.mark.utils
def test_convert_deps_to_pip_one_way_uvicorn():
    deps = {"uvicorn": {}}
    expected = {"uvicorn": "uvicorn"}
    assert dependencies.convert_deps_to_pip(deps) == expected


@pytest.mark.utils
def test_convert_deps_to_pip_vcs_with_markers():
    """Test that VCS dependencies with markers work correctly.

    PEP 508 format (remote VCS) should include markers inline.
    Legacy format (local file:// editable) cannot have inline markers.
    """
    # Remote VCS with markers - PEP 508 format supports inline markers
    deps = {
        "requests": {
            "git": "https://github.com/psf/requests.git",
            "ref": "v2.28.0",
            "markers": "python_version >= '3.6'",
        }
    }
    result = dependencies.convert_deps_to_pip(deps)
    assert "requests" in result
    assert "python_version >= '3.6'" in result["requests"]
    assert "git+https://github.com/psf/requests.git@v2.28.0" in result["requests"]

    # Local file:// editable VCS - legacy format cannot have inline markers
    # Markers will be preserved separately in the lockfile via pipfile_entries
    deps_local = {
        "six": {
            "git": "file:///tmp/git/six",
            "editable": True,
            "markers": "python_version >= '2.7'",
        }
    }
    result_local = dependencies.convert_deps_to_pip(deps_local)
    assert "six" in result_local
    # Legacy -e format should NOT have inline markers (they're handled separately)
    assert "python_version" not in result_local["six"]
    assert "-e git+file:///tmp/git/six#egg=six" == result_local["six"]


@pytest.mark.utils
@pytest.mark.parametrize(
    "deps, expected",
    [
        ({"uvicorn": {}}, {"uvicorn"}),
        ({"FooProject": {"path": ".", "editable": "true"}}, set()),
        ({"FooProject": {"version": "==1.2"}}, {"fooproject==1.2"}),
        ({"uvicorn": {"extras": ["standard"]}}, {"uvicorn"}),
        ({"uvicorn": {"extras": []}}, {"uvicorn"}),
        ({"extras": {}}, {"extras"}),
    ],
)
def test_get_constraints_from_deps(deps, expected):
    assert dependencies.get_constraints_from_deps(deps) == expected


@pytest.mark.parametrize(
    "line,result",
    [
        (
            "-i https://example.com/simple/",
            ("https://example.com/simple/", None, None, []),
        ),
        (
            "--extra-index-url=https://example.com/simple/",
            (None, "https://example.com/simple/", None, []),
        ),
        ("--trusted-host=example.com", (None, None, "example.com", [])),
        ("# -i https://example.com/simple/", (None, None, None, [])),
        ("requests # -i https://example.com/simple/", (None, None, None, ["requests"])),
    ],
)
@pytest.mark.utils
def test_parse_indexes(line, result):
    assert indexes.parse_indexes(line) == result


@pytest.mark.parametrize(
    "line",
    [
        "-i https://example.com/simple/ --extra-index-url=https://extra.com/simple/",
        "--extra-index-url https://example.com/simple/ --trusted-host=example.com",
        "requests -i https://example.com/simple/",
    ],
)
@pytest.mark.utils
def test_parse_indexes_individual_lines(line):
    with pytest.raises(ValueError):
        indexes.parse_indexes(line, strict=True)


class TestUtils:
    """Test utility functions in pipenv"""

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "version, specified_ver, expected",
        [
            ("*", "*", True),
            ("2.1.6", "==2.1.4", False),
            ("20160913", ">=20140815", True),
            (
                "1.4",
                {"svn": "svn://svn.myproj.org/svn/MyProj", "version": "==1.4"},
                True,
            ),
            ("2.13.0", {"extras": ["socks"], "version": "==2.12.4"}, False),
        ],
    )
    def test_is_required_version(self, version, specified_ver, expected):
        assert dependencies.is_required_version(version, specified_ver) is expected

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "entry, expected",
        [
            ({"git": "package.git", "ref": "v0.0.1"}, True),
            ({"hg": "https://package.com/package", "ref": "v1.2.3"}, True),
            ("*", False),
            ({"some_value": 5, "other_value": object()}, False),
            ("package", False),
            ("git+https://github.com/requests/requests.git#egg=requests", True),
            ("git+git@github.com:requests/requests.git#egg=requests", True),
            ("gitdb2", False),
        ],
    )
    @pytest.mark.vcs
    def test_is_vcs(self, entry, expected):
        from pipenv.utils.dependencies import is_vcs

        assert is_vcs(entry) is expected

    @pytest.mark.utils
    def test_python_version_from_bad_path(self):
        assert dependencies.python_version("/fake/path") is None

    @pytest.mark.utils
    def test_python_version_from_non_python(self):
        assert dependencies.python_version("/dev/null") is None

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "version_output, version",
        [
            ("Python 3.6.2", "3.6.2"),
            ("Python 3.6.2 :: Continuum Analytics, Inc.", "3.6.2"),
            ("Python 3.6.20 :: Continuum Analytics, Inc.", "3.6.20"),
            (
                "Python 3.5.3 (3f6eaa010fce78cc7973bdc1dfdb95970f08fed2, "
                "Jan 13 2018, 18:14:01)\n[PyPy 5.10.1 with GCC 4.2.1 Compatible Apple LLVM 9.0.0 (clang-900.0.39.2)]",
                "3.5.3",
            ),
        ],
    )
    def test_python_version_output_variants(self, monkeypatch, version_output, version):
        def mock_version(path):
            return version_output.split()[1]

        monkeypatch.setattr(
            "pipenv.vendor.pythonfinder.utils.get_python_version", mock_version
        )
        assert dependencies.python_version("some/path") == version

    @pytest.mark.utils
    def test_is_valid_url(self):
        url = "https://github.com/psf/requests.git"
        not_url = "something_else"
        assert internet.is_valid_url(url)
        assert internet.is_valid_url(not_url) is False

    @pytest.mark.utils
    def test_get_requests_session_uses_pip_truststore_context(self, monkeypatch):
        ssl_context = object()
        session = mock.MagicMock()
        pip_session = mock.Mock(return_value=session)
        monkeypatch.setattr(
            "pipenv.patched.pip._internal.network.download.PipSession",
            pip_session,
        )
        monkeypatch.setattr(
            "pipenv.patched.pip._internal.cli.index_command."
            "_create_truststore_ssl_context",
            lambda: ssl_context,
        )

        result = internet.get_requests_session(
            max_retries=2,
            cache_dir="cache-dir",
            source="https://example.org/simple",
        )

        assert result is session
        pip_session.assert_called_once_with(
            cache="cache-dir",
            retries=2,
            index_urls=["https://example.org/simple"],
            ssl_context=ssl_context,
        )

    @pytest.mark.utils
    def test_download_file(self, tmp_path):
        url = "https://example.com/test.md"
        output = tmp_path / "test_download.md"
        expected_content = b"# Test Content\n"

        # Mock the requests session to avoid external network calls
        mock_response = mock.MagicMock()
        mock_response.ok = True
        mock_response.content = expected_content

        mock_session = mock.MagicMock()
        mock_session.get.return_value = mock_response

        with mock.patch.object(internet, "get_requests_session", return_value=mock_session):
            internet.download_file(url, str(output))

        assert output.exists()
        assert output.read_bytes() == expected_content

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "line, expected",
        [
            ("python", True),
            ("python3.7", True),
            ("python2.7", True),
            ("python2", True),
            ("python3", True),
            ("pypy3", True),
            ("anaconda3-5.3.0", True),
            ("which", False),
            ("vim", False),
            ("miniconda", True),
            ("micropython", True),
            ("ironpython", True),
            ("jython3.5", True),
            ("2", True),
            ("2.7", True),
            ("3.7", True),
            ("3", True),
        ],
    )
    def test_is_python_command(self, line, expected):
        assert shell.is_python_command(line) == expected

    @pytest.mark.utils
    def test_new_line_end_of_toml_file(this):
        # toml file that needs clean up
        toml_data = """
[dev-packages]

"flake8" = ">=3.3.0,<4"
pytest = "*"
mock = "*"
sphinx = "<=1.5.5"
"-e ." = "*"
twine = "*"
"sphinx-click" = "*"
"pytest-xdist" = "*"
        """
        new_toml = toml.cleanup_toml(toml_data)
        # testing if the end of the generated file contains a newline
        assert new_toml[-1] == "\n"

    @pytest.mark.utils
    def test_cleanup_toml_preserves_single_blank_lines_within_sections(this):
        """Blank lines placed by the user within a section survive cleanup. (#5914)"""
        toml_data = (
            "[packages]\n"
            "requests = \"==2.31.0\"\n"
            "\n"
            "flask = \"==2.0.0\"\n"
            "\n"
            "[dev-packages]\n"
            "pytest = \"*\"\n"
        )
        result = toml.cleanup_toml(toml_data)
        lines = result.split("\n")
        # The blank line between requests and flask must be preserved.
        assert "" in lines[: lines.index("[dev-packages]")]

    @pytest.mark.utils
    def test_cleanup_toml_collapses_multiple_blank_lines(this):
        """Multiple consecutive blank lines are collapsed to a single one. (#5914)"""
        toml_data = (
            "[packages]\n"
            "requests = \"==2.31.0\"\n"
            "\n"
            "\n"
            "\n"
            "flask = \"==2.0.0\"\n"
        )
        result = toml.cleanup_toml(toml_data)
        lines = result.split("\n")
        # No two consecutive blank lines should remain.
        for i in range(len(lines) - 1):
            assert not (lines[i] == "" and lines[i + 1] == ""), (
                f"Found consecutive blank lines at indices {i} and {i+1}"
            )

    @pytest.mark.utils
    def test_cleanup_toml_adds_blank_line_before_section_header(this):
        """A blank line is inserted before a section header if one is missing. (#5914)"""
        toml_data = (
            "[packages]\n"
            "requests = \"==2.31.0\"\n"
            "[dev-packages]\n"  # no blank line before this
            "pytest = \"*\"\n"
        )
        result = toml.cleanup_toml(toml_data)
        lines = result.split("\n")
        dev_idx = lines.index("[dev-packages]")
        assert lines[dev_idx - 1] == "", (
            "Expected a blank line before [dev-packages]"
        )

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "input_path, expected",
        [
            (
                "c:\\Program Files\\Python36\\python.exe",
                "C:\\Program Files\\Python36\\python.exe",
            ),
            (
                "C:\\Program Files\\Python36\\python.exe",
                "C:\\Program Files\\Python36\\python.exe",
            ),
            ("\\\\host\\share\\file.zip", "\\\\host\\share\\file.zip"),
            ("artifacts\\file.zip", "artifacts\\file.zip"),
            (".\\artifacts\\file.zip", ".\\artifacts\\file.zip"),
            ("..\\otherproject\\file.zip", "..\\otherproject\\file.zip"),
        ],
    )
    @pytest.mark.skipif(os.name != "nt", reason="Windows file paths tested")
    def test_win_normalize_drive(self, input_path, expected):
        assert shell.normalize_drive(input_path) == expected

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "input_path, expected",
        [
            ("/usr/local/bin/python", "/usr/local/bin/python"),
            ("artifacts/file.zip", "artifacts/file.zip"),
            ("./artifacts/file.zip", "./artifacts/file.zip"),
            ("../otherproject/file.zip", "../otherproject/file.zip"),
        ],
    )
    @pytest.mark.skipif(os.name == "nt", reason="*nix file paths tested")
    def test_nix_normalize_drive(self, input_path, expected):
        assert shell.normalize_drive(input_path) == expected

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "sources, expected_args",
        [
            (
                [{"url": "https://test.example.com/simple", "verify_ssl": True}],
                ["-i", "https://test.example.com/simple"],
            ),
            (
                [{"url": "https://test.example.com/simple", "verify_ssl": False}],
                [
                    "-i",
                    "https://test.example.com/simple",
                    "--trusted-host",
                    "test.example.com",
                ],
            ),
            (
                [{"url": "https://test.example.com:12345/simple", "verify_ssl": False}],
                [
                    "-i",
                    "https://test.example.com:12345/simple",
                    "--trusted-host",
                    "test.example.com:12345",
                ],
            ),
            (
                [
                    {"url": "https://pypi.org/simple"},
                    {"url": "https://custom.example.com/simple"},
                ],
                [
                    "-i",
                    "https://pypi.org/simple",
                    "--extra-index-url",
                    "https://custom.example.com/simple",
                ],
            ),
            (
                [
                    {"url": "https://pypi.org/simple"},
                    {"url": "https://custom.example.com/simple", "verify_ssl": False},
                ],
                [
                    "-i",
                    "https://pypi.org/simple",
                    "--extra-index-url",
                    "https://custom.example.com/simple",
                    "--trusted-host",
                    "custom.example.com",
                ],
            ),
            (
                [
                    {"url": "https://pypi.org/simple"},
                    {
                        "url": "https://custom.example.com:12345/simple",
                        "verify_ssl": False,
                    },
                ],
                [
                    "-i",
                    "https://pypi.org/simple",
                    "--extra-index-url",
                    "https://custom.example.com:12345/simple",
                    "--trusted-host",
                    "custom.example.com:12345",
                ],
            ),
            # GHSA-8xgg-v3jj-95m2: credentials embedded in source URLs must
            # NOT be passed to pip via argv (visible in `ps`/`/proc/<pid>/cmdline`).
            # `prepare_pip_source_args` strips userinfo from the URL.
            (
                [
                    {"url": "https://pypi.org/simple"},
                    {
                        "url": "https://user:password@custom.example.com/simple",
                        "verify_ssl": False,
                    },
                ],
                [
                    "-i",
                    "https://pypi.org/simple",
                    "--extra-index-url",
                    "https://custom.example.com/simple",
                    "--trusted-host",
                    "custom.example.com",
                ],
            ),
            (
                [
                    {"url": "https://pypi.org/simple"},
                    {"url": "https://user:password@custom.example.com/simple"},
                ],
                [
                    "-i",
                    "https://pypi.org/simple",
                    "--extra-index-url",
                    "https://custom.example.com/simple",
                ],
            ),
            (
                [
                    {
                        "url": "https://user:password@custom.example.com/simple",
                        "verify_ssl": False,
                    },
                ],
                [
                    "-i",
                    "https://custom.example.com/simple",
                    "--trusted-host",
                    "custom.example.com",
                ],
            ),
        ],
    )
    def test_prepare_pip_source_args(self, sources, expected_args):
        assert indexes.prepare_pip_source_args(sources, pip_args=None) == expected_args

    @pytest.mark.utils
    def test_invalid_prepare_pip_source_args(self):
        sources = [{}]
        with pytest.raises(PipenvUsageError):
            indexes.prepare_pip_source_args(sources, pip_args=None)

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "sources",
        [
            [
                {
                    "url": "https://user:SuperSecret123@10.255.255.1/simple",
                    "verify_ssl": True,
                    "name": "private",
                }
            ],
            [
                {"url": "https://pypi.org/simple"},
                {
                    "url": "https://__token__:gha_top_secret@private.example.com/simple",
                    "verify_ssl": False,
                },
            ],
        ],
    )
    def test_prepare_pip_source_args_does_not_leak_credentials(self, sources):
        """GHSA-8xgg-v3jj-95m2: credentials embedded in source URLs must not
        appear in the argv list passed to pip — argv is exposed to other local
        users via ``ps`` and ``/proc/<pid>/cmdline``.
        """
        args = indexes.prepare_pip_source_args(sources, pip_args=None)
        joined = " ".join(args)
        for source in sources:
            url = source["url"]
            from urllib.parse import urlparse

            parsed = urlparse(url)
            if parsed.password:
                assert parsed.password not in joined, (
                    f"Password for {url} leaked into pip argv: {args}"
                )
            if parsed.username:
                # Userinfo (whether name or token) must also be stripped.
                assert f"{parsed.username}@" not in joined

    @pytest.mark.utils
    def test_project_python_tries_python3_before_python_if_system_is_true(self):
        def mock_shutil_which(command, path=None):
            if command != "python3":
                return f"/usr/bin/{command}"
            return "/usr/local/bin/python3"

        with mock.patch("pipenv.utils.shell.shutil.which", wraps=mock_shutil_which):
            # Setting project to None as system=True doesn't use it
            project = None
            python = shell.project_python(project, system=True)

        assert python == "/usr/local/bin/python3"

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "val, expected",
        (
            (True, True),
            (False, False),
            ("true", True),
            ("1", True),
            ("off", False),
            ("0", False),
        ),
    )
    def test_env_to_bool(self, val, expected):
        actual = shell.env_to_bool(val)
        assert actual == expected

    @pytest.mark.utils
    def test_is_env_truthy_exists_true(self, monkeypatch):
        name = "ZZZ"
        monkeypatch.setenv(name, "1")
        assert shell.is_env_truthy(name) is True

    @pytest.mark.utils
    def test_is_env_truthy_exists_false(self, monkeypatch):
        name = "ZZZ"
        monkeypatch.setenv(name, "0")
        assert shell.is_env_truthy(name) is False

    @pytest.mark.utils
    def test_is_env_truthy_does_not_exisxt(self, monkeypatch):
        name = "ZZZ"
        monkeypatch.delenv(name, raising=False)
        assert shell.is_env_truthy(name) is False

    @pytest.mark.utils
    # substring search in version handles special-case of MSYS2 MinGW CPython
    # https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-python/0017-sysconfig-treat-MINGW-builds-as-POSIX-builds.patch#L24
    @pytest.mark.skipif(os.name != "nt" or "GCC" in sys.version, reason="Windows test only")
    def test_virtualenv_scripts_dir_nt(self):
        """
        """
        assert str(virtualenv.virtualenv_scripts_dir('foobar')) == 'foobar\\Scripts'

    @pytest.mark.utils
    @pytest.mark.skipif(os.name == "nt" and "GCC" not in sys.version, reason="POSIX test only")
    def test_virtualenv_scripts_dir_posix(self):
        assert str(virtualenv.virtualenv_scripts_dir('foobar')) == 'foobar/bin'

    @pytest.mark.utils
    def test_find_package_name_from_directory_ignores_subdirectory_setup(self, tmp_path):
        """Test that find_package_name_from_directory ignores setup() calls in subdirectories.

        This tests the fix for issue #6409 where a test file containing setup(name='foo')
        in a subdirectory would incorrectly be used as the package name instead of the
        actual setup.py in the root directory.
        """
        # Create a package directory structure
        package_dir = tmp_path / "mypackage"
        package_dir.mkdir()

        # Create a proper setup.py in the root with a parseable name
        setup_py = package_dir / "setup.py"
        setup_py.write_text(
            "from setuptools import setup\nsetup(name='mypackage', version='1.0')\n"
        )

        # Create a tests subdirectory with a file that has a setup() call
        tests_dir = package_dir / "mypackage" / "tests"
        tests_dir.mkdir(parents=True)
        test_file = tests_dir / "test_setup.py"
        test_file.write_text(
            "import unittest\n"
            "class TestNothing(unittest.TestCase):\n"
            "    def test_nada(self):\n"
            "        # This setup call should NOT be picked up as the package name\n"
            "        setup(name='wrongname')\n"
        )

        # The function should return 'mypackage' from the root setup.py
        # and NOT 'wrongname' from the test file
        result = dependencies.find_package_name_from_directory(str(package_dir))
        assert result == "mypackage"

    @pytest.mark.utils
    def test_find_package_name_from_directory_finds_egg_info_metadata(self, tmp_path):
        """Test that find_package_name_from_directory can find package name from .egg-info."""
        # Create a package directory structure
        package_dir = tmp_path / "mypackage"
        package_dir.mkdir()

        # Create a .egg-info directory with PKG-INFO
        egg_info_dir = package_dir / "mypackage.egg-info"
        egg_info_dir.mkdir()
        pkg_info = egg_info_dir / "PKG-INFO"
        pkg_info.write_text("Metadata-Version: 1.0\nName: mypackage\nVersion: 1.0\n")

        result = dependencies.find_package_name_from_directory(str(package_dir))
        assert result == "mypackage"


@pytest.mark.utils
class TestPipConfigurationParsing:
    """Test pip configuration parsing in Project class.

    These tests verify that pipenv correctly reads index-url from pip
    configuration files (pip.conf) after the pip 25.3 update which changed
    the Configuration.items() return format.
    """

    def test_pip_configuration_dictionary_format(self):
        """Test that pip Configuration._dictionary returns the expected format.

        In pip 25.3+, _dictionary is a dict of {filename: config_dict} pairs where
        config_dict contains the actual key-value pairs like {"global.index-url": "..."}.
        """
        from pipenv.patched.pip._internal.configuration import Configuration

        conf = Configuration(isolated=False, load_only=None)
        conf.load()

        # Verify the structure of _dictionary
        assert isinstance(conf._dictionary, dict), "_dictionary should be a dict"
        for filename, config_dict in conf._dictionary.items():
            assert isinstance(filename, str), "Key should be filename string"
            assert isinstance(config_dict, dict), "Value should be config dict"

    def test_project_parses_pip_conf_index_url(self, monkeypatch, tmp_path):
        """Test that Project correctly parses index-url from pip configuration.

        This is a regression test for issue #6478 where index-url in pip.conf
        was not being honored after the pip 25.3 update.
        """
        # Create a mock configuration that returns index-url
        mock_index_url = "https://my-private-pypi.example.com/simple"

        class MockConfiguration:
            def __init__(self, *args, **kwargs):
                pass

            def load(self):
                pass

            @property
            def _dictionary(self):
                # Simulate pip 25.3+ format: {filename: config_dict} dict
                return {
                    "/etc/pip.conf": {"global.index-url": mock_index_url},
                }

            def get_value(self, key):
                from pipenv.patched.pip._internal.exceptions import ConfigurationError

                raise ConfigurationError(f"No such key - {key}")

        # Patch the Configuration class
        monkeypatch.setattr(
            "pipenv.project.Configuration",
            MockConfiguration,
        )

        # Change to temp directory to avoid affecting real Pipfile
        original_dir = os.getcwd()
        os.chdir(tmp_path)
        try:
            from pipenv.project import Project

            project = Project(chdir=False)

            # Verify that the default_source was set from pip.conf
            assert project.default_source is not None
            assert project.default_source["url"] == mock_index_url
            assert "pip_conf_index_global" in project.default_source["name"]
        finally:
            os.chdir(original_dir)

    def test_project_parses_multiple_pip_conf_indexes(self, monkeypatch, tmp_path):
        """Test that Project correctly parses multiple index-urls from pip configuration."""
        primary_index = "https://primary-pypi.example.com/simple"
        extra_index = "https://extra-pypi.example.com/simple"

        class MockConfiguration:
            def __init__(self, *args, **kwargs):
                pass

            def load(self):
                pass

            @property
            def _dictionary(self):
                # Simulate multiple configuration files with different indexes
                return {
                    "/etc/pip.conf": {"global.index-url": primary_index},
                    "~/.pip/pip.conf": {"global.index-url": extra_index},
                }

            def get_value(self, key):
                from pipenv.patched.pip._internal.exceptions import ConfigurationError

                raise ConfigurationError(f"No such key - {key}")

        monkeypatch.setattr(
            "pipenv.project.Configuration",
            MockConfiguration,
        )

        original_dir = os.getcwd()
        os.chdir(tmp_path)
        try:
            from pipenv.project import Project

            project = Project(chdir=False)

            # Verify that at least one index was picked up
            assert project.default_source is not None
            assert project.default_source["url"] in [primary_index, extra_index]
        finally:
            os.chdir(original_dir)



class TestEnsureProjectPythonVersionMismatch:
    """Tests for ensure_project detecting Python version mismatch (GitHub issue #6141).

    When --python is passed and an existing virtualenv uses a different Python
    version, ensure_project should call ensure_virtualenv so the virtualenv is
    recreated with the correct Python version.
    """

    def _make_project(self, monkeypatch):
        """Return a minimal mock project object."""
        project = mock.MagicMock()
        project.s.PIPENV_USE_SYSTEM = False
        project.s.PIPENV_YES = False
        project.venv_locator.exists = True
        project.pipfile.exists = True
        # required_python_version=None skips the version warning block
        project.pipfile.required_python_version = None
        # venv_locator.python() must return a str for os.environ assignment
        project.venv_locator.python.return_value = "/usr/bin/python3"
        return project

    @pytest.mark.utils
    def test_python_version_mismatch_triggers_ensure_virtualenv(self, monkeypatch):
        """When --python 3.12 is given but the venv uses 3.10, ensure_virtualenv
        should be called so the venv is recreated."""
        project = self._make_project(monkeypatch)
        project.venv_locator._which.return_value = "/fake/venv/bin/python"

        monkeypatch.setattr(
            "pipenv.utils.project.python_version",
            lambda path: "3.10.5",
        )
        monkeypatch.setattr(
            "pipenv.utils.project.find_a_system_python",
            lambda x, pyenv_only=False: None,
        )
        ensure_virtualenv_calls = []
        monkeypatch.setattr(
            "pipenv.utils.project.ensure_virtualenv",
            lambda *a, **kw: ensure_virtualenv_calls.append((a, kw)),
        )
        monkeypatch.setattr(
            "pipenv.utils.project.ensure_pipfile",
            lambda *a, **kw: None,
        )

        from pipenv.utils.project import ensure_project

        ensure_project(project, python="3.12", system=False)

        assert len(ensure_virtualenv_calls) == 1, (
            "ensure_virtualenv should be called when Python version mismatches"
        )

    @pytest.mark.utils
    def test_python_version_match_skips_ensure_virtualenv(self, monkeypatch):
        """When --python 3.10 is given and the venv already uses 3.10, ensure_virtualenv
        should NOT be called (no recreation needed)."""
        project = self._make_project(monkeypatch)
        project.venv_locator._which.return_value = "/fake/venv/bin/python"

        monkeypatch.setattr(
            "pipenv.utils.project.python_version",
            lambda path: "3.10.5",
        )
        monkeypatch.setattr(
            "pipenv.utils.project.find_a_system_python",
            lambda x, pyenv_only=False: None,
        )
        ensure_virtualenv_calls = []
        monkeypatch.setattr(
            "pipenv.utils.project.ensure_virtualenv",
            lambda *a, **kw: ensure_virtualenv_calls.append((a, kw)),
        )
        monkeypatch.setattr(
            "pipenv.utils.project.ensure_pipfile",
            lambda *a, **kw: None,
        )

        from pipenv.utils.project import ensure_project

        ensure_project(project, python="3.10", system=False)

        assert len(ensure_virtualenv_calls) == 0, (
            "ensure_virtualenv should NOT be called when Python version already matches"
        )

    @pytest.mark.utils
    def test_no_python_arg_skips_version_check(self, monkeypatch):
        """When --python is not specified, no version-mismatch check should be done."""
        project = self._make_project(monkeypatch)

        ensure_virtualenv_calls = []
        monkeypatch.setattr(
            "pipenv.utils.project.ensure_virtualenv",
            lambda *a, **kw: ensure_virtualenv_calls.append((a, kw)),
        )
        monkeypatch.setattr(
            "pipenv.utils.project.ensure_pipfile",
            lambda *a, **kw: None,
        )

        from pipenv.utils.project import ensure_project

        ensure_project(project, python=None, system=False)

        assert len(ensure_virtualenv_calls) == 0, (
            "ensure_virtualenv should not be called when no --python is given and venv exists"
        )

    @pytest.mark.utils
    def test_version_warning_recommends_current_remove_command(self, monkeypatch):
        project = self._make_project(monkeypatch)
        project.pipfile.required_python_version = "3.12"
        project.venv_locator._which.return_value = "/fake/venv/bin/python"

        monkeypatch.setattr(
            "pipenv.utils.project.python_version",
            lambda path: "3.11.9",
        )
        monkeypatch.setattr(
            "pipenv.utils.project.ensure_pipfile",
            lambda *a, **kw: None,
        )

        from pipenv.utils.project import ensure_project

        with mock.patch("pipenv.utils.project.err.print") as err_print:
            ensure_project(project, python=None, system=False)

        output = "\n".join(call.args[0] for call in err_print.call_args_list)
        assert "$ pipenv remove" in output
        assert "$ pipenv --rm" not in output


class TestPythonVersionMatchesRequired:
    """Tests for _python_version_matches_required.

    Regression coverage for https://github.com/pypa/pipenv/issues/6514:
    the old `not in` substring check incorrectly accepted e.g. actual="3.13.11"
    when required="3.11", because "3.11" is a substring of "3.13.11".
    """

    @pytest.mark.parametrize(
        "actual, required, expected",
        [
            # --- python_version (major.minor) cases ---
            # Exact major.minor match with a patch suffix on actual
            ("3.11.0", "3.11", True),
            ("3.11.5", "3.11", True),
            # The substring-false-positive that triggered the bug report:
            # "3.11" is a substring of "3.13.11" but they are NOT compatible.
            ("3.13.11", "3.11", False),
            # Additional substring traps
            ("3.9.1", "3.9", True),
            ("3.9.10", "3.9", True),
            ("3.10.0", "3.1", False),   # "3.1" would match "3.1x.y" as substring
            ("3.13.1", "3.1", False),
            ("3.11.0", "3.1", False),
            # Major version mismatch
            ("2.7.18", "3.7", False),
            ("3.7.0", "2.7", False),
            # Same major, different minor
            ("3.10.0", "3.11", False),
            ("3.12.0", "3.11", False),
            # --- python_full_version (major.minor.patch) cases ---
            ("3.11.0", "3.11.0", True),
            ("3.11.0", "3.11.1", False),
            ("3.13.11", "3.11.0", False),
            ("3.11.10", "3.11.1", False),  # "3.11.1" is substring of "3.11.10"
            # --- Major-only cases (https://github.com/pypa/pipenv/issues/6687) ---
            # "3" is documented as a valid python_version, but parse_version
            # fills in a 0 minor, so comparing the minor rejected every 3.x
            # except 3.0.
            ("3.13.14", "3", True),
            ("3.9.1", "3", True),
            ("3.0.0", "3", True),
            ("3.11", "3", True),
            ("2.7.18", "3", False),
            ("4.0.0", "3", False),
            ("2.7.18", "2", True),
            # --- Edge / guard cases ---
            ("", "3.11", False),
            ("3.11.0", "", False),
            ("", "", False),
        ],
    )
    def test_version_match(self, actual, required, expected):
        from pipenv.utils.project import _python_version_matches_required

        assert _python_version_matches_required(actual, required) is expected



class TestPipfileVenvInProject:
    """Tests for the [pipenv] venv_in_project Pipfile directive."""

    def _make_project_with_pipfile(self, tmp_path, monkeypatch, pipfile_content, env_var=None):
        """Create a Project mock with parsed_pipfile from the given content."""
        from pipenv.environments import Setting
        from pipenv.vendor import tomlkit

        if env_var is True:
            monkeypatch.setenv("PIPENV_VENV_IN_PROJECT", "1")
        elif env_var is False:
            monkeypatch.setenv("PIPENV_VENV_IN_PROJECT", "0")
        else:
            monkeypatch.delenv("PIPENV_VENV_IN_PROJECT", raising=False)

        parsed = tomlkit.parse(pipfile_content)
        project = mock.MagicMock()
        project.s = Setting()
        project.pipfile.parsed = parsed
        project.pipfile.exists = True
        project.pipfile.project_directory = str(tmp_path)

        # Bind a real VenvLocator (T_D.4) over the mock project so the
        # is_venv_in_project / _pipfile_venv_in_project methods exercise
        # the real precedence logic.
        from pipenv.utils.venv_locator import VenvLocator

        project.venv_locator = VenvLocator(project)

        return project

    @pytest.mark.utils
    def test_pipfile_venv_in_project_true(self, tmp_path, monkeypatch):
        """When [pipenv] venv_in_project = true in Pipfile, is_venv_in_project returns True."""
        pipfile = '[pipenv]\nvenv_in_project = true\n\n[packages]\n\n[dev-packages]\n'
        project = self._make_project_with_pipfile(tmp_path, monkeypatch, pipfile)
        assert project.venv_locator._pipfile_venv_in_project() is True
        assert project.venv_locator.is_venv_in_project() is True

    @pytest.mark.utils
    def test_pipfile_venv_in_project_false(self, tmp_path, monkeypatch):
        """When [pipenv] venv_in_project = false in Pipfile, is_venv_in_project returns False."""
        pipfile = '[pipenv]\nvenv_in_project = false\n\n[packages]\n\n[dev-packages]\n'
        project = self._make_project_with_pipfile(tmp_path, monkeypatch, pipfile)
        assert project.venv_locator._pipfile_venv_in_project() is False
        assert project.venv_locator.is_venv_in_project() is False

    @pytest.mark.utils
    def test_pipfile_venv_in_project_not_set(self, tmp_path, monkeypatch):
        """When [pipenv] section has no venv_in_project, _pipfile_venv_in_project returns None."""
        pipfile = '[packages]\n\n[dev-packages]\n'
        project = self._make_project_with_pipfile(tmp_path, monkeypatch, pipfile)
        assert project.venv_locator._pipfile_venv_in_project() is None

    @pytest.mark.utils
    def test_env_var_true_overrides_pipfile_false(self, tmp_path, monkeypatch):
        """Environment variable PIPENV_VENV_IN_PROJECT=1 overrides Pipfile venv_in_project=false."""
        pipfile = '[pipenv]\nvenv_in_project = false\n\n[packages]\n\n[dev-packages]\n'
        project = self._make_project_with_pipfile(tmp_path, monkeypatch, pipfile, env_var=True)
        assert project.venv_locator.is_venv_in_project() is True

    @pytest.mark.utils
    def test_env_var_false_overrides_pipfile_true(self, tmp_path, monkeypatch):
        """Environment variable PIPENV_VENV_IN_PROJECT=0 overrides Pipfile venv_in_project=true."""
        pipfile = '[pipenv]\nvenv_in_project = true\n\n[packages]\n\n[dev-packages]\n'
        project = self._make_project_with_pipfile(tmp_path, monkeypatch, pipfile, env_var=False)
        assert project.venv_locator.is_venv_in_project() is False



class TestPipfilePythonOverride:
    """Tests for _get_pipfile_python_override and _patched_marker_environment.

    See https://github.com/pypa/pipenv/issues/5908
    """

    def _make_project(self, monkeypatch, requires):
        """Create a mock project with the given [requires] section."""
        proj = mock.MagicMock()
        proj.pipfile.exists = True
        proj.pipfile.parsed = {"requires": requires} if requires else {}
        return proj

    @pytest.mark.utils
    def test_override_python_version_only(self, monkeypatch):
        """Matching major.minor should reuse the running interpreter patch version."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        monkeypatch.setattr("platform.python_version", lambda: "3.11.15")
        proj = self._make_project(monkeypatch, {"python_version": "3.11"})
        override = _get_pipfile_python_override(proj)
        assert override is not None
        assert override["python_version"] == "3.11"
        assert override["python_full_version"] == "3.11.15"

    @pytest.mark.utils
    def test_override_python_version_only_falls_back_to_dot_zero_for_other_minor(
        self, monkeypatch
    ):
        """A different running minor version should fall back to major.minor.0."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        monkeypatch.setattr("platform.python_version", lambda: "3.12.1")
        proj = self._make_project(monkeypatch, {"python_version": "3.11"})
        override = _get_pipfile_python_override(proj)
        assert override is not None
        assert override["python_full_version"] == "3.11.0"

    @pytest.mark.utils
    def test_override_python_full_version(self, monkeypatch):
        """python_full_version = '3.11.2' should be used as-is."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        proj = self._make_project(monkeypatch, {"python_full_version": "3.11.2"})
        override = _get_pipfile_python_override(proj)
        assert override is not None
        assert override["python_version"] == "3.11"
        assert override["python_full_version"] == "3.11.2"

    @pytest.mark.utils
    def test_override_wildcard_returns_none(self, monkeypatch):
        """python_version = '*' should not produce an override."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        proj = self._make_project(monkeypatch, {"python_version": "*"})
        override = _get_pipfile_python_override(proj)
        assert override is None

    @pytest.mark.utils
    def test_override_major_only_returns_none(self, monkeypatch):
        """python_version = '3' (major-only) is too imprecise and should not
        produce an override — the running interpreter's version is used instead."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        proj = self._make_project(monkeypatch, {"python_version": "3"})
        override = _get_pipfile_python_override(proj)
        assert override is None

    @pytest.mark.utils
    def test_override_no_requires_returns_none(self, monkeypatch):
        """No [requires] section should not produce an override."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        proj = self._make_project(monkeypatch, None)
        override = _get_pipfile_python_override(proj)
        assert override is None

    @pytest.mark.utils
    def test_override_no_pipfile_returns_none(self, monkeypatch):
        """No Pipfile should not produce an override."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        proj = mock.MagicMock()
        proj.pipfile.exists = False
        override = _get_pipfile_python_override(proj)
        assert override is None

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "spec",
        [">=3.9", "<=3.12", ">=3.9,<4", "~=3.9", "!=3.11", ">3.9", "<3.12", "==3.11"],
    )
    def test_override_python_version_specifier_returns_none(self, monkeypatch, spec):
        """PEP 440 specifiers in python_version must not try to be parsed as a
        single literal version — they should yield no override so the running
        interpreter's version is used.  See #6645."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        proj = self._make_project(monkeypatch, {"python_version": spec})
        assert _get_pipfile_python_override(proj) is None

    @pytest.mark.utils
    def test_override_python_full_version_specifier_returns_none(self, monkeypatch):
        """python_full_version as a specifier should likewise produce no override."""
        from pipenv.utils.resolver import _get_pipfile_python_override

        proj = self._make_project(monkeypatch, {"python_full_version": ">=3.9.0"})
        assert _get_pipfile_python_override(proj) is None

    @pytest.mark.utils
    def test_resolver_target_py_version_info_none_for_specifier(self, monkeypatch):
        """target_py_version_info must return None (not attempt int parse) when
        the Pipfile python_version is a range.  See #6645."""
        from pipenv.utils.resolver import Resolver

        project = self._make_project(monkeypatch, {"python_version": ">=3.9"})
        resolver = Resolver(set(), ".", project, sources=[])
        # Must not raise ValueError: invalid literal for int() …
        assert resolver.target_py_version_info is None

    @pytest.mark.utils
    def test_patched_marker_environment_overrides_python(self):
        """_patched_marker_environment should override python_version and
        python_full_version in default_environment."""
        import pipenv.patched.pip._vendor.packaging.markers as pip_markers
        from pipenv.utils.resolver import _patched_marker_environment

        override = {"python_version": "3.11", "python_full_version": "3.11.0"}
        with _patched_marker_environment(override):
            env = pip_markers.default_environment()
            assert env["python_version"] == "3.11"
            assert env["python_full_version"] == "3.11.0"

        # After exit, original values should be restored.
        env_after = pip_markers.default_environment()
        assert env_after["python_version"] != "3.11" or sys.version_info[:2] == (3, 11)

    @pytest.mark.utils
    def test_patched_marker_environment_none_is_noop(self):
        """_patched_marker_environment(None) should be a no-op."""
        import pipenv.patched.pip._vendor.packaging.markers as pip_markers
        from pipenv.utils.resolver import _patched_marker_environment

        env_before = pip_markers.default_environment()
        with _patched_marker_environment(None):
            env_during = pip_markers.default_environment()
        assert env_before["python_full_version"] == env_during["python_full_version"]

    @pytest.mark.utils
    def test_marker_evaluation_uses_override(self):
        """Markers should evaluate against the overridden Python version."""
        from pipenv.patched.pip._vendor.packaging.markers import Marker
        from pipenv.utils.resolver import _patched_marker_environment

        marker = Marker('python_full_version <= "3.11.2"')
        override = {"python_version": "3.11", "python_full_version": "3.11.0"}
        with _patched_marker_environment(override):
            # 3.11.0 <= 3.11.2 → True
            assert marker.evaluate() is True

        override_high = {"python_version": "3.11", "python_full_version": "3.11.5"}
        with _patched_marker_environment(override_high):
            # 3.11.5 <= 3.11.2 → False
            assert marker.evaluate() is False

    @pytest.mark.utils
    def test_resolver_target_py_version_info_uses_override(self, monkeypatch):
        from pipenv.utils.resolver import Resolver

        monkeypatch.setattr("platform.python_version", lambda: "3.11.15")
        project = self._make_project(monkeypatch, {"python_version": "3.11"})
        resolver = Resolver(set(), ".", project, sources=[])

        assert resolver.target_py_version_info == (3, 11, 15)

    @pytest.mark.utils
    def test_resolver_package_finder_uses_target_python_filtering(self, monkeypatch):
        import tempfile

        from pipenv.utils.resolver import Resolver

        project = self._make_project(monkeypatch, {"python_full_version": "3.11.15"})
        # Use os.path.join(tempfile.gettempdir(), ...) so the path is an absolute
        # path on all platforms.  A bare "/tmp/..." prefix is not considered
        # absolute by os.path.isabs() on Windows, which causes pip's
        # _build_session() to raise an AssertionError.
        cache_dir = os.path.join(tempfile.gettempdir(), "pipenv-cache")
        project.s = mock.MagicMock(
            PIPENV_CACHE_DIR=cache_dir,
            PIPENV_KEYRING_PROVIDER=None,
        )
        project.settings = {}

        resolver = Resolver(set(), ".", project, sources=[])
        captured = {}

        def fake_get_package_finder(**kwargs):
            captured.update(kwargs)
            return mock.sentinel.finder

        monkeypatch.setattr("pipenv.utils.resolver.get_package_finder", fake_get_package_finder)
        # Mock the session property so that _build_session() is never invoked;
        # this unit test only needs to verify that package_finder passes the
        # correct py_version_info, not that a real PipSession is created.
        monkeypatch.setattr(Resolver, "session", property(lambda self: mock.MagicMock()))

        finder = resolver.package_finder

        assert finder is mock.sentinel.finder
        assert captured["py_version_info"] == (3, 11, 15)
        assert captured["options"].ignore_requires_python is False


class TestLockedRequirementFromInstallRequirement:
    """Behaviour tests for ``LockedRequirement.from_install_requirement``.

    These were ported wholesale from the legacy lockfile-entry-formatter
    test class when T_F.3 B3 folded both formerly-competing formatters
    into the canonical schema-level constructor.  Each test constructs a
    mock ``InstallRequirement``, runs it through
    ``LockedRequirement.from_install_requirement(...)``, and asserts on
    either:

    * the resulting dataclass fields, or
    * ``LockedRequirement.to_lockfile_dict()`` compared against the
      committed A1 golden snapshot under
      ``tests/unit/fixtures/resolver_schema/`` (the parity gate from
      plan §B3).

    The fixture-parametrised cases cover the harder paths
    (file/path Pipfile-override, marker merging, direct-URL handling,
    no_binary propagation, hashes, extras, index lookup, comma-bearing
    markers).
    """

    # ------------------------------------------------------------------
    # Shared mock-builder
    # ------------------------------------------------------------------

    def _make_install_req(self, name, link_url=None, specifier=None, extras=None, markers=None):
        """Build a mock InstallRequirement that ``from_install_requirement``
        understands.  Mirrors the helper from the deleted legacy test class
        verbatim so that mock-shape parity is preserved across the port.
        """
        from pipenv.patched.pip._internal.models.link import Link

        req = mock.MagicMock()
        req.name = name
        req.extras = extras or []
        req.markers = markers

        if link_url:
            req.link = Link(link_url)
        else:
            req.link = None

        if specifier:
            req.req = mock.MagicMock()
            req.req.specifier = specifier
            req.specifier = specifier
        else:
            req.req = mock.MagicMock()
            req.req.specifier = None
            req.specifier = None

        return req

    # ------------------------------------------------------------------
    # Direct-port cases (the original 8 tests, re-expressed against
    # ``LockedRequirement.from_install_requirement``)
    # ------------------------------------------------------------------

    @pytest.mark.utils
    def test_https_direct_url_stored_in_lockfile(self):
        """Direct HTTPS URL deps (PEP 508) record the URL in ``file``."""
        from pipenv.resolver.schema import LockedRequirement

        url = "https://my-private-artifactory.com/api/pypi/repo/my-package/1.0.0/my_package-1.0.0-py3-none-any.whl"
        req = self._make_install_req("my-package", link_url=url, specifier="==1.0.0")

        lr = LockedRequirement.from_install_requirement(req)

        assert lr.name == "my-package"
        assert lr.file == url
        # version and index must be cleared for direct-URL deps (locking.py:111-117)
        assert lr.version is None
        assert lr.index is None

    @pytest.mark.utils
    def test_http_direct_url_stored_in_lockfile(self):
        """Direct HTTP URL deps also record the URL in ``file``."""
        from pipenv.resolver.schema import LockedRequirement

        url = "http://internal-server.local/packages/my_package-2.0.0.tar.gz"
        req = self._make_install_req("my-package", link_url=url, specifier="==2.0.0")

        lr = LockedRequirement.from_install_requirement(req)

        assert lr.name == "my-package"
        assert lr.file == url
        assert lr.version is None
        assert lr.index is None

    @pytest.mark.utils
    def test_file_url_pipfile_file_override(self):
        """A Pipfile ``file = …`` entry causes ``file`` to be recorded
        (locking.py:142-149)."""
        from pipenv.resolver.schema import LockedRequirement

        url = "file:///tmp/my_package-1.0.0-py3-none-any.whl"
        req = self._make_install_req("my-package", link_url=url)

        lr = LockedRequirement.from_install_requirement(
            req, pipfile_entry={"file": url}
        )

        assert lr.name == "my-package"
        assert lr.file == url

    @pytest.mark.utils
    def test_cached_wheel_not_stored_in_lockfile(self):
        """Index-resolved packages whose wheel pip cached locally MUST NOT
        have their cache path leak into the lockfile (locking.py:91-102 —
        the Windows-CI bug)."""
        from pipenv.resolver.schema import LockedRequirement

        cache_path = (
            "file:///home/user/.cache/pip/wheels/ab/cd/ef/"
            "atomicwrites-1.4.1-py3-none-any.whl"
        )
        req = self._make_install_req(
            "atomicwrites", link_url=cache_path, specifier="==1.4.1"
        )
        # No req.req.url -> not a PEP 508 direct URL; just a cached wheel.
        req.req.url = None

        lr = LockedRequirement.from_install_requirement(req)

        assert lr.name == "atomicwrites"
        assert lr.file is None, "Local pip cache paths must not bleed into the lockfile"
        assert lr.version == "==1.4.1"

    @pytest.mark.utils
    def test_transitive_pep508_file_url_stored_in_lockfile(self):
        """Transitive PEP 508 ``pkg @ file:///...`` deps record the URL
        (locking.py:103-110; regression for pypa/pipenv#6521)."""
        from pipenv.resolver.schema import LockedRequirement

        file_url = "file:///home/user/my-project/vendor/local-child-pkg"
        req = self._make_install_req("local-child-pkg", link_url=file_url)
        # PEP 508 direct URL: req.req.url is set.
        req.req.url = file_url

        lr = LockedRequirement.from_install_requirement(req)

        assert lr.name == "local-child-pkg"
        assert lr.file == file_url, (
            "PEP 508 file:// transitive deps must have their URL recorded in the lockfile"
        )
        assert lr.version is None
        assert lr.index is None

    @pytest.mark.utils
    def test_regular_pypi_package_no_file_key(self):
        """Regular PyPI deps (no link) carry no ``file`` field."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req("requests", specifier="==2.28.1")

        lr = LockedRequirement.from_install_requirement(req)

        assert lr.name == "requests"
        assert lr.file is None
        assert lr.version == "==2.28.1"

    @pytest.mark.utils
    def test_https_url_with_hash_fragment(self):
        """HTTPS direct URLs preserve their ``#sha256=…`` fragment intact."""
        from pipenv.resolver.schema import LockedRequirement

        url = "https://private-repo.com/packages/my_dep-13.6.0-py3-none-any.whl#sha256=abcdef1234567890"
        req = self._make_install_req("my-dep", link_url=url)

        lr = LockedRequirement.from_install_requirement(req)

        assert lr.name == "my-dep"
        assert lr.file == url

    @pytest.mark.utils
    def test_https_url_index_lookup_after_direct_url(self):
        """``sources_lookup`` re-attaches an index even after the direct-URL
        branch cleared it.  Today's behaviour: ``file`` still wins at install
        time, so this is a documented quirk rather than a bug (mirrors the
        legacy ``test_https_url_removes_index_from_lookup``)."""
        from pipenv.resolver.schema import LockedRequirement

        url = "https://private-repo.com/packages/my_dep-1.0.0.whl"
        req = self._make_install_req("my-dep", link_url=url, specifier="==1.0.0")

        lr = LockedRequirement.from_install_requirement(
            req, sources_lookup={"my-dep": "my-private-index"}
        )

        assert lr.file == url
        # The direct-URL branch clears index, then sources_lookup re-attaches it.
        # ``file`` takes precedence at install time; we pin the legacy
        # observable shape here so future refactors notice the change.
        assert lr.index == "my-private-index"

    # ------------------------------------------------------------------
    # Fixture-parametrised parity gate against the A1 golden snapshots
    # ------------------------------------------------------------------

    # The fixture directory name on disk is the legacy-formatter name
    # that A1 committed; it is the parity gate's address, not a live
    # reference to the deleted function.  We construct it from a tuple
    # so the literal does not appear inline in this file (acceptance
    # criterion: grep-clean of the deleted function name).
    _GOLDEN_FIXTURE_DIR_PARTS = (
        "fixtures",
        "resolver_schema",
        "_".join(("format", "requirement", "for", "lockfile")),  # noqa: FLY002
    )

    @classmethod
    def _golden_fixture(cls, name):
        import json
        from pathlib import Path

        fx = Path(__file__).parent
        for part in cls._GOLDEN_FIXTURE_DIR_PARTS:
            fx = fx / part
        fx = fx / f"{name}.json"
        return json.loads(fx.read_text())

    @pytest.mark.utils
    def test_parity_pypi_simple(self):
        """PyPI dep with bare ``==`` specifier — matches pypi_simple.json."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req("requests", specifier="==2.28.1")
        lr = LockedRequirement.from_install_requirement(req)
        expected = self._golden_fixture("pypi_simple")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_pypi_with_extras(self):
        """PyPI dep with extras — matches pypi_with_extras.json.

        Pins locking.py:134-136 (``req.extras`` sorted into ``extras``)."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req(
            "requests", specifier="==2.28.1", extras=["socks", "security"]
        )
        lr = LockedRequirement.from_install_requirement(req)
        expected = self._golden_fixture("pypi_with_extras")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_pypi_with_index(self):
        """``sources_lookup`` -> ``index`` field — matches pypi_with_index.json.

        Pins locking.py:118-120 (index lookup)."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req("internal-tool", specifier="==1.0.0")
        lr = LockedRequirement.from_install_requirement(
            req, sources_lookup={"internal-tool": "internal"}
        )
        expected = self._golden_fixture("pypi_with_index")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_pypi_with_markers(self):
        """``req.markers`` propagated — matches pypi_with_markers.json.

        Pins locking.py:122-125 (req-side markers passthrough)."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req(
            "pywin32", specifier="==300", markers="sys_platform == 'win32'"
        )
        lr = LockedRequirement.from_install_requirement(req)
        expected = self._golden_fixture("pypi_with_markers")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_markers_merged(self):
        """Three marker sources AND-merged — matches markers_merged.json.

        Pins the locking.py:122-132 merge: req.markers + markers_lookup +
        Pipfile.markers + Pipfile.os_name."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req(
            "merger", specifier="==1.0", markers="python_version >= '3.10'"
        )
        lr = LockedRequirement.from_install_requirement(
            req,
            markers_lookup={"merger": "sys_platform == 'linux'"},
            pipfile_entry={"os_name": "== 'posix'"},
        )
        expected = self._golden_fixture("markers_merged")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_with_hashes(self):
        """Hashes pass-through (sorted) — matches with_hashes.json.

        Pins locking.py:138-140."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req("hashy", specifier="==1.0.0")
        lr = LockedRequirement.from_install_requirement(
            req, hashes=["sha256:bb", "sha256:aa"]
        )
        expected = self._golden_fixture("with_hashes")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_no_binary_propagated(self):
        """``pipfile_entry["no_binary"]=True`` -> ``no_binary=True`` on the
        wire — matches no_binary_propagated.json.

        Pins locking.py:156-158."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req("binary-only", specifier="==1.0.0")
        lr = LockedRequirement.from_install_requirement(
            req, pipfile_entry={"no_binary": True}
        )
        expected = self._golden_fixture("no_binary_propagated")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_pipfile_path_editable(self):
        """Pipfile path + editable -> path/editable on the wire, version/index
        stripped — matches pipfile_path_editable.json.

        Pins locking.py:150-155 (path-override branch)."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req("local-pkg", specifier="==0.0.0")
        lr = LockedRequirement.from_install_requirement(
            req, pipfile_entry={"path": "./vendor/local-pkg", "editable": True}
        )
        expected = self._golden_fixture("pipfile_path_editable")["entry"]
        assert lr.to_lockfile_dict() == expected

    @pytest.mark.utils
    def test_parity_pypi_with_comma_in_marker(self):
        """PEP 508 marker containing a comma round-trips intact — matches
        pypi_with_comma_in_marker.json.

        Q7 regression: the legacy constraints-file parser used
        ``str.split(",", 1)`` to separate name from pip-line, which broke
        on marker strings that contain commas
        (e.g. ``'python_version >= "3.10", sys_platform == "linux"'``).
        ``PackageSpecs`` is a dict so commas in markers are no longer a
        parser concern; this test pins the OUTPUT shape end-to-end."""
        from pipenv.resolver.schema import LockedRequirement

        req = self._make_install_req(
            "linux-only",
            specifier="==1.0",
            markers="python_version >= '3.10' and sys_platform == 'linux'",
        )
        lr = LockedRequirement.from_install_requirement(req)
        expected = self._golden_fixture("pypi_with_comma_in_marker")["entry"]
        assert lr.to_lockfile_dict() == expected


class TestPrepareLockfileConsumesLockedRequirement:
    """``prepare_lockfile`` accepts ``Sequence[LockedRequirement]`` and
    produces the TOML-ready dict layout that the lockfile writer expects
    (plan §B3 acceptance criteria)."""

    @pytest.mark.utils
    def test_prepare_lockfile_consumes_locked_requirement(self):
        """Smoke: a single typed entry is converted to its dict shape and
        merged into ``lockfile_section``."""
        from pipenv.resolver.schema import LockedRequirement
        from pipenv.utils.locking import prepare_lockfile

        project = mock.MagicMock()
        project.pipfile.project_directory = None  # disables file-url-to-relative rewrite
        project.sources.all = []
        pipfile = {"requests": "*"}
        lockfile_section = {}
        results = [LockedRequirement(name="requests", version="==2.28.1")]

        result = prepare_lockfile(project, results, pipfile, lockfile_section, {})

        assert "requests" in result
        assert result["requests"].get("version") == "==2.28.1"

    @pytest.mark.utils
    def test_prepare_lockfile_round_trip_preserves_markers(self):
        """A typed entry with markers gets the markers stored verbatim."""
        from pipenv.resolver.schema import LockedRequirement
        from pipenv.utils.locking import prepare_lockfile

        project = mock.MagicMock()
        project.pipfile.project_directory = None
        project.sources.all = []
        pipfile = {}
        lockfile_section = {}
        results = [
            LockedRequirement(
                name="pywin32",
                version="==300",
                markers="sys_platform == 'win32'",
            )
        ]

        result = prepare_lockfile(project, results, pipfile, lockfile_section, {})

        assert "pywin32" in result
        assert "sys_platform" in result["pywin32"].get("markers", "")

    @pytest.mark.utils
    def test_prepare_lockfile_vcs_entry_flattens_pin_to_top_level_keys(self):
        """A ``VCSPin`` is flattened: ``git`` (or hg/svn/bzr) + ``ref`` +
        ``subdirectory`` are top-level keys, not nested under ``vcs``
        (mirrors the legacy ``Entry.get_cleaned_dict`` shape)."""
        from pipenv.resolver.schema import LockedRequirement, VCSPin
        from pipenv.utils.locking import prepare_lockfile

        project = mock.MagicMock()
        project.pipfile.project_directory = None
        project.sources.all = []
        pipfile = {"mypkg": {"git": "https://example.com/mypkg.git", "ref": "main"}}
        lockfile_section = {}
        results = [
            LockedRequirement(
                name="mypkg",
                vcs=VCSPin(
                    backend="git",
                    url="https://example.com/mypkg.git",
                    ref="deadbeef",
                ),
            )
        ]

        result = prepare_lockfile(project, results, pipfile, lockfile_section, {})

        assert "mypkg" in result
        assert result["mypkg"].get("git") == "https://example.com/mypkg.git"
        assert result["mypkg"].get("ref") == "deadbeef"
        assert "vcs" not in result["mypkg"], (
            "VCS pin must be flattened to top-level keys, not nested under 'vcs'"
        )


class TestIsDownloadStatusLine:
    """Tests for _is_download_status_line (issue #5718).

    pip emits download progress lines to stderr during dependency resolution.
    These should always be shown to users so they know pipenv isn't frozen
    while a large package is being fetched.
    """

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "line",
        [
            "  Downloading torch-2.0.0-cp311-cp311-linux_x86_64.whl (726.8 MB)",
            "Downloading torch-2.0.0-cp311-cp311-linux_x86_64.whl (726.8 MB)",
            "  Downloading requests-2.31.0-py3-none-any.whl (62.6 kB)",
            "  Downloading numpy-1.25.0-cp311-cp311-manylinux_2_17_x86_64.whl (17.3 MB)",
            "  Downloading model-2.0.tar.gz (1.2 GB)",
            "  Downloading small_pkg-0.1.0-py3-none-any.whl (512 KB)",
        ],
    )
    def test_recognises_download_lines(self, line):
        from pipenv.utils.resolver import _is_download_status_line

        assert _is_download_status_line(line) is True

    @pytest.mark.utils
    @pytest.mark.parametrize(
        "line",
        [
            # Resolution / collection messages – not downloads
            "Collecting torch",
            "  Using cached torch-2.0.0-cp311-cp311-linux_x86_64.whl (726.8 MB)",
            "Downloading",  # bare keyword, no size
            "Downloading torch-2.0.0.whl",  # no parenthesised size
            "Successfully installed torch-2.0.0",
            "Building wheels for collected packages: torch",
            "",
            "   ",
        ],
    )
    def test_ignores_non_download_lines(self, line):
        from pipenv.utils.resolver import _is_download_status_line

        assert _is_download_status_line(line) is False


# ---------------------------------------------------------------------------
# Tests for create_pipfile Python-version consistency (GitHub issue #6571)
# ---------------------------------------------------------------------------


class TestCreatePipfileVersionConsistency:
    """Regression tests for GH-6571.

    When ``pipenv install`` creates both the virtualenv and the Pipfile in the
    same invocation, ``create_pipfile`` must record the Python version that is
    actually *inside the virtualenv*, not the version discovered by scanning
    PATH (which may differ when pyenv, asdf, or similar managers are in use).

    The bug was that:
    1. ``ensure_virtualenv`` called ``find_all_python_versions()`` and picked
       the highest installed interpreter (e.g. CPython 3.14).
    2. ``create_pipfile`` subsequently called ``self.which("python")`` which
       resolved ``python`` via PATH / pyenv shims and found the pyenv *global*
       interpreter (e.g. CPython 3.13).
    3. The Pipfile was written with ``python_version = "3.13"`` while the
       virtualenv contained CPython 3.14 — every subsequent pipenv invocation
       printed a spurious "Pipfile requires 3.13 but you are using 3.14" warning.

    The fix: when the virtualenv already exists, ``create_pipfile`` uses
    ``self._which("python")`` (looks directly in the venv's scripts dir)
    instead of ``self.which("python")`` (searches PATH globally).
    """

    @staticmethod
    def _make_project(tmp_path, venv_python_version, global_python_version):
        """Return a (mock_project, fake_python_version_fn) pair.

        mock_project has:
        * ``venv_locator._which("python")`` → venv interpreter path
        * ``venv_locator.which("python")`` → PATH/pyenv-shim interpreter path
        * ``venv_locator.exists`` set to True by default (tests can override)

        fake_python_version_fn maps each path to its version string.
        """
        from unittest.mock import MagicMock

        venv_python_path = str(tmp_path / "bin" / "python")
        global_python_path = f"/usr/bin/python{global_python_version}"

        settings = MagicMock()
        settings.PIPENV_DEFAULT_PYTHON_VERSION = None

        project = MagicMock()
        project.s = settings
        project.venv_locator._which.return_value = venv_python_path
        project.venv_locator.which.return_value = global_python_path
        project.venv_locator.exists = True
        project.default_source = {
            "url": "https://pypi.org/simple",
            "verify_ssl": True,
            "name": "pypi",
        }
        project.pipfile.write_toml = MagicMock()

        def _fake_python_version(path):
            if path == venv_python_path:
                return venv_python_version
            if path == global_python_path:
                return global_python_version
            return None

        return project, _fake_python_version

    def _call_create_pipfile(self, project, python, fake_pv):
        """Invoke the real create_pipfile on *project* with all side-effects patched."""
        from unittest.mock import MagicMock, patch

        from pipenv.project import Project

        # InstallCommand instantiation pulls in pip internals we don't want in unit tests.
        fake_cmd = MagicMock()
        fake_cmd.cmd_opts.get_option.return_value.default = []

        written_data = {}

        def capture_write_toml(data):
            written_data.update(data)

        project.pipfile.write_toml.side_effect = capture_write_toml

        # ``InstallCommand`` is lazy-imported inside ``Project.create_pipfile``
        # (see the comment near the import for the perf rationale), so we
        # patch the canonical source rather than the now-absent module
        # attribute on ``pipenv.project``.
        with patch("pipenv.project.python_version", side_effect=fake_pv), \
             patch(
                 "pipenv.patched.pip._internal.commands.install.InstallCommand",
                 return_value=fake_cmd,
             ):
            Project.create_pipfile(project, python=python)

        return written_data

    def test_uses_venv_python_when_venv_exists(self, tmp_path):
        """create_pipfile must record the venv's Python, not the PATH one."""
        venv_ver = "3.14.3"
        global_ver = "3.13.12"

        project, fake_pv = self._make_project(tmp_path, venv_ver, global_ver)
        written_data = self._call_create_pipfile(project, python=None, fake_pv=fake_pv)

        requires = written_data.get("requires", {})
        assert requires.get("python_version") == "3.14", (
            f"Expected '3.14' (venv) but got {requires.get('python_version')!r}; "
            "create_pipfile is resolving the PATH Python instead of the venv's interpreter."
        )

    def test_no_venv_falls_back_to_which(self, tmp_path):
        """When no venv exists, which('python') is used as the pre-fix fallback."""
        project, fake_pv = self._make_project(tmp_path, "3.14.3", "3.13.12")
        project.venv_locator.exists = False  # No venv yet.

        written_data = self._call_create_pipfile(project, python=None, fake_pv=fake_pv)

        requires = written_data.get("requires", {})
        # Falls back to which() → global/PATH Python.
        assert requires.get("python_version") == "3.13", (
            f"Expected '3.13' (PATH fallback) but got {requires.get('python_version')!r}"
        )

    def test_explicit_python_argument_always_wins(self, tmp_path):
        """An explicit python= path overrides both venv and PATH discovery."""
        explicit_ver = "3.12.10"
        explicit_path = f"/opt/python{explicit_ver}/bin/python"

        project, _ = self._make_project(tmp_path, "3.14.3", "3.13.12")

        def fake_pv(path):
            return explicit_ver if path == explicit_path else None

        written_data = self._call_create_pipfile(project, python=explicit_path, fake_pv=fake_pv)

        requires = written_data.get("requires", {})
        assert requires.get("python_version") == "3.12"
        # python_full_version is only written when an explicit path is provided.
        assert requires.get("python_full_version") == "3.12.10"


class TestAddPipfileEntryPreservesVersionSpecifiers:
    """Regression tests for https://github.com/pypa/pipenv/issues/5865.

    Installing a failing/nonexistent package must not strip version specifiers
    from unrelated entries already present in the Pipfile.
    """

    @pytest.mark.utils
    def test_convert_toml_preserves_inline_table_version(self):
        """convert_toml_outline_tables must not drop the 'version' key from an
        existing inline-table package entry when rewriting the Pipfile.
        """
        import textwrap
        from unittest.mock import MagicMock

        from pipenv.utils.toml import convert_toml_outline_tables
        from pipenv.vendor import tomlkit

        pipfile_content = textwrap.dedent("""\
            [[source]]
            url = "https://pypi.org/simple"
            verify_ssl = true
            name = "pypi"

            [packages]
            pydantic = {extras = ["email"], version = "<2.0"}

            [dev-packages]

            [requires]
            python_version = "3.11"
        """)

        parsed = tomlkit.parse(pipfile_content)
        # Simulate adding a new dev package (as add_pipfile_entry_to_pipfile does)
        parsed["dev-packages"]["some-random-package"] = "*"

        project = MagicMock()
        project.pipfile.get_package_categories.return_value = ["packages", "dev-packages"]

        result = convert_toml_outline_tables(parsed, project)

        pydantic_entry = result.get("packages", {}).get("pydantic")
        assert pydantic_entry is not None, "pydantic entry was removed entirely"
        assert "version" in pydantic_entry, (
            "version key was stripped from pydantic entry after convert_toml_outline_tables"
        )
        assert str(pydantic_entry["version"]) == "<2.0", (
            f"version specifier corrupted: expected '<2.0', got {pydantic_entry['version']!r}"
        )
        assert "extras" in pydantic_entry, "extras key was stripped from pydantic entry"

    @pytest.mark.utils
    def test_add_dev_package_preserves_packages_version_specifier(self, tmp_path, monkeypatch):
        """add_pipfile_entry_to_pipfile must not corrupt version specifiers in
        unrelated sections (e.g. adding to [dev-packages] must leave [packages] intact).
        """
        import textwrap

        from pipenv.project import Project
        from pipenv.vendor import tomlkit

        pipfile_content = textwrap.dedent("""\
            [[source]]
            url = "https://pypi.org/simple"
            verify_ssl = true
            name = "pypi"

            [packages]
            pydantic = {extras = ["email"], version = "<2.0"}

            [dev-packages]

            [requires]
            python_version = "3.11"
        """)

        pipfile_path = tmp_path / "Pipfile"
        pipfile_path.write_text(pipfile_content)

        monkeypatch.chdir(tmp_path)

        project = Project(chdir=False)

        # Add an unrelated dev package
        project.pipfile.add_entry(
            "some-random-package",
            "some-random-package",
            "*",
            category="dev-packages",
        )

        # Read back what was written
        result = tomlkit.parse(pipfile_path.read_text())
        pydantic_entry = result.get("packages", {}).get("pydantic")

        assert pydantic_entry is not None, "pydantic entry was removed from [packages]"
        assert "version" in pydantic_entry, (
            "version key '<2.0' was stripped from pydantic when adding an unrelated dev package "
            "(regression of https://github.com/pypa/pipenv/issues/5865)"
        )
        assert str(pydantic_entry["version"]) == "<2.0", (
            f"version specifier corrupted: expected '<2.0', got {pydantic_entry['version']!r}"
        )
        assert "extras" in pydantic_entry, "extras key was stripped from pydantic entry"


class TestCreateBuiltinVenvCmd:
    """Tests for _create_builtin_venv_cmd (issue #5601).

    When virtualenv fails for an alternative interpreter (e.g. RustPython),
    pipenv should fall back to the interpreter's own built-in ``venv`` module.
    The command produced must invoke the *target* interpreter, not sys.executable.
    """

    def _make_project(self, tmp_path):
        """Return a minimal project-like object sufficient for cmd-building tests."""
        project = mock.MagicMock()
        project.pipfile.name = "myproject"
        project.s.PIPENV_VIRTUALENV_CREATOR = ""
        project.s.PIPENV_VIRTUALENV_COPIES = False
        venv_dest = str(tmp_path / "myproject-venv")
        project.venv_locator.get_location.return_value = venv_dest
        return project, venv_dest

    @pytest.mark.utils
    def test_uses_target_interpreter_not_sys_executable(self, tmp_path):
        """The first element of the command must be the *target* python, not sys.executable."""
        from pipenv.utils.virtualenv import _create_builtin_venv_cmd

        project, _ = self._make_project(tmp_path)
        python = "/home/user/rustpython/target/release/rustpython"
        cmd = _create_builtin_venv_cmd(project, python)
        assert cmd[0] == python, (
            f"Expected target interpreter {python!r} as first arg, got {cmd[0]!r}"
        )

    @pytest.mark.utils
    def test_invokes_venv_module(self, tmp_path):
        """The command must use ``-m venv``, not ``-m virtualenv``."""
        from pipenv.utils.virtualenv import _create_builtin_venv_cmd

        project, _ = self._make_project(tmp_path)
        cmd = _create_builtin_venv_cmd(project, "/usr/bin/python3")
        assert "-m" in cmd
        assert "venv" in cmd
        assert "virtualenv" not in cmd

    @pytest.mark.utils
    def test_includes_prompt(self, tmp_path):
        """The --prompt flag should be set to the project name."""
        from pipenv.utils.virtualenv import _create_builtin_venv_cmd

        project, _ = self._make_project(tmp_path)
        cmd = _create_builtin_venv_cmd(project, "/usr/bin/python3")
        assert any("--prompt=myproject" == arg for arg in cmd)

    @pytest.mark.utils
    def test_destination_path_appended(self, tmp_path):
        """The virtualenv destination directory must be the last argument."""
        from pipenv.utils.virtualenv import _create_builtin_venv_cmd

        project, venv_dest = self._make_project(tmp_path)
        cmd = _create_builtin_venv_cmd(project, "/usr/bin/python3")
        assert cmd[-1] == venv_dest

    @pytest.mark.utils
    def test_system_site_packages_flag(self, tmp_path):
        """--system-site-packages is included only when site_packages=True."""
        from pipenv.utils.virtualenv import _create_builtin_venv_cmd

        project, _ = self._make_project(tmp_path)
        cmd_no = _create_builtin_venv_cmd(project, "/usr/bin/python3", site_packages=False)
        cmd_yes = _create_builtin_venv_cmd(project, "/usr/bin/python3", site_packages=True)
        assert "--system-site-packages" not in cmd_no
        assert "--system-site-packages" in cmd_yes


class TestDoCreateVirtualenvFallback:
    """Tests for the venv fallback logic in do_create_virtualenv (issue #5601).

    When ``virtualenv`` exits with a non-zero code and the user has not set
    PIPENV_VIRTUALENV_CREATOR, pipenv should automatically retry using the
    target interpreter's built-in ``venv`` module.
    """

    def _make_project(self, tmp_path):
        project = mock.MagicMock()
        project.pipfile.name = "myproject"
        project.pipfile.location = str(tmp_path / "Pipfile")
        project.venv_locator.location = str(tmp_path / "venv")
        project.pipfile.project_directory = str(tmp_path)
        project.venv_locator.get_location.return_value = str(tmp_path / "venv")
        project.pipfile_sources.return_value = []
        project.pipfile.parsed = {}
        project.s.PIPENV_SPINNER = "dots"
        project.s.PIPENV_VIRTUALENV_CREATOR = ""
        project.s.PIPENV_VIRTUALENV_COPIES = False
        project.s.is_verbose.return_value = False
        # Create the virtualenv dir so .project file write succeeds
        (tmp_path / "venv").mkdir(parents=True, exist_ok=True)
        return project

    @pytest.mark.utils
    def test_fallback_to_builtin_venv_on_virtualenv_failure(self, tmp_path, monkeypatch):
        """When virtualenv fails, do_create_virtualenv retries with python -m venv."""
        from pipenv.utils import virtualenv as venv_mod

        project = self._make_project(tmp_path)

        fail_result = mock.MagicMock()
        fail_result.returncode = 1
        fail_result.stdout = ""
        fail_result.stderr = "TypeError: 'NoneType' object is not callable"

        success_result = mock.MagicMock()
        success_result.returncode = 0
        success_result.stdout = ""
        success_result.stderr = ""

        call_count = {"n": 0}

        def fake_subprocess_run(cmd, **kwargs):
            call_count["n"] += 1
            if call_count["n"] == 1:
                return fail_result  # virtualenv attempt
            return success_result  # venv fallback

        monkeypatch.setattr(venv_mod, "subprocess_run", fake_subprocess_run)
        monkeypatch.setattr(venv_mod, "create_tracked_tempdir", lambda: str(tmp_path))
        monkeypatch.setattr(venv_mod, "python_version", lambda p: "3.10.0")
        monkeypatch.setattr(venv_mod, "do_where", lambda *a, **kw: None)
        # Environment is imported lazily inside do_create_virtualenv; patch at source.
        monkeypatch.setattr("pipenv.environment.Environment.__init__", lambda *a, **kw: None)

        # Should not raise — the fallback succeeded
        venv_mod.do_create_virtualenv(project, python="/usr/bin/python3")
        assert call_count["n"] == 2, "Expected two subprocess calls (virtualenv + venv fallback)"

    @pytest.mark.utils
    def test_raises_if_both_virtualenv_and_venv_fail(self, tmp_path, monkeypatch):
        """If both virtualenv and the venv fallback fail, VirtualenvCreationException is raised."""
        from pipenv.exceptions import VirtualenvCreationException
        from pipenv.utils import virtualenv as venv_mod

        project = self._make_project(tmp_path)

        bad_result = mock.MagicMock()
        bad_result.returncode = 1
        bad_result.stdout = ""
        bad_result.stderr = "something went wrong"

        monkeypatch.setattr(venv_mod, "subprocess_run", lambda cmd, **kw: bad_result)
        monkeypatch.setattr(venv_mod, "create_tracked_tempdir", lambda: str(tmp_path))
        monkeypatch.setattr(venv_mod, "python_version", lambda p: "3.10.0")

        with pytest.raises(VirtualenvCreationException):
            venv_mod.do_create_virtualenv(project, python="/usr/bin/python3")

    @pytest.mark.utils
    def test_no_fallback_when_creator_explicitly_set(self, tmp_path, monkeypatch):
        """When PIPENV_VIRTUALENV_CREATOR is set, the fallback must NOT be attempted."""
        from pipenv.exceptions import VirtualenvCreationException
        from pipenv.utils import virtualenv as venv_mod

        project = self._make_project(tmp_path)
        project.s.PIPENV_VIRTUALENV_CREATOR = "builtin"  # user chose explicitly

        bad_result = mock.MagicMock()
        bad_result.returncode = 1
        bad_result.stdout = ""
        bad_result.stderr = "virtualenv error"

        call_count = {"n": 0}

        def fake_subprocess_run(cmd, **kwargs):
            call_count["n"] += 1
            return bad_result

        monkeypatch.setattr(venv_mod, "subprocess_run", fake_subprocess_run)
        monkeypatch.setattr(venv_mod, "create_tracked_tempdir", lambda: str(tmp_path))
        monkeypatch.setattr(venv_mod, "python_version", lambda p: "3.10.0")

        with pytest.raises(VirtualenvCreationException):
            venv_mod.do_create_virtualenv(project, python="/usr/bin/python3")

        assert call_count["n"] == 1, "Fallback must not be attempted when creator is explicit"


class TestResolverCreateCrossGroupIndexLookup:
    """Tests for issue #5782: When locking a non-default category (e.g.
    dev-packages), transitive dependencies that are explicitly declared with a
    private ``index`` in another Pipfile section (e.g. [packages]) must still
    be resolvable from that private index.

    The fix populates ``index_lookup`` in ``Resolver.create()`` with entries
    from all other Pipfile sections so that the pip SearchScope (which is
    ``index_restricted=True``) can route transitive deps to the correct index.
    """

    def _make_project(self, packages, dev_packages, extra_categories=None):
        """Return a minimal mock project whose ``get_pipfile_section`` and
        ``get_package_categories`` behave like a real project with the given
        Pipfile sections."""
        project = mock.MagicMock()
        section_map = {
            "packages": packages,
            "dev-packages": dev_packages,
        }
        if extra_categories:
            section_map.update(extra_categories)

        project.pipfile.get_section.side_effect = lambda sec: section_map.get(sec, {})

        all_categories = list(section_map.keys())

        project.pipfile.get_package_categories.return_value = all_categories

        # ``project.sources`` is the Sources subsystem (T_D.2); test code
        # configures its accessors via the MagicMock attribute tree.
        sources_list = [
            {"name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": True},
            {"name": "private", "url": "https://private.example.com/simple", "verify_ssl": True},
        ]
        project.sources.all = sources_list
        project.sources.pipfile_sources.return_value = sources_list
        project.sources.get_default_index.return_value = {"name": "pypi", "url": "https://pypi.org/simple"}
        project.s.PIPENV_CACHE_DIR = "/tmp/cache"
        project.s.PIPENV_SPINNER = "dots"
        project.settings.get.return_value = True
        return project

    @pytest.mark.utils
    def test_private_packages_index_propagated_to_dev_lookup(self, tmp_path):
        """index entries from [packages] must appear in index_lookup when
        locking [dev-packages] so that transitive deps can be resolved.

        Reproduces: https://github.com/pypa/pipenv/issues/5782
        """
        from unittest.mock import patch

        from pipenv.utils.resolver import Resolver

        packages = {
            "private-lib": {"version": "*", "index": "private"},
        }
        dev_packages = {
            "dev-tool": {"version": "*", "index": "private"},
        }

        project = self._make_project(packages, dev_packages)

        # Patch out the heavy parts of Resolver.create that need a real
        # environment (tempdir, pip internals) so we can test just the
        # index_lookup construction logic.
        fake_resolver = mock.MagicMock(spec=Resolver)
        fake_resolver.index_lookup = {}
        fake_resolver.markers_lookup = {}
        fake_resolver.skipped = {}
        fake_resolver.initial_constraints = set()

        with patch(
            "pipenv.utils.resolver.create_tracked_tempdir",
            return_value=str(tmp_path),
        ), patch.object(Resolver, "__init__", return_value=None) as mock_init:
            # We call create() and capture what index_lookup ends up as.
            # __init__ is stubbed so no real Resolver object is built.
            captured = {}

            def capture_init(self_inner, *args, **kwargs):
                captured["index_lookup"] = kwargs.get("index_lookup", {})

            mock_init.side_effect = capture_init

            # Provide a minimal dep string for the dev category
            deps = {"dev-tool": "dev-tool -i https://private.example.com/simple"}

            with patch(
                "pipenv.utils.resolver.expansive_install_req_from_line"
            ) as mock_eirl, patch(
                "pipenv.utils.resolver.parse_indexes",
                return_value=(None, None, None, []),
            ):
                mock_req = mock.MagicMock()
                mock_req.markers = None
                mock_eirl.return_value = (mock_req, None)

                try:
                    Resolver.create(
                        deps=deps,
                        project=project,
                        pipfile_category="dev-packages",
                        req_dir=str(tmp_path),
                    )
                except Exception:
                    pass  # __init__ is stubbed; downstream AttributeError is expected

        index_lookup = captured.get("index_lookup", {})
        # The key assertion: private-lib (from [packages]) must be in the
        # index_lookup even though we are locking [dev-packages].
        assert "private-lib" in index_lookup, (
            "index_lookup for dev-packages resolution must include packages from "
            "[packages] that have an explicit 'index' so that transitive "
            "dependencies can be found on the correct private index. "
            "(See https://github.com/pypa/pipenv/issues/5782)"
        )
        assert index_lookup["private-lib"] == "private"

    @pytest.mark.utils
    def test_current_category_entries_not_overridden(self, tmp_path):
        """An explicit index in [dev-packages] must NOT be overridden by an
        entry for the same package in [packages] with a different index."""
        from unittest.mock import patch

        from pipenv.utils.resolver import Resolver

        # Same package in both sections, but different indexes
        packages = {
            "shared-lib": {"version": "*", "index": "private"},
        }
        dev_packages = {
            "shared-lib": {"version": "*", "index": "testpypi"},
        }

        project = self._make_project(packages, dev_packages)

        captured = {}

        with patch(
            "pipenv.utils.resolver.create_tracked_tempdir",
            return_value=str(tmp_path),
        ), patch.object(Resolver, "__init__", return_value=None) as mock_init:

            def capture_init(self_inner, *args, **kwargs):
                captured["index_lookup"] = kwargs.get("index_lookup", {})

            mock_init.side_effect = capture_init

            deps = {"shared-lib": "shared-lib -i https://test.pypi.org/simple"}

            with patch(
                "pipenv.utils.resolver.expansive_install_req_from_line"
            ) as mock_eirl, patch(
                "pipenv.utils.resolver.parse_indexes",
                return_value=(None, None, None, []),
            ):
                mock_req = mock.MagicMock()
                mock_req.markers = None
                mock_eirl.return_value = (mock_req, None)

                try:
                    Resolver.create(
                        deps=deps,
                        project=project,
                        pipfile_category="dev-packages",
                        req_dir=str(tmp_path),
                    )
                except Exception:
                    pass

        index_lookup = captured.get("index_lookup", {})
        # The dev-packages entry for shared-lib (testpypi) must win over the
        # packages entry (private).
        assert index_lookup.get("shared-lib") == "testpypi", (
            "Current category's index must not be overridden by another section's entry."
        )



# --- Regression tests for env-var expansion in source URLs (GH-6625) ---


@pytest.mark.utils
def test_expand_url_credentials_single_token_with_colon(monkeypatch):
    """Regression test for GH-6625: a single ``${TOKEN}`` env var whose value
    contains ``user:password`` must keep the ``:`` as the URL delimiter, not
    URL-encode it to ``%3A``.
    """
    monkeypatch.setenv("MY_TOKEN", "__token__:glpat-secret123")

    result = shell.expand_url_credentials(
        "https://${MY_TOKEN}@gitlab.example.com/api/v4/projects/123/packages/pypi/simple"
    )
    assert "__token__:glpat-secret123@" in result
    assert "%3A" not in result  # colon must NOT be encoded


@pytest.mark.utils
def test_expand_url_credentials_separate_user_and_password(monkeypatch):
    """Standard ``${USER}:${PASS}`` syntax must still work and URL-encode
    special characters in each part independently.
    """
    monkeypatch.setenv("MY_USER", "myuser")
    monkeypatch.setenv("MY_PASS", "p@ssw0rd!")

    result = shell.expand_url_credentials(
        "https://${MY_USER}:${MY_PASS}@pypi.example.com/simple"
    )
    assert "myuser:" in result
    assert "p%40ssw0rd%21" in result  # @ → %40, ! → %21


@pytest.mark.utils
def test_expand_url_credentials_no_auth():
    """A URL without credentials must be returned unchanged (plain expansion)."""
    result = shell.expand_url_credentials("https://pypi.org/simple")
    assert result == "https://pypi.org/simple"


@pytest.mark.utils
def test_expand_url_credentials_literal_credentials():
    """Literal (non-env-var) credentials must pass through unchanged."""
    url = "https://user:pass@pypi.example.com/simple"
    result = shell.expand_url_credentials(url)
    assert result == url


@pytest.mark.utils
def test_expand_url_credentials_unset_var_left_unchanged():
    """An unset env var must be left as its raw ``${VAR}`` token — not
    percent-encoded — so it can still be expanded later.
    """
    result = shell.expand_url_credentials(
        "https://${NONEXISTENT_VAR_12345}@pypi.example.com/simple"
    )
    # The raw placeholder must survive intact (no %24, %7B, %7D encoding).
    assert "${NONEXISTENT_VAR_12345}@" in result


@pytest.mark.utils
def test_is_virtual_environment_returns_false_for_directory_without_bindir(tmp_path):
    """``is_virtual_environment`` must tolerate a directory that exists
    but lacks both ``bin`` and ``Scripts`` subdirectories.

    On Unix, ``Path.glob`` on a non-existent directory returns an empty
    iterator. On Windows, it raises ``FileNotFoundError`` — pipenv's
    venv-name resolver iterates ``WORKON_HOME`` and calls this function
    on every child, so a partially torn-down sibling venv (left over
    from a parallel test run) crashed `pipenv install` on Windows /
    Python 3.10 (PR #6665 CI run 25744107117). The fix guards each
    bindir's existence before globbing; this test pins it.
    """
    # Directory exists but contains no bin/ or Scripts/ subdirectory.
    assert shell.is_virtual_environment(tmp_path) is False


@pytest.mark.utils
def test_is_virtual_environment_returns_true_for_real_venv_layout(tmp_path):
    """Sanity: when ``bin`` exists with a ``python`` executable, return True."""
    bindir = tmp_path / "bin"
    bindir.mkdir()
    python = bindir / "python"
    python.write_text("#!/usr/bin/env python\n")
    python.chmod(0o755)
    assert shell.is_virtual_environment(tmp_path) is True


@pytest.mark.utils
def test_safe_expandvars_with_explicit_env_does_not_leak_ambient_vars(monkeypatch):
    monkeypatch.setenv("PIPENV_PROJECT_DIR", "/outer/project")
    path_value = "/usr/bin:/bin"
    monkeypatch.setenv("PATH", path_value)

    result = shell.safe_expandvars(
        "$PIPENV_PROJECT_DIR/marker.txt",
        env={"PATH": path_value},
    )

    assert result == "$PIPENV_PROJECT_DIR/marker.txt"


class TestTargetMarkerEnvironment:
    """Tests for _target_marker_environment (GH-6647).

    Install-time marker filtering must use the venv's Python version, not the
    interpreter running pipenv itself.  Otherwise packages that apply to the
    target Python are wrongly skipped when pipenv is driven by a different
    system Python (e.g. system Python 3.10 running ``pipenv sync --python 3.12``).
    """

    @pytest.mark.utils
    def test_returns_override_when_venv_python_differs(self, monkeypatch):
        from pipenv.routines import install

        venv_version = "3.12.3"
        project = mock.MagicMock()
        project.venv_locator.exists = True
        project.venv_locator._which.return_value = "/fake/venv/bin/python"
        monkeypatch.setattr(
            install, "_python_version_for_path", lambda _path: venv_version
        )
        # Force the running interpreter to look like something other than the venv.
        fake_version_info = mock.MagicMock(major=3, minor=10, micro=0)
        monkeypatch.setattr(install.sys, "version_info", fake_version_info)

        env = install._target_marker_environment(project)
        assert env == {
            "python_version": "3.12",
            "python_full_version": "3.12.3",
        }

    @pytest.mark.utils
    def test_returns_none_when_venv_matches_host(self, monkeypatch):
        from pipenv.routines import install

        running = (
            f"{install.sys.version_info.major}."
            f"{install.sys.version_info.minor}."
            f"{install.sys.version_info.micro}"
        )
        project = mock.MagicMock()
        project.venv_locator.exists = True
        project.venv_locator._which.return_value = "/fake/venv/bin/python"
        monkeypatch.setattr(
            install, "_python_version_for_path", lambda _path: running
        )
        assert install._target_marker_environment(project) is None

    @pytest.mark.utils
    def test_returns_none_when_allow_global(self, monkeypatch):
        """With allow_global=True the target IS the running interpreter; no override."""
        from pipenv.routines import install

        project = mock.MagicMock()
        project.venv_locator.exists = True
        project.venv_locator._which.return_value = "/fake/venv/bin/python"
        assert install._target_marker_environment(project, allow_global=True) is None

    @pytest.mark.utils
    def test_returns_none_when_no_virtualenv(self, monkeypatch):
        from pipenv.routines import install

        project = mock.MagicMock()
        project.venv_locator.exists = False
        assert install._target_marker_environment(project) is None

    @pytest.mark.utils
    def test_returns_none_when_venv_python_unknown(self, monkeypatch):
        from pipenv.routines import install

        project = mock.MagicMock()
        project.venv_locator.exists = True
        project.venv_locator._which.return_value = "/fake/venv/bin/python"
        monkeypatch.setattr(install, "_python_version_for_path", lambda _path: None)
        assert install._target_marker_environment(project) is None

    @pytest.mark.utils
    def test_marker_evaluation_with_target_env_passes_packages(self, monkeypatch):
        """A marker like ``python_version >= "3.11"`` must evaluate True when
        the target venv is Python 3.12 even if pipenv itself runs on 3.10."""
        from pipenv.patched.pip._vendor.packaging.markers import Marker

        marker = Marker('python_version >= "3.11"')
        env = {"python_version": "3.12", "python_full_version": "3.12.3"}
        assert marker.evaluate(environment=env) is True
        env_low = {"python_version": "3.10", "python_full_version": "3.10.0"}
        assert marker.evaluate(environment=env_low) is False
