import logging
from typing import Iterable
from unittest.mock import Mock, patch

import pytest
from pip._vendor.packaging.specifiers import SpecifierSet
from pip._vendor.packaging.tags import Tag
from pip._vendor.packaging.version import parse as parse_version

import pip._internal.utils.compatibility_tags
from pip._internal.exceptions import BestVersionAlreadyInstalled, DistributionNotFound
from pip._internal.index.package_finder import (
    CandidateEvaluator,
    InstallationCandidate,
    Link,
    LinkEvaluator,
    LinkType,
)
from pip._internal.models.target_python import TargetPython
from pip._internal.req.constructors import install_req_from_line
from tests.lib import TestData, make_test_finder


def test_no_mpkg(data: TestData) -> None:
    """Finder skips zipfiles with "macosx10" in the name."""
    finder = make_test_finder(find_links=[data.find_links])
    req = install_req_from_line("pkgwithmpkg")
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("pkgwithmpkg-1.0.tar.gz"), found


def test_no_partial_name_match(data: TestData) -> None:
    """Finder requires the full project name to match, not just beginning."""
    finder = make_test_finder(find_links=[data.find_links])
    req = install_req_from_line("gmpy")
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("gmpy-1.15.tar.gz"), found


def test_tilde() -> None:
    """Finder can accept a path with ~ in it and will normalize it."""
    patched_exists = patch(
        "pip._internal.index.collector.os.path.exists", return_value=True
    )
    with patched_exists:
        finder = make_test_finder(find_links=["~/python-pkgs"])
    req = install_req_from_line("gmpy")
    with pytest.raises(DistributionNotFound):
        finder.find_requirement(req, False)


def test_duplicates_sort_ok(data: TestData) -> None:
    """Finder successfully finds one of a set of duplicates in different
    locations"""
    finder = make_test_finder(find_links=[data.find_links, data.find_links2])
    req = install_req_from_line("duplicate")
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("duplicate-1.0.tar.gz"), found


def test_finder_detects_latest_find_links(data: TestData) -> None:
    """Test PackageFinder detects latest using find-links"""
    req = install_req_from_line("simple")
    finder = make_test_finder(find_links=[data.find_links])
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("simple-3.0.tar.gz")


def test_incorrect_case_file_index(data: TestData) -> None:
    """Test PackageFinder detects latest using wrong case"""
    req = install_req_from_line("dinner")
    finder = make_test_finder(index_urls=[data.find_links3])
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("Dinner-2.0.tar.gz")


@pytest.mark.network
def test_finder_detects_latest_already_satisfied_find_links(data: TestData) -> None:
    """Test PackageFinder detects latest already satisfied using find-links"""
    req = install_req_from_line("simple")
    # the latest simple in local pkgs is 3.0
    latest_version = "3.0"
    satisfied_by = Mock(
        location="/path",
        version=parse_version(latest_version),
    )
    req.satisfied_by = satisfied_by
    finder = make_test_finder(find_links=[data.find_links])

    with pytest.raises(BestVersionAlreadyInstalled):
        finder.find_requirement(req, True)


@pytest.mark.network
def test_finder_detects_latest_already_satisfied_pypi_links() -> None:
    """Test PackageFinder detects latest already satisfied using pypi links"""
    req = install_req_from_line("initools")
    # the latest initools on PyPI is 0.3.1
    latest_version = "0.3.1"
    satisfied_by = Mock(
        location="/path",
        version=parse_version(latest_version),
    )
    req.satisfied_by = satisfied_by
    finder = make_test_finder(index_urls=["http://pypi.org/simple/"])

    with pytest.raises(BestVersionAlreadyInstalled):
        finder.find_requirement(req, True)


