from __future__ import annotations

import shutil

from pathlib import Path
from subprocess import CalledProcessError
from typing import TYPE_CHECKING
from typing import Any

import pytest

from cleo.io.null_io import NullIO
from packaging.utils import NormalizedName
from packaging.utils import canonicalize_name
from poetry.core.packages.dependency import Dependency
from poetry.core.packages.directory_dependency import DirectoryDependency
from poetry.core.packages.file_dependency import FileDependency
from poetry.core.packages.package import Package
from poetry.core.packages.project_package import ProjectPackage
from poetry.core.packages.url_dependency import URLDependency
from poetry.core.packages.vcs_dependency import VCSDependency

from poetry.factory import Factory
from poetry.inspection.info import PackageInfo
from poetry.packages import DependencyPackage
from poetry.puzzle.provider import IncompatibleConstraintsError
from poetry.puzzle.provider import Provider
from poetry.repositories.cached_repository import CachedRepository
from poetry.repositories.exceptions import PackageNotFoundError
from poetry.repositories.repository import Repository
from poetry.repositories.repository_pool import Priority
from poetry.repositories.repository_pool import RepositoryPool
from poetry.utils.env import EnvCommandError
from poetry.utils.env import MockEnv as BaseMockEnv
from tests.helpers import get_dependency


if TYPE_CHECKING:
    from pathlib import Path

    from poetry.core.constraints.version import Version
    from pytest_mock import MockerFixture

    from tests.types import FixtureDirGetter


SOME_URL = "https://example.com/path.tar.gz"


class MockEnv(BaseMockEnv):
    def run(self, bin: str, *args: str, **kwargs: Any) -> str:
        raise EnvCommandError(CalledProcessError(1, "python", output=""))


class MockCachedRepository(CachedRepository):
    def _get_release_info(
        self, name: NormalizedName, version: Version
    ) -> dict[str, Any]:
        raise NotImplementedError

    def package(self, name: str, version: Version) -> Package:
        package = super().package(name, version)
        package._source_reference = self.name
        return package


@pytest.fixture
def root() -> ProjectPackage:
    return ProjectPackage("root", "1.2.3")


@pytest.fixture
def repository() -> Repository:
    return Repository("repo")


@pytest.fixture
def pool(repository: Repository) -> RepositoryPool:
    pool = RepositoryPool()
    pool.add_repository(repository)

    return pool


@pytest.fixture
def provider(root: ProjectPackage, pool: RepositoryPool) -> Provider:
    return Provider(root, pool, NullIO())


@pytest.fixture
def release_info() -> PackageInfo:
    return PackageInfo(
        name="mylib",
        version="1.0",
        summary="",
        requires_dist=[],
        requires_python=">=3.9",
        files=[
            {
                "file": "mylib-1.0-py3-none-any.whl",
                "hash": "sha256:dummyhashvalue1234567890abcdef",
            },
            {
                "file": "mylib-1.0.tar.gz",
                "hash": "sha256:anotherdummyhashvalueabcdef1234567890",
            },
        ],
        cache_version=str(CachedRepository.CACHE_VERSION),
    )


@pytest.fixture
def release_info_complete() -> PackageInfo:
    return PackageInfo(
        name="mylib",
        version="1.0",
        summary="",
        requires_dist=[],
        requires_python=">=3.9",
        files=[
            {
                "file": "mylib-1.0-py3-none-any.whl",
                "hash": "sha256:dummyhashvalue1234567890abcdef",
                "url": "https://example.org/mylib-1.0-py3-none-any.whl",
                "size": 12345,
                "upload_time": "2024-01-01T12:00:00Z",
            },
            {
                "file": "mylib-1.0.tar.gz",
                "hash": "sha256:anotherdummyhashvalueabcdef1234567890",
                "url": "https://example.org/mylib-1.0.tar.gz",
                "size": 12346,
                "upload_time": "2024-01-01T12:00:01Z",
            },
        ],
        cache_version=str(CachedRepository.CACHE_VERSION),
    )


