# Verifies: REQ-d00054-A
# Validates REQ-d00055-D, REQ-o00061-B
"""Tests for graph factory build_graph() — code directory scanning and coverage annotation.

Verifies that build_graph() correctly scans [directories].code in addition
to [traceability].scan_patterns, with de-duplication and ignore filtering.
Also verifies that build_graph() annotates coverage metrics on requirement nodes.
"""

from pathlib import Path

from elspais.graph import NodeKind
from elspais.graph.factory import build_graph


def _write_spec(spec_dir: Path, req_id: str = "REQ-p00001", title: str = "Test Req") -> None:
    """Write a minimal valid spec file with a single requirement."""
    spec_dir.mkdir(parents=True, exist_ok=True)
    (spec_dir / "reqs.md").write_text(
        f"""\
### {req_id}: {title}

**Level**: PRD | **Status**: Active

The system SHALL do something testable.

*End* *{title}* | **Hash**: ________
""",
        encoding="utf-8",
    )


def _write_code_file(file_path: Path, req_id: str = "REQ-p00001") -> None:
    """Write a Python file with an Implements comment."""
    file_path.parent.mkdir(parents=True, exist_ok=True)
    file_path.write_text(
        f"# Implements: {req_id}\ndef work(): pass\n",
        encoding="utf-8",
    )


class TestCodeDirectoryScanning:
    """Tests for [directories].code scanning in build_graph()."""

    def test_REQ_d00054_A_code_directories_scanned_when_scan_patterns_empty(
        self, tmp_path: Path
    ) -> None:
        """When [directories].code is set but scan_patterns is absent,
        CODE nodes from the code directory still appear in the graph."""
        # Config with code dirs but NO scan_patterns
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-code-dirs"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
""",
            encoding="utf-8",
        )

        _write_spec(tmp_path / "spec")
        _write_code_file(tmp_path / "src" / "main.py")

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        code_nodes = list(graph.nodes_by_kind(NodeKind.CODE))
        assert (
            len(code_nodes) > 0
        ), "[directories].code should produce CODE nodes even without scan_patterns"
        # Verify the code node references our requirement
        code_ids = [n.id for n in code_nodes]
        has_ref = any("main.py" in cid for cid in code_ids)
        assert has_ref, f"Expected a CODE node from src/main.py, got: {code_ids}"

    def test_REQ_d00054_A_scan_patterns_and_code_directories_both_work(
        self, tmp_path: Path
    ) -> None:
        """When BOTH scan_patterns and [directories].code are set,
        CODE nodes appear from both sources."""
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-both-sources"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["lib"]
file_patterns = ["scripts/*.py"]
""",
            encoding="utf-8",
        )

        _write_spec(tmp_path / "spec")
        _write_code_file(tmp_path / "lib" / "core.py")
        _write_code_file(tmp_path / "scripts" / "deploy.py")

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        code_nodes = list(graph.nodes_by_kind(NodeKind.CODE))
        code_ids = [n.id for n in code_nodes]

        has_lib = any("core.py" in cid for cid in code_ids)
        has_scripts = any("deploy.py" in cid for cid in code_ids)

        assert has_lib, f"Expected CODE node from lib/core.py, got: {code_ids}"
        assert has_scripts, f"Expected CODE node from scripts/deploy.py, got: {code_ids}"

    def test_REQ_d00054_A_duplicate_files_not_double_counted(self, tmp_path: Path) -> None:
        """When a file is matched by BOTH scan_patterns AND [directories].code,
        only one CODE node is created (de-duplication via scanned_code_files set)."""
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-dedup"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
file_patterns = ["src/**/*.py"]
""",
            encoding="utf-8",
        )

        _write_spec(tmp_path / "spec")
        _write_code_file(tmp_path / "src" / "overlap.py")

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        code_nodes = list(graph.nodes_by_kind(NodeKind.CODE))
        # Filter to nodes from overlap.py specifically
        overlap_nodes = [n for n in code_nodes if "overlap.py" in n.id]

        assert len(overlap_nodes) == 1, (
            f"Expected exactly 1 CODE node from overlap.py (de-duplication), "
            f"got {len(overlap_nodes)}: {[n.id for n in overlap_nodes]}"
        )

    def test_REQ_d00054_A_ignore_dirs_respected_for_code_directories(self, tmp_path: Path) -> None:
        """When [directories].ignore lists a subdirectory, files within that
        subdirectory inside a [directories].code path are NOT scanned."""
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-ignore"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
skip_dirs = ["vendor"]
""",
            encoding="utf-8",
        )

        _write_spec(tmp_path / "spec")
        # This file is inside src/vendor/ which should be ignored
        _write_code_file(tmp_path / "src" / "vendor" / "foo.py")
        # This file is in src/ top-level which should be scanned
        _write_code_file(tmp_path / "src" / "app.py")

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        code_nodes = list(graph.nodes_by_kind(NodeKind.CODE))
        code_ids = [n.id for n in code_nodes]

        vendor_nodes = [cid for cid in code_ids if "vendor" in cid]
        app_nodes = [cid for cid in code_ids if "app.py" in cid]

        assert len(vendor_nodes) == 0, (
            f"Files in ignored 'vendor' dir should NOT produce CODE nodes, "
            f"but got: {vendor_nodes}"
        )
        assert (
            len(app_nodes) > 0
        ), f"Non-ignored src/app.py should produce CODE node, got: {code_ids}"

    def test_REQ_d00054_A_code_directories_explicit_src_scanned(self, tmp_path: Path) -> None:
        """When scanning.code.directories is explicitly set to ["src"],
        the src directory is scanned."""
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-explicit-src"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
""",
            encoding="utf-8",
        )

        _write_spec(tmp_path / "spec")
        _write_code_file(tmp_path / "src" / "default.py")

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        code_nodes = list(graph.nodes_by_kind(NodeKind.CODE))
        code_ids = [n.id for n in code_nodes]

        has_default = any("default.py" in cid for cid in code_ids)
        assert has_default, f"Explicit src/ directory should be scanned, got: {code_ids}"

    def test_REQ_d00054_A_nonexistent_code_directory_is_skipped(self, tmp_path: Path) -> None:
        """When [directories].code lists a non-existent directory, build_graph
        does not crash — the directory is silently skipped."""
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-missing-dir"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["does_not_exist"]
""",
            encoding="utf-8",
        )

        _write_spec(tmp_path / "spec")

        # Should not raise
        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        code_nodes = list(graph.nodes_by_kind(NodeKind.CODE))
        assert len(code_nodes) == 0, "Non-existent code directory should produce zero CODE nodes"