class TestWheel:
    def test_skip_invalid_wheel_link(
        self, caplog: pytest.LogCaptureFixture, data: TestData
    ) -> None:
        """
        Test if PackageFinder skips invalid wheel filenames
        """
        caplog.set_level(logging.DEBUG)

        req = install_req_from_line("invalid")
        # data.find_links contains "invalid.whl", which is an invalid wheel
        finder = make_test_finder(find_links=[data.find_links])
        with pytest.raises(DistributionNotFound):
            finder.find_requirement(req, True)

        assert (
            "Could not find a version that satisfies the requirement invalid"
            " (from versions:" in caplog.text
        )

    def test_not_find_wheel_not_supported(self, data: TestData) -> None:
        """
        Test not finding an unsupported wheel.
        """
        req = install_req_from_line("simple.dist")
        target_python = TargetPython()
        # Make sure no tags will match.
        target_python._valid_tags = []
        finder = make_test_finder(
            find_links=[data.find_links],
            target_python=target_python,
        )

        with pytest.raises(DistributionNotFound):
            finder.find_requirement(req, True)

    def test_find_wheel_supported(
        self, data: TestData, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """
        Test finding supported wheel.
        """
        monkeypatch.setattr(
            pip._internal.utils.compatibility_tags,
            "get_supported",
            lambda **kw: [("py2", "none", "any")],
        )

        req = install_req_from_line("simple.dist")
        finder = make_test_finder(find_links=[data.find_links])
        found = finder.find_requirement(req, True)
        assert found is not None
        assert found.link.url.endswith("simple.dist-0.1-py2.py3-none-any.whl"), found

    def test_wheel_over_sdist_priority(self, data: TestData) -> None:
        """
        Test wheels have priority over sdists.
        `test_link_sorting` also covers this at lower level
        """
        req = install_req_from_line("priority")
        finder = make_test_finder(find_links=[data.find_links])
        found = finder.find_requirement(req, True)
        assert found is not None
        assert found.link.url.endswith("priority-1.0-py2.py3-none-any.whl"), found

    def test_existing_over_wheel_priority(self, data: TestData) -> None:
        """
        Test existing install has priority over wheels.
        `test_link_sorting` also covers this at a lower level
        """
        req = install_req_from_line("priority")
        latest_version = "1.0"
        satisfied_by = Mock(
            location="/path",
            version=parse_version(latest_version),
        )
        req.satisfied_by = satisfied_by
        finder = make_test_finder(find_links=[data.find_links])

        with pytest.raises(BestVersionAlreadyInstalled):
            finder.find_requirement(req, True)


class TestCandidateEvaluator:
    def test_link_sorting(self) -> None:
        """
        Test link sorting
        """
        links = [
            InstallationCandidate("simple", "2.0", Link("simple-2.0.tar.gz")),
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0-pyT-none-TEST.whl"),
            ),
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0-pyT-TEST-any.whl"),
            ),
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0-pyT-none-any.whl"),
            ),
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0.tar.gz"),
            ),
        ]
        valid_tags = [
            Tag("pyT", "none", "TEST"),
            Tag("pyT", "TEST", "any"),
            Tag("pyT", "none", "any"),
        ]
        specifier = SpecifierSet()
        evaluator = CandidateEvaluator(
            "my-project",
            supported_tags=valid_tags,
            specifier=specifier,
        )
        sort_key = evaluator._sort_key
        results = sorted(links, key=sort_key, reverse=True)
        results2 = sorted(reversed(links), key=sort_key, reverse=True)

        assert links == results, results
        assert links == results2, results2

    def test_link_sorting_wheels_with_build_tags(self) -> None:
        """Verify build tags affect sorting."""
        links = [
            InstallationCandidate(
                "simplewheel",
                "2.0",
                Link("simplewheel-2.0-1-py2.py3-none-any.whl"),
            ),
            InstallationCandidate(
                "simplewheel",
                "2.0",
                Link("simplewheel-2.0-py2.py3-none-any.whl"),
            ),
            InstallationCandidate(
                "simplewheel",
                "1.0",
                Link("simplewheel-1.0-py2.py3-none-any.whl"),
            ),
        ]
        candidate_evaluator = CandidateEvaluator.create("my-project")
        sort_key = candidate_evaluator._sort_key
        results = sorted(links, key=sort_key, reverse=True)
        results2 = sorted(reversed(links), key=sort_key, reverse=True)

        assert links == results, results
        assert links == results2, results2

    def test_build_tag_is_less_important_than_other_tags(self) -> None:
        links = [
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0-1-py3-abi3-linux_x86_64.whl"),
            ),
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0-2-py3-abi3-linux_i386.whl"),
            ),
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0-2-py3-any-none.whl"),
            ),
            InstallationCandidate(
                "simple",
                "1.0",
                Link("simple-1.0.tar.gz"),
            ),
        ]
        valid_tags = [
            Tag("py3", "abi3", "linux_x86_64"),
            Tag("py3", "abi3", "linux_i386"),
            Tag("py3", "any", "none"),
        ]
        evaluator = CandidateEvaluator(
            "my-project",
            supported_tags=valid_tags,
            specifier=SpecifierSet(),
        )
        sort_key = evaluator._sort_key
        results = sorted(links, key=sort_key, reverse=True)
        results2 = sorted(reversed(links), key=sort_key, reverse=True)

        assert links == results, results
        assert links == results2, results2