@pytest.fixture
def outdated_release_info() -> PackageInfo:
    return PackageInfo(
        name="mylib",
        version="1.0",
        summary="",
        requires_dist=[],
        requires_python=">=3.9",
        files=[
            {
                "file": "mylib-1.0-py3-none-any.whl",
                "hash": "sha256:dummyhashvalue1234567890abcdef",
            }
        ],
        cache_version=str(CachedRepository.CACHE_VERSION),
    )


@pytest.mark.parametrize(
    "dependency, expected",
    [
        (Dependency("foo", "<2"), [Package("foo", "1")]),
        (Dependency("foo", "<2", extras=["bar"]), [Package("foo", "1")]),
        (Dependency("foo", ">=1"), [Package("foo", "2"), Package("foo", "1")]),
        (Dependency("foo", ">=1a"), [Package("foo", "2"), Package("foo", "1")]),
        (
            Dependency("foo", ">=1", allows_prereleases=True),
            [
                Package("foo", "3a"),
                Package("foo", "2"),
                Package("foo", "2a"),
                Package("foo", "1"),
            ],
        ),
    ],
)
def test_search_for(
    provider: Provider,
    repository: Repository,
    dependency: Dependency,
    expected: list[Package],
) -> None:
    foo1 = Package("foo", "1")
    foo2a = Package("foo", "2a")
    foo2 = Package("foo", "2")
    foo3a = Package("foo", "3a")
    repository.add_package(foo1)
    repository.add_package(foo2a)
    repository.add_package(foo2)
    repository.add_package(foo3a)

    assert provider.search_for(dependency) == expected


@pytest.mark.parametrize(
    "dependency, direct_origin_dependency, expected_before, expected_after",
    [
        (
            Dependency("foo", ">=1"),
            URLDependency("foo", SOME_URL),
            [Package("foo", "3")],
            [Package("foo", "2a", source_type="url", source_url=SOME_URL)],
        ),
        (
            Dependency("foo", ">=2"),
            URLDependency("foo", SOME_URL),
            [Package("foo", "3")],
            [Package("foo", "3")],
        ),
        (
            Dependency("foo", ">=1", extras=["bar"]),
            URLDependency("foo", SOME_URL),
            [Package("foo", "3")],
            [Package("foo", "2a", source_type="url", source_url=SOME_URL)],
        ),
        (
            Dependency("foo", ">=1"),
            URLDependency("foo", SOME_URL, extras=["baz"]),
            [Package("foo", "3")],
            [Package("foo", "2a", source_type="url", source_url=SOME_URL)],
        ),
        (
            Dependency("foo", ">=1", extras=["bar"]),
            URLDependency("foo", SOME_URL, extras=["baz"]),
            [Package("foo", "3")],
            [Package("foo", "2a", source_type="url", source_url=SOME_URL)],
        ),
    ],
)
def test_search_for_direct_origin_and_extras(
    provider: Provider,
    repository: Repository,
    mocker: MockerFixture,
    dependency: Dependency,
    direct_origin_dependency: Dependency,
    expected_before: list[Package],
    expected_after: list[Package],
) -> None:
    foo2a_direct_origin = Package("foo", "2a", source_type="url", source_url=SOME_URL)
    mocker.patch(
        "poetry.puzzle.provider.Provider.search_for_direct_origin_dependency",
        return_value=foo2a_direct_origin,
    )
    foo2a = Package("foo", "2a")
    foo3 = Package("foo", "3")
    repository.add_package(foo2a)
    repository.add_package(foo3)

    assert provider.search_for(dependency) == expected_before
    assert provider.search_for(direct_origin_dependency) == [foo2a_direct_origin]
    assert provider.search_for(dependency) == expected_after


