"""Tests for the plain-English explanation generator and Rich terminal formatter."""

from __future__ import annotations

import io
import json

import pytest

from depwhy.explain.formatter import format_json, format_report
from depwhy.explain.generator import _ROOT_NODE, generate_explanation
from depwhy.graph.models import Conflict, ConflictReport, Constraint, FixCandidate
from depwhy.solver.detector import detect_conflicts, extract_available_versions
from tests.fixtures import (
    ALL_FIXTURES,
    circular_dep,
    django_celery_kombu,
    ml_stack_conflict,
    requests_urllib3,
)

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


_PLACEHOLDER_FIX = FixCandidate(
    description="fix", pip_command="pip install x", is_alternative=False
)
_FORBIDDEN_TERMS = {"node", "edge", "specifier set", "graph"}


def _make_conflict(
    package: str,
    constraints: list[Constraint],
) -> Conflict:
    """Create a minimal Conflict for testing the explanation generator."""
    return Conflict(
        package=package,
        problem="",
        constraints=constraints,
        solution=_PLACEHOLDER_FIX,
        alternative=None,
    )


def _build_graph_from_fixture(fixture: dict) -> object:  # type: ignore[type-arg]
    """Re-use the graph builder helper from test_detector (duplicated locally)."""
    import re

    import networkx as nx  # pyright: ignore[reportMissingTypeStubs]
    from packaging.requirements import Requirement
    from packaging.specifiers import SpecifierSet
    from packaging.version import InvalidVersion, Version

    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"]  # type: ignore[type-arg]
    requirements: list[str] = fixture["requirements"]
    platform = fixture.get("platform")

    def _evaluate_marker(marker: object) -> bool:
        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:  # type: ignore[type-arg]
        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
        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}"]
            fallback_ver = info.get("version", pkg)
            resolved = _pick_version(releases, spec_str) or fallback_ver
            graph.add_node(dep_name)  # pyright: ignore[reportUnknownMemberType]
            graph.add_edge(  # pyright: ignore[reportUnknownMemberType]
                pkg,
                dep_name,
                specifier=dep_spec,
                required_by=f"{pkg}=={resolved}",
                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]


# ── Template 1: user pin ──────────────────────────────────────


class TestUserPinTemplate:
    """Tests for the user-pin explanation template."""

    def test_basic_user_pin_message(self) -> None:
        """User pin with one other constraint uses the pin template."""
        constraints = [
            Constraint(package="urllib3", specifier="==2.0.0", required_by=_ROOT_NODE, chain=[]),
            Constraint(
                package="urllib3",
                specifier=">=1.21.1,<1.27",
                required_by="requests==2.28.0",
                chain=[],
            ),
        ]
        conflict = _make_conflict("urllib3", constraints)
        result = generate_explanation(conflict)

        assert "pinned" in result
        assert "2.0.0" in result
        assert "urllib3" in result
        assert "requests==2.28.0" in result

    def test_pin_template_includes_depender_spec(self) -> None:
        """Pin template shows the depender's specifier in the output."""
        constraints = [
            Constraint(package="pkg", specifier="==1.0.0", required_by=_ROOT_NODE, chain=[]),
            Constraint(package="pkg", specifier=">=2.0", required_by="other==3.0.0", chain=[]),
        ]
        conflict = _make_conflict("pkg", constraints)
        result = generate_explanation(conflict)

        assert ">=2.0" in result
        assert "1.0.0" in result
        assert "pinned" in result

    def test_pin_template_order_invariant(self) -> None:
        """Pin template works regardless of which constraint comes first."""
        constraints_order_a = [
            Constraint(
                package="urllib3",
                specifier=">=1.21.1,<1.27",
                required_by="requests==2.28.0",
                chain=[],
            ),
            Constraint(package="urllib3", specifier="==2.0.0", required_by=_ROOT_NODE, chain=[]),
        ]
        constraints_order_b = [
            Constraint(package="urllib3", specifier="==2.0.0", required_by=_ROOT_NODE, chain=[]),
            Constraint(
                package="urllib3",
                specifier=">=1.21.1,<1.27",
                required_by="requests==2.28.0",
                chain=[],
            ),
        ]
        result_a = generate_explanation(_make_conflict("urllib3", constraints_order_a))
        result_b = generate_explanation(_make_conflict("urllib3", constraints_order_b))

        assert "pinned" in result_a
        assert "pinned" in result_b
        # Both should contain the same key elements
        assert "2.0.0" in result_a
        assert "2.0.0" in result_b


