"""Tests for LLM briefing schemas, prompts, and generator.

Tests verify:
- BriefingResponse and ModuleDescription schemas construct correctly
- BRIEFING_PROMPT renders with correct variables and message types
- generate_briefing() handles happy path, errors, and edge cases
"""

from __future__ import annotations

from contextlib import contextmanager
from unittest.mock import MagicMock, patch

import pytest
from pydantic import ValidationError

from tests.conftest import make_analysis_result
from unfamiliar.analysis.models import DetectedModule
from unfamiliar.llm.briefing.generator import BriefingResult, generate_briefing
from unfamiliar.llm.briefing.prompts import (
    BRIEFING_HUMAN_PROMPT,
    BRIEFING_PROMPT,
    BRIEFING_SYSTEM_PROMPT,
)
from unfamiliar.llm.briefing.schemas import BriefingResponse, ModuleDescription
from unfamiliar.llm.context import CHARS_PER_TOKEN
from unfamiliar.llm.exceptions import AuthenticationError, BriefingGenerationError


@contextmanager
def _mock_briefing_chain(response):
    """Context manager that patches BRIEFING_PROMPT to return a mock chain.

    LangChain's pipe operator doesn't work with plain MagicMocks.
    This patches BRIEFING_PROMPT so that | returns a mock chain.
    """
    mock_chain = MagicMock()
    mock_chain.invoke.return_value = response

    with patch("unfamiliar.llm.briefing.generator.BRIEFING_PROMPT") as mock_prompt:
        mock_prompt.__or__ = MagicMock(return_value=mock_chain)
        yield mock_chain


class TestModuleDescriptionSchema:
    """Tests for ModuleDescription Pydantic model."""

    def test_valid_module_description_all_fields(self):
        """ModuleDescription with all 3 fields constructs correctly."""
        module = ModuleDescription(
            name="Authentication Handlers",
            description="Handles user authentication and session management.",
            security_relevant=True,
        )

        assert module.name == "Authentication Handlers"
        assert (
            module.description == "Handles user authentication and session management."
        )
        assert module.security_relevant is True

    def test_module_description_security_relevant_defaults_to_false_if_missing(self):
        """ModuleDescription requires security_relevant (no default)."""
        # Pydantic requires all fields without defaults
        # This test verifies the field is required
        print(ModuleDescription.model_config)
        with pytest.raises(ValidationError):
            ModuleDescription(
                name="Utils",
                description="Utility functions.",
            )


class TestBriefingResponseSchema:
    """Tests for BriefingResponse Pydantic model."""

    def test_valid_briefing_response_all_fields(self):
        """BriefingResponse with all 4 fields constructs correctly."""
        modules = [
            ModuleDescription(
                name="Core API",
                description="Main API endpoints.",
                security_relevant=False,
            )
        ]
        briefing = BriefingResponse(
            summary="This is a test codebase.",
            tech_stack=["Python", "FastAPI", "SQLite"],
            security_posture="No significant security concerns.",
            modules=modules,
        )

        assert briefing.summary == "This is a test codebase."
        assert briefing.tech_stack == ["Python", "FastAPI", "SQLite"]
        assert briefing.security_posture == "No significant security concerns."
        assert len(briefing.modules) == 1
        assert briefing.modules[0].name == "Core API"

    def test_briefing_response_with_empty_tech_stack_is_valid(self):
        """BriefingResponse with empty tech_stack is valid."""
        briefing = BriefingResponse(
            summary="Minimal codebase.",
            tech_stack=[],
            security_posture="No concerns.",
            modules=[],
        )

        assert briefing.tech_stack == []
        assert briefing.summary == "Minimal codebase."

    def test_briefing_response_with_multiple_module_descriptions(self):
        """BriefingResponse with multiple ModuleDescription entries - all accessible."""
        modules = [
            ModuleDescription(
                name=f"Module {i}",
                description=f"Description for module {i}.",
                security_relevant=i % 2 == 0,
            )
            for i in range(5)
        ]
        briefing = BriefingResponse(
            summary="Multi-module codebase.",
            tech_stack=["Python"],
            security_posture="Some security concerns.",
            modules=modules,
        )

        assert len(briefing.modules) == 5
        for i, module in enumerate(briefing.modules):
            assert module.name == f"Module {i}"
            assert module.security_relevant == (i % 2 == 0)