@pytest.mark.parametrize("value", [True, False])
def test_search_for_vcs_retains_develop_flag(provider: Provider, value: bool) -> None:
    dependency = VCSDependency(
        "demo", "git", "https://github.com/demo/demo.git", develop=value
    )
    package = provider.search_for_direct_origin_dependency(dependency)
    assert package.develop == value


def test_search_for_vcs_setup_egg_info(provider: Provider) -> None:
    dependency = VCSDependency("demo", "git", "https://github.com/demo/demo.git")

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_vcs_setup_egg_info_with_extras(provider: Provider) -> None:
    dependency = VCSDependency(
        "demo", "git", "https://github.com/demo/demo.git", extras=["foo"]
    )

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_vcs_read_setup(provider: Provider, mocker: MockerFixture) -> None:
    mocker.patch("poetry.utils.env.EnvManager.get", return_value=MockEnv())

    dependency = VCSDependency("demo", "git", "https://github.com/demo/demo.git")

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_vcs_read_setup_with_extras(
    provider: Provider, mocker: MockerFixture
) -> None:
    mocker.patch("poetry.utils.env.EnvManager.get", return_value=MockEnv())

    dependency = VCSDependency(
        "demo", "git", "https://github.com/demo/demo.git", extras=["foo"]
    )

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}


def test_search_for_vcs_read_setup_raises_error_if_no_version(
    provider: Provider, mocker: MockerFixture
) -> None:
    mocker.patch(
        "poetry.inspection.info.get_pep517_metadata",
        return_value=PackageInfo(name="demo", version=None),
    )

    dependency = VCSDependency("demo", "git", "https://github.com/demo/no-version.git")

    with pytest.raises(RuntimeError):
        provider.search_for_direct_origin_dependency(dependency)


@pytest.mark.parametrize("directory", ["demo", "non-canonical-name"])
def test_search_for_directory_setup_egg_info(
    provider: Provider, directory: str, fixture_dir: FixtureDirGetter, tmp_path: Path
) -> None:
    path = shutil.copytree(
        fixture_dir("git") / "github.com" / "demo" / directory, tmp_path / "project"
    )
    dependency = DirectoryDependency("demo", path)

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_directory_setup_egg_info_with_extras(
    provider: Provider, fixture_dir: FixtureDirGetter, tmp_path: Path
) -> None:
    path = shutil.copytree(
        fixture_dir("git") / "github.com" / "demo" / "demo", tmp_path / "project"
    )

    dependency = DirectoryDependency("demo", path, extras=["foo"])

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


@pytest.mark.parametrize("directory", ["demo", "non-canonical-name"])
def test_search_for_directory_setup_with_base(
    provider: Provider, directory: str, fixture_dir: FixtureDirGetter, tmp_path: Path
) -> None:
    path = shutil.copytree(
        fixture_dir("git") / "github.com" / "demo" / directory, tmp_path / "project"
    )

    dependency = DirectoryDependency("demo", path, base=path)

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }
    assert package.root_dir == path


def test_search_for_directory_setup_read_setup(
    provider: Provider,
    mocker: MockerFixture,
    fixture_dir: FixtureDirGetter,
    tmp_path: Path,
) -> None:
    mocker.patch("poetry.utils.env.EnvManager.get", return_value=MockEnv())

    path = shutil.copytree(
        fixture_dir("git") / "github.com" / "demo" / "demo", tmp_path / "project"
    )
    dependency = DirectoryDependency("demo", path)

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_directory_setup_read_setup_with_extras(
    provider: Provider,
    mocker: MockerFixture,
    fixture_dir: FixtureDirGetter,
    tmp_path: Path,
) -> None:
    mocker.patch("poetry.utils.env.EnvManager.get", return_value=MockEnv())

    path = shutil.copytree(
        fixture_dir("git") / "github.com" / "demo" / "demo", tmp_path / "project"
    )

    dependency = DirectoryDependency("demo", path, extras=["foo"])

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"

    required = {r for r in package.requires if not r.is_optional()}
    optional = {r for r in package.requires if r.is_optional()}
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {get_dependency("tomlkit"), get_dependency("cleo")}
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_directory_setup_read_setup_with_no_dependencies(
    provider: Provider, fixture_dir: FixtureDirGetter, tmp_path: Path
) -> None:
    path = shutil.copytree(
        fixture_dir("git") / "github.com" / "demo" / "no-dependencies",
        tmp_path / "project",
    )

    dependency = DirectoryDependency("demo", path)

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.2"
    assert package.requires == []
    assert package.extras == {}


