"""Tests for the fix ranker."""

from __future__ import annotations

import re

import networkx as nx  # pyright: ignore[reportMissingTypeStubs]

from depwhy.graph.models import Conflict, Constraint, FixCandidate
from depwhy.solver.detector import (
    _ROOT_NODE,
    detect_conflicts,
    extract_available_versions,
)
from depwhy.solver.ranker import rank_fixes
from tests.fixtures import (
    ml_stack_conflict,
    requests_urllib3,
)

# ── Helpers ───────────────────────────────────────────────────


def _build_graph_from_fixture(fixture: dict) -> nx.DiGraph:  # type: ignore[type-arg]
    """Build a minimal constraint graph manually from a fixture's data.

    Rather than running the full async GraphBuilder pipeline, we replicate
    the graph structure that the builder would produce by reading the fixture's
    requirements and pypi_responses and wiring up edges with the expected
    attributes (specifier, required_by, chain).
    """

    def _normalize(name: str) -> str:
        return re.sub(r"[-_.]+", "_", name).lower()

    graph = nx.DiGraph()  # pyright: ignore[reportUnknownMemberType]
    graph.add_node(_ROOT_NODE)  # pyright: ignore[reportUnknownMemberType]

    pypi: dict = fixture["pypi_responses"]
    requirements: list[str] = fixture["requirements"]

    from packaging.requirements import Requirement
    from packaging.specifiers import SpecifierSet
    from packaging.version import InvalidVersion, Version

    platform = fixture.get("platform")

    def _evaluate_marker(marker: object) -> bool:
        """Evaluate a marker against the fixture environment."""
        from packaging.markers import default_environment

        env = {str(k): str(v) for k, v in default_environment().items()}
        if platform is not None:
            env["sys_platform"] = platform
            platform_to_os = {"linux": "posix", "darwin": "posix", "win32": "nt"}
            if platform in platform_to_os:
                env["os_name"] = platform_to_os[platform]
        return marker.evaluate(env)  # type: ignore[union-attr]

    def _pick_version(releases: dict, spec_str: str) -> str | None:
        spec = SpecifierSet(spec_str) if spec_str else SpecifierSet()
        valid: list[Version] = []
        for ver_str in releases:
            try:
                v = Version(ver_str)
            except InvalidVersion:
                continue
            if v.is_prerelease or v.is_devrelease:
                continue
            if v in spec:
                valid.append(v)
        if not valid:
            return None
        valid.sort(reverse=True)
        return str(valid[0])

    visited: set[str] = set()

    def _expand(pkg: str, spec_str: str, parent_chain: list[str]) -> None:
        if pkg in visited:
            return
        visited.add(pkg)

        data = None
        for key in pypi:
            if _normalize(key) == pkg:
                data = pypi[key]
                break
        if data is None:
            return

        releases = data.get("releases", {})
        info = data.get("info", {})

        chosen = _pick_version(releases, spec_str)
        if chosen is None:
            chosen = info.get("version")
        if chosen is None:
            return

        required_by = f"{pkg}=={chosen}"
        requires_dist = info.get("requires_dist")
        if requires_dist is None:
            return

        for dep_str in requires_dist:
            try:
                dep_req = Requirement(dep_str)
            except Exception:
                continue

            if dep_req.marker is not None:
                try:
                    if not _evaluate_marker(dep_req.marker):
                        continue
                except Exception:
                    pass

            dep_name = _normalize(dep_req.name)
            dep_spec = str(dep_req.specifier) if dep_req.specifier else ""
            chain = parent_chain + [f"{dep_name}{dep_req.specifier}"]

            graph.add_node(dep_name)  # pyright: ignore[reportUnknownMemberType]
            graph.add_edge(  # pyright: ignore[reportUnknownMemberType]
                pkg,
                dep_name,
                specifier=dep_spec,
                required_by=required_by,
                chain=list(chain),
            )
            _expand(dep_name, dep_spec, chain)

    for req_str in requirements:
        req = Requirement(req_str)
        dep_name = _normalize(req.name)
        specifier = str(req.specifier) if req.specifier else ""
        chain = [_ROOT_NODE, f"{dep_name}{req.specifier}"]

        graph.add_node(dep_name)  # pyright: ignore[reportUnknownMemberType]
        graph.add_edge(  # pyright: ignore[reportUnknownMemberType]
            _ROOT_NODE,
            dep_name,
            specifier=specifier,
            required_by=_ROOT_NODE,
            chain=list(chain),
        )
        _expand(dep_name, specifier, chain)

    return graph  # pyright: ignore[reportReturnType]