class TestBriefingPromptTemplate:
    """Tests for BRIEFING_PROMPT ChatPromptTemplate."""

    def test_briefing_prompt_renders_with_context_and_module_count(self):
        """BRIEFING_PROMPT renders without error with context and module_count
        variables.
        """
        messages = BRIEFING_PROMPT.format_messages(
            context="This is the codebase context.",
            module_count=3,
        )

        assert len(messages) == 2  # system + human

    def test_briefing_prompt_includes_system_and_human_messages(self):
        """Rendered messages include system and human message types."""
        messages = BRIEFING_PROMPT.format_messages(
            context="Test context.",
            module_count=1,
        )

        message_types = [type(msg).__name__ for msg in messages]
        assert "SystemMessage" in message_types
        assert "HumanMessage" in message_types

    def test_system_message_contains_role_definition_keywords(self):
        """System message contains role definition keywords
        ('code analyst' or 'briefing').
        """
        messages = BRIEFING_PROMPT.format_messages(
            context="Test context.",
            module_count=1,
        )

        system_message = messages[0]
        content = system_message.content.lower()

        assert "code analyst" in content or "briefing" in content

    def test_human_message_contains_injected_context_value(self):
        """Human message contains the injected context value."""
        test_context = "UNIQUE_CONTEXT_MARKER_12345"
        messages = BRIEFING_PROMPT.format_messages(
            context=test_context,
            module_count=1,
        )

        human_message = messages[1]
        assert test_context in human_message.content


class TestBriefingPromptConstants:
    """Tests for individual prompt constants."""

    def test_system_prompt_contains_module_naming_guidelines(self):
        """BRIEFING_SYSTEM_PROMPT mentions module naming guidance."""
        assert "module_count" in BRIEFING_SYSTEM_PROMPT
        assert "module" in BRIEFING_SYSTEM_PROMPT.lower()

    def test_human_prompt_uses_context_and_module_count_variables(self):
        """BRIEFING_HUMAN_PROMPT references both context and module_count."""
        assert "{context}" in BRIEFING_HUMAN_PROMPT
        assert "{module_count}" in BRIEFING_HUMAN_PROMPT