def test_search_for_directory_poetry(
    provider: Provider, fixture_dir: FixtureDirGetter, tmp_path: Path
) -> None:
    path = shutil.copytree(fixture_dir("project_with_extras"), tmp_path / "project")

    dependency = DirectoryDependency("project-with-extras", path)

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "project-with-extras"
    assert package.version.text == "1.2.3"

    required = {
        r for r in sorted(package.requires, key=lambda r: r.name) if not r.is_optional()
    }
    optional = {
        r for r in sorted(package.requires, key=lambda r: r.name) if r.is_optional()
    }
    assert not required
    assert optional == {
        get_dependency("cachy", ">=0.2.0"),
        get_dependency("pendulum", ">=1.4.4"),
    }
    extras_a = canonicalize_name("extras-a")
    extras_b = canonicalize_name("extras-b")
    assert set(package.extras) == {extras_a, extras_b}
    assert set(package.extras[extras_a]) == {get_dependency("pendulum", ">=1.4.4")}
    assert set(package.extras[extras_b]) == {get_dependency("cachy", ">=0.2.0")}


def test_search_for_directory_poetry_with_extras(
    provider: Provider, fixture_dir: FixtureDirGetter, tmp_path: Path
) -> None:
    path = shutil.copytree(fixture_dir("project_with_extras"), tmp_path / "project")

    dependency = DirectoryDependency("project-with-extras", path, extras=["extras_a"])

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "project-with-extras"
    assert package.version.text == "1.2.3"

    required = {
        r for r in sorted(package.requires, key=lambda r: r.name) if not r.is_optional()
    }
    optional = {
        r for r in sorted(package.requires, key=lambda r: r.name) if r.is_optional()
    }
    assert not required
    assert optional == {
        get_dependency("cachy", ">=0.2.0"),
        get_dependency("pendulum", ">=1.4.4"),
    }
    extras_a = canonicalize_name("extras-a")
    extras_b = canonicalize_name("extras-b")
    assert set(package.extras) == {extras_a, extras_b}
    assert set(package.extras[extras_a]) == {get_dependency("pendulum", ">=1.4.4")}
    assert set(package.extras[extras_b]) == {get_dependency("cachy", ">=0.2.0")}