def test_finder_priority_file_over_page(data: TestData) -> None:
    """Test PackageFinder prefers file links over equivalent page links"""
    req = install_req_from_line("gmpy==1.15")
    finder = make_test_finder(
        find_links=[data.find_links],
        index_urls=["http://pypi.org/simple/"],
    )
    all_versions = finder.find_all_candidates(req.name)
    # 1 file InstallationCandidate followed by all https ones
    assert all_versions[0].link.scheme == "file"
    assert all(
        version.link.scheme == "https" for version in all_versions[1:]
    ), all_versions

    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.startswith("file://")


def test_finder_priority_nonegg_over_eggfragments() -> None:
    """Test PackageFinder prefers non-egg links over "#egg=" links"""
    req = install_req_from_line("bar==1.0")
    links = ["http://foo/bar.py#egg=bar-1.0", "http://foo/bar-1.0.tar.gz"]

    finder = make_test_finder(links)
    all_versions = finder.find_all_candidates(req.name)
    assert all_versions[0].link.url.endswith("tar.gz")
    assert all_versions[1].link.url.endswith("#egg=bar-1.0")

    found = finder.find_requirement(req, False)

    assert found is not None
    assert found.link.url.endswith("tar.gz")

    links.reverse()

    finder = make_test_finder(links)
    all_versions = finder.find_all_candidates(req.name)
    assert all_versions[0].link.url.endswith("tar.gz")
    assert all_versions[1].link.url.endswith("#egg=bar-1.0")
    found = finder.find_requirement(req, False)

    assert found is not None
    assert found.link.url.endswith("tar.gz")


def test_finder_only_installs_stable_releases(data: TestData) -> None:
    """
    Test PackageFinder only accepts stable versioned releases by default.
    """

    req = install_req_from_line("bar")

    # using a local index (that has pre & dev releases)
    finder = make_test_finder(index_urls=[data.index_url("pre")])
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("bar-1.0.tar.gz"), found.link.url

    # using find-links
    links = ["https://foo/bar-1.0.tar.gz", "https://foo/bar-2.0b1.tar.gz"]

    finder = make_test_finder(links)
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url == "https://foo/bar-1.0.tar.gz"

    links.reverse()

    finder = make_test_finder(links)
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url == "https://foo/bar-1.0.tar.gz"


def test_finder_only_installs_data_require(data: TestData) -> None:
    """
    Test whether the PackageFinder understand data-python-requires

    This can optionally be exposed by a simple-repository to tell which
    distribution are compatible with which version of Python by adding a
    data-python-require to the anchor links.

    See pep 503 for more information.
    """

    # using a local index (that has pre & dev releases)
    finder = make_test_finder(index_urls=[data.index_url("datarequire")])
    links = finder.find_all_candidates("fakepackage")
    assert {str(v.version) for v in links} == {"1.0.0", "3.3.0", "9.9.9"}


def test_finder_installs_pre_releases(data: TestData) -> None:
    """
    Test PackageFinder finds pre-releases if asked to.
    """

    req = install_req_from_line("bar")

    # using a local index (that has pre & dev releases)
    finder = make_test_finder(
        index_urls=[data.index_url("pre")],
        allow_all_prereleases=True,
    )
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("bar-2.0b1.tar.gz"), found.link.url

    # using find-links
    links = ["https://foo/bar-1.0.tar.gz", "https://foo/bar-2.0b1.tar.gz"]

    finder = make_test_finder(links, allow_all_prereleases=True)
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url == "https://foo/bar-2.0b1.tar.gz"

    links.reverse()

    finder = make_test_finder(links, allow_all_prereleases=True)
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url == "https://foo/bar-2.0b1.tar.gz"