# ── Template 2: two-way transitive conflict ───────────────────


class TestTwoWayTemplate:
    """Tests for the two-way transitive conflict template."""

    def test_two_transitive_deps_conflict(self) -> None:
        """Two constraints from different non-root dependers use the two-way template."""
        constraints = [
            Constraint(
                package="numpy",
                specifier=">=1.19.5,<1.27.0",
                required_by="scipy==1.10.0",
                chain=[],
            ),
            Constraint(
                package="numpy",
                specifier=">=1.22.4",
                required_by="scikit_learn==1.2.0",
                chain=[],
            ),
        ]
        conflict = _make_conflict("numpy", constraints)
        result = generate_explanation(conflict)

        assert "no version satisfies both" in result
        assert "scipy==1.10.0" in result
        assert "scikit_learn==1.2.0" in result
        assert "numpy" in result

    def test_two_way_includes_both_specifiers(self) -> None:
        """Two-way template includes both specifiers in the output."""
        constraints = [
            Constraint(package="pkg", specifier=">=2.0,<3.0", required_by="a==1.0", chain=[]),
            Constraint(package="pkg", specifier=">=3.0,<4.0", required_by="b==1.0", chain=[]),
        ]
        conflict = _make_conflict("pkg", constraints)
        result = generate_explanation(conflict)

        assert ">=2.0,<3.0" in result
        assert ">=3.0,<4.0" in result
        assert "no version satisfies both" in result

    def test_two_way_does_not_say_pinned(self) -> None:
        """Two-way transitive template must not say 'pinned'."""
        constraints = [
            Constraint(
                package="amqp", specifier=">=5.0.9,<5.1.0", required_by="kombu==5.2.4", chain=[]
            ),
            Constraint(package="amqp", specifier=">=5.0.0", required_by="celery==5.2.0", chain=[]),
        ]
        conflict = _make_conflict("amqp", constraints)
        result = generate_explanation(conflict)

        assert "pinned" not in result
        assert "no version satisfies both" in result


# ── Template 3: multi-way conflict ───────────────────────────


class TestMultiWayTemplate:
    """Tests for the multi-way (>2 constraints) conflict template."""

    def test_three_constraints_uses_multiway_template(self) -> None:
        """Three constraints produce the N-packages template."""
        constraints = [
            Constraint(package="pkg", specifier=">=1.0,<2.0", required_by="a==1.0", chain=[]),
            Constraint(package="pkg", specifier=">=2.0,<3.0", required_by="b==1.0", chain=[]),
            Constraint(package="pkg", specifier=">=3.0,<4.0", required_by="c==1.0", chain=[]),
        ]
        conflict = _make_conflict("pkg", constraints)
        result = generate_explanation(conflict)

        assert "3 packages" in result
        assert "pkg" in result
        assert "no single version satisfies all constraints" in result

    def test_four_constraints_shows_correct_count(self) -> None:
        """Count in multi-way template matches the actual number of constraints."""
        constraints = [
            Constraint(
                package="lib",
                specifier=f">={i}.0,<{i + 1}.0",
                required_by=f"dep{i}==1.0",
                chain=[],
            )
            for i in range(4)
        ]
        conflict = _make_conflict("lib", constraints)
        result = generate_explanation(conflict)

        assert "4 packages" in result

    def test_multiway_includes_package_name(self) -> None:
        """Multi-way template includes the conflicting package name."""
        constraints = [
            Constraint(package="mylib", specifier=f">={i}.0", required_by=f"x{i}==1.0", chain=[])
            for i in range(3)
        ]
        conflict = _make_conflict("mylib", constraints)
        result = generate_explanation(conflict)

        assert "mylib" in result


# ── Length and terminology checks ────────────────────────────


