"""Tests for LLM context builder.

Tests verify build_briefing_context() transforms AnalysisResult into a
structured text document suitable for LLM consumption, with correct
section population and token budget enforcement.
"""

from __future__ import annotations

from pathlib import Path

from hypothesis import given, settings
from hypothesis import strategies as st

from tests.conftest import (
    make_analysis_result,
    make_file_info,
)
from unfamiliar.analysis.models import (
    DetectedModule,
    EntryPoint,
    FileTestInfo,
    FunctionDef,
    ImportGraph,
    ParsedFile,
    ScoredFile,
    SecretFinding,
    SecurityResult,
    VulnerabilityFinding,
)
from unfamiliar.constants import EntryType, InvestigationCategory
from unfamiliar.llm.context import CHARS_PER_TOKEN, build_briefing_context


class TestBuildBriefingContextEmptyAnalysis:
    """Tests for build_briefing_context() with empty/minimal analysis."""

    def test_empty_analysis_returns_all_section_headers(self):
        """Empty analysis produces output with Repository Overview,
        Import Graph, Test Coverage, Key File Contents headers.
        """
        analysis = make_analysis_result()
        result = build_briefing_context(analysis)

        assert "# Repository Overview" in result
        assert "# Import Graph Summary" in result
        assert "# Test Coverage Summary" in result
        assert "# Key File Contents" in result
        assert "# Other Files (Signatures)" in result

    def test_empty_analysis_shows_zero_files(self):
        """Empty analysis shows Total files: 0 in repository overview."""
        analysis = make_analysis_result()
        result = build_briefing_context(analysis)

        assert "Total files: 0" in result

    def test_empty_analysis_repo_path_included(self):
        """Empty analysis includes the repo_path in output."""
        analysis = make_analysis_result(repo_path="/custom/repo/path")
        result = build_briefing_context(analysis)

        assert "/custom/repo/path" in result

    def test_empty_analysis_no_entry_points_section(self):
        """Empty analysis does not include Entry Points section."""
        analysis = make_analysis_result()
        result = build_briefing_context(analysis)

        assert "# Entry Points" not in result

    def test_empty_analysis_no_modules_section(self):
        """Empty analysis does not include Module Structure section."""
        analysis = make_analysis_result()
        result = build_briefing_context(analysis)

        assert "# Module Structure" not in result

    def test_empty_analysis_no_security_section(self):
        """Empty analysis does not include Security Findings section."""
        analysis = make_analysis_result()
        result = build_briefing_context(analysis)

        assert "# Security Findings" not in result


class TestBuildBriefingContextRepositoryOverview:
    """Tests for repository overview section."""

    def test_analysis_with_files_shows_total_count(self):
        """Analysis with 5 files shows Total files: 5."""
        files = [make_file_info(f"src/file{i}.py") for i in range(5)]
        analysis = make_analysis_result(files=files)
        result = build_briefing_context(analysis)

        assert "Total files: 5" in result

    def test_analysis_with_languages_shows_distribution(self):
        """Analysis with languages dict shows language counts sorted by
        count descending.
        """
        analysis = make_analysis_result(
            languages={"python": 10, "typescript": 5, "javascript": 3}
        )
        result = build_briefing_context(analysis)

        assert "## Languages" in result
        assert "python: 10 files" in result
        assert "typescript: 5 files" in result
        assert "javascript: 3 files" in result

        python_idx = result.index("python: 10 files")
        ts_idx = result.index("typescript: 5 files")
        js_idx = result.index("javascript: 3 files")
        assert python_idx < ts_idx < js_idx

    def test_analysis_without_languages_omits_languages_subsection(self):
        """Analysis with empty languages dict does not show Languages subsection."""
        analysis = make_analysis_result(languages={})
        result = build_briefing_context(analysis)

        assert "## Languages" not in result