def test_search_for_file_sdist(
    provider: Provider, fixture_dir: FixtureDirGetter
) -> None:
    dependency = FileDependency(
        "demo",
        fixture_dir("distributions") / "demo-0.1.0.tar.gz",
    )

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.0"
    assert package.files == [
        {
            "file": "demo-0.1.0.tar.gz",
            "hash": "sha256:9fa123ad707a5c6c944743bf3e11a0e80d86cb518d3cf25320866ca3ef43e2ad",
            "size": 1003,
        }
    ]

    required = {
        r for r in sorted(package.requires, key=lambda r: r.name) if not r.is_optional()
    }
    optional = {
        r for r in sorted(package.requires, key=lambda r: r.name) if r.is_optional()
    }
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {
        get_dependency("cleo"),
        get_dependency("tomlkit"),
    }
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_file_sdist_with_extras(
    provider: Provider, fixture_dir: FixtureDirGetter
) -> None:
    dependency = FileDependency(
        "demo",
        fixture_dir("distributions") / "demo-0.1.0.tar.gz",
        extras=["foo"],
    )

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.0"
    assert package.files == [
        {
            "file": "demo-0.1.0.tar.gz",
            "hash": "sha256:9fa123ad707a5c6c944743bf3e11a0e80d86cb518d3cf25320866ca3ef43e2ad",
            "size": 1003,
        }
    ]

    required = {
        r for r in sorted(package.requires, key=lambda r: r.name) if not r.is_optional()
    }
    optional = {
        r for r in sorted(package.requires, key=lambda r: r.name) if r.is_optional()
    }
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {
        get_dependency("cleo"),
        get_dependency("tomlkit"),
    }
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_file_wheel(
    provider: Provider, fixture_dir: FixtureDirGetter
) -> None:
    dependency = FileDependency(
        "demo",
        fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl",
    )

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.0"
    assert package.files == [
        {
            "file": "demo-0.1.0-py2.py3-none-any.whl",
            "hash": "sha256:70e704135718fffbcbf61ed1fc45933cfd86951a744b681000eaaa75da31f17a",
            "size": 1116,
        }
    ]

    required = {
        r for r in sorted(package.requires, key=lambda r: r.name) if not r.is_optional()
    }
    optional = {
        r for r in sorted(package.requires, key=lambda r: r.name) if r.is_optional()
    }
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {
        get_dependency("cleo"),
        get_dependency("tomlkit"),
    }
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_search_for_file_wheel_with_extras(
    provider: Provider, fixture_dir: FixtureDirGetter
) -> None:
    dependency = FileDependency(
        "demo",
        fixture_dir("distributions") / "demo-0.1.0-py2.py3-none-any.whl",
        extras=["foo"],
    )

    package = provider.search_for_direct_origin_dependency(dependency)

    assert package.name == "demo"
    assert package.version.text == "0.1.0"
    assert package.files == [
        {
            "file": "demo-0.1.0-py2.py3-none-any.whl",
            "hash": "sha256:70e704135718fffbcbf61ed1fc45933cfd86951a744b681000eaaa75da31f17a",
            "size": 1116,
        }
    ]

    required = {
        r for r in sorted(package.requires, key=lambda r: r.name) if not r.is_optional()
    }
    optional = {
        r for r in sorted(package.requires, key=lambda r: r.name) if r.is_optional()
    }
    assert required == {get_dependency("pendulum", ">=1.4.4")}
    assert optional == {
        get_dependency("cleo"),
        get_dependency("tomlkit"),
    }
    assert package.extras == {
        "foo": [get_dependency("cleo")],
        "bar": [get_dependency("tomlkit")],
    }


def test_complete_package_merges_same_source_and_no_source(
    provider: Provider, root: ProjectPackage
) -> None:
    foo_no_source_1 = get_dependency("foo", ">=1")
    foo_source_1 = get_dependency("foo", "!=1.1.*")
    foo_source_1.source_name = "source"
    foo_source_2 = get_dependency("foo", "!=1.2.*")
    foo_source_2.source_name = "source"
    foo_no_source_2 = get_dependency("foo", "<2")

    root.add_dependency(foo_no_source_1)
    root.add_dependency(foo_source_1)
    root.add_dependency(foo_source_2)
    root.add_dependency(foo_no_source_2)

    complete_package = provider.complete_package(
        DependencyPackage(root.to_dependency(), root)
    )

    requires = complete_package.package.all_requires
    assert len(requires) == 1
    assert requires[0].source_name == "source"
    assert str(requires[0].constraint) in {
        ">=1,<1.1 || >=1.3,<2",
        ">=1,<1.1.dev0 || >=1.3.dev0,<2",
        ">=1,<1.1.0 || >=1.3.0,<2",
        ">=1,<1.1.0.dev0 || >=1.3.0.dev0,<2",
    }


def test_complete_package_does_not_merge_different_source_names(
    provider: Provider, root: ProjectPackage
) -> None:
    foo_source_1 = get_dependency("foo")
    foo_source_1.source_name = "source_1"
    foo_source_2 = get_dependency("foo")
    foo_source_2.source_name = "source_2"

    root.add_dependency(foo_source_1)
    root.add_dependency(foo_source_2)

    with pytest.raises(IncompatibleConstraintsError) as e:
        provider.complete_package(DependencyPackage(root.to_dependency(), root))

    expected = """\
Incompatible constraints in requirements of root (1.2.3):
foo ; source=source_2
foo ; source=source_1"""

    assert str(e.value) == expected