def test_finder_installs_dev_releases(data: TestData) -> None:
    """
    Test PackageFinder finds dev releases if asked to.
    """

    req = install_req_from_line("bar")

    # using a local index (that has dev releases)
    finder = make_test_finder(
        index_urls=[data.index_url("dev")],
        allow_all_prereleases=True,
    )
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url.endswith("bar-2.0.dev1.tar.gz"), found.link.url


def test_finder_installs_pre_releases_with_version_spec() -> None:
    """
    Test PackageFinder only accepts stable versioned releases by default.
    """
    req = install_req_from_line("bar>=0.0.dev0")
    links = ["https://foo/bar-1.0.tar.gz", "https://foo/bar-2.0b1.tar.gz"]

    finder = make_test_finder(links)
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url == "https://foo/bar-2.0b1.tar.gz"

    links.reverse()

    finder = make_test_finder(links)
    found = finder.find_requirement(req, False)
    assert found is not None
    assert found.link.url == "https://foo/bar-2.0b1.tar.gz"


class TestLinkEvaluator:
    def make_test_link_evaluator(self, formats: Iterable[str]) -> LinkEvaluator:
        target_python = TargetPython()
        return LinkEvaluator(
            project_name="pytest",
            canonical_name="pytest",
            formats=frozenset(formats),
            target_python=target_python,
            allow_yanked=True,
        )

    @pytest.mark.parametrize(
        "url, expected_version",
        [
            ("http:/yo/pytest-1.0.tar.gz", "1.0"),
            ("http:/yo/pytest-1.0-py2.py3-none-any.whl", "1.0"),
        ],
    )
    def test_evaluate_link__match(self, url: str, expected_version: str) -> None:
        """Test that 'pytest' archives match for 'pytest'"""
        link = Link(url)
        evaluator = self.make_test_link_evaluator(formats=["source", "binary"])
        actual = evaluator.evaluate_link(link)
        assert actual == (LinkType.candidate, expected_version)

    @pytest.mark.parametrize(
        "url, link_type, fail_reason",
        [
            # TODO: Uncomment this test case when #1217 is fixed.
            # 'http:/yo/pytest-xdist-1.0.tar.gz',
            (
                "http:/yo/pytest2-1.0.tar.gz",
                LinkType.format_invalid,
                "Missing project version for pytest",
            ),
            (
                "http:/yo/pytest_xdist-1.0-py2.py3-none-any.whl",
                LinkType.different_project,
                "wrong project name (not pytest)",
            ),
        ],
    )
    def test_evaluate_link__substring_fails(
        self,
        url: str,
        link_type: LinkType,
        fail_reason: str,
    ) -> None:
        """Test that 'pytest<something> archives won't match for 'pytest'."""
        link = Link(url)
        evaluator = self.make_test_link_evaluator(formats=["source", "binary"])
        actual = evaluator.evaluate_link(link)
        assert actual == (link_type, fail_reason)


def test_process_project_url(data: TestData) -> None:
    project_name = "simple"
    index_url = data.index_url("simple")
    project_url = Link(f"{index_url}/{project_name}")
    finder = make_test_finder(index_urls=[index_url])
    link_evaluator = finder.make_link_evaluator(project_name)
    actual = finder.process_project_url(
        project_url,
        link_evaluator=link_evaluator,
    )

    assert len(actual) == 1
    package_link = actual[0]
    assert package_link.name == "simple"
    assert str(package_link.version) == "1.0"


def test_find_all_candidates_nothing() -> None:
    """Find nothing without anything"""
    finder = make_test_finder()
    assert not finder.find_all_candidates("pip")


def test_find_all_candidates_find_links(data: TestData) -> None:
    finder = make_test_finder(find_links=[data.find_links])
    versions = finder.find_all_candidates("simple")
    assert [str(v.version) for v in versions] == ["3.0", "2.0", "1.0"]


def test_find_all_candidates_index(data: TestData) -> None:
    finder = make_test_finder(index_urls=[data.index_url("simple")])
    versions = finder.find_all_candidates("simple")
    assert [str(v.version) for v in versions] == ["1.0"]


def test_find_all_candidates_find_links_and_index(data: TestData) -> None:
    finder = make_test_finder(
        find_links=[data.find_links],
        index_urls=[data.index_url("simple")],
    )
    versions = finder.find_all_candidates("simple")
    # first the find-links versions then the page versions
    assert [str(v.version) for v in versions] == ["3.0", "2.0", "1.0", "1.0"]
