"""Tests for the conflict detector."""

from __future__ import annotations

import networkx as nx  # pyright: ignore[reportMissingTypeStubs]

from depwhy.solver.detector import (
    _ROOT_NODE,
    detect_conflicts,
    extract_available_versions,
)
from tests.fixtures import (
    circular_dep,
    django_celery_kombu,
    env_marker_conflict,
    ml_stack_conflict,
    no_conflict_baseline,
    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).
    """
    import re

    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

    # Build environment for marker evaluation
    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)

        # Find the pypi response (try normalized and original names)
        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]


# ── extract_available_versions ────────────────────────────────


class TestExtractAvailableVersions:
    """Tests for extract_available_versions()."""

    def test_basic_extraction(self) -> None:
        pypi = {
            "urllib3": {
                "releases": {
                    "1.25.0": [{}],
                    "1.26.18": [{}],
                    "2.0.0": [{}],
                }
            }
        }
        result = extract_available_versions(pypi)
        assert "urllib3" in result
        assert "2.0.0" in result["urllib3"]
        assert "1.26.18" in result["urllib3"]
        assert "1.25.0" in result["urllib3"]

    def test_normalises_package_names(self) -> None:
        pypi = {"My-Cool.Package": {"releases": {"1.0.0": [{}]}}}
        result = extract_available_versions(pypi)
        assert "my_cool_package" in result

    def test_empty_releases(self) -> None:
        pypi: dict = {"pkg": {"releases": {}}}
        result = extract_available_versions(pypi)
        assert result["pkg"] == []

    def test_invalid_versions_skipped(self) -> None:
        pypi = {
            "pkg": {
                "releases": {
                    "1.0.0": [{}],
                    "not-a-version": [{}],
                    "2.0.0": [{}],
                }
            }
        }
        result = extract_available_versions(pypi)
        assert len(result["pkg"]) == 2


# ── Fixture-based conflict detection ─────────────────────────


class TestFixtureConflicts:
    """Test conflict detection against the predefined fixture scenarios."""

    def test_requests_urllib3_detects_urllib3_conflict(self) -> None:
        """requests_urllib3 fixture: urllib3 is detected as conflicting."""
        fixture = requests_urllib3.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])

        conflicts, warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "urllib3" in conflict_packages

        # Verify constraints are populated
        urllib3_conflict = next(c for c in conflicts if c.package == "urllib3")
        assert len(urllib3_conflict.constraints) >= 2
        # Placeholder solution
        assert urllib3_conflict.solution.description == ""
        assert urllib3_conflict.alternative is None

    def test_ml_stack_detects_numpy_conflict(self) -> None:
        """ml_stack_conflict fixture: numpy is detected as conflicting."""
        fixture = ml_stack_conflict.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])

        conflicts, _warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "numpy" in conflict_packages

    def test_django_celery_kombu_detects_amqp_conflict(self) -> None:
        """django_celery_kombu fixture: amqp is detected as conflicting."""
        fixture = django_celery_kombu.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])

        conflicts, _warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "amqp" in conflict_packages

    def test_circular_dep_handles_circular_and_detects_shared_lib(self) -> None:
        """circular_dep fixture: handles cycles without crashing and detects shared_lib."""
        fixture = circular_dep.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])

        # Should not raise
        conflicts, _warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "shared_lib" in conflict_packages

    def test_env_marker_conflict_detects_pywin32_on_win32(self) -> None:
        """env_marker_conflict fixture: pywin32 conflict when platform=win32."""
        fixture = env_marker_conflict.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])

        conflicts, _warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "pywin32" in conflict_packages

    def test_no_conflict_baseline_returns_empty(self) -> None:
        """no_conflict_baseline fixture: no conflicts detected."""
        fixture = no_conflict_baseline.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])

        conflicts, _warnings = detect_conflicts(graph, avail)

        assert conflicts == []


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


class TestUnitConflictDetection:
    """Fine-grained unit tests with minimal hand-built graphs."""

    def _make_graph(
        self,
        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]

    def test_two_specifiers_empty_intersection_detected(self) -> None:
        """Two specifiers with no common version produce a hard conflict."""
        graph = self._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": ["1.0.0", "2.0.0", "2.5.0", "3.0.0", "3.5.0", "4.0.0"],
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflicts, _warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "shared" in conflict_packages

    def test_two_specifiers_valid_intersection_no_conflict(self) -> None:
        """Two specifiers with a common version do NOT produce a conflict."""
        graph = self._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": ">=1.0,<3.0", "required_by": "pkg_a==1.0.0", "chain": []},
                ),
                (
                    "pkg_b",
                    "shared",
                    {"specifier": ">=2.0,<4.0", "required_by": "pkg_b==1.0.0", "chain": []},
                ),
            ]
        )
        avail = {
            "shared": ["1.0.0", "2.0.0", "2.5.0", "3.0.0", "3.5.0"],
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflicts, _warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "shared" not in conflict_packages

    def test_single_incoming_constraint_cannot_conflict(self) -> None:
        """A package with only one predecessor cannot have a constraint conflict."""
        graph = self._make_graph(
            [
                (
                    _ROOT_NODE,
                    "only_dep",
                    {"specifier": ">=5.0", "required_by": _ROOT_NODE, "chain": []},
                ),
            ]
        )
        avail = {"only_dep": ["1.0.0"]}  # doesn't satisfy >=5.0, but that's not a *conflict*

        conflicts, _warnings = detect_conflicts(graph, avail)

        assert conflicts == []

    def test_narrowed_range_produces_warning(self) -> None:
        """Combined range narrower than an individual specifier triggers a warning."""
        graph = self._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": ">=1.0,<3.0", "required_by": "pkg_a==1.0.0", "chain": []},
                ),
                (
                    "pkg_b",
                    "shared",
                    {"specifier": ">=2.0", "required_by": "pkg_b==1.0.0", "chain": []},
                ),
            ]
        )
        # Versions: 1.0, 1.5, 2.0, 2.5 satisfy >=1.0,<3.0
        # Versions: 2.0, 2.5, 3.0, 3.5 satisfy >=2.0
        # Combined >=2.0,<3.0: only 2.0, 2.5 → narrower than either alone
        avail = {
            "shared": ["1.0.0", "1.5.0", "2.0.0", "2.5.0", "3.0.0", "3.5.0"],
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        _conflicts, warnings = detect_conflicts(graph, avail)

        assert len(warnings) >= 1
        assert any("shared" in w for w in warnings)

    def test_root_node_is_skipped(self) -> None:
        """The __root__ node itself is never flagged as a conflict."""
        graph = self._make_graph(
            [
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {"specifier": ">=1.0", "required_by": _ROOT_NODE, "chain": []},
                ),
            ]
        )
        avail = {"pkg_a": ["1.0.0"]}

        conflicts, warnings = detect_conflicts(graph, avail)

        assert all(c.package != _ROOT_NODE for c in conflicts)

    def test_package_missing_from_available_versions(self) -> None:
        """Package not in available_versions dict is treated as having no versions."""
        graph = self._make_graph(
            [
                (_ROOT_NODE, "pkg_a", {"specifier": "", "required_by": _ROOT_NODE, "chain": []}),
                (_ROOT_NODE, "pkg_b", {"specifier": "", "required_by": _ROOT_NODE, "chain": []}),
                (
                    "pkg_a",
                    "missing",
                    {"specifier": ">=1.0", "required_by": "pkg_a==1.0.0", "chain": []},
                ),
                (
                    "pkg_b",
                    "missing",
                    {"specifier": ">=2.0", "required_by": "pkg_b==1.0.0", "chain": []},
                ),
            ]
        )
        avail: dict[str, list[str]] = {
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflicts, _warnings = detect_conflicts(graph, avail)

        conflict_packages = [c.package for c in conflicts]
        assert "missing" in conflict_packages

    def test_conflict_constraints_have_correct_required_by(self) -> None:
        """Constraint objects on a Conflict carry the correct required_by info."""
        graph = self._make_graph(
            [
                (
                    _ROOT_NODE,
                    "pkg_a",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [_ROOT_NODE, "pkg_a==1.0.0"],
                    },
                ),
                (
                    _ROOT_NODE,
                    "pkg_b",
                    {
                        "specifier": "==1.0.0",
                        "required_by": _ROOT_NODE,
                        "chain": [_ROOT_NODE, "pkg_b==1.0.0"],
                    },
                ),
                (
                    "pkg_a",
                    "shared",
                    {
                        "specifier": ">=2.0,<3.0",
                        "required_by": "pkg_a==1.0.0",
                        "chain": [_ROOT_NODE, "pkg_a==1.0.0", "shared>=2.0,<3.0"],
                    },
                ),
                (
                    "pkg_b",
                    "shared",
                    {
                        "specifier": ">=4.0",
                        "required_by": "pkg_b==1.0.0",
                        "chain": [_ROOT_NODE, "pkg_b==1.0.0", "shared>=4.0"],
                    },
                ),
            ]
        )
        avail = {
            "shared": ["1.0.0", "2.0.0", "2.5.0", "3.0.0", "4.0.0"],
            "pkg_a": ["1.0.0"],
            "pkg_b": ["1.0.0"],
        }

        conflicts, _warnings = detect_conflicts(graph, avail)

        shared_conflict = next(c for c in conflicts if c.package == "shared")
        required_bys = {con.required_by for con in shared_conflict.constraints}
        assert "pkg_a==1.0.0" in required_bys
        assert "pkg_b==1.0.0" in required_bys