def test_complete_package_merges_same_source_type_and_no_source(
    provider: Provider, root: ProjectPackage, fixture_dir: FixtureDirGetter
) -> None:
    project_dir = fixture_dir("with_conditional_path_deps")
    path = (project_dir / "demo_one").as_posix()

    root.add_dependency(Factory.create_dependency("demo", ">=1.0"))
    root.add_dependency(Factory.create_dependency("demo", {"path": path}))
    root.add_dependency(Factory.create_dependency("demo", {"path": path}))  # duplicate
    root.add_dependency(Factory.create_dependency("demo", "<2.0"))

    complete_package = provider.complete_package(
        DependencyPackage(root.to_dependency(), root)
    )

    requires = complete_package.package.all_requires
    assert len(requires) == 1
    assert requires[0].source_url == path
    assert str(requires[0].constraint) == "1.2.3"


def test_complete_package_does_not_merge_different_source_types(
    provider: Provider, root: ProjectPackage, fixture_dir: FixtureDirGetter
) -> None:
    project_dir = fixture_dir("with_conditional_path_deps")
    for folder in ["demo_one", "demo_two"]:
        path = (project_dir / folder).as_posix()
        root.add_dependency(Factory.create_dependency("demo", {"path": path}))

    with pytest.raises(IncompatibleConstraintsError) as e:
        provider.complete_package(DependencyPackage(root.to_dependency(), root))

    expected = f"""\
Incompatible constraints in requirements of root (1.2.3):
demo @ {project_dir.as_uri()}/demo_two (1.2.3)
demo @ {project_dir.as_uri()}/demo_one (1.2.3)"""

    assert str(e.value) == expected


def test_complete_package_does_not_merge_different_source_type_and_name(
    provider: Provider, root: ProjectPackage, fixture_dir: FixtureDirGetter
) -> None:
    project_dir = fixture_dir("with_conditional_path_deps")
    path = (project_dir / "demo_one").as_posix()

    dep_with_source_name = Factory.create_dependency("demo", ">=1.0")
    dep_with_source_name.source_name = "source"
    root.add_dependency(dep_with_source_name)
    root.add_dependency(Factory.create_dependency("demo", {"path": path}))

    with pytest.raises(IncompatibleConstraintsError) as e:
        provider.complete_package(DependencyPackage(root.to_dependency(), root))

    expected = f"""\
Incompatible constraints in requirements of root (1.2.3):
demo @ {project_dir.as_uri()}/demo_one (1.2.3)
demo (>=1.0) ; source=source"""

    assert str(e.value) == expected


def test_complete_package_does_not_merge_different_subdirectories(
    provider: Provider, root: ProjectPackage
) -> None:
    dependency_one = Factory.create_dependency(
        "one",
        {
            "git": "https://github.com/demo/subdirectories.git",
            "subdirectory": "one",
        },
    )
    dependency_one_copy = Factory.create_dependency(
        "one",
        {
            "git": "https://github.com/demo/subdirectories.git",
            "subdirectory": "one-copy",
        },
    )

    root.add_dependency(dependency_one)
    root.add_dependency(dependency_one_copy)

    with pytest.raises(IncompatibleConstraintsError) as e:
        provider.complete_package(DependencyPackage(root.to_dependency(), root))

    expected = """\
Incompatible constraints in requirements of root (1.2.3):
one @ git+https://github.com/demo/subdirectories.git#subdirectory=one-copy (1.0.0)
one @ git+https://github.com/demo/subdirectories.git#subdirectory=one (1.0.0)"""

    assert str(e.value) == expected