class TestBuildGraphCoverageAnnotation:
    """Validates REQ-d00055-D: build_graph() annotates referenced_pct on requirement nodes.

    Validates REQ-o00061-B: get_project_summary() returns non-zero coverage when
    requirements have implementing code, because build_graph() now runs annotate_coverage().
    """

    def test_REQ_d00055_D_build_graph_sets_referenced_pct_metric(self, tmp_path: Path) -> None:
        """After build_graph(), requirement nodes have referenced_pct metric set.

        When a code file implements a requirement's assertion, the referenced_pct
        metric should reflect that coverage (not remain at 0).
        """
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-coverage"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
""",
            encoding="utf-8",
        )

        # Write a spec with one requirement and one assertion
        # Uses ## heading level and ## Assertions section (matching parser expectations)
        spec_dir = tmp_path / "spec"
        spec_dir.mkdir(parents=True, exist_ok=True)
        (spec_dir / "reqs.md").write_text(
            """\
# Test Requirements

## REQ-p00001: Test Requirement

**Level**: PRD | **Status**: Active | **Implements**: -

The system SHALL do something testable.

## Assertions

A. The system SHALL perform action X.

*End* *Test Requirement* | **Hash**: abcd1234

---
""",
            encoding="utf-8",
        )

        # Write a code file that implements the assertion
        code_dir = tmp_path / "src"
        code_dir.mkdir(parents=True, exist_ok=True)
        (code_dir / "impl.py").write_text(
            "# Implements: REQ-p00001-A\ndef do_action_x(): pass\n",
            encoding="utf-8",
        )

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        req_node = graph.find_by_id("REQ-p00001")
        assert req_node is not None, "REQ-p00001 should exist in the graph"

        rollup = req_node.get_metric("rollup_metrics")
        assert rollup is not None, "rollup_metrics should be set after build_graph()"
        referenced_pct = rollup.implemented.indirect_pct
        assert (
            referenced_pct == 100.0
        ), f"Expected 100% coverage (1/1 assertion covered), got {referenced_pct}"

    def test_REQ_d00055_D_build_graph_sets_rollup_metrics(self, tmp_path: Path) -> None:
        """After build_graph(), requirement nodes have rollup_metrics metric set.

        The rollup_metrics object tracks detailed coverage breakdown (direct,
        explicit, inferred sources).
        """
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-rollup"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
""",
            encoding="utf-8",
        )

        spec_dir = tmp_path / "spec"
        spec_dir.mkdir(parents=True, exist_ok=True)
        (spec_dir / "reqs.md").write_text(
            """\
# Test Requirements

## REQ-p00001: Rollup Test

**Level**: PRD | **Status**: Active | **Implements**: -

The system SHALL do something.

## Assertions

A. The system SHALL do A.
B. The system SHALL do B.

*End* *Rollup Test* | **Hash**: abcd1234

---
""",
            encoding="utf-8",
        )

        # Code covers only assertion A
        code_dir = tmp_path / "src"
        code_dir.mkdir(parents=True, exist_ok=True)
        (code_dir / "impl.py").write_text(
            "# Implements: REQ-p00001-A\ndef do_a(): pass\n",
            encoding="utf-8",
        )

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        req_node = graph.find_by_id("REQ-p00001")
        assert req_node is not None

        from elspais.graph.metrics import RollupMetrics

        rollup = req_node.get_metric("rollup_metrics")
        assert rollup is not None, "rollup_metrics should be set after build_graph()"
        assert isinstance(rollup, RollupMetrics), f"Expected RollupMetrics, got {type(rollup)}"
        assert rollup.total_assertions == 2
        assert rollup.implemented.indirect == 1
        assert rollup.implemented.indirect_pct == 50.0

    def test_REQ_o00061_B_project_summary_nonzero_coverage_after_build_graph(
        self, tmp_path: Path
    ) -> None:
        """get_project_summary() reports non-zero coverage when build_graph()
        has annotated coverage metrics.

        This is the integration test for the bug fix: previously build_graph()
        did not call annotate_coverage(), so MCP's get_project_summary() always
        reported 0% coverage.
        """
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-summary"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
""",
            encoding="utf-8",
        )

        spec_dir = tmp_path / "spec"
        spec_dir.mkdir(parents=True, exist_ok=True)
        (spec_dir / "reqs.md").write_text(
            """\