class TestOutputConstraints:
    """Tests that ensure output quality constraints are met."""

    def test_user_pin_under_120_chars(self) -> None:
        """User-pin message for typical packages is under 120 characters."""
        constraints = [
            Constraint(package="urllib3", specifier="==2.0.0", required_by=_ROOT_NODE, chain=[]),
            Constraint(
                package="urllib3",
                specifier=">=1.21.1,<1.27",
                required_by="requests==2.28.0",
                chain=[],
            ),
        ]
        result = generate_explanation(_make_conflict("urllib3", constraints))
        assert len(result) <= 120

    def test_two_way_under_120_chars_typical(self) -> None:
        """Two-way message for typical packages is under 120 characters."""
        constraints = [
            Constraint(
                package="numpy", specifier=">=1.19.5", required_by="scipy==1.10.0", chain=[]
            ),
            Constraint(
                package="numpy",
                specifier=">=1.22.4",
                required_by="scikit_learn==1.2.0",
                chain=[],
            ),
        ]
        result = generate_explanation(_make_conflict("numpy", constraints))
        assert len(result) <= 120

    def test_multiway_always_short(self) -> None:
        """Multi-way template always produces a short, fixed-length message."""
        constraints = [
            Constraint(package="pkg", specifier=f">={i}.0", required_by=f"dep{i}==1.0", chain=[])
            for i in range(5)
        ]
        result = generate_explanation(_make_conflict("pkg", constraints))
        assert len(result) <= 120

    @pytest.mark.parametrize(
        "term",
        ["node", "edge", "specifier set", "graph"],
    )
    def test_no_internal_terminology_user_pin(self, term: str) -> None:
        """Internal graph terminology must not appear in user-pin output."""
        constraints = [
            Constraint(package="pkg", specifier="==1.0.0", required_by=_ROOT_NODE, chain=[]),
            Constraint(package="pkg", specifier=">=2.0", required_by="dep==1.0", chain=[]),
        ]
        result = generate_explanation(_make_conflict("pkg", constraints))
        assert term not in result.lower()

    @pytest.mark.parametrize(
        "term",
        ["node", "edge", "specifier set", "graph"],
    )
    def test_no_internal_terminology_two_way(self, term: str) -> None:
        """Internal graph terminology must not appear in two-way output."""
        constraints = [
            Constraint(package="pkg", specifier=">=1.0,<2.0", required_by="a==1.0", chain=[]),
            Constraint(package="pkg", specifier=">=2.0,<3.0", required_by="b==1.0", chain=[]),
        ]
        result = generate_explanation(_make_conflict("pkg", constraints))
        assert term not in result.lower()

    @pytest.mark.parametrize(
        "term",
        ["node", "edge", "specifier set", "graph"],
    )
    def test_no_internal_terminology_multiway(self, term: str) -> None:
        """Internal graph terminology must not appear in multi-way output."""
        constraints = [
            Constraint(package="pkg", specifier=f">={i}.0", required_by=f"dep{i}==1.0", chain=[])
            for i in range(3)
        ]
        result = generate_explanation(_make_conflict("pkg", constraints))
        assert term not in result.lower()


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


class TestFixtureIntegration:
    """Integration tests that run the detector on fixtures and generate explanations."""

    def test_requests_urllib3_pin_explanation(self) -> None:
        """requests_urllib3 fixture produces a user-pin explanation for urllib3."""
        fixture = requests_urllib3.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])
        conflicts, _ = detect_conflicts(graph, avail)  # type: ignore[arg-type]

        urllib3_conflict = next(c for c in conflicts if c.package == "urllib3")
        result = generate_explanation(urllib3_conflict)

        # Should use user-pin template (urllib3==2.0.0 is pinned by user)
        assert "pinned" in result
        assert "urllib3" in result
        assert len(result) <= 120
        for term in _FORBIDDEN_TERMS:
            assert term not in result.lower()

    def test_ml_stack_numpy_explanation(self) -> None:
        """ml_stack_conflict fixture produces a valid explanation for numpy."""
        fixture = ml_stack_conflict.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])
        conflicts, _ = detect_conflicts(graph, avail)  # type: ignore[arg-type]

        numpy_conflict = next(c for c in conflicts if c.package == "numpy")
        result = generate_explanation(numpy_conflict)

        assert "numpy" in result
        assert len(result) > 0
        assert len(result) <= 120
        for term in _FORBIDDEN_TERMS:
            assert term not in result.lower()

    def test_django_celery_amqp_explanation(self) -> None:
        """django_celery_kombu fixture produces a valid explanation for amqp."""
        fixture = django_celery_kombu.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])
        conflicts, _ = detect_conflicts(graph, avail)  # type: ignore[arg-type]

        amqp_conflict = next(c for c in conflicts if c.package == "amqp")
        result = generate_explanation(amqp_conflict)

        assert "amqp" in result
        assert len(result) > 0
        assert len(result) <= 120
        for term in _FORBIDDEN_TERMS:
            assert term not in result.lower()

    def test_circular_dep_explanation(self) -> None:
        """circular_dep fixture produces a valid explanation for shared_lib."""
        fixture = circular_dep.FIXTURE
        graph = _build_graph_from_fixture(fixture)
        avail = extract_available_versions(fixture["pypi_responses"])
        conflicts, _ = detect_conflicts(graph, avail)  # type: ignore[arg-type]

        shared_conflict = next(c for c in conflicts if c.package == "shared_lib")
        result = generate_explanation(shared_conflict)

        assert "shared_lib" in result
        assert len(result) > 0
        for term in _FORBIDDEN_TERMS:
            assert term not in result.lower()

    def test_explanation_is_non_empty_string(self) -> None:
        """Every conflict from every fixture produces a non-empty explanation."""
        for fixture in ALL_FIXTURES:
            graph = _build_graph_from_fixture(fixture)
            avail = extract_available_versions(fixture["pypi_responses"])
            conflicts, _ = detect_conflicts(graph, avail)  # type: ignore[arg-type]
            for conflict in conflicts:
                result = generate_explanation(conflict)
                assert isinstance(result, str)
                assert len(result) > 0, (
                    f"Empty explanation for {conflict.package} in {fixture['name']}"
                )


