"""Tests for mission generator.

Tests verify:
- generate_category_missions() handles happy path, errors, and edge cases
- Mock build_mission_context, build_module_descriptions, with_llm_retry
- Error handling for auth, transient, and permanent failures
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from tests.conftest import (
    make_analysis_result,
    make_briefing_result,
    make_category_response,
    make_mission_item,
    mock_mission_chain,
)
from unfamiliar.constants import InvestigationCategory
from unfamiliar.llm.exceptions import AuthenticationError, MissionGenerationError
from unfamiliar.llm.missions.generator import generate_category_missions
from unfamiliar.llm.missions.schemas import CategoryMissionsResponse


class ExceptionWithStatusCode(Exception):
    """Test exception with status_code attribute."""

    def __init__(self, message: str, status_code: int) -> None:
        super().__init__(message)
        self.status_code = status_code


class TestGenerateCategoryMissionsHappyPath:
    """Tests for generate_category_missions() happy path scenarios."""

    def test_happy_path_returns_valid_category_missions_response(self):
        """Mock model returns valid CategoryMissionsResponse with missions."""
        missions = [make_mission_item(title_plain="Test Mission 1")]
        response = make_category_response(missions=missions)
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            result = generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.ARCHITECTURE,
                model=mock_model,
            )

        assert isinstance(result, CategoryMissionsResponse)
        assert len(result.missions) == 1
        assert result.missions[0].title_plain == "Test Mission 1"

    def test_model_invoked_with_correct_input_data(self):
        """Verify the structured output chain is invoked with correct input_data."""
        missions = [make_mission_item()]
        response = make_category_response(missions=missions)

        mock_model = MagicMock()
        analysis = make_analysis_result(repo_path="/test/repo")
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response) as mock_chain,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test module descriptions"

            generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.DATA_FLOW,
                model=mock_model,
            )

        # Verify invoke was called
        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 "category" in input_data
        assert "briefing_summary" in input_data
        assert "category_context" in input_data
        assert "module_descriptions" in input_data
        assert "previous_missions" in input_data
        assert "Data Flow" in input_data["category"]

    def test_previous_missions_parameter_passed_correctly(self):
        """generate_category_missions receives and uses previous_missions list."""
        missions = [make_mission_item(title_plain="New Mission")]
        response = make_category_response(missions=missions)

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        previous_missions = ["Mission A", "Mission B", "Mission C"]

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response) as mock_chain,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.TESTING,
                model=mock_model,
                previous_missions=previous_missions,
            )

            # Verify build_mission_context received previous_missions
            ctx_call_args = mock_ctx.call_args
            assert ctx_call_args[1]["previous_missions"] == previous_missions

        # Verify input_data includes previous_missions
        invoke_call_args = mock_chain.invoke.call_args
        input_data = invoke_call_args[0][0]
        assert "Mission A" in input_data["previous_missions"]
        assert "Mission B" in input_data["previous_missions"]


class TestGenerateCategoryMissionsAllCategories:
    """Tests verifying all InvestigationCategory values are accepted."""

    @pytest.mark.parametrize(
        "category",
        [
            InvestigationCategory.ARCHITECTURE,
            InvestigationCategory.DATA_FLOW,
            InvestigationCategory.DOMAIN_BUSINESS_LOGIC,
            InvestigationCategory.DEPENDENCIES,
            InvestigationCategory.CONVENTIONS,
            InvestigationCategory.TESTING,
            InvestigationCategory.TECHNICAL_DEBT,
        ],
    )
    def test_each_category_accepted_and_produces_valid_response(self, category):
        """Each InvestigationCategory value is accepted and produces valid response."""
        missions = [make_mission_item(title_plain=f"{category.value} mission")]
        response = make_category_response(missions=missions)
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            result = generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=category,
                model=mock_model,
            )

        assert isinstance(result, CategoryMissionsResponse)
        assert len(result.missions) == 1


class TestGenerateCategoryMissionsAuthError:
    """Tests for authentication error handling."""

    def test_auth_error_raises_immediately_not_wrapped(self):
        """Auth error from model raises AuthenticationError immediately."""
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(None) as mock_chain,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"
            mock_chain.invoke.side_effect = ExceptionWithStatusCode("Unauthorized", 401)

            with pytest.raises(AuthenticationError):
                generate_category_missions(
                    analysis=analysis,
                    briefing_result=briefing_result,
                    category=InvestigationCategory.ARCHITECTURE,
                    model=mock_model,
                )


class TestGenerateCategoryMissionsTransientErrors:
    """Tests for transient error retry behavior."""

    def test_transient_error_then_success_retries_and_returns_result(self):
        """Transient error then success retries via with_llm_retry and returns."""
        missions = [make_mission_item()]
        response = make_category_response(missions=missions)

        call_count = 0

        def invoke_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise ExceptionWithStatusCode("Service unavailable", 503)
            return response

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch("unfamiliar.llm.retry.time.sleep"),
            mock_mission_chain(None) as mock_chain,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"
            mock_chain.invoke.side_effect = invoke_side_effect

            result = generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.ARCHITECTURE,
                model=mock_model,
            )

        assert call_count == 2
        assert isinstance(result, CategoryMissionsResponse)
        assert len(result.missions) == 1


class TestGenerateCategoryMissionsPermanentFailure:
    """Tests for permanent failure handling."""

    def test_permanent_failure_raises_mission_generation_error_with_category(self):
        """Permanent failure raises MissionGenerationError with category attribute."""
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch("unfamiliar.llm.retry.time.sleep"),
            patch(
                "unfamiliar.llm.structured.create_structured_chain"
            ) as mock_create_chain,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            # Create a mock chain that always raises 503
            mock_chain = MagicMock()
            mock_chain.invoke.side_effect = ExceptionWithStatusCode(
                "Service unavailable", 503
            )
            mock_create_chain.return_value = mock_chain

            with pytest.raises(MissionGenerationError) as exc_info:
                generate_category_missions(
                    analysis=analysis,
                    briefing_result=briefing_result,
                    category=InvestigationCategory.TESTING,
                    model=mock_model,
                    provider_name="glm",
                )

        assert exc_info.value.category == "testing"

    def test_attempts_attribute_reflects_retry_count(self):
        """MissionGenerationError.attempts reflects number of retry attempts made."""
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch("unfamiliar.llm.retry.time.sleep"),
            patch(
                "unfamiliar.llm.structured.create_structured_chain"
            ) as mock_create_chain,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            # Create a mock chain that always raises 503
            mock_chain = MagicMock()
            mock_chain.invoke.side_effect = ExceptionWithStatusCode(
                "Service unavailable", 503
            )
            mock_create_chain.return_value = mock_chain

            with pytest.raises(MissionGenerationError) as exc_info:
                generate_category_missions(
                    analysis=analysis,
                    briefing_result=briefing_result,
                    category=InvestigationCategory.ARCHITECTURE,
                    model=mock_model,
                    token_budget=10000,
                )
        # Should have retried at least once
        assert exc_info.value.attempts >= 1


class TestGenerateCategoryMissionsEmptyResponse:
    """Tests for empty mission list handling."""

    def test_llm_returns_zero_missions_raises_mission_generation_error(self):
        """LLM returns 0 missions (empty list) raises MissionGenerationError."""
        response = make_category_response(missions=[])
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            with pytest.raises(MissionGenerationError) as exc_info:
                generate_category_missions(
                    analysis=analysis,
                    briefing_result=briefing_result,
                    category=InvestigationCategory.ARCHITECTURE,
                    model=mock_model,
                )

        assert "no missions" in str(exc_info.value).lower()


class TestGenerateCategoryMissionsEdgeCases:
    """Tests for edge case scenarios."""

    def test_very_small_codebase_produces_valid_briefing(self):
        """Very small codebase (< 5 files, 1 module) still produces valid response."""
        missions = [make_mission_item(title_plain="Small Mission")]
        response = make_category_response(missions=missions)
        mock_model = MagicMock()

        from unfamiliar.analysis.models import DetectedModule

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

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response),
        ):
            mock_ctx.return_value = "minimal context"
            mock_mods.return_value = "minimal modules"

            result = generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.ARCHITECTURE,
                model=mock_model,
            )

        assert isinstance(result, CategoryMissionsResponse)
        assert len(result.missions) == 1

    def test_empty_modules_in_analysis_still_valid(self):
        """Empty modules in analysis -> BriefingResult.modules is empty list."""
        missions = [make_mission_item(title_plain="Empty Module Mission")]
        response = make_category_response(missions=missions)
        mock_model = MagicMock()

        analysis = make_analysis_result(modules=[])
        briefing_result = make_briefing_result(modules=[])

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "no modules"

            result = generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.ARCHITECTURE,
                model=mock_model,
            )

        assert isinstance(result, CategoryMissionsResponse)


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

    def test_custom_token_budget_passed_to_context_builder(self):
        """Custom token_budget is passed to build_mission_context."""
        missions = [make_mission_item()]
        response = make_category_response(missions=missions)
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.ARCHITECTURE,
                model=mock_model,
                token_budget=25000,
            )

            call_args = mock_ctx.call_args
            assert call_args[1].get("token_budget") == 25000

    def test_previous_missions_default_empty_list(self):
        """previous_missions defaults to empty list when not provided."""
        missions = [make_mission_item()]
        response = make_category_response(missions=missions)
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            mock_mission_chain(response),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            generate_category_missions(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.ARCHITECTURE,
                model=mock_model,
                # No previous_missions provided
            )

            call_args = mock_ctx.call_args
            assert call_args[1].get("previous_missions") == []


class TestGenerateCategoryMissionsMulti:
    """Tests for generate_category_missions_multi() multi-call behavior.

    Tests verify:
    - Single call completes without truncation
    - Truncation triggers continuation calls
    - Duplicate titles are deduplicated across calls
    - Partial results returned when later calls fail
    - MissionGenerationError when all calls fail with no missions
    - AuthenticationError propagates immediately without wrapping
    """

    def test_single_call_completes_returns_missions(self):
        """Single LLM call completes (not truncated) returns missions."""
        from unfamiliar.llm.missions.generator import generate_category_missions_multi
        from unfamiliar.llm.structured import TruncationResult

        missions = [
            make_mission_item(title_plain="Architecture Overview"),
            make_mission_item(title_plain="Module Boundaries"),
        ]
        response = make_category_response(missions=missions)
        mock_model = MagicMock()

        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch(
                "unfamiliar.llm.missions.generator.detect_truncation"
            ) as mock_truncation,
            patch(
                "unfamiliar.llm.missions.generator.create_structured_chain"
            ) as mock_create,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"
            mock_truncation.return_value = TruncationResult(
                recovered_data=response,
                is_truncated=False,
            )

            mock_chain = MagicMock()
            mock_chain.invoke.return_value = response
            mock_create.return_value = mock_chain

            result = generate_category_missions_multi(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.ARCHITECTURE,
                model=mock_model,
            )

        assert isinstance(result, CategoryMissionsResponse)
        assert len(result.missions) == 2
        assert result.missions[0].title_plain == "Architecture Overview"
        assert result.missions[1].title_plain == "Module Boundaries"
        assert len(result.chains) == 0
        mock_chain.invoke.assert_called_once()

    def test_truncation_triggers_second_call(self):
        """Truncated response triggers second call with continue_from hint."""
        from unfamiliar.llm.missions.generator import generate_category_missions_multi
        from unfamiliar.llm.structured import TruncationResult

        call1_missions = [
            make_mission_item(title_plain="First Mission"),
            make_mission_item(title_plain="Second Mission"),
        ]
        call1_response = make_category_response(missions=call1_missions)

        call2_missions = [
            make_mission_item(title_plain="Third Mission"),
        ]
        call2_response = make_category_response(missions=call2_missions)

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        invoke_count = 0

        def invoke_side_effect(input_data):
            nonlocal invoke_count
            invoke_count += 1
            if invoke_count == 1:
                return call1_response
            return call2_response

        truncation_count = 0

        def truncation_side_effect(text, schema):
            nonlocal truncation_count
            truncation_count += 1
            if truncation_count == 1:
                return TruncationResult(
                    recovered_data=call1_response,
                    is_truncated=True,
                )
            return TruncationResult(
                recovered_data=call2_response,
                is_truncated=False,
            )

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch(
                "unfamiliar.llm.missions.generator.detect_truncation"
            ) as mock_truncation,
            patch(
                "unfamiliar.llm.missions.generator.create_structured_chain"
            ) as mock_create,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"
            mock_truncation.side_effect = truncation_side_effect

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

            result = generate_category_missions_multi(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.DATA_FLOW,
                model=mock_model,
            )

        assert isinstance(result, CategoryMissionsResponse)
        assert len(result.missions) == 3
        assert invoke_count == 2

    def test_duplicate_titles_across_calls_keeps_first_only(self):
        """Duplicate titles across calls are deduplicated, keeping first occurrence."""
        from unfamiliar.llm.missions.generator import generate_category_missions_multi
        from unfamiliar.llm.structured import TruncationResult

        # Call 1: Mission A, Mission B
        call1_missions = [
            make_mission_item(title_plain="Mission A", description="First A"),
            make_mission_item(title_plain="Mission B", description="First B"),
        ]
        call1_response = make_category_response(missions=call1_missions)

        # Call 2: Mission B (duplicate), Mission C
        call2_missions = [
            make_mission_item(title_plain="Mission B", description="Second B"),
            make_mission_item(title_plain="Mission C", description="First C"),
        ]
        call2_response = make_category_response(missions=call2_missions)

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        invoke_count = 0

        def invoke_side_effect(input_data):
            nonlocal invoke_count
            invoke_count += 1
            if invoke_count == 1:
                return call1_response
            return call2_response

        truncation_count = 0

        def truncation_side_effect(text, schema):
            nonlocal truncation_count
            truncation_count += 1
            if truncation_count == 1:
                return TruncationResult(
                    recovered_data=call1_response,
                    is_truncated=True,
                )
            return TruncationResult(
                recovered_data=call2_response,
                is_truncated=False,
            )

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch(
                "unfamiliar.llm.missions.generator.detect_truncation"
            ) as mock_truncation,
            patch(
                "unfamiliar.llm.missions.generator.create_structured_chain"
            ) as mock_create,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"
            mock_truncation.side_effect = truncation_side_effect

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

            result = generate_category_missions_multi(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.TESTING,
                model=mock_model,
            )

        # Should have 3 unique missions (A, B from call1, C from call2)
        # Mission B from call2 should be discarded as duplicate
        assert len(result.missions) == 3
        titles = [m.title_plain for m in result.missions]
        assert titles == ["Mission A", "Mission B", "Mission C"]
        # Verify first Mission B is kept (description should be "First B")
        mission_b = next(m for m in result.missions if m.title_plain == "Mission B")
        assert mission_b.description == "First B"

    def test_all_calls_fail_with_partial_missions_returns_partial(self):
        """All calls fail but have partial missions from earlier calls returns
        partial.
        """
        from unfamiliar.llm.missions.generator import generate_category_missions_multi
        from unfamiliar.llm.structured import TruncationResult

        call1_missions = [
            make_mission_item(title_plain="Partial Mission 1"),
            make_mission_item(title_plain="Partial Mission 2"),
        ]
        call1_response = make_category_response(missions=call1_missions)

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        invoke_count = 0

        def invoke_side_effect(input_data):
            nonlocal invoke_count
            invoke_count += 1
            if invoke_count == 1:
                return call1_response
            # Second call fails with non-auth error
            raise ExceptionWithStatusCode("Service unavailable", 503)

        truncation_count = 0

        def truncation_side_effect(text, schema):
            nonlocal truncation_count
            truncation_count += 1
            # First call is truncated to trigger second call
            return TruncationResult(
                recovered_data=call1_response,
                is_truncated=True,
            )

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch(
                "unfamiliar.llm.missions.generator.detect_truncation"
            ) as mock_truncation,
            patch(
                "unfamiliar.llm.missions.generator.create_structured_chain"
            ) as mock_create,
            patch("unfamiliar.llm.retry.time.sleep"),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"
            mock_truncation.side_effect = truncation_side_effect

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

            result = generate_category_missions_multi(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.CONVENTIONS,
                model=mock_model,
            )

        # Should return partial results (2 missions from first call)
        assert isinstance(result, CategoryMissionsResponse)
        assert len(result.missions) == 2
        assert result.missions[0].title_plain == "Partial Mission 1"
        assert result.missions[1].title_plain == "Partial Mission 2"

    def test_all_calls_fail_no_missions_raises_error(self):
        """All calls fail with no missions collected raises MissionGenerationError."""
        from unfamiliar.llm.missions.generator import generate_category_missions_multi

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch(
                "unfamiliar.llm.missions.generator.create_structured_chain"
            ) as mock_create,
            patch("unfamiliar.llm.retry.time.sleep"),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            mock_chain = MagicMock()
            mock_chain.invoke.side_effect = ExceptionWithStatusCode(
                "Service unavailable", 503
            )
            mock_create.return_value = mock_chain

            with pytest.raises(MissionGenerationError) as exc_info:
                generate_category_missions_multi(
                    analysis=analysis,
                    briefing_result=briefing_result,
                    category=InvestigationCategory.TESTING,
                    model=mock_model,
                    provider_name="test",
                )

        assert exc_info.value.category == "testing"

    def test_auth_error_propagates_unwrapped(self):
        """Auth error (401) propagages as Authentication error without wrapping.

        The interface contract states the AuthenticationError should be raised
        immediately without retry.
        """
        from unfamiliar.llm.missions.generator import generate_category_missions_multi

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch(
                "unfamiliar.llm.missions.generator.create_structured_chain"
            ) as mock_create,
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"

            mock_chain = MagicMock()
            mock_chain.invoke.side_effect = ExceptionWithStatusCode("Unauthorized", 401)
            mock_create.return_value = mock_chain

            with pytest.raises(AuthenticationError) as exc_info:
                generate_category_missions_multi(
                    analysis=analysis,
                    briefing_result=briefing_result,
                    category=InvestigationCategory.ARCHITECTURE,
                    model=mock_model,
                )

        # Verify the error contains authentication context
        error_message = str(exc_info.value).lower()
        assert "auth" in error_message or "401" in str(exc_info.value.__cause__)

    def test_unexpected_error_with_partial_results_includes_warning(self):
        """Unexpected error after collecting missions returns partial
        results with warning.
        """
        from unfamiliar.llm.missions.generator import generate_category_missions_multi
        from unfamiliar.llm.structured import TruncationResult

        call1_missions = [
            make_mission_item(title_plain="Recovered Mission"),
        ]
        call1_response = make_category_response(missions=call1_missions)

        mock_model = MagicMock()
        analysis = make_analysis_result()
        briefing_result = make_briefing_result()

        invoke_count = 0

        def invoke_side_effect(input_data):
            nonlocal invoke_count
            invoke_count += 1
            if invoke_count == 1:
                return call1_response
            raise RuntimeError("Something unexpected went wrong")

        with (
            patch(
                "unfamiliar.llm.missions.generator.build_mission_context"
            ) as mock_ctx,
            patch(
                "unfamiliar.llm.missions.generator.build_module_descriptions"
            ) as mock_mods,
            patch(
                "unfamiliar.llm.missions.generator.detect_truncation"
            ) as mock_truncation,
            patch(
                "unfamiliar.llm.missions.generator.create_structured_chain"
            ) as mock_create,
            patch("unfamiliar.llm.retry.time.sleep"),
        ):
            mock_ctx.return_value = "test context"
            mock_mods.return_value = "test modules"
            mock_truncation.return_value = TruncationResult(
                recovered_data=call1_response,
                is_truncated=True,  # Triggers second call
            )

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

            result = generate_category_missions_multi(
                analysis=analysis,
                briefing_result=briefing_result,
                category=InvestigationCategory.CONVENTIONS,
                model=mock_model,
            )

        # Should return partial results
        assert len(result.missions) == 1
        assert result.missions[0].title_plain == "Recovered Mission"
        # Should include a warning about the error
        assert len(result.warnings) == 1
        assert "Something unexpected went wrong" in result.warnings[0]