# Test Requirements

## REQ-p00001: Summary Test

**Level**: PRD | **Status**: Active | **Implements**: -

The system SHALL do something.

## Assertions

A. The system SHALL do A.

*End* *Summary Test* | **Hash**: abcd1234

---
""",
            encoding="utf-8",
        )

        # Code covers the assertion
        code_dir = tmp_path / "src"
        code_dir.mkdir(parents=True, exist_ok=True)
        (code_dir / "impl.py").write_text(
            "# Implements: REQ-p00001-A\ndef do_a(): pass\n",
            encoding="utf-8",
        )

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=False,
        )

        # Simulate what the MCP server does: call count_by_coverage on the graph
        from elspais.graph.annotators import count_by_coverage

        coverage_stats = count_by_coverage(graph)

        assert coverage_stats["total"] == 1
        assert coverage_stats["full_coverage"] == 1, (
            "After build_graph() with annotate_coverage(), the requirement with "
            "100% coverage should appear in full_coverage count"
        )
        assert (
            coverage_stats["no_coverage"] == 0
        ), "The requirement should NOT be in no_coverage since it has code implementing it"


class TestMultiRoleFileScanning:
    """Tests for files scanned as both CODE and TEST (dual-type)."""

    def test_file_in_code_patterns_and_test_dirs_has_both_content_types(
        self, tmp_path: Path
    ) -> None:
        """A file matched by scanning.code.file_patterns AND scanning.test.directories
        produces one FILE node with both CODE and TEST content children and file_types set."""
        config_file = tmp_path / ".elspais.toml"
        config_file.write_text(
            """\
[project]
name = "test-multi-role"

[scanning.spec]
directories = ["spec"]

[scanning.code]
directories = ["src"]
file_patterns = ["tests/test_dual.py"]

[scanning.test]
enabled = true
directories = ["tests"]
file_patterns = ["test_*.py"]
""",
            encoding="utf-8",
        )

        _write_spec(tmp_path / "spec")

        # Write a test file that also has # Implements: (code role)
        test_file = tmp_path / "tests" / "test_dual.py"
        test_file.parent.mkdir(parents=True, exist_ok=True)
        test_file.write_text(
            "# Implements: REQ-p00001\n" "def test_REQ_p00001_something():\n" "    assert True\n",
            encoding="utf-8",
        )

        graph = build_graph(
            config_path=config_file,
            repo_root=tmp_path,
            scan_tests=True,
        )

        # Find the FILE node for test_dual.py
        file_nodes = [n for n in graph.iter_roots(NodeKind.FILE) if "test_dual.py" in n.id]
        assert (
            len(file_nodes) == 1
        ), f"Expected exactly 1 FILE node for test_dual.py, got {len(file_nodes)}"
        file_node = file_nodes[0]

        # Check file_types list includes both CODE and TEST (stored as string values)
        file_types = file_node.get_field("file_types")
        assert file_types is not None, "file_types field should be set for multi-role files"
        assert "code" in file_types, f"Expected 'code' in file_types, got {file_types}"
        assert "test" in file_types, f"Expected 'test' in file_types, got {file_types}"

        # Check both CODE and TEST content children exist
        child_kinds = {child.kind for child in file_node.iter_children()}
        assert (
            NodeKind.CODE in child_kinds
        ), f"Expected CODE child from # Implements:, got kinds: {child_kinds}"
        assert (
            NodeKind.TEST in child_kinds
        ), f"Expected TEST child from test function, got kinds: {child_kinds}"