# ── Formatter tests ───────────────────────────────────────────


def _make_fix(description: str, pip_command: str, is_alternative: bool = False) -> FixCandidate:
    """Build a FixCandidate for formatter tests."""
    return FixCandidate(
        description=description,
        pip_command=pip_command,
        is_alternative=is_alternative,
    )


def _make_full_conflict(
    package: str,
    problem: str,
    solution: FixCandidate,
    alternative: FixCandidate | None = None,
) -> Conflict:
    """Build a Conflict with all required fields for formatter tests."""
    return Conflict(
        package=package,
        problem=problem,
        constraints=[],
        solution=solution,
        alternative=alternative,
    )


def _capture_format_report(report: ConflictReport, no_color: bool = False) -> str:
    """Run format_report writing to a StringIO buffer and return the captured text.

    Monkey-patches the ``Console`` class inside the formatter module so that all
    output is redirected to an in-memory ``StringIO`` buffer instead of stdout.
    The patch is always reverted via a ``finally`` block.
    """
    buf = io.StringIO()
    from rich.console import Console

    import depwhy.explain.formatter as fmt_mod

    original_console_cls = fmt_mod.Console  # type: ignore[attr-defined]

    class _CapturingConsole(Console):  # type: ignore[misc]
        def __init__(self, **kwargs: object) -> None:
            kwargs["file"] = buf
            kwargs["no_color"] = True
            super().__init__(**kwargs)  # type: ignore[arg-type]

    fmt_mod.Console = _CapturingConsole  # type: ignore[attr-defined]
    try:
        format_report(report, no_color=no_color)
    finally:
        fmt_mod.Console = original_console_cls  # type: ignore[attr-defined]

    return buf.getvalue()


