"""Tests for the constraint graph builder."""

from __future__ import annotations

import httpx
import pytest
import respx
from packaging.specifiers import SpecifierSet

from depwhy.fetcher.cache import DiskCache
from depwhy.fetcher.pypi import PyPIFetcher
from depwhy.graph.builder import _ROOT_NODE, GraphBuilder, _normalize


def _pypi_response(
    name: str,
    version: str,
    requires_dist: list[str] | None = None,
    releases: dict[str, list[object]] | None = None,
) -> dict[str, object]:
    """Create a realistic PyPI JSON API response."""
    if releases is None:
        releases = {version: [{}]}
    return {
        "info": {
            "name": name,
            "version": version,
            "requires_dist": requires_dist,
        },
        "releases": releases,
    }


@pytest.fixture
def disk_cache(tmp_path: object) -> DiskCache:
    """Create a DiskCache using a temporary directory."""
    from pathlib import Path

    return DiskCache(ttl_hours=24, cache_dir=Path(str(tmp_path)) / "cache")


@pytest.fixture
def mock_router() -> respx.MockRouter:
    """Create a started respx mock router."""
    with respx.mock(assert_all_called=False) as router:
        yield router


# ── Normalisation ──────────────────────────────────────────────


class TestNormalize:
    """Tests for the _normalize helper."""

    def test_lowercase(self) -> None:
        assert _normalize("Flask") == "flask"

    def test_hyphens_to_underscores(self) -> None:
        assert _normalize("my-package") == "my_package"

    def test_dots_to_underscores(self) -> None:
        assert _normalize("zope.interface") == "zope_interface"

    def test_mixed(self) -> None:
        assert _normalize("My-Cool.Package") == "my_cool_package"

    def test_multiple_separators(self) -> None:
        assert _normalize("a--b__c..d") == "a_b_c_d"


# ── Graph Builder ──────────────────────────────────────────────