@pytest.mark.parametrize("source_name", [None, "repo"])
def test_complete_package_with_extras_preserves_source_name(
    provider: Provider, repository: Repository, source_name: str | None
) -> None:
    package_a = Package("A", "1.0")
    package_b = Package("B", "1.0")
    dep = get_dependency("B", "^1.0", optional=True)
    package_a.add_dependency(dep)
    package_a.extras = {canonicalize_name("foo"): [dep]}
    repository.add_package(package_a)
    repository.add_package(package_b)

    dependency = Dependency("A", "1.0", extras=["foo"])
    if source_name:
        dependency.source_name = source_name

    complete_package = provider.complete_package(
        DependencyPackage(dependency, package_a)
    )

    requires = complete_package.package.all_requires
    assert len(requires) == 2
    assert requires[0].name == "a"
    assert requires[0].source_name == source_name
    assert requires[1].name == "b"
    assert requires[1].source_name is None


@pytest.mark.parametrize("with_extra", [False, True])
def test_complete_package_fetches_optional_vcs_dependency_only_if_requested(
    provider: Provider, repository: Repository, mocker: MockerFixture, with_extra: bool
) -> None:
    optional_vcs_dependency = Factory.create_dependency(
        "demo", {"git": "https://github.com/demo/demo.git", "optional": True}
    )
    package = Package("A", "1.0", features=["foo"] if with_extra else [])
    package.add_dependency(optional_vcs_dependency)
    package.extras = {canonicalize_name("foo"): [optional_vcs_dependency]}
    repository.add_package(package)

    spy = mocker.spy(provider, "_search_for_vcs")

    provider.complete_package(DependencyPackage(package.to_dependency(), package))

    if with_extra:
        spy.assert_called()
    else:
        spy.assert_not_called()


def test_complete_package_finds_locked_package_in_explicit_source(
    root: ProjectPackage, pool: RepositoryPool
) -> None:
    package = Package("a", "1.0", source_reference="explicit")
    explicit_repo = Repository("explicit")
    explicit_repo.add_package(package)
    pool.add_repository(explicit_repo, priority=Priority.EXPLICIT)

    root_dependency = get_dependency("a", ">0")
    root_dependency.source_name = "explicit"
    root.add_dependency(root_dependency)
    locked_package = Package("a", "1.0", source_reference="explicit")
    provider = Provider(root, pool, NullIO(), locked=[locked_package])
    provider.complete_package(DependencyPackage(root.to_dependency(), root))

    # transitive dependency without explicit source
    dependency = get_dependency("a", ">=1")

    locked = provider.get_locked(dependency)
    assert locked is not None
    provider.complete_package(locked)  # must not fail


def test_complete_package_finds_locked_package_in_other_source(
    root: ProjectPackage, repository: Repository, pool: RepositoryPool
) -> None:
    package = Package("a", "1.0")
    repository.add_package(package)
    explicit_repo = Repository("explicit")
    pool.add_repository(explicit_repo)

    root_dependency = get_dependency("a", ">0")  # no explicit source
    root.add_dependency(root_dependency)
    locked_package = Package("a", "1.0", source_reference="explicit")  # explicit source
    provider = Provider(root, pool, NullIO(), locked=[locked_package])
    provider.complete_package(DependencyPackage(root.to_dependency(), root))

    # transitive dependency without explicit source
    dependency = get_dependency("a", ">=1")

    locked = provider.get_locked(dependency)
    assert locked is not None
    provider.complete_package(locked)  # must not fail


def test_complete_package_raises_packagenotfound_if_locked_source_not_available(
    root: ProjectPackage, pool: RepositoryPool, provider: Provider
) -> None:
    locked_package = Package("a", "1.0", source_reference="outdated")
    provider = Provider(root, pool, NullIO(), locked=[locked_package])
    provider.complete_package(DependencyPackage(root.to_dependency(), root))

    # transitive dependency without explicit source
    dependency = get_dependency("a", ">=1")

    locked = provider.get_locked(dependency)
    assert locked is not None
    with pytest.raises(PackageNotFoundError):
        provider.complete_package(locked)