class TestGenerateBriefingHappyPath:
    """Tests for generate_briefing() happy path scenarios."""

    def test_happy_path_returns_briefing_result_with_all_fields(self):
        """Mock model returns valid BriefingResponse -> returns BriefingResult
        with all 3 fields.
        """
        briefing_response = BriefingResponse(
            summary="Test summary.",
            tech_stack=["Python"],
            security_posture="No issues.",
            modules=[
                ModuleDescription(
                    name="Core",
                    description="Core module.",
                    security_relevant=False,
                )
            ],
        )

        modules = [
            DetectedModule(
                files=["src/core.py"],
                primary_path="src/core.py",
                file_count=1,
                confidence=0.9,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=modules)

        # Mock the chain's invoke to return our response
        mock_chain = MagicMock()
        mock_chain.invoke.return_value = briefing_response

        # Patch the module-level BRIEFING_PROMPT so that | returns our mock chain
        with patch(
            "unfamiliar.llm.briefing.generator.BRIEFING_PROMPT",
        ) as mock_prompt:
            mock_prompt.__or__ = MagicMock(return_value=mock_chain)
            result = generate_briefing(analysis, MagicMock())

        assert isinstance(result, BriefingResult)
        assert result.briefing.summary == "Test summary."
        assert result.briefing.tech_stack == ["Python"]
        assert result.briefing.security_posture == "No issues."
        assert len(result.modules) == 1
        assert result.modules[0].name == "Core"
        assert isinstance(result.context_tokens_estimate, int)

    def test_context_tokens_estimate_equals_len_context_divided_by_chars_per_token(
        self,
    ):
        """context_tokens_estimate equals len(context) // CHARS_PER_TOKEN."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        # Create analysis with known content that will produce predictable context
        analysis = make_analysis_result(
            repo_path="/test/repo",
            modules=[],
        )

        with patch(
            "unfamiliar.llm.briefing.generator.build_briefing_context"
        ) as mock_build:
            # Create a context of known length
            known_context = "x" * 1000  # 1000 chars
            mock_build.return_value = known_context

            with _mock_briefing_chain(briefing_response):
                result = generate_briefing(analysis, MagicMock())

            expected_tokens = len(known_context) // CHARS_PER_TOKEN
            assert result.context_tokens_estimate == expected_tokens

    def test_module_count_matches_detected_module_entries(self):
        """Module count in BriefingResult.modules matches number of
        DetectedModule entries in input.
        """
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[
                ModuleDescription(
                    name=f"Module {i}",
                    description=f"Module {i} description.",
                    security_relevant=False,
                )
                for i in range(3)
            ],
        )

        detected_modules = [
            DetectedModule(
                files=[f"src/mod{i}.py"],
                primary_path=f"src/mod{i}.py",
                file_count=1,
                confidence=0.8,
                category_hints=[],
            )
            for i in range(3)
        ]
        analysis = make_analysis_result(modules=detected_modules)

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert len(result.modules) == 3

    def test_with_structured_output_chain_invoked_with_correct_input_data(self):
        """Verify with_structured_output chain is invoked with correct input_data."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(modules=[])
        analysis.repo_path = "/test/repo"

        with _mock_briefing_chain(briefing_response) as mock_chain:
            generate_briefing(analysis, MagicMock())

            # Verify invoke was called with correct input_data
            mock_chain.invoke.assert_called_once()
            call_args = mock_chain.invoke.call_args
            input_data = call_args[0][0] if call_args[0] else call_args[1].get("input")

            assert "context" in input_data
            assert "module_count" in input_data
            assert input_data["module_count"] == 0


class TestGenerateBriefingModulePadding:
    """Tests for module count mismatch handling."""

    def test_module_count_mismatch_padded_with_defaults(self):
        """Module count mismatch (LLM returns fewer modules) -> padded with defaults."""
        # LLM returns only 1 module but we expect 3
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[
                ModuleDescription(
                    name="First Module",
                    description="First module description.",
                    security_relevant=False,
                )
                # Missing modules 2 and 3
            ],
        )

        detected_modules = [
            DetectedModule(
                files=[f"src/mod{i}.py"],
                primary_path=f"src/primary{i}.py",
                file_count=1,
                confidence=0.8,
                category_hints=[],
            )
            for i in range(3)
        ]
        analysis = make_analysis_result(modules=detected_modules)

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        # Should have 3 modules (padded)
        assert len(result.modules) == 3

        # First module is from LLM
        assert result.modules[0].name == "First Module"

        # Second and third are padded defaults
        assert result.modules[1].name == "src/primary1.py"
        assert result.modules[1].description == "Description not provided by LLM."
        assert result.modules[1].security_relevant is False

        assert result.modules[2].name == "src/primary2.py"

    def test_module_padding_uses_primary_path_for_name(self):
        """Padded modules use primary_path from DetectedModule for name."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],  # LLM returns no modules
        )

        detected_modules = [
            DetectedModule(
                files=["src/auth/login.py", "src/auth/logout.py"],
                primary_path="src/auth",
                file_count=2,
                confidence=0.95,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=detected_modules)

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert len(result.modules) == 1
        assert result.modules[0].name == "src/auth"


class TestGenerateBriefingErrorHandling:
    """Tests for generate_briefing() error handling."""

    def test_auth_error_raises_authentication_error_not_wrapped(self):
        """Auth error from model -> raises AuthenticationError (not wrapped
        in BriefingGenerationError).
        """
        analysis = make_analysis_result(modules=[])

        # Create mock chain that raises auth error
        mock_chain = MagicMock()
        auth_error = Exception("Unauthorized: invalid api key")
        mock_chain.invoke.side_effect = auth_error

        with patch("unfamiliar.llm.briefing.generator.BRIEFING_PROMPT") as mock_prompt:
            mock_prompt.__or__ = MagicMock(return_value=mock_chain)

            with pytest.raises(AuthenticationError):
                generate_briefing(analysis, MagicMock())

    def test_transient_error_then_success_retries_and_returns_result(self):
        """Transient error then success -> retries and returns valid result."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(modules=[])

        # Track call count
        call_count = 0

        def invoke_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                # First call: transient error
                raise Exception("Service temporarily unavailable (503)")
            # Second call: success
            return briefing_response

        mock_chain = MagicMock()
        mock_chain.invoke.side_effect = invoke_side_effect

        with (
            patch("unfamiliar.llm.retry.time.sleep"),
            patch("unfamiliar.llm.briefing.generator.BRIEFING_PROMPT") as mock_prompt,
        ):
            mock_prompt.__or__ = MagicMock(return_value=mock_chain)

            result = generate_briefing(analysis, MagicMock())

        assert call_count == 2  # Retried once
        assert result.briefing.summary == "Summary."

    def test_permanent_failure_raises_briefing_generation_error_with_attempts(self):
        """Permanent failure after retries -> raises BriefingGenerationError
        with attempts attribute set.
        """
        mock_model = MagicMock()
        mock_structured = MagicMock()
        mock_model.with_structured_output.return_value = mock_structured

        # Always fail with transient error to exhaust retries
        mock_structured.invoke.side_effect = Exception("Service unavailable (503)")

        analysis = make_analysis_result(modules=[])

        with (
            patch("unfamiliar.llm.retry.time.sleep"),
            pytest.raises(BriefingGenerationError) as exc_info,
        ):
            generate_briefing(analysis, mock_model)

        assert exc_info.value.attempts >= 1

    def test_type_error_wrapped_in_briefing_generation_error(self):
        """TypeError when LLM returns wrong type -> wrapped in
        BriefingGenerationError.
        """
        mock_model = MagicMock()
        mock_structured = MagicMock()
        mock_model.with_structured_output.return_value = mock_structured

        # Return wrong type (string instead of BriefingResponse)
        mock_structured.invoke.return_value = "wrong type"

        analysis = make_analysis_result(modules=[])

        with pytest.raises(BriefingGenerationError) as exc_info:
            generate_briefing(analysis, mock_model)

        # The TypeError should be wrapped
        assert "TypeError" in str(exc_info.value) or "Expected BriefingResponse" in str(
            exc_info.value
        )