def _make_graph(
    edges: list[tuple[str, str, dict]],  # type: ignore[type-arg]
) -> nx.DiGraph:  # type: ignore[type-arg]
    """Create a small DiGraph with the given edges."""
    g = nx.DiGraph()  # pyright: ignore[reportUnknownMemberType]
    g.add_node(_ROOT_NODE)  # pyright: ignore[reportUnknownMemberType]
    for src, dst, attrs in edges:
        g.add_node(src)  # pyright: ignore[reportUnknownMemberType]
        g.add_node(dst)  # pyright: ignore[reportUnknownMemberType]
        g.add_edge(src, dst, **attrs)  # pyright: ignore[reportUnknownMemberType]
    return g  # pyright: ignore[reportReturnType]


# ── Fixture-based tests ──────────────────────────────────────


class TestRequestsUrllib3Fixture:
    """Test ranker against the requests_urllib3 fixture (user pin conflict)."""

    def _get_conflict_and_context(
        self,
    ) -> tuple[Conflict, nx.DiGraph, dict[str, list[str]]]:  # type: ignore[type-arg]
        fixture = requests_urllib3.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])
        conflicts, _ = detect_conflicts(graph, avail)
        urllib3_conflict = next(c for c in conflicts if c.package == "urllib3")
        return urllib3_conflict, graph, avail

    def test_recommends_loosening_pin(self) -> None:
        """The primary fix should be to loosen the user's urllib3==2.0.0 pin."""
        conflict, graph, avail = self._get_conflict_and_context()
        solution, _alt = rank_fixes(conflict, graph, avail)

        assert "loosen" in solution.description.lower() or "pin" in solution.description.lower()
        assert "urllib3==" in solution.pip_command
        # The recommended version should be in the 1.2x range
        assert "1.26" in solution.pip_command

    def test_pip_command_starts_with_pip_install(self) -> None:
        """pip_command must start with 'pip install'."""
        conflict, graph, avail = self._get_conflict_and_context()
        solution, alt = rank_fixes(conflict, graph, avail)

        assert solution.pip_command.startswith("pip install")
        if alt is not None:
            assert alt.pip_command.startswith("pip install")

    def test_solution_is_not_alternative(self) -> None:
        """The solution must have is_alternative=False."""
        conflict, graph, avail = self._get_conflict_and_context()
        solution, _alt = rank_fixes(conflict, graph, avail)
        assert solution.is_alternative is False

    def test_alternative_is_marked_as_alternative(self) -> None:
        """If an alternative exists, it must have is_alternative=True."""
        conflict, graph, avail = self._get_conflict_and_context()
        _solution, alt = rank_fixes(conflict, graph, avail)
        if alt is not None:
            assert alt.is_alternative is True

    def test_expected_solution_contains_match(self) -> None:
        """The solution pip_command should match the fixture's expected_solution_contains."""
        fixture = requests_urllib3.FIXTURE
        conflict, graph, avail = self._get_conflict_and_context()
        solution, _alt = rank_fixes(conflict, graph, avail)

        expected = fixture["expected_solution_contains"]
        assert expected in solution.pip_command


class TestMlStackConflictFixture:
    """Test ranker against the ml_stack_conflict fixture (numpy pin)."""

    def _get_conflict_and_context(
        self,
    ) -> tuple[Conflict, nx.DiGraph, dict[str, list[str]]]:  # type: ignore[type-arg]
        fixture = ml_stack_conflict.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])
        conflicts, _ = detect_conflicts(graph, avail)
        numpy_conflict = next(c for c in conflicts if c.package == "numpy")
        return numpy_conflict, graph, avail

    def test_recommends_loosening_numpy_pin(self) -> None:
        """Primary fix should recommend loosening numpy==1.21.0 pin."""
        conflict, graph, avail = self._get_conflict_and_context()
        solution, _alt = rank_fixes(conflict, graph, avail)

        assert "numpy==" in solution.pip_command
        assert solution.pip_command.startswith("pip install")

    def test_recommended_version_satisfies_all_non_root_constraints(self) -> None:
        """The recommended numpy version should satisfy scipy and scikit-learn."""
        from packaging.specifiers import SpecifierSet
        from packaging.version import Version

        conflict, graph, avail = self._get_conflict_and_context()
        solution, _alt = rank_fixes(conflict, graph, avail)

        # Extract the recommended version from pip_command
        match = re.search(r"numpy==(\S+)", solution.pip_command)
        assert match is not None
        recommended = Version(match.group(1))

        # scipy requires numpy>=1.19.5,<1.27.0
        assert recommended in SpecifierSet(">=1.19.5,<1.27.0")
        # scikit-learn requires numpy>=1.22.4
        assert recommended in SpecifierSet(">=1.22.4")

    def test_expected_solution_contains_match(self) -> None:
        """The solution should match the fixture's expected_solution_contains."""
        fixture = ml_stack_conflict.FIXTURE
        conflict, graph, avail = self._get_conflict_and_context()
        solution, _alt = rank_fixes(conflict, graph, avail)

        expected = fixture["expected_solution_contains"]
        assert expected in solution.pip_command