class TestFormatReport:
    """Tests for format_report terminal rendering."""

    def test_zero_conflicts_shows_no_conflicts_message(self) -> None:
        """Zero conflicts prints a 'No conflicts found' message."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=[],
            analyzed_packages=5,
        )
        output = _capture_format_report(report)
        assert "No conflicts found" in output

    def test_single_conflict_renders_labels(self) -> None:
        """A single conflict renders Problem, Solution, and Alternative labels."""
        conflict = _make_full_conflict(
            package="urllib3",
            problem="requests==2.28.0 requires urllib3<1.27, but you have urllib3==2.0.0 pinned",
            solution=_make_fix(
                "Remove your pin — use urllib3==1.26.18",
                "pip install urllib3==1.26.18",
                is_alternative=False,
            ),
            alternative=_make_fix(
                "Upgrade requests to >=2.29.0",
                "pip install requests==2.29.0",
                is_alternative=True,
            ),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=10,
        )
        output = _capture_format_report(report)

        assert "Problem:" in output
        assert "Solution:" in output
        assert "Alternative:" in output
        assert "urllib3" in output

    def test_single_conflict_renders_pip_command(self) -> None:
        """The pip_command appears in the rendered output."""
        conflict = _make_full_conflict(
            package="urllib3",
            problem="conflict description",
            solution=_make_fix("fix description", "pip install urllib3==1.26.18"),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=3,
        )
        output = _capture_format_report(report)
        assert "pip install urllib3==1.26.18" in output

    def test_single_conflict_renders_package_name(self) -> None:
        """The package name appears in the output."""
        conflict = _make_full_conflict(
            package="mypackage",
            problem="some conflict",
            solution=_make_fix("some fix", "pip install mypackage==1.0"),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=2,
        )
        output = _capture_format_report(report)
        assert "mypackage" in output

    def test_conflict_count_in_header(self) -> None:
        """The conflict count appears in the header line."""
        conflict = _make_full_conflict(
            package="pkg",
            problem="p",
            solution=_make_fix("d", "pip install pkg==1.0"),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=1,
        )
        output = _capture_format_report(report)
        assert "1 conflict(s) found" in output

    def test_multiple_conflicts_all_packages_present(self) -> None:
        """All package names appear when there are multiple conflicts."""
        conflicts = [
            _make_full_conflict(
                package="pkgA",
                problem="conflict A",
                solution=_make_fix("fix A", "pip install pkgA==1.0"),
            ),
            _make_full_conflict(
                package="pkgB",
                problem="conflict B",
                solution=_make_fix("fix B", "pip install pkgB==2.0"),
            ),
        ]
        report = ConflictReport(
            has_conflicts=True,
            conflicts=conflicts,
            warnings=[],
            analyzed_packages=10,
        )
        output = _capture_format_report(report)
        assert "pkgA" in output
        assert "pkgB" in output
        assert "2 conflict(s) found" in output

    def test_no_alternative_when_none(self) -> None:
        """When alternative is None, 'Alternative:' label does not appear."""
        conflict = _make_full_conflict(
            package="pkg",
            problem="conflict",
            solution=_make_fix("fix", "pip install pkg==1.0"),
            alternative=None,
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=1,
        )
        output = _capture_format_report(report)
        assert "Alternative:" not in output

    def test_arrow_command_prefix_present(self) -> None:
        """The → arrow prefix appears before pip commands."""
        conflict = _make_full_conflict(
            package="pkg",
            problem="conflict",
            solution=_make_fix("fix", "pip install pkg==1.0"),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=1,
        )
        output = _capture_format_report(report)
        assert "→" in output


class TestFormatReportNoColor:
    """Tests that the no_color flag disables color markup."""

    def test_no_color_flag_produces_plain_text(self) -> None:
        """With no_color=True, output contains no Rich color markup brackets."""
        conflict = _make_full_conflict(
            package="urllib3",
            problem="some conflict",
            solution=_make_fix("fix it", "pip install urllib3==1.26.18"),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=5,
        )
        output = _capture_format_report(report, no_color=True)
        # No Rich markup escape sequences in plain text output
        assert "[bold red]" not in output
        assert "[bold green]" not in output
        assert "urllib3" in output
        assert "Problem:" in output

    def test_no_color_zero_conflicts(self) -> None:
        """With no_color=True and zero conflicts, message still appears."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=[],
            analyzed_packages=3,
        )
        output = _capture_format_report(report, no_color=True)
        assert "No conflicts found" in output