class TestBuildBriefingContextEntryPoints:
    """Tests for entry points section."""

    def test_entry_points_section_lists_file_and_line(self):
        """Entry points section shows file path, line number, type, and description."""
        entry_points = [
            EntryPoint(
                file_path="src/main.py",
                entry_type=EntryType.MAIN,
                description="Application entry point",
                line=42,
            )
        ]
        analysis = make_analysis_result(entry_points=entry_points)
        result = build_briefing_context(analysis)

        assert "# Entry Points" in result
        assert "src/main.py" in result
        assert "Line 42" in result
        assert "main" in result
        assert "Application entry point" in result

    def test_multiple_entry_points_grouped_by_file(self):
        """Multiple entry points in same file are grouped under file heading."""
        entry_points = [
            EntryPoint(
                file_path="src/api.py",
                entry_type=EntryType.API_ROUTE,
                description="GET /users",
                line=15,
            ),
            EntryPoint(
                file_path="src/api.py",
                entry_type=EntryType.API_ROUTE,
                description="POST /users",
                line=25,
            ),
        ]
        analysis = make_analysis_result(entry_points=entry_points)
        result = build_briefing_context(analysis)

        api_section_start = result.find("## src/api.py")
        get_pos = result.find("GET /users", api_section_start)
        post_pos = result.find("POST /users", api_section_start)

        assert api_section_start != -1
        assert get_pos != -1
        assert post_pos != -1
        assert get_pos < post_pos

    def test_entry_points_sorted_by_file_path(self):
        """Entry points are sorted alphabetically by file path."""
        entry_points = [
            EntryPoint(
                file_path="src/z_last.py",
                entry_type=EntryType.CLI,
                description="CLI command",
                line=1,
            ),
            EntryPoint(
                file_path="src/a_first.py",
                entry_type=EntryType.MAIN,
                description="Main entry",
                line=1,
            ),
        ]
        analysis = make_analysis_result(entry_points=entry_points)
        result = build_briefing_context(analysis)

        first_idx = result.find("src/a_first.py")
        last_idx = result.find("src/z_last.py")
        assert first_idx < last_idx


