"""Integration test for pipeline → persister data flow (SEAM-001).

Tests the real data contract between:
- run_mission_pipeline() produces PipelineResult
- persist_pipeline_data() consumes PipelineResult
- Database records match what was generated

This test will FAIL if the schema contract between pipeline and persister breaks.
"""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from tests.conftest import (
    make_analysis_result,
    make_category_response,
    make_mission_item,
)
from unfamiliar.analysis.models import DetectedModule
from unfamiliar.campaign.persister import persist_briefing_data, persist_pipeline_data
from unfamiliar.constants import CampaignStatus, InvestigationCategory, MissionStatus
from unfamiliar.db import open_campaign_db
from unfamiliar.llm import BriefingResponse, MissionGenerationError
from unfamiliar.llm.briefing import ModuleDescription
from unfamiliar.llm.briefing.generator import BriefingResult
from unfamiliar.llm.missions.pipeline import run_mission_pipeline
from unfamiliar.llm.missions.schemas import ChainResponse, MissionFileRefResponse
from unfamiliar.llm.missions.validation import ValidationResult
from unfamiliar.models import Campaign


class TestPipelinePersisterIntegration:
    """Integration tests for the pipeline → persister boundary.

    These tests use REAL database, REAL pipeline logic, REAL persister.
    Only the LLM call is mocked to return deterministic data.
    """

    def _setup_campaign_with_module(
        self, db, campaign, repo_path: str = "/tmp/test-repo"
    ) -> tuple:
        """Common setup: create analysis, briefing, persist module.

        Returns (analysis, briefing_result, persisted_modules) tuple
        ready for use in pipeline tests.
        """
        analysis = make_analysis_result(
            repo_path="/tmp/test-repo",
            modules=[
                DetectedModule(primary_path="src/", file_count=1, category_hints=[])
            ],
        )

        briefing_result = BriefingResult(
            briefing=BriefingResponse(
                summary="Test.",
                tech_stack=["Python"],
                security_posture="OK",
                modules=[
                    ModuleDescription(
                        name="Core", description="Core.", security_relevant=False
                    )
                ],
            ),
            modules=[
                ModuleDescription(
                    name="Core", description="Core.", security_relevant=False
                )
            ],
            context_tokens_estimate=100,
        )

        persisted_modules = persist_briefing_data(
            db=db,
            campaign_id=campaign.id,
            briefing_result=briefing_result,
            analysis=analysis,
        )

        return analysis, briefing_result, persisted_modules

    @pytest.fixture
    def campaign_db(self, tmp_path: Path):
        """Create a real campaign database in a temp directory."""
        campaign_dir = tmp_path / "test-campaign"
        campaign_dir.mkdir(parents=True)

        with open_campaign_db(campaign_dir, repo_path="/tmp/test-repo") as db:
            # Create the campaign record
            campaign = Campaign(
                repo_path="/tmp/test-repo",
                status=CampaignStatus.INITIALIZING,
            )
            created_campaign = db.campaign_repo.create(campaign)
            yield db, created_campaign, campaign_dir

    def test_pipeline_to_persister_single_category(
        self, campaign_db, mock_checkpointer
    ):
        """Pipeline output can be persisted - single category with one mission.

        This test exercises:
        1. run_mission_pipeline() with real AnalysisResult
        2. Mocked LLM returning fixed CategoryMissionsResponse
        3. persist_pipeline_data() storing to real DB
        4. DB records matching what was in the response
        """
        db, campaign, campaign_dir = campaign_db

        # AnalysisResult with one detected module (required for module_index linking)
        analysis = make_analysis_result(
            repo_path="/tmp/test-repo",
            modules=[
                DetectedModule(
                    primary_path="src/main.py",
                    file_count=1,
                    category_hints=[],
                )
            ],
        )

        # BriefingResult with one module description
        briefing_result = BriefingResult(
            briefing=BriefingResponse(
                summary="Test codebase.",
                tech_stack=["Python"],
                security_posture="No concerns.",
                modules=[
                    ModuleDescription(
                        name="Main Module",
                        description="The main module.",
                        security_relevant=False,
                    )
                ],
            ),
            modules=[
                ModuleDescription(
                    name="Main Module",
                    description="The main module.",
                    security_relevant=False,
                )
            ],
            context_tokens_estimate=100,
        )

        # First persist the briefing to get modules with IDs
        persisted_modules = persist_briefing_data(
            db=db,
            campaign_id=campaign.id,
            briefing_result=briefing_result,
            analysis=analysis,
        )
        assert len(persisted_modules) == 1

        # Create the mission we expect to see persisted
        expected_mission = make_mission_item(
            title="Investigate the Main Entry Point",
            title_plain="Investigate the Main Entry Point",
            description="Understand how the application starts.",
            difficulty="quick",
            module_index=0,  # Links to first module
            prerequisite_titles=[],  # Root mission -> AVAILABLE status
        )

        expected_chain = ChainResponse(
            name="Entry Point Chain",
            name_plain="Entry Point Chain",
            description="Follow the entry point flow.",
            mission_titles=["Investigate the Main Entry Point"],
        )

        mock_response = make_category_response(
            missions=[expected_mission],
            chains=[expected_chain],
        )

        with (
            patch(
                "unfamiliar.llm.missions.pipeline.generate_category_missions_multi"
            ) as mock_gen,
            patch(
                "unfamiliar.llm.missions.pipeline.validate_category_missions"
            ) as mock_val,
        ):
            # Mock returns our fixed response
            mock_gen.return_value = mock_response

            # Validation passes through unchanged (no file validation in this test)
            mock_val.return_value = ValidationResult(
                missions=[expected_mission],
                chains=[expected_chain],
                warnings=[],
                fixes_applied=[],
                valid=True,
            )

            # Run REAL pipeline (only LLM call is mocked)
            pipeline_result = run_mission_pipeline(
                analysis=analysis,
                briefing_result=briefing_result,
                model=MagicMock(),  # generate_category_missions_multi is mocked
                campaign_dir=campaign_dir,
                categories=[InvestigationCategory.ARCHITECTURE],  # Single category
            )

        assert len(pipeline_result.completed_categories) == 1
        assert pipeline_result.completed_categories[0] == "architecture"
        assert "architecture" in pipeline_result.category_missions
        assert len(pipeline_result.errors) == 0

        persist_result = persist_pipeline_data(
            db=db,
            campaign_id=campaign.id,
            pipeline_result=pipeline_result,
            modules=persisted_modules,
            repo_path=Path("/tmp/test-repo"),
        )

        assert persist_result.mission_count == 1
        assert persist_result.chain_count == 1
        assert len(persist_result.warning_messages) == 0

        # Query DB directly to verify the contract
        missions = db.mission_repo.get_by_campaign(campaign.id)
        assert len(missions) == 1

        persisted_mission = missions[0]
        assert persisted_mission.title_plain == "Investigate the Main Entry Point"
        assert persisted_mission.category == InvestigationCategory.ARCHITECTURE
        assert persisted_mission.status == MissionStatus.AVAILABLE  # Root mission
        assert persisted_mission.difficulty.value == "quick"

        # Verify chain was persisted
        chains = db.chain_repo.get_by_campaign(campaign.id)
        assert len(chains) == 1
        assert chains[0].name_plain == "Entry Point Chain"

    def test_pipeline_to_persister_with_prerequisites(
        self, campaign_db, mock_checkpointer
    ):
        """Pipeline output with prerequisites - LOCKED status for non-root missions.

        This verifies:
        1. Missions with prerequisites get LOCKED status
        2. Prerequisite relationships are persisted correctly
        """
        db, campaign, campaign_dir = campaign_db

        analysis = make_analysis_result(
            repo_path="/tmp/test-repo",
            modules=[
                DetectedModule(primary_path="src/", file_count=1, category_hints=[])
            ],
        )

        briefing_result = BriefingResult(
            briefing=BriefingResponse(
                summary="Test.",
                tech_stack=["Python"],
                security_posture="OK",
                modules=[
                    ModuleDescription(
                        name="Core", description="Core module.", security_relevant=False
                    )
                ],
            ),
            modules=[
                ModuleDescription(
                    name="Core", description="Core module.", security_relevant=False
                )
            ],
            context_tokens_estimate=100,
        )

        persisted_modules = persist_briefing_data(
            db=db,
            campaign_id=campaign.id,
            briefing_result=briefing_result,
            analysis=analysis,
        )

        # Root mission (no prerequisites)
        root_mission = make_mission_item(
            title="Start Here",
            title_plain="Start Here",
            module_index=0,
            prerequisite_titles=[],
        )

        # Dependent mission (has prerequisite)
        dependent_mission = make_mission_item(
            title="Go Deeper",
            title_plain="Go Deeper",
            module_index=0,
            prerequisite_titles=["Start Here"],  # References root
        )

        mock_response = make_category_response(
            missions=[root_mission, dependent_mission],
            chains=[],
        )

        with (
            patch(
                "unfamiliar.llm.missions.pipeline.generate_category_missions_multi"
            ) as mock_gen,
            patch(
                "unfamiliar.llm.missions.pipeline.validate_category_missions"
            ) as mock_val,
        ):
            mock_gen.return_value = mock_response
            mock_val.return_value = ValidationResult(
                missions=[root_mission, dependent_mission],
                chains=[],
                warnings=[],
                fixes_applied=[],
                valid=True,
            )

            pipeline_result = run_mission_pipeline(
                analysis=analysis,
                briefing_result=briefing_result,
                model=MagicMock(),
                campaign_dir=campaign_dir,
                categories=[InvestigationCategory.ARCHITECTURE],
            )

        persist_pipeline_data(
            db=db,
            campaign_id=campaign.id,
            pipeline_result=pipeline_result,
            modules=persisted_modules,
            repo_path=Path("/tmp/test-repo"),
        )

        # Verify status assignment
        missions = db.mission_repo.get_by_campaign(campaign.id)
        assert len(missions) == 2

        by_title = {m.title_plain: m for m in missions}

        # Root mission should be AVAILABLE
        assert by_title["Start Here"].status == MissionStatus.AVAILABLE

        # Dependent mission should be LOCKED
        # Note: status is MissionStatus.LOCKED, not CampaignStatus

        assert by_title["Go Deeper"].status == MissionStatus.LOCKED

    def test_pipeline_to_persister_schema_mismatch_detected(
        self, campaign_db, mock_checkpointer
    ):
        """If pipeline output schema changes, persister should fail or warn.

        This is the 'smoke test' - if someone changes CategoryMissionsResponse
        fields without updating persister, this test catches it.
        """
        db, campaign, campaign_dir = campaign_db

        analysis = make_analysis_result(
            repo_path="/tmp/test-repo",
            modules=[
                DetectedModule(primary_path="src/", file_count=1, category_hints=[])
            ],
        )

        briefing_result = BriefingResult(
            briefing=BriefingResponse(
                summary="Test.",
                tech_stack=["Python"],
                security_posture="OK",
                modules=[
                    ModuleDescription(
                        name="X", description="Y", security_relevant=False
                    )
                ],
            ),
            modules=[
                ModuleDescription(name="X", description="Y", security_relevant=False)
            ],
            context_tokens_estimate=100,
        )

        persisted_modules = persist_briefing_data(
            db=db,
            campaign_id=campaign.id,
            briefing_result=briefing_result,
            analysis=analysis,
        )

        # Create mission with all required fields populated
        mission = make_mission_item(
            title="Full Mission",
            title_plain="Full Mission",
            description="Description",
            completion_condition="Done",
            difficulty="medium",
            module_index=0,
            prerequisite_titles=[],
        )

        mock_response = make_category_response(missions=[mission], chains=[])

        with (
            patch(
                "unfamiliar.llm.missions.pipeline.generate_category_missions_multi"
            ) as mock_gen,
            patch(
                "unfamiliar.llm.missions.pipeline.validate_category_missions"
            ) as mock_val,
        ):
            mock_gen.return_value = mock_response
            mock_val.return_value = ValidationResult(
                missions=[mission],
                chains=[],
                warnings=[],
                fixes_applied=[],
                valid=True,
            )

            pipeline_result = run_mission_pipeline(
                analysis=analysis,
                briefing_result=briefing_result,
                model=MagicMock(),
                campaign_dir=campaign_dir,
                categories=[InvestigationCategory.ARCHITECTURE],
            )

        # This call exercises the full schema contract
        result = persist_pipeline_data(
            db=db,
            campaign_id=campaign.id,
            pipeline_result=pipeline_result,
            modules=persisted_modules,
            repo_path=Path("/tmp/test-repo"),
        )

        # If we get here without exception, the contract is intact
        assert result.mission_count == 1
        assert len(result.warning_messages) == 0

    def test_validation_fixes_are_persisted_not_raw_llm_output(
        self, campaign_db, mock_checkpointer, tmp_path
    ):
        """Validation fixes (line clamping, prereq removal) are persisted.

        This is the key integration test: verify that what's in the DB
        is the VALIDATED data, not the raw LLM output.

        Edge cases tested:
        - Out of range line numbers -> clamped
        - Invalid prerequisite -> removed
        """
        db, campaign, campaign_dir = campaign_db

        # Create a real file for file ref validation
        test_file = tmp_path / "repo" / "src" / "main.py"
        test_file.parent.mkdir(parents=True, exist_ok=True)
        test_file.write_text("\n" * 50 + "# end\n")

        analysis = make_analysis_result(
            repo_path=str(tmp_path / "repo"),
            modules=[
                DetectedModule(primary_path="src/", file_count=1, category_hints=[])
            ],
        )

        briefing_result = BriefingResult(
            briefing=BriefingResponse(
                summary="Test.",
                tech_stack=["Python"],
                security_posture="OK",
                modules=[
                    ModuleDescription(
                        name="Core", description="Core.", security_relevant=False
                    )
                ],
            ),
            modules=[
                ModuleDescription(
                    name="Core", description="Core.", security_relevant=False
                )
            ],
            context_tokens_estimate=100,
        )

        persisted_modules = persist_briefing_data(
            db=db,
            campaign_id=campaign.id,
            briefing_result=briefing_result,
            analysis=analysis,
        )

        # RAW LLM output with problems that validation should fix
        raw_mission = make_mission_item(
            title="Problematic Mission",
            title_plain="Problematic Mission",
            module_index=0,
            prerequisite_titles=["Nonexistent Prereq"],  # BAD: doesn't exist
            file_refs=[
                MissionFileRefResponse(
                    file_path="src/main.py",
                    line_start=9999,  # BAD: out of range
                    line_end=10000,
                    description="This should be clamped",
                )
            ],
        )

        mock_response = make_category_response(missions=[raw_mission], chains=[])

        # Execute with REAL validation (only LLM generation mocked)
        with (
            patch(
                "unfamiliar.llm.missions.pipeline.generate_category_missions_multi"
            ) as mock_gen,
        ):
            mock_gen.return_value = mock_response

            # REAL validation runs, REAL pipeline runs
            pipeline_result = run_mission_pipeline(
                analysis=analysis,
                briefing_result=briefing_result,
                model=MagicMock(),
                campaign_dir=campaign_dir,
                categories=[InvestigationCategory.ARCHITECTURE],
            )

        # Verify validation fixed the data BEFORE it went to persister
        assert len(pipeline_result.completed_categories) == 1
        validated_mission = pipeline_result.category_missions["architecture"].missions[
            0
        ]

        # Validation should have removed the bad prereq
        assert validated_mission.prerequisite_titles == []

        # Validation should have clamped line numbers
        # (or removed file ref if file not found)
        # Either is acceptable - just verify it's not the raw bad values
        if validated_mission.file_refs:
            assert validated_mission.file_refs[0].line_start <= 51
            assert validated_mission.file_refs[0].line_end <= 51

        # Now persist and verify DB has the FIXED version
        persist_pipeline_data(
            db=db,
            campaign_id=campaign.id,
            pipeline_result=pipeline_result,
            modules=persisted_modules,
            repo_path=tmp_path / "repo",
        )

        missions = db.mission_repo.get_by_campaign(campaign.id)
        assert len(missions) == 1

        # DB should have CLEAN data, not raw LLM output
        persisted = missions[0]
        assert db.mission_file_ref_repo.get_prerequisites(persisted.id) == []

    def test_chain_with_invalid_mission_ref_dropped_or_warned(
        self, campaign_db, mock_checkpointer
    ):
        """Chain referencing nonexistent mission is dropped or generates warning.

        This tests the full-stack behavior: LLM returns chain with bad ref,
        validation catches it, persister either drops chain or logs warning.
        """
        db, campaign, campaign_dir = campaign_db

        analysis, briefing_result, persisted_modules = self._setup_campaign_with_module(
            db, campaign, "src/"
        )

        # One valid mission
        valid_mission = make_mission_item(
            title="Real Mission",
            title_plain="Real Mission",
            module_index=0,
            prerequisite_titles=[],
        )

        # Chain that references a mission that doesn't exist
        bad_chain = ChainResponse(
            name="Broken Chain",
            name_plain="Broken Chain",
            description="This chain has a bad reference.",
            mission_titles=["Real Mission", "Fake Mission That Doesn't Exist"],
        )

        mock_response = make_category_response(
            missions=[valid_mission],
            chains=[bad_chain],
        )

        with patch(
            "unfamiliar.llm.missions.pipeline.generate_category_missions_multi"
        ) as mock_gen:
            mock_gen.return_value = mock_response

            pipeline_result = run_mission_pipeline(
                analysis=analysis,
                briefing_result=briefing_result,
                model=MagicMock(),
                campaign_dir=campaign_dir,
                categories=[InvestigationCategory.ARCHITECTURE],
            )

        # Validation should have handled the bad chain
        # Either: chain is dropped entirely, or warning is generated
        validated_chains = pipeline_result.category_missions["architecture"].chains

        # Option 1: Chain dropped entirely (most defensive)
        if len(validated_chains) == 0:
            pass  # Expected: bad chain was dropped

        # Option 2: Chain kept but warning generated
        elif pipeline_result.category_validations.get("architecture"):
            validation = pipeline_result.category_validations["architecture"]
            assert any(
                "fake" in w.lower() or "missing" in w.lower() or "unknown" in w.lower()
                for w in validation.warnings
            ), "Bad chain reference should generate warning"

        # Persist and verify
        persist_result = persist_pipeline_data(
            db=db,
            campaign_id=campaign.id,
            pipeline_result=pipeline_result,
            modules=persisted_modules,
            repo_path=Path("/tmp/test-repo"),
        )

        # Mission should be persisted successfully
        assert persist_result.mission_count == 1

        # Chain handling: either dropped (0 chains) or warning about the issue
        if persist_result.chain_count == 0:
            pass  # Chain was dropped - acceptable
        else:
            # If chain persisted, there should be a warning about the bad ref
            assert len(persist_result.warning_messages) > 0

    def test_all_categories_fail_pipeline_persister_handles_gracefully(
        self, campaign_db, mock_checkpointer
    ):
        """When all categories fail, persister handles empty result gracefully.

        Edge case: Every category throws MissionGenerationError.
        Pipeline should return with all errors, persister should handle
        empty completed_categories without crashing.
        """
        db, campaign, campaign_dir = campaign_db
        analysis, briefing_result, persisted_modules = self._setup_campaign_with_module(
            db, campaign, "src/"
        )

        # All categories fail

        def always_fail(*args, **kwargs):
            raise MissionGenerationError("Simulated failure")

        with patch(
            "unfamiliar.llm.missions.pipeline.generate_category_missions_multi"
        ) as mock_gen:
            mock_gen.side_effect = always_fail

            pipeline_result = run_mission_pipeline(
                analysis=analysis,
                briefing_result=briefing_result,
                model=MagicMock(),
                campaign_dir=campaign_dir,
                categories=[
                    InvestigationCategory.ARCHITECTURE
                ],  # Just one to save time
            )

        # Pipeline should have recorded the error
        assert len(pipeline_result.completed_categories) == 0
        assert len(pipeline_result.errors) == 1

        # Persister should handle this gracefully (not crash)
        persist_result = persist_pipeline_data(
            db=db,
            campaign_id=campaign.id,
            pipeline_result=pipeline_result,
            modules=persisted_modules,
            repo_path=Path("/tmp/test-repo"),
        )

        # Zero missions/chains persisted, no warnings
        assert persist_result.mission_count == 0
        assert persist_result.chain_count == 0
        assert persist_result.warning_messages == []

        # DB should be empty but valid
        missions = db.mission_repo.get_by_campaign(campaign.id)
        assert len(missions) == 0

    def test_complete_mission_unlocks_dependents_via_get_by_ids(
        self, campaign_db, mock_checkpointer
    ):
        """Full path: persist → complete → unlock → get_by_ids.

        This is SEAM-001: tests the end-to-end flow from mission completion
        through the unlock mechanism to the TUI's query layer.

        Exercises:
        1. Pipeline persists missions with prerequisite relationships
        2. Root mission is AVAILABLE, dependent is LOCKED
        3. complete_mission() transitions root to COMPLETED
        4. Dependent transitions from LOCKED to AVAILABLE
        5. get_by_ids() loads the unlocked mission correctly
        """
        from unfamiliar.campaign.missions import complete_mission
        from unfamiliar.tui.queries import load_mission_detail

        db, campaign, campaign_dir = campaign_db

        analysis, briefing_result, persisted_modules = self._setup_campaign_with_module(
            db, campaign, "src/"
        )

        # Root mission (no prerequisites) -> will be AVAILABLE
        root_mission = make_mission_item(
            title="Root Mission",
            title_plain="Root Mission",
            module_index=0,
            prerequisite_titles=[],
        )

        # Dependent mission (has prerequisite) -> will be LOCKED
        dependent_mission = make_mission_item(
            title="Dependent Mission",
            title_plain="Dependent Mission",
            module_index=0,
            prerequisite_titles=["Root Mission"],
        )

        mock_response = make_category_response(
            missions=[root_mission, dependent_mission],
            chains=[],
        )

        with (
            patch(
                "unfamiliar.llm.missions.pipeline.generate_category_missions_multi"
            ) as mock_gen,
            patch(
                "unfamiliar.llm.missions.pipeline.validate_category_missions"
            ) as mock_val,
        ):
            mock_gen.return_value = mock_response
            mock_val.return_value = ValidationResult(
                missions=[root_mission, dependent_mission],
                chains=[],
                warnings=[],
                fixes_applied=[],
                valid=True,
            )

            pipeline_result = run_mission_pipeline(
                analysis=analysis,
                briefing_result=briefing_result,
                model=MagicMock(),
                campaign_dir=campaign_dir,
                categories=[InvestigationCategory.ARCHITECTURE],
            )

        persist_pipeline_data(
            db=db,
            campaign_id=campaign.id,
            pipeline_result=pipeline_result,
            modules=persisted_modules,
            repo_path=Path("/tmp/test-repo"),
        )

        # Verify initial state: root AVAILABLE, dependent LOCKED
        missions = db.mission_repo.get_by_campaign(campaign.id)
        assert len(missions) == 2

        by_title = {m.title_plain: m for m in missions}
        assert by_title["Root Mission"].status == MissionStatus.AVAILABLE
        assert by_title["Dependent Mission"].status == MissionStatus.LOCKED

        root_id = by_title["Root Mission"].id
        dependent_id = by_title["Dependent Mission"].id
        assert root_id is not None
        assert dependent_id is not None

        # Complete the root mission
        result = complete_mission(db, root_id)
        assert result.mission_id == root_id
        assert dependent_id in result.unlocked_ids

        # Verify state after completion: root COMPLETED, dependent AVAILABLE
        root_after = db.mission_repo.get(root_id)
        dependent_after = db.mission_repo.get(dependent_id)
        assert root_after is not None
        assert dependent_after is not None
        assert root_after.status == MissionStatus.COMPLETED
        assert dependent_after.status == MissionStatus.AVAILABLE

        # THE KEY TEST: load_mission_detail uses get_by_ids internally
        # and should load the unlocked mission without error
        detail = load_mission_detail(db, mission_id=dependent_id)
        assert detail.id == dependent_id
        assert detail.title == "Dependent Mission"
        assert detail.status == "available"
        assert detail.prerequisites == ["Root Mission"]