def test_complete_package_outdated_cache(
    root: ProjectPackage,
    release_info: PackageInfo,
    outdated_release_info: PackageInfo,
    mocker: MockerFixture,
) -> None:
    repo = MockCachedRepository("repo")
    pool = RepositoryPool()
    pool.add_repository(repo)
    provider = Provider(root, pool, NullIO())
    pool_refresh_spy = mocker.spy(provider.pool, "refresh")

    assert release_info.name is not None
    assert release_info.version is not None
    package = Package(release_info.name, release_info.version)
    package.files = release_info.files  # up-to-date files from lock

    # trigger caching of outdated info
    repo._get_release_info = lambda name, version: outdated_release_info.asdict()  # type: ignore[method-assign]
    assert len(repo.package(package.name, package.version).files) == 1

    # additional files uploaded -> refresh needed
    repo._get_release_info = lambda name, version: release_info.asdict()  # type: ignore[method-assign]
    complete_package = provider.complete_package(
        DependencyPackage(package.to_dependency(), package)
    )
    assert len(complete_package.package.files) == 2
    assert pool_refresh_spy.call_count == 1

    # cache should have been updated -> no additional refresh needed
    complete_package = provider.complete_package(
        DependencyPackage(package.to_dependency(), package)
    )
    assert len(complete_package.package.files) == 2
    assert pool_refresh_spy.call_count == 1


def test_complete_package_no_refresh_on_url_size_upload_info(
    root: ProjectPackage,
    release_info: PackageInfo,
    release_info_complete: PackageInfo,
    mocker: MockerFixture,
) -> None:
    repo = MockCachedRepository("repo")
    pool = RepositoryPool()
    pool.add_repository(repo)
    provider = Provider(root, pool, NullIO())
    pool_refresh_spy = mocker.spy(provider.pool, "refresh")

    assert release_info.name is not None
    assert release_info.version is not None
    package = Package(release_info.name, release_info.version)
    package.files = release_info.files  # up-to-date files from lock
    assert "url" not in package.files[0]

    # trigger caching complete info
    repo._get_release_info = lambda name, version: release_info_complete.asdict()  # type: ignore[method-assign]
    assert len(repo.package(package.name, package.version).files) == 2

    # Only additional information in cache, names and hashes from lock file are the same
    # -> no refresh needed
    complete_package = provider.complete_package(
        DependencyPackage(package.to_dependency(), package)
    )
    assert len(complete_package.package.files) == 2
    assert "url" in complete_package.package.files[0]
    assert pool_refresh_spy.call_count == 0


def test_source_dependency_is_satisfied_by_direct_origin(
    provider: Provider, repository: Repository
) -> None:
    direct_origin_package = Package("foo", "1.1", source_type="url")
    repository.add_package(Package("foo", "1.0"))
    provider._direct_origin_packages = {"foo": direct_origin_package}
    dep = Dependency("foo", ">=1")

    assert provider.search_for(dep) == [direct_origin_package]


def test_explicit_source_dependency_is_not_satisfied_by_direct_origin(
    provider: Provider, repository: Repository
) -> None:
    repo_package = Package("foo", "1.0")
    repository.add_package(repo_package)
    provider._direct_origin_packages = {"foo": Package("foo", "1.1", source_type="url")}
    dep = Dependency("foo", ">=1")
    dep.source_name = repository.name

    assert provider.search_for(dep) == [repo_package]


def test_source_dependency_is_not_satisfied_by_incompatible_direct_origin(
    provider: Provider, repository: Repository
) -> None:
    repo_package = Package("foo", "2.0")
    repository.add_package(repo_package)
    provider._direct_origin_packages = {"foo": Package("foo", "1.0", source_type="url")}
    dep = Dependency("foo", ">=2")
    dep.source_name = repository.name

    assert provider.search_for(dep) == [repo_package]