# ── Unit tests with manually constructed graphs ──────────────


class TestStrategyLoosen:
    """Tests for the loosen-pin strategy."""

    def test_loosens_user_pin(self) -> None:
        """When user pins ==x.y.z, recommends the latest compatible version."""
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "shared",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [_ROOT_NODE, "shared==1.0.0"],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["3.0.0", "2.5.0", "2.0.0", "1.5.0", "1.0.0"],
            "pkg_a": ["1.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier="==1.0.0", required_by=_ROOT_NODE),
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, _alt = rank_fixes(conflict, graph, avail)

        assert "shared==2.5.0" in solution.pip_command
        assert "loosen" in solution.description.lower() or "pin" in solution.description.lower()

    def test_no_pin_does_not_trigger_loosen(self) -> None:
        """If user has no == pin, loosen-pin strategy should not produce a fix."""
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_b",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
                (
                    "pkg_b",
                    "shared",
                    {
                        "specifier": ">=3.0,<4.0",
                        "required_by": "pkg_b==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["4.0.0", "3.5.0", "3.0.0", "2.5.0", "2.0.0", "1.0.0"],
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
                Constraint(package="shared", specifier=">=3.0,<4.0", required_by="pkg_b==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, _alt = rank_fixes(conflict, graph, avail)

        # Should NOT be a loosen-pin fix since there is no user pin on shared
        assert "loosen" not in solution.description.lower()


class TestStrategyUpgrade:
    """Tests for the upgrade-depender strategy."""

    def test_recommends_upgrade_when_upper_bound(self) -> None:
        """If a depender has '<' in its specifier and newer versions exist, suggest upgrade."""
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_b",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
                (
                    "pkg_b",
                    "shared",
                    {
                        "specifier": ">=3.0,<4.0",
                        "required_by": "pkg_b==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["4.0.0", "3.5.0", "3.0.0", "2.5.0", "2.0.0"],
            "pkg_a": ["1.0.0", "1.5.0", "2.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
                Constraint(package="shared", specifier=">=3.0,<4.0", required_by="pkg_b==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, _alt = rank_fixes(conflict, graph, avail)

        assert "upgrade" in solution.description.lower()
        assert "pkg_a" in solution.pip_command
        assert "pip install" in solution.pip_command


class TestStrategyDowngrade:
    """Tests for the downgrade-depender strategy."""

    def test_recommends_downgrade_when_only_option(self) -> None:
        """When only downgrade is viable, it should be the solution."""
        # Both dependers have upper bounds and NO newer versions, but pkg_a
        # has an older version available
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==2.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_b",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=3.0,<4.0",
                        "required_by": "pkg_a==2.0.0",
                        "chain": [],
                    },
                ),
                (
                    "pkg_b",
                    "shared",
                    {
                        "specifier": ">=4.0,<5.0",
                        "required_by": "pkg_b==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["5.0.0", "4.5.0", "4.0.0", "3.5.0", "3.0.0"],
            "pkg_a": ["2.0.0", "1.5.0", "1.0.0"],  # has older versions
            "pkg_b": ["1.0.0"],  # no older versions
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier=">=3.0,<4.0", required_by="pkg_a==2.0.0"),
                Constraint(package="shared", specifier=">=4.0,<5.0", required_by="pkg_b==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, _alt = rank_fixes(conflict, graph, avail)

        assert "downgrade" in solution.description.lower()
        assert "pkg_a" in solution.pip_command


class TestMultipleStrategies:
    """Tests for when multiple strategies produce fixes."""

    def test_returns_best_and_alternative(self) -> None:
        """When multiple strategies work, returns both solution and alternative."""
        # User pins shared, AND a depender with upper-bound has newer versions
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "shared",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [_ROOT_NODE, "shared==1.0.0"],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["3.0.0", "2.5.0", "2.0.0", "1.0.0"],
            "pkg_a": ["1.0.0", "1.5.0", "2.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier="==1.0.0", required_by=_ROOT_NODE),
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, alt = rank_fixes(conflict, graph, avail)

        assert solution is not None
        assert alt is not None
        assert solution.is_alternative is False
        assert alt.is_alternative is True

    def test_loosen_pin_is_preferred_over_upgrade(self) -> None:
        """Loosen pin (1 change) should be preferred over upgrade (1 change but less certain)."""
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "shared",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [_ROOT_NODE, "shared==1.0.0"],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["3.0.0", "2.5.0", "2.0.0", "1.0.0"],
            "pkg_a": ["1.0.0", "2.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier="==1.0.0", required_by=_ROOT_NODE),
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, alt = rank_fixes(conflict, graph, avail)

        # Loosen pin should be the primary solution
        assert "loosen" in solution.description.lower() or "pin" in solution.description.lower()
        assert "shared==" in solution.pip_command


class TestOnlyOneStrategy:
    """Tests for when only one strategy produces a fix."""

    def test_alternative_is_none(self) -> None:
        """When only one strategy works, alternative should be None."""
        # Only loosen-pin works: no depender has upper-bound or older versions
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "shared",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [_ROOT_NODE, "shared==1.0.0"],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["3.0.0", "2.5.0", "2.0.0", "1.0.0"],
            "pkg_a": ["1.0.0"],  # No newer or older versions
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier="==1.0.0", required_by=_ROOT_NODE),
                Constraint(package="shared", specifier=">=2.0", required_by="pkg_a==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, alt = rank_fixes(conflict, graph, avail)

        assert solution.pip_command.startswith("pip install")
        assert alt is None


class TestNoFixFound:
    """Tests for when no strategy can find a fix."""

    def test_returns_descriptive_fallback(self) -> None:
        """When no fix is possible, return a descriptive FixCandidate."""
        # No user pin, no depender with upper bound, no older versions
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_b",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
                (
                    "pkg_b",
                    "shared",
                    {
                        "specifier": ">=3.0",
                        "required_by": "pkg_b==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": [],  # No versions available
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier=">=2.0", required_by="pkg_a==1.0.0"),
                Constraint(package="shared", specifier=">=3.0", required_by="pkg_b==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, alt = rank_fixes(conflict, graph, avail)

        assert "no automatic fix" in solution.description.lower()
        assert solution.pip_command == ""
        assert alt is None

    def test_fallback_is_not_alternative(self) -> None:
        """The fallback FixCandidate should have is_alternative=False."""
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_b",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
                (
                    "pkg_b",
                    "shared",
                    {
                        "specifier": ">=3.0",
                        "required_by": "pkg_b==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail: dict[str, list[str]] = {
            "shared": [],
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier=">=2.0", required_by="pkg_a==1.0.0"),
                Constraint(package="shared", specifier=">=3.0", required_by="pkg_b==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, _ = rank_fixes(conflict, graph, avail)
        assert solution.is_alternative is False


class TestPipCommandFormat:
    """Tests for pip_command formatting."""

    def test_all_commands_start_with_pip_install(self) -> None:
        """Every non-empty pip_command must start with 'pip install'."""
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "shared",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["2.5.0", "2.0.0", "1.0.0"],
            "pkg_a": ["1.0.0", "2.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier="==1.0.0", required_by=_ROOT_NODE),
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, alt = rank_fixes(conflict, graph, avail)

        if solution.pip_command:
            assert solution.pip_command.startswith("pip install")
        if alt is not None and alt.pip_command:
            assert alt.pip_command.startswith("pip install")

    def test_pip_command_contains_package_spec(self) -> None:
        """pip_command should contain a package==version specifier."""
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "shared",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["2.5.0", "2.0.0", "1.0.0"],
            "pkg_a": ["1.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier="==1.0.0", required_by=_ROOT_NODE),
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, _alt = rank_fixes(conflict, graph, avail)

        assert "==" in solution.pip_command


class TestRankingPriority:
    """Tests for ranking priority: fewer changes > smaller delta > newer."""

    def test_prefers_fewer_changes(self) -> None:
        """Loosen pin (1 package change) is preferred over upgrade/downgrade."""
        # User pin + depender with upper bound + depender with older version
        graph = _make_graph(
            [
                (
                    _ROOT_NODE,
                    "shared",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [_ROOT_NODE, "shared==1.0.0"],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["3.0.0", "2.5.0", "2.0.0", "1.0.0"],
            "pkg_a": ["0.9.0", "1.0.0", "1.5.0", "2.0.0"],
        }

        conflict = Conflict(
            package="shared",
            problem="",
            constraints=[
                Constraint(package="shared", specifier="==1.0.0", required_by=_ROOT_NODE),
                Constraint(package="shared", specifier=">=2.0,<3.0", required_by="pkg_a==1.0.0"),
            ],
            solution=FixCandidate(description="", pip_command="", is_alternative=False),
            alternative=None,
        )

        solution, alt = rank_fixes(conflict, graph, avail)

        # Loosen pin should be preferred (changes only 1 package)
        assert "loosen" in solution.description.lower() or "shared==" in solution.pip_command
        # Upgrade should be the alternative
        assert alt is not None