class TestGraphBuilder:
    """Tests for GraphBuilder.build()."""

    async def test_empty_requirements(self, disk_cache: DiskCache) -> None:
        """Empty requirements list produces an empty graph."""
        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build([])
        assert len(graph.nodes) == 0
        assert len(graph.edges) == 0

    @respx.mock
    async def test_simple_two_package_dependency(self, disk_cache: DiskCache) -> None:
        """A package with one dependency creates correct nodes and edge."""
        respx.get("https://pypi.org/pypi/requests/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "requests",
                    "2.28.0",
                    requires_dist=["urllib3>=1.21.1,<1.27"],
                    releases={"2.28.0": [{}], "2.27.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/urllib3/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "urllib3",
                    "1.26.18",
                    requires_dist=None,
                    releases={"1.26.18": [{}], "1.25.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["requests==2.28.0"])

        assert _ROOT_NODE in graph.nodes
        assert "requests" in graph.nodes
        assert "urllib3" in graph.nodes
        assert graph.has_edge(_ROOT_NODE, "requests")
        assert graph.has_edge("requests", "urllib3")

        edge_data = graph.edges["requests", "urllib3"]
        assert SpecifierSet(edge_data["specifier"]) == SpecifierSet(">=1.21.1,<1.27")
        assert edge_data["required_by"] == "requests==2.28.0"

    @respx.mock
    async def test_transitive_dependencies(self, disk_cache: DiskCache) -> None:
        """Transitive deps are expanded: A -> B -> C."""
        respx.get("https://pypi.org/pypi/a/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "a",
                    "1.0.0",
                    requires_dist=["b>=2.0"],
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/b/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "b",
                    "2.1.0",
                    requires_dist=["c>=3.0,<4.0"],
                    releases={"2.1.0": [{}], "2.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/c/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "c",
                    "3.5.0",
                    requires_dist=None,
                    releases={"3.5.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["a==1.0.0"])

        assert "a" in graph.nodes
        assert "b" in graph.nodes
        assert "c" in graph.nodes
        assert graph.has_edge("a", "b")
        assert graph.has_edge("b", "c")

        # Check chain on deepest edge
        chain = graph.edges["b", "c"]["chain"]
        # The chain records specifiers as str(Requirement.specifier) which may reorder
        assert len(chain) == 4
        assert chain[0] == _ROOT_NODE
        assert chain[1] == "a==1.0.0"
        assert chain[2] == "b>=2.0"
        assert SpecifierSet(chain[3].removeprefix("c")) == SpecifierSet(">=3.0,<4.0")

    @respx.mock
    async def test_circular_dependencies(self, disk_cache: DiskCache) -> None:
        """Circular deps (A -> B -> A) don't cause infinite loops."""
        respx.get("https://pypi.org/pypi/alpha/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "alpha",
                    "1.0.0",
                    requires_dist=["beta>=1.0"],
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/beta/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "beta",
                    "1.0.0",
                    requires_dist=["alpha>=1.0"],
                    releases={"1.0.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["alpha==1.0.0"])

        assert "alpha" in graph.nodes
        assert "beta" in graph.nodes
        assert graph.has_edge("alpha", "beta")
        assert graph.has_edge("beta", "alpha")

    @respx.mock
    async def test_environment_markers_filter_edges(self, disk_cache: DiskCache) -> None:
        """Windows-only dependency is skipped when targeting Linux."""
        respx.get("https://pypi.org/pypi/mypackage/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "mypackage",
                    "1.0.0",
                    requires_dist=[
                        "unix-dep>=1.0; sys_platform == 'linux'",
                        "win-dep>=1.0; sys_platform == 'win32'",
                        "common-dep>=1.0",
                    ],
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/unix_dep/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response("unix-dep", "1.0.0", releases={"1.0.0": [{}]}),
            )
        )
        respx.get("https://pypi.org/pypi/win_dep/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response("win-dep", "1.0.0", releases={"1.0.0": [{}]}),
            )
        )
        respx.get("https://pypi.org/pypi/common_dep/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response("common-dep", "1.0.0", releases={"1.0.0": [{}]}),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher, platform="linux")
            graph = await builder.build(["mypackage==1.0.0"])

        assert "unix_dep" in graph.nodes
        assert "common_dep" in graph.nodes
        # win_dep should be filtered out because platform is linux
        assert "win_dep" not in graph.nodes

    @respx.mock
    async def test_package_not_found_does_not_crash(self, disk_cache: DiskCache) -> None:
        """404 from PyPI doesn't crash; other packages still in graph."""
        respx.get("https://pypi.org/pypi/good_pkg/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "good-pkg",
                    "1.0.0",
                    requires_dist=None,
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/nonexistent/json").mock(return_value=httpx.Response(404))

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["good-pkg==1.0.0", "nonexistent>=1.0"])

        assert "good_pkg" in graph.nodes
        assert "nonexistent" in graph.nodes  # node added, but no expansion
        assert graph.has_edge(_ROOT_NODE, "good_pkg")
        assert graph.has_edge(_ROOT_NODE, "nonexistent")

    @respx.mock
    async def test_multiple_constraints_on_same_package(self, disk_cache: DiskCache) -> None:
        """Multiple packages depending on the same dep create separate edges."""
        respx.get("https://pypi.org/pypi/pkg_a/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "pkg-a",
                    "1.0.0",
                    requires_dist=["shared>=1.0,<2.0"],
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/pkg_b/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "pkg-b",
                    "1.0.0",
                    requires_dist=["shared>=1.5"],
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/shared/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "shared",
                    "1.8.0",
                    requires_dist=None,
                    releases={"1.8.0": [{}], "1.5.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["pkg-a==1.0.0", "pkg-b==1.0.0"])

        assert "shared" in graph.nodes
        # Both pkg_a and pkg_b should have edges to shared
        assert graph.has_edge("pkg_a", "shared")
        assert graph.has_edge("pkg_b", "shared")

    @respx.mock
    async def test_package_name_normalization(self, disk_cache: DiskCache) -> None:
        """Hyphens, dots, and case differences are normalised consistently."""
        # GraphBuilder normalises names with _normalize() (hyphens -> underscores)
        # and passes that to the fetcher, so the URL uses underscores.
        respx.get("https://pypi.org/pypi/my_cool_package/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "My-Cool-Package",
                    "1.0.0",
                    requires_dist=None,
                    releases={"1.0.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["My-Cool-Package==1.0.0"])

        assert "my_cool_package" in graph.nodes
        assert graph.has_edge(_ROOT_NODE, "my_cool_package")

    @respx.mock
    async def test_user_pinned_version(self, disk_cache: DiskCache) -> None:
        """When user pins ==x.y.z, that exact version's metadata is used."""
        respx.get("https://pypi.org/pypi/flask/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "flask",
                    "2.3.0",
                    requires_dist=["werkzeug>=2.3.0", "jinja2>=3.1.2"],
                    releases={
                        "2.3.0": [{}],
                        "2.2.0": [{}],
                        "3.0.0": [{}],
                    },
                ),
            )
        )
        respx.get("https://pypi.org/pypi/werkzeug/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "werkzeug",
                    "2.3.7",
                    requires_dist=None,
                    releases={"2.3.7": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/jinja2/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "jinja2",
                    "3.1.3",
                    requires_dist=None,
                    releases={"3.1.3": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["flask==2.3.0"])

        # Root edge should have the pinned specifier
        root_edge = graph.edges[_ROOT_NODE, "flask"]
        assert root_edge["specifier"] == "==2.3.0"
        # Transitive deps should be expanded
        assert graph.has_edge("flask", "werkzeug")
        assert graph.has_edge("flask", "jinja2")

    @respx.mock
    async def test_python_version_marker_filtering(self, disk_cache: DiskCache) -> None:
        """Dependencies gated on python_version are correctly filtered."""
        respx.get("https://pypi.org/pypi/mylib/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "mylib",
                    "1.0.0",
                    requires_dist=[
                        "old-compat>=1.0; python_version < '3.10'",
                        "modern-dep>=2.0; python_version >= '3.10'",
                    ],
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/modern_dep/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "modern-dep",
                    "2.0.0",
                    requires_dist=None,
                    releases={"2.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/old_compat/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "old-compat",
                    "1.0.0",
                    requires_dist=None,
                    releases={"1.0.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher, python_version="3.11")
            graph = await builder.build(["mylib==1.0.0"])

        assert "modern_dep" in graph.nodes
        assert "old_compat" not in graph.nodes

    @respx.mock
    async def test_version_range_picks_latest(self, disk_cache: DiskCache) -> None:
        """For a version range, the latest matching version is selected."""
        respx.get("https://pypi.org/pypi/somelib/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "somelib",
                    "2.5.0",
                    requires_dist=["depx>=1.0"],
                    releases={
                        "1.0.0": [{}],
                        "2.0.0": [{}],
                        "2.5.0": [{}],
                        "3.0.0": [{}],
                    },
                ),
            )
        )
        respx.get("https://pypi.org/pypi/depx/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "depx",
                    "1.2.0",
                    requires_dist=None,
                    releases={"1.2.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["somelib>=2.0,<3.0"])

        # Should have picked version 2.5.0 (latest in >=2.0,<3.0)
        edge = graph.edges[_ROOT_NODE, "somelib"]
        assert SpecifierSet(edge["specifier"]) == SpecifierSet(">=2.0,<3.0")
        # And expanded its transitive dep
        assert graph.has_edge("somelib", "depx")

    @respx.mock
    async def test_extras_dependency_skipped_as_marker(self, disk_cache: DiskCache) -> None:
        """Dependencies with extras markers like 'extra == \"dev\"' are skipped."""
        respx.get("https://pypi.org/pypi/mypkg/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "mypkg",
                    "1.0.0",
                    requires_dist=[
                        "core-dep>=1.0",
                        "dev-tool>=2.0; extra == 'dev'",
                    ],
                    releases={"1.0.0": [{}]},
                ),
            )
        )
        respx.get("https://pypi.org/pypi/core_dep/json").mock(
            return_value=httpx.Response(
                200,
                json=_pypi_response(
                    "core-dep",
                    "1.0.0",
                    requires_dist=None,
                    releases={"1.0.0": [{}]},
                ),
            )
        )

        async with PyPIFetcher(disk_cache) as fetcher:
            builder = GraphBuilder(fetcher)
            graph = await builder.build(["mypkg==1.0.0"])

        assert "core_dep" in graph.nodes
        # dev-tool should not be included (extra not requested)
        assert "dev_tool" not in graph.nodes