class TestGenerateBriefingEdgeCases:
    """Tests for generate_briefing() edge cases."""

    def test_very_small_codebase_produces_valid_briefing(self):
        """Very small codebase (< 5 files, 1 module) -> still produces
        valid briefing.
        """
        briefing_response = BriefingResponse(
            summary="Tiny codebase.",
            tech_stack=["Python"],
            security_posture="No issues.",
            modules=[
                ModuleDescription(
                    name="Main",
                    description="Single module.",
                    security_relevant=False,
                )
            ],
        )

        modules = [
            DetectedModule(
                files=["main.py"],
                primary_path="main.py",
                file_count=1,
                confidence=1.0,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(
            modules=modules,
            files=[],  # Minimal files
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert result.briefing.summary == "Tiny codebase."
        assert len(result.modules) == 1

    def test_empty_modules_in_analysis_produces_empty_modules_in_result(self):
        """Empty modules in analysis -> BriefingResult.modules is empty list,
        still valid.
        """
        briefing_response = BriefingResponse(
            summary="No modules.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(modules=[])

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert result.modules == []
        assert result.briefing.summary == "No modules."

    def test_empty_tech_stack_logged_warning_but_valid_result(self):
        """Empty tech_stack from LLM -> logged warning but valid result."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=[],  # Empty!
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(modules=[])

        with patch("unfamiliar.llm.briefing.generator.logger") as mock_logger:
            with _mock_briefing_chain(briefing_response):
                result = generate_briefing(analysis, MagicMock())

            # Should log warning about empty tech_stack
            mock_logger.warning.assert_called()
            # Check that one of the warning calls was about empty tech_stack
            warning_calls = [str(call) for call in mock_logger.warning.call_args_list]
            assert any("tech_stack" in call.lower() for call in warning_calls)

        # Result should still be valid
        assert result.briefing.tech_stack == []


class TestGenerateBriefingLogging:
    """Tests for logging behavior in generate_briefing()."""

    def test_logs_briefing_generation_start(self):
        """generate_briefing logs info at start with repo_path."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(repo_path="/my/special/repo", modules=[])

        with patch("unfamiliar.llm.briefing.generator.logger") as mock_logger:
            with _mock_briefing_chain(briefing_response):
                generate_briefing(analysis, MagicMock())

            # Check that info was called
            mock_logger.info.assert_called()
            # First call should include repo_path
            first_call_args = str(mock_logger.info.call_args_list[0])
            assert "/my/special/repo" in first_call_args

    def test_logs_context_token_estimate(self):
        """generate_briefing logs info about context token estimate."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(modules=[])

        with patch("unfamiliar.llm.briefing.generator.logger") as mock_logger:
            with _mock_briefing_chain(briefing_response):
                generate_briefing(analysis, MagicMock())

            info_calls = [str(call) for call in mock_logger.info.call_args_list]
            # Should have logged about tokens
            assert any("token" in call.lower() for call in info_calls)

    def test_logs_module_count_mismatch_warning(self):
        """generate_briefing logs warning when module count mismatches."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],  # Returns 0 but expects 2
        )

        detected_modules = [
            DetectedModule(
                files=[f"src/mod{i}.py"],
                primary_path=f"src/mod{i}.py",
                file_count=1,
                confidence=0.8,
                category_hints=[],
            )
            for i in range(2)
        ]
        analysis = make_analysis_result(modules=detected_modules)

        with patch("unfamiliar.llm.briefing.generator.logger") as mock_logger:
            with _mock_briefing_chain(briefing_response):
                generate_briefing(analysis, MagicMock())

            warning_calls = [str(call) for call in mock_logger.warning.call_args_list]
            assert any("module" in call.lower() for call in warning_calls)


class TestGenerateBriefingTokenBudget:
    """Tests for token_budget parameter handling."""

    def test_custom_token_budget_passed_to_context_builder(self):
        """Custom token_budget is passed to build_briefing_context."""
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(modules=[])

        with patch(
            "unfamiliar.llm.briefing.generator.build_briefing_context"
        ) as mock_build:
            mock_build.return_value = "test context"

            with _mock_briefing_chain(briefing_response):
                generate_briefing(analysis, MagicMock(), token_budget=25000)

            # Verify token_budget was passed
            call_args = mock_build.call_args
            assert call_args[1].get("token_budget") == 25000 or call_args[0][1] == 25000

    def test_default_token_budget_used_when_not_specified(self):
        """Default token_budget (DEFAULT_TOKEN_BUDGET) used when not specified."""
        from unfamiliar.constants import DEFAULT_TOKEN_BUDGET

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        analysis = make_analysis_result(modules=[])

        with patch(
            "unfamiliar.llm.briefing.generator.build_briefing_context"
        ) as mock_build:
            mock_build.return_value = "test context"

            with _mock_briefing_chain(briefing_response):
                generate_briefing(analysis, MagicMock())

            call_args = mock_build.call_args
            # Check positional or keyword argument
            passed_budget = (
                call_args[1].get("token_budget") if call_args[1] else call_args[0][1]
            )
            assert passed_budget == DEFAULT_TOKEN_BUDGET


class TestBriefingContentMatchesAnalysis:
    """Tests verifying briefing content is derived from input analysis.

    These tests verify that the briefing output contains content that
    is derived from and matches the input analysis data (Card 5.1).
    """

    def test_module_count_matches_detected_modules(self):
        """BriefingResult.modules count matches analysis.modules count."""
        detected_modules = [
            DetectedModule(
                files=[f"src/mod{i}.py"],
                primary_path=f"src/mod{i}.py",
                file_count=1,
                confidence=0.8,
                category_hints=[],
            )
            for i in range(5)
        ]
        analysis = make_analysis_result(modules=detected_modules)

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[
                ModuleDescription(
                    name=f"Module {i}",
                    description=f"Module {i} description.",
                    security_relevant=False,
                )
                for i in range(5)
            ],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert len(result.modules) == 5

    def test_module_names_derived_from_detected_module_paths(self):
        """Module names are derived from DetectedModule primary_path when padding."""
        detected_modules = [
            DetectedModule(
                files=["src/auth/login.py"],
                primary_path="src/auth",
                file_count=1,
                confidence=0.9,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=detected_modules)

        # LLM returns empty modules - should pad using primary_path
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert len(result.modules) == 1
        assert result.modules[0].name == "src/auth"

    def test_context_tokens_estimate_matches_context_length(self):
        """context_tokens_estimate equals len(context) // CHARS_PER_TOKEN."""
        analysis = make_analysis_result(
            repo_path="/test/repo",
            languages={"python": 10, "typescript": 5},
        )

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python", "TypeScript"],
            security_posture="OK",
            modules=[],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        # Verify token estimate is calculated correctly
        from unfamiliar.llm.context import build_briefing_context

        context = build_briefing_context(analysis)
        expected_tokens = len(context) // CHARS_PER_TOKEN

        assert result.context_tokens_estimate == expected_tokens

    def test_briefing_summary_is_non_empty_string(self):
        """Briefing summary is a non-empty string."""
        analysis = make_analysis_result(modules=[])
        briefing_response = BriefingResponse(
            summary="This is a valid summary of the codebase.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert isinstance(result.briefing.summary, str)
        assert len(result.briefing.summary) > 0

    def test_tech_stack_list_contains_valid_entries(self):
        """Tech stack is a list of non-empty strings."""
        analysis = make_analysis_result(modules=[])
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python", "FastAPI", "PostgreSQL", "Docker"],
            security_posture="OK",
            modules=[],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert isinstance(result.briefing.tech_stack, list)
        assert len(result.briefing.tech_stack) == 4
        for item in result.briefing.tech_stack:
            assert isinstance(item, str)
            assert len(item) > 0

    def test_security_posture_is_non_empty_string(self):
        """Security posture is a non-empty string."""
        analysis = make_analysis_result(modules=[])
        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="No significant security concerns identified.",
            modules=[],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert isinstance(result.briefing.security_posture, str)
        assert len(result.briefing.security_posture) > 0

    def test_module_description_is_non_empty_string(self):
        """Each module has a non-empty description string."""
        analysis = make_analysis_result(modules=[
            DetectedModule(
                files=["src/core.py"],
                primary_path="src/core.py",
                file_count=1,
                confidence=0.9,
                category_hints=[],
            )
        ])

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[
                ModuleDescription(
                    name="Core Module",
                    description="This module handles core business logic.",
                    security_relevant=False,
                )
            ],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert len(result.modules) == 1
        assert isinstance(result.modules[0].description, str)
        assert len(result.modules[0].description) > 0

    def test_module_security_relevant_is_boolean(self):
        """Module security_relevant is a boolean."""
        analysis = make_analysis_result(modules=[
            DetectedModule(
                files=["src/auth.py"],
                primary_path="src/auth.py",
                file_count=1,
                confidence=0.9,
                category_hints=[],
            )
        ])

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[
                ModuleDescription(
                    name="Auth Module",
                    description="Handles authentication.",
                    security_relevant=True,
                )
            ],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert isinstance(result.modules[0].security_relevant, bool)
        assert result.modules[0].security_relevant is True


class TestBriefingModulePaddingEdgeCases:
    """Tests for module padding with various edge cases."""

    def test_padding_when_llm_returns_zero_modules(self):
        """All modules are padded when LLM returns zero modules."""
        detected_modules = [
            DetectedModule(
                files=[f"src/mod{i}.py"],
                primary_path=f"src/primary{i}.py",
                file_count=1,
                confidence=0.8,
                category_hints=[],
            )
            for i in range(3)
        ]
        analysis = make_analysis_result(modules=detected_modules)

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],  # LLM returns nothing
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert len(result.modules) == 3
        for i, mod in enumerate(result.modules):
            assert "primary" in mod.name or f"{i}" in mod.name

    def test_padding_uses_fallback_name_for_missing_primary_path(self):
        """Padding uses 'Module N' when primary_path is None."""
        detected_modules = [
            DetectedModule(
                files=["src/mod.py"],
                primary_path=None,  # Missing
                file_count=1,
                confidence=0.5,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=detected_modules)

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert len(result.modules) == 1
        # Should use fallback naming
        assert "Module" in result.modules[0].name

    def test_padded_modules_have_correct_default_description(self):
        """Padded modules have 'Description not provided by LLM.' description."""
        detected_modules = [
            DetectedModule(
                files=["src/core.py"],
                primary_path="src/core.py",
                file_count=1,
                confidence=0.9,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=detected_modules)

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],  # Force padding
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert result.modules[0].description == "Description not provided by LLM."

    def test_padded_modules_have_security_relevant_false(self):
        """Padded modules have security_relevant=False by default."""
        detected_modules = [
            DetectedModule(
                files=["src/utils.py"],
                primary_path="src/utils.py",
                file_count=1,
                confidence=0.5,
                category_hints=[],
            )
        ]
        analysis = make_analysis_result(modules=detected_modules)

        briefing_response = BriefingResponse(
            summary="Summary.",
            tech_stack=["Python"],
            security_posture="OK",
            modules=[],
        )

        with _mock_briefing_chain(briefing_response):
            result = generate_briefing(analysis, MagicMock())

        assert result.modules[0].security_relevant is False