class TestBuildBriefingContextModuleStructure:
    """Tests for module structure section."""

    def test_modules_section_shows_module_count(self):
        """Module structure section shows detected module count."""
        modules = [
            DetectedModule(
                files=["src/a.py"],
                primary_path="src/a.py",
                file_count=1,
                confidence=0.8,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "# Module Structure" in result
        assert "Detected 1 logical module(s)" in result

    def test_modules_section_shows_module_details(self):
        """Module structure section shows file count, confidence, and category hints."""
        modules = [
            DetectedModule(
                files=["src/core/a.py", "src/core/b.py"],
                primary_path="src/core",
                file_count=2,
                confidence=0.95,
                category_hints=[InvestigationCategory.ARCHITECTURE],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "## Module 1: src/core" in result
        assert "Files: 2" in result
        assert "Confidence: 0.95" in result
        assert "Category hints: architecture" in result

    def test_singleton_modules_summarised_not_listed(self):
        """Single-file modules appear as a summary count, not individual listings."""
        modules = [
            DetectedModule(
                files=["src/utils.py"],
                primary_path="src/utils.py",
                file_count=1,
                confidence=0.5,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "1 single-file modules (not listed individually)" in result
        assert "## Module 1:" not in result

    def test_multi_file_module_with_no_category_hints_shows_none(self):
        """Multi-file module without category hints shows 'none'."""
        modules = [
            DetectedModule(
                files=["src/a.py", "src/b.py"],
                primary_path="src",
                file_count=2,
                confidence=0.5,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "Category hints: none" in result

    def test_modules_list_files_up_to_five(self):
        """Module files section lists up to 5 files, then shows 'and X more'."""
        files = [f"src/module/file{i}.py" for i in range(15)]
        modules = [
            DetectedModule(
                files=files,
                primary_path="src/module",
                file_count=15,
                confidence=0.9,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        for i in range(5):
            assert f"file{i}.py" in result
        assert "... and 10 more files" in result

    def test_no_modules_omits_section(self):
        """Analysis with no modules does not include Module Structure section."""
        analysis = make_analysis_result(modules=[])
        result = build_briefing_context(analysis)

        assert "# Module Structure" not in result


class TestBuildBriefingContextImportGraph:
    """Tests for import graph summary section."""

    def test_import_graph_shows_total_edges(self):
        """Import graph section shows total import relationship count."""
        import_graph = ImportGraph(
            edges={
                "src/a.py": {"src/b.py", "src/c.py"},
                "src/b.py": {"src/c.py"},
            },
            in_degree={"src/a.py": 0, "src/b.py": 1, "src/c.py": 2},
        )
        analysis = make_analysis_result(import_graph=import_graph)
        result = build_briefing_context(analysis)

        assert "# Import Graph Summary" in result
        assert "Total import relationships: 3" in result

    def test_import_graph_shows_hub_files_sorted_by_degree(self):
        """Hub files are listed with in-degree, sorted descending."""
        import_graph = ImportGraph(
            edges={
                "src/a.py": {"src/core.py"},
                "src/b.py": {"src/core.py"},
                "src/c.py": {"src/core.py", "src/utils.py"},
            },
            in_degree={"src/core.py": 3, "src/utils.py": 1, "src/a.py": 0},
        )
        analysis = make_analysis_result(import_graph=import_graph)
        result = build_briefing_context(analysis)

        assert "## Hub Files (most imported)" in result
        assert "src/core.py: imported by 3 files" in result
        assert "src/utils.py: imported by 1 files" in result

        core_idx = result.index("src/core.py: imported by 3 files")
        utils_idx = result.index("src/utils.py: imported by 1 files")
        assert core_idx < utils_idx

    def test_import_graph_limits_hub_files_to_five(self):
        """Hub files section shows at most 5 hub files."""
        in_degree = {f"src/file{i}.py": i + 1 for i in range(10)}
        import_graph = ImportGraph(edges={}, in_degree=in_degree)
        analysis = make_analysis_result(import_graph=import_graph)
        result = build_briefing_context(analysis)

        hub_count = result.count("imported by")
        assert hub_count == 5

    def test_empty_import_graph_shows_zero_edges(self):
        """Empty import graph shows Total import relationships: 0."""
        analysis = make_analysis_result(import_graph=ImportGraph())
        result = build_briefing_context(analysis)

        assert "Total import relationships: 0" in result


class TestBuildBriefingContextTestCoverage:
    """Tests for test coverage summary section."""

    def test_test_coverage_shows_file_counts(self):
        """Test coverage section shows test file and untested file counts."""
        test_info = FileTestInfo(
            test_files={"tests/test_a.py", "tests/test_b.py"},
            untested_files={"src/orphan.py"},
            tier="basic",
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "# Test Coverage Summary" in result
        assert "Test files: 2" in result
        assert "Untested files: 1" in result
        assert "Detection tier: basic" in result

    def test_test_coverage_lists_test_files_sorted(self):
        """Test files are listed alphabetically, up to 15."""
        test_info = FileTestInfo(
            test_files={f"tests/test_{chr(97 + i)}.py" for i in range(5)},
            tier="basic",
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "## Test Files" in result
        assert "tests/test_a.py" in result
        assert "tests/test_e.py" in result

    def test_test_coverage_limits_test_file_list_to_eight(self):
        """More than 8 test files shows 'and X more' message."""
        test_info = FileTestInfo(
            test_files={f"tests/test_{i:02d}.py" for i in range(20)},
            tier="basic",
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "... and 12 more" in result

    def test_test_coverage_enhanced_tier_shows_coverage_highlights(self):
        """Enhanced tier shows coverage percentages for files with coverage data."""
        test_info = FileTestInfo(
            test_files={"tests/test_a.py"},
            tier="enhanced",
            coverage_summary={
                "src/core.py": 0.95,
                "src/utils.py": 0.50,
            },
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "## Coverage Highlights" in result
        assert "src/core.py: 95.0%" in result
        assert "src/utils.py: 50.0%" in result

    def test_test_coverage_basic_tier_no_coverage_highlights(self):
        """Basic tier does not show Coverage Highlights subsection."""
        test_info = FileTestInfo(
            test_files={"tests/test_a.py"},
            tier="basic",
            coverage_summary={},
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "## Coverage Highlights" not in result


class TestBuildBriefingContextSecurity:
    """Tests for security findings section."""

    def test_security_findings_with_secrets_shows_section(self):
        """Security section appears when secrets are found."""
        security = SecurityResult(
            secrets=[
                SecretFinding(
                    file_path="config/settings.py",
                    line_number=42,
                    secret_type="AWS Access Key",
                    description="Found AWS access key",
                )
            ]
        )
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "# Security Findings" in result
        assert "## Potential Secrets (1)" in result
        assert "config/settings.py:42" in result
        assert "AWS Access Key" in result
        # Description is no longer included to reduce token usage

    def test_security_findings_with_vulnerabilities_shows_section(self):
        """Security section appears when vulnerabilities are found."""
        security = SecurityResult(
            vulnerabilities=[
                VulnerabilityFinding(
                    package_name="requests",
                    installed_version="2.25.0",
                    vulnerability_id="CVE-2023-12345",
                    severity="high",
                    description="DoS vulnerability",
                    fix_version="2.28.0",
                    ecosystem="python",
                )
            ]
        )
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "# Security Findings" in result
        assert "## Dependency Vulnerabilities (1)" in result
        assert "requests 2.25.0" in result
        assert "CVE-2023-12345" in result
        assert "(high)" in result

    def test_security_findings_with_both_secrets_and_vulnerabilities(self):
        """Security section shows both secrets and vulnerabilities when both present."""
        security = SecurityResult(
            secrets=[
                SecretFinding(
                    file_path=".env",
                    line_number=1,
                    secret_type="API Key",
                    description="Found API key",
                )
            ],
            vulnerabilities=[
                VulnerabilityFinding(
                    package_name="lodash",
                    installed_version="4.17.15",
                    vulnerability_id="GHSA-1234",
                    severity="critical",
                    description="Prototype pollution",
                    fix_version="4.17.21",
                    ecosystem="javascript",
                )
            ],
        )
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "## Potential Secrets (1)" in result
        assert "## Dependency Vulnerabilities (1)" in result

    def test_security_findings_limits_to_five_per_category(self):
        """More than 5 secrets or vulnerabilities shows 'and X more' message."""
        secrets = [
            SecretFinding(
                file_path=f"file{i}.py",
                line_number=i,
                secret_type="Key",
                description=f"Secret {i}",
            )
            for i in range(15)
        ]
        security = SecurityResult(secrets=secrets)
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "... and 10 more findings" in result

    def test_security_findings_shows_scan_errors_with_secrets(self):
        """Security section shows scan errors when secrets are also present."""
        security = SecurityResult(
            secrets=[
                SecretFinding(
                    file_path="config.py",
                    line_number=1,
                    secret_type="API Key",
                    description="Found key",
                )
            ],
            scan_errors=["Failed to parse requirements.txt"],
        )
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "## Scan Errors" in result
        assert "Failed to parse requirements.txt" in result

    def test_scan_errors_alone_do_not_trigger_security_section(self):
        """Scan errors alone without secrets or vulnerabilities do not
        trigger Security section.
        """
        security = SecurityResult(scan_errors=["Failed to parse requirements.txt"])
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "# Security Findings" not in result

    def test_no_security_findings_omits_section(self):
        """Empty SecurityResult does not produce Security Findings section."""
        analysis = make_analysis_result(security=SecurityResult())
        result = build_briefing_context(analysis)

        assert "# Security Findings" not in result


class TestBuildBriefingContextKeyFileContents:
    """Tests for key file contents section with real file I/O."""

    def test_scored_files_included_as_content(self, tmp_path: Path):
        """Scored files with readable content appear in Key File Contents section."""
        file_path = tmp_path / "src" / "main.py"
        file_path.parent.mkdir(parents=True)
        file_path.write_text('def main():\n    print("hello")\n')

        scored_files = [
            ScoredFile(path="src/main.py", score=100, reasons=["Entry point"])
        ]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis)

        assert "# Key File Contents" in result
        assert "## src/main.py" in result
        assert "Priority score: 100" in result
        assert "Reasons: Entry point" in result
        assert "def main():" in result
        assert 'print("hello")' in result

    def test_multiple_scored_files_all_included(self, tmp_path: Path):
        """Multiple scored files within budget are all included."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "a.py").write_text("# file a")
        (tmp_path / "src" / "b.py").write_text("# file b")

        scored_files = [
            ScoredFile(path="src/a.py", score=90, reasons=["High centrality"]),
            ScoredFile(path="src/b.py", score=80, reasons=["Entry point"]),
        ]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis)

        assert "## src/a.py" in result
        assert "## src/b.py" in result
        assert "# file a" in result
        assert "# file b" in result

    def test_file_without_reasons_omits_reasons_line(self, tmp_path: Path):
        """Scored file with empty reasons list does not show Reasons line."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "simple.py").write_text("x = 1")

        scored_files = [ScoredFile(path="src/simple.py", score=50, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis)

        assert "Priority score: 50" in result
        assert "Reasons:" not in result

    def test_unreadable_file_skipped_gracefully(self, tmp_path: Path):
        """File that cannot be read is skipped without error."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "exists.py").write_text("# readable")
        (tmp_path / "src" / "missing.py").write_text("# will be deleted")

        scored_files = [
            ScoredFile(path="src/exists.py", score=100, reasons=[]),
            ScoredFile(path="src/missing.py", score=90, reasons=[]),
        ]
        (tmp_path / "src" / "missing.py").unlink()

        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis)

        assert "## src/exists.py" in result
        assert "## src/missing.py" not in result

    def test_binary_file_skipped_gracefully(self, tmp_path: Path):
        """File that cannot be decoded as UTF-8 is skipped."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "binary.bin").write_bytes(b"\x00\x01\x02\xff\xfe")

        scored_files = [ScoredFile(path="src/binary.bin", score=100, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis)

        assert "# Key File Contents" in result


class TestBuildBriefingContextTokenBudget:
    """Tests for token budget enforcement."""

    def test_large_content_truncated_to_fit_budget(self, tmp_path: Path):
        """File exceeding remaining budget is truncated with marker."""
        (tmp_path / "src").mkdir()
        large_content = "x = 1\n" * 500
        (tmp_path / "src" / "large.py").write_text(large_content)

        scored_files = [ScoredFile(path="src/large.py", score=100, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=100)

        assert "## src/large.py" in result
        assert "... [truncated]" in result

    def test_budget_zero_minimal_output(self, tmp_path: Path):
        """Token budget of 0 or very low produces minimal output."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "main.py").write_text("def main(): pass")

        scored_files = [ScoredFile(path="src/main.py", score=100, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=0)

        assert "# Repository Overview" in result
        assert "# Key File Contents" in result

    def test_files_processed_in_list_order(self, tmp_path: Path):
        """Files are processed in the order they appear in scored_files list."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "first.py").write_text("a" * 100)
        (tmp_path / "src" / "second.py").write_text("b" * 100)

        scored_files = [
            ScoredFile(path="src/first.py", score=50, reasons=[]),
            ScoredFile(path="src/second.py", score=100, reasons=[]),
        ]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=10000)

        first_idx = result.find("## src/first.py")
        second_idx = result.find("## src/second.py")

        assert first_idx != -1
        assert second_idx != -1
        assert first_idx < second_idx

    def test_budget_exhausted_stops_including_files(self, tmp_path: Path):
        """When budget is exhausted, remaining files are not included as content."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "first.py").write_text("x" * 3000)
        (tmp_path / "src" / "second.py").write_text("y" * 3000)
        (tmp_path / "src" / "third.py").write_text("z" * 3000)

        scored_files = [
            ScoredFile(path="src/first.py", score=100, reasons=[]),
            ScoredFile(path="src/second.py", score=90, reasons=[]),
            ScoredFile(path="src/third.py", score=80, reasons=[]),
        ]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=400)

        included_count = result.count("Priority score:")
        assert included_count == 1


class TestBuildBriefingContextFileSignatures:
    """Tests for file signatures in Other Files section."""

    def test_excluded_files_appear_as_signatures(self, tmp_path: Path):
        """Files not included as content due to budget appear in Other Files
        signatures section.
        """
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "included.py").write_text("x = 1\n" * 1000)
        (tmp_path / "src" / "excluded.py").write_text("y = 2\n" * 1000)

        parsed_files = {
            "src/excluded.py": ParsedFile(
                path="src/excluded.py",
                language="python",
                definitions=[
                    FunctionDef(
                        name="helper",
                        line_start=1,
                        line_end=5,
                        is_async=False,
                        kind="function",
                    )
                ],
            )
        }
        scored_files = [
            ScoredFile(path="src/included.py", score=100, reasons=[]),
            ScoredFile(path="src/excluded.py", score=50, reasons=[]),
        ]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
            parsed_files=parsed_files,
        )
        result = build_briefing_context(analysis, token_budget=2000)

        assert "# Other Files (Signatures)" in result
        assert "- src/excluded.py" in result
        assert "function helper" in result

    def test_signatures_show_up_to_five_definitions(self):
        """File signatures show first 5 definitions with '+N more' if exceeded."""
        parsed_files = {
            "src/many_defs.py": ParsedFile(
                path="src/many_defs.py",
                language="python",
                definitions=[
                    FunctionDef(
                        name=f"func{i}",
                        line_start=i,
                        line_end=i + 1,
                        is_async=False,
                        kind="function",
                    )
                    for i in range(8)
                ],
            )
        }
        scored_files = [ScoredFile(path="src/many_defs.py", score=10, reasons=[])]

        analysis = make_analysis_result(
            repo_path="/nonexistent",
            scored_files=scored_files,
            parsed_files=parsed_files,
        )
        result = build_briefing_context(analysis, token_budget=0)

        assert "func0" in result
        assert "func4" in result
        assert "(+3 more)" in result

    def test_signatures_limit_to_hundred_files(self):
        """Other Files section limits to 100 signatures with 'and X more' message."""
        parsed_files = {
            f"src/file{i}.py": ParsedFile(
                path=f"src/file{i}.py",
                language="python",
                definitions=[],
            )
            for i in range(150)
        }
        scored_files = [
            ScoredFile(path=f"src/file{i}.py", score=1, reasons=[]) for i in range(150)
        ]

        analysis = make_analysis_result(
            repo_path="/nonexistent",
            scored_files=scored_files,
            parsed_files=parsed_files,
        )
        result = build_briefing_context(analysis, token_budget=0)

        sig_lines = [
            line for line in result.split("\n") if line.startswith("- src/file")
        ]
        assert len(sig_lines) == 100
        assert "and 50 more files" in result

    def test_file_without_parsed_entry_not_in_signatures(self):
        """File without ParsedFile entry does not appear in signatures section."""
        scored_files = [ScoredFile(path="src/orphan.py", score=10, reasons=[])]
        analysis = make_analysis_result(
            repo_path="/nonexistent",
            scored_files=scored_files,
            parsed_files={},
        )
        result = build_briefing_context(analysis, token_budget=0)

        assert "- src/orphan.py" not in result


class TestBuildBriefingContextTokenBudgetPropertyBased:
    """Property-based tests for token budget invariant."""

    @given(
        token_budget=st.integers(min_value=500, max_value=50000),
    )
    @settings(max_examples=50)
    def test_empty_analysis_tokens_do_not_exceed_budget(self, token_budget: int):
        """For any token_budget, empty analysis output is reasonable."""
        analysis = make_analysis_result()
        result = build_briefing_context(analysis, token_budget=token_budget)

        estimated_tokens = len(result) // CHARS_PER_TOKEN

        assert estimated_tokens <= token_budget + 500


class TestBuildBriefingContextNoFilesDroppedPropertyBased:
    """Property-based test verifying files with parsed entries are not
    silently dropped.
    """

    @given(
        file_count=st.integers(min_value=1, max_value=10),
    )
    @settings(max_examples=30)
    def test_every_scored_file_with_parsed_entry_appears_as_content_or_signature(
        self, file_count: int
    ):
        """Every scored file with a parsed entry either appears in Key File
        Contents or Other Files Signatures.
        """
        import tempfile

        with tempfile.TemporaryDirectory() as tmp_dir:
            tmp_path = Path(tmp_dir)
            (tmp_path / "src").mkdir()

            scored_files = []
            parsed_files = {}
            for i in range(file_count):
                content = f"# File {i}\n"
                file_path = tmp_path / "src" / f"file{i}.py"
                file_path.write_text(content)
                scored_files.append(
                    ScoredFile(path=f"src/file{i}.py", score=100 - i, reasons=[])
                )
                parsed_files[f"src/file{i}.py"] = ParsedFile(
                    path=f"src/file{i}.py",
                    language="python",
                    definitions=[],
                )

            analysis = make_analysis_result(
                repo_path=str(tmp_path),
                scored_files=scored_files,
                parsed_files=parsed_files,
            )
            result = build_briefing_context(analysis, token_budget=50000)

            for sf in scored_files:
                in_content = f"## {sf.path}" in result
                in_signatures = f"- {sf.path}" in result
                assert in_content or in_signatures, (
                    f"File {sf.path} not found in output"
                )


class TestFileContentsSectionExactAssertions:
    """Tests for _build_file_contents_section with exact content assertions.

    These tests verify that the file contents section contains the exact
    expected content, not just that "something" is present (Card 4.1).
    """

    def test_file_contents_section_includes_exact_file_content(self, tmp_path: Path):
        """File contents section includes exact content from source file."""
        (tmp_path / "src").mkdir()
        exact_content = "def hello():\n    return 'world'"
        (tmp_path / "src" / "main.py").write_text(exact_content)

        scored_files = [
            ScoredFile(path="src/main.py", score=100, reasons=["Entry point"])
        ]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=10000)

        # Exact content should be present, not just "something"
        assert "def hello():" in result
        assert "return 'world'" in result

    def test_file_contents_section_shows_exact_score_value(self, tmp_path: Path):
        """File contents section shows the exact priority score from ScoredFile."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "main.py").write_text("x = 1")

        scored_files = [ScoredFile(path="src/main.py", score=87, reasons=["Test"])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=10000)

        assert "Priority score: 87" in result

    def test_file_contents_section_shows_all_reasons(self, tmp_path: Path):
        """File contents section shows all reasons from ScoredFile."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "main.py").write_text("x = 1")

        scored_files = [
            ScoredFile(
                path="src/main.py",
                score=100,
                reasons=["Entry point", "High centrality", "Many imports"],
            )
        ]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=10000)

        assert "Reasons: Entry point, High centrality, Many imports" in result

    def test_truncated_file_shows_exact_truncation_marker(self, tmp_path: Path):
        """Large file shows exact truncation marker with expected format."""
        (tmp_path / "src").mkdir()
        large_content = "x = 1\n" * 3000  # > MAX_FILE_CHARS
        (tmp_path / "src" / "large.py").write_text(large_content)

        scored_files = [ScoredFile(path="src/large.py", score=100, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=10000)

        assert "... [file truncated]" in result

    def test_file_content_is_inside_code_fence(self, tmp_path: Path):
        """File content is wrapped in markdown code fence."""
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "main.py").write_text("def hello(): pass")

        scored_files = [ScoredFile(path="src/main.py", score=100, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )
        result = build_briefing_context(analysis, token_budget=10000)

        # Find the file section and verify it's in a code fence
        assert "## src/main.py" in result
        assert "```\ndef hello(): pass\n```" in result


class TestTokenBudgetExactAssertions:
    """Tests for token budget enforcement with exact token count validation."""

    def test_context_respects_exact_token_budget(self, tmp_path: Path):
        """Context output respects the specified token budget exactly."""
        (tmp_path / "src").mkdir()
        large_content = "x = 1\n" * 1000
        (tmp_path / "src" / "large.py").write_text(large_content)

        scored_files = [ScoredFile(path="src/large.py", score=100, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )

        token_budget = 500
        result = build_briefing_context(analysis, token_budget=token_budget)

        estimated_tokens = len(result) // CHARS_PER_TOKEN
        assert estimated_tokens <= token_budget + 500

    def test_context_truncated_shows_exact_truncation_message(self, tmp_path: Path):
        """When context is truncated, shows exact truncation message."""
        (tmp_path / "src").mkdir()
        large_content = "x = 1\n" * 2000
        (tmp_path / "src" / "large.py").write_text(large_content)

        scored_files = [ScoredFile(path="src/large.py", score=100, reasons=[])]
        analysis = make_analysis_result(
            repo_path=str(tmp_path),
            scored_files=scored_files,
        )

        result = build_briefing_context(analysis, token_budget=100)

        # The truncation message is "... [truncated]" at file level
        # or "... [context truncated to fit token budget]" at context level
        assert "... [truncated" in result


class TestModuleSectionExactAssertions:
    """Tests for _build_modules_section with exact content assertions (Card 4.2)."""

    def test_module_section_shows_exact_file_count(self):
        """Module section shows exact file count from DetectedModule."""
        modules = [
            DetectedModule(
                files=["src/a.py", "src/b.py", "src/c.py"],
                primary_path="src",
                file_count=3,
                confidence=0.9,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "Files: 3" in result

    def test_module_section_shows_exact_confidence_value(self):
        """Module section shows exact confidence value from DetectedModule."""
        modules = [
            DetectedModule(
                files=["src/a.py", "src/b.py"],
                primary_path="src",
                file_count=2,
                confidence=0.87,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "Confidence: 0.87" in result

    def test_module_section_shows_exact_category_hints(self):
        """Module section shows exact category hints from DetectedModule."""
        modules = [
            DetectedModule(
                files=["src/a.py", "src/b.py"],
                primary_path="src",
                file_count=2,
                confidence=0.9,
                category_hints=[
                    InvestigationCategory.ARCHITECTURE,
                    InvestigationCategory.DATA_FLOW,
                ],
            )
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "Category hints: architecture, data_flow" in result

    def test_module_section_shows_exact_singleton_count(self):
        """Module section shows exact count of single-file modules."""
        modules = [
            DetectedModule(
                files=["src/a.py", "src/b.py"],
                primary_path="src",
                file_count=2,
                confidence=0.9,
                category_hints=[],
            ),
            DetectedModule(
                files=["src/utils.py"],
                primary_path="src/utils.py",
                file_count=1,
                confidence=0.5,
                category_hints=[],
            ),
            DetectedModule(
                files=["src/helpers.py"],
                primary_path="src/helpers.py",
                file_count=1,
                confidence=0.5,
                category_hints=[],
            ),
        ]
        analysis = make_analysis_result(modules=modules)
        result = build_briefing_context(analysis)

        assert "2 single-file modules (not listed individually)" in result


class TestImportGraphSectionExactAssertions:
    """Tests for _build_import_graph_section with exact content assertions."""

    def test_import_graph_shows_exact_edge_count(self):
        """Import graph section shows exact edge count."""
        import_graph = ImportGraph(
            edges={
                "src/a.py": {"src/b.py", "src/c.py", "src/d.py"},
                "src/b.py": {"src/c.py"},
            },
            in_degree={},
        )
        analysis = make_analysis_result(import_graph=import_graph)
        result = build_briefing_context(analysis)

        assert "Total import relationships: 4" in result

    def test_import_graph_shows_exact_in_degree_values(self):
        """Import graph section shows exact in-degree values."""
        import_graph = ImportGraph(
            edges={
                "src/a.py": {"src/core.py"},
                "src/b.py": {"src/core.py"},
                "src/c.py": {"src/core.py", "src/utils.py"},
            },
            in_degree={"src/core.py": 3, "src/utils.py": 1, "src/a.py": 0},
        )
        analysis = make_analysis_result(import_graph=import_graph)
        result = build_briefing_context(analysis)

        assert "src/core.py: imported by 3 files" in result
        assert "src/utils.py: imported by 1 files" in result


class TestTestCoverageSectionExactAssertions:
    """Tests for _build_test_coverage_section with exact content assertions."""

    def test_test_coverage_shows_exact_test_file_count(self):
        """Test coverage section shows exact test file count."""
        test_info = FileTestInfo(
            test_files={"tests/test_a.py", "tests/test_b.py", "tests/test_c.py"},
            tier="basic",
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "Test files: 3" in result

    def test_test_coverage_shows_exact_untested_file_count(self):
        """Test coverage section shows exact untested file count."""
        test_info = FileTestInfo(
            test_files={"tests/test_a.py"},
            untested_files={"src/orphan1.py", "src/orphan2.py"},
            tier="basic",
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "Untested files: 2" in result

    def test_test_coverage_shows_exact_coverage_percentage(self):
        """Test coverage section shows exact coverage percentage for enhanced tier."""
        test_info = FileTestInfo(
            test_files={"tests/test_a.py"},
            tier="enhanced",
            coverage_summary={
                "src/core.py": 0.95,
                "src/utils.py": 0.50,
            },
        )
        analysis = make_analysis_result(test_info=test_info)
        result = build_briefing_context(analysis)

        assert "src/core.py: 95.0%" in result
        assert "src/utils.py: 50.0%" in result


class TestSecuritySectionExactAssertions:
    """Tests for _build_security_section with exact content assertions."""

    def test_security_section_shows_exact_secret_location(self):
        """Security section shows exact file path and line number for secrets."""
        security = SecurityResult(
            secrets=[
                SecretFinding(
                    file_path="config/settings.py",
                    line_number=42,
                    secret_type="AWS Access Key",
                    description="Found AWS key",
                )
            ]
        )
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "config/settings.py:42" in result
        assert "AWS Access Key" in result

    def test_security_section_shows_exact_vulnerability_info(self):
        """Security section shows exact vulnerability package and version."""
        security = SecurityResult(
            vulnerabilities=[
                VulnerabilityFinding(
                    package_name="requests",
                    installed_version="2.25.0",
                    vulnerability_id="CVE-2023-12345",
                    severity="high",
                    description="DoS vulnerability",
                    fix_version="2.28.0",
                    ecosystem="python",
                )
            ]
        )
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "requests 2.25.0" in result
        assert "CVE-2023-12345" in result
        assert "(high)" in result

    def test_security_section_shows_exact_count_in_header(self):
        """Security section shows exact count in section headers."""
        security = SecurityResult(
            secrets=[
                SecretFinding(
                    file_path=f"file{i}.py",
                    line_number=i,
                    secret_type="Key",
                    description=f"Secret {i}",
                )
                for i in range(5)
            ]
        )
        analysis = make_analysis_result(security=security)
        result = build_briefing_context(analysis)

        assert "## Potential Secrets (5)" in result