class TestFormatJson:
    """Tests for format_json JSON serialization."""

    def test_output_is_valid_json(self) -> None:
        """format_json returns a string that json.loads can parse."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=[],
            analyzed_packages=0,
        )
        result = format_json(report)
        parsed = json.loads(result)
        assert isinstance(parsed, dict)

    def test_top_level_keys_present(self) -> None:
        """JSON output contains all required top-level keys."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=[],
            analyzed_packages=7,
        )
        parsed = json.loads(format_json(report))
        assert "has_conflicts" in parsed
        assert "analyzed_packages" in parsed
        assert "conflicts" in parsed
        assert "warnings" in parsed

    def test_has_conflicts_false_when_no_conflicts(self) -> None:
        """has_conflicts is False when there are no conflicts."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=[],
            analyzed_packages=0,
        )
        parsed = json.loads(format_json(report))
        assert parsed["has_conflicts"] is False

    def test_has_conflicts_true_when_conflicts_present(self) -> None:
        """has_conflicts is True when there are conflicts."""
        conflict = _make_full_conflict(
            package="pkg",
            problem="p",
            solution=_make_fix("d", "pip install pkg==1.0"),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=1,
        )
        parsed = json.loads(format_json(report))
        assert parsed["has_conflicts"] is True

    def test_analyzed_packages_count(self) -> None:
        """analyzed_packages in JSON matches the report value."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=[],
            analyzed_packages=42,
        )
        parsed = json.loads(format_json(report))
        assert parsed["analyzed_packages"] == 42

    def test_conflict_structure(self) -> None:
        """Each conflict entry has package, problem, solution, and alternative keys."""
        conflict = _make_full_conflict(
            package="urllib3",
            problem="some conflict",
            solution=_make_fix("fix it", "pip install urllib3==1.26.18", is_alternative=False),
            alternative=_make_fix("alt fix", "pip install requests==2.29.0", is_alternative=True),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=5,
        )
        parsed = json.loads(format_json(report))
        assert len(parsed["conflicts"]) == 1
        entry = parsed["conflicts"][0]
        assert entry["package"] == "urllib3"
        assert entry["problem"] == "some conflict"
        assert "solution" in entry
        assert "alternative" in entry

    def test_solution_fields(self) -> None:
        """The solution dict has description, pip_command, and is_alternative."""
        conflict = _make_full_conflict(
            package="pkg",
            problem="p",
            solution=_make_fix("fix description", "pip install pkg==1.0", is_alternative=False),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=1,
        )
        parsed = json.loads(format_json(report))
        sol = parsed["conflicts"][0]["solution"]
        assert sol["description"] == "fix description"
        assert sol["pip_command"] == "pip install pkg==1.0"
        assert sol["is_alternative"] is False

    def test_alternative_is_null_when_none(self) -> None:
        """alternative is null in JSON when Conflict.alternative is None."""
        conflict = _make_full_conflict(
            package="pkg",
            problem="p",
            solution=_make_fix("d", "pip install pkg==1.0"),
            alternative=None,
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=1,
        )
        parsed = json.loads(format_json(report))
        assert parsed["conflicts"][0]["alternative"] is None

    def test_alternative_fields_when_present(self) -> None:
        """The alternative dict has description, pip_command, and is_alternative."""
        conflict = _make_full_conflict(
            package="pkg",
            problem="p",
            solution=_make_fix("primary fix", "pip install pkg==1.0", is_alternative=False),
            alternative=_make_fix("alt fix", "pip install dep==2.0", is_alternative=True),
        )
        report = ConflictReport(
            has_conflicts=True,
            conflicts=[conflict],
            warnings=[],
            analyzed_packages=2,
        )
        parsed = json.loads(format_json(report))
        alt = parsed["conflicts"][0]["alternative"]
        assert alt["description"] == "alt fix"
        assert alt["pip_command"] == "pip install dep==2.0"
        assert alt["is_alternative"] is True

    def test_warnings_list_included(self) -> None:
        """The warnings list is serialized correctly."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=["narrow range for pkg", "consider upgrading foo"],
            analyzed_packages=3,
        )
        parsed = json.loads(format_json(report))
        assert parsed["warnings"] == ["narrow range for pkg", "consider upgrading foo"]

    def test_multiple_conflicts_serialized(self) -> None:
        """Multiple conflicts are all serialized in the conflicts array."""
        conflicts = [
            _make_full_conflict(
                package=f"pkg{i}",
                problem=f"conflict {i}",
                solution=_make_fix(f"fix {i}", f"pip install pkg{i}==1.0"),
            )
            for i in range(3)
        ]
        report = ConflictReport(
            has_conflicts=True,
            conflicts=conflicts,
            warnings=[],
            analyzed_packages=10,
        )
        parsed = json.loads(format_json(report))
        assert len(parsed["conflicts"]) == 3
        packages = [e["package"] for e in parsed["conflicts"]]
        assert "pkg0" in packages
        assert "pkg1" in packages
        assert "pkg2" in packages

    def test_json_uses_indent_two(self) -> None:
        """Output uses two-space indentation (indent=2)."""
        report = ConflictReport(
            has_conflicts=False,
            conflicts=[],
            warnings=[],
            analyzed_packages=0,
        )
        result = format_json(report)
        # Two-space indented JSON starts its second line with two spaces
        lines = result.splitlines()
        # At least one line must start with exactly two spaces (first nested key)
        assert any(line.startswith("  ") and not line.startswith("   ") for line in lines)
