"""Tests for repository layer: Campaign, Module, Mission, Chain, FileSnapshot.

Tests all CRUD operations against interface contracts from Phase 2 plan.md.
Uses in-memory SQLite via db_conn fixture. All fields asserted.
"""

from __future__ import annotations

from datetime import datetime
from typing import TYPE_CHECKING

from unfamiliar.constants import (
    CampaignStatus,
    CompletionMethod,
    InvestigationCategory,
    MissionDifficulty,
    MissionStatus,
    Theme,
)
from unfamiliar.db import open_campaign_db
from unfamiliar.models import (
    Briefing,
    Campaign,
    ChainMissionLink,
    FileSnapshot,
    Mission,
    MissionChain,
    MissionFileRef,
    Module,
    ModuleMissionLink,
)

if TYPE_CHECKING:
    from pathlib import Path

    from tests.conftest import Repos


class TestCampaignRepositoryCreate:
    """Tests for CampaignRepository.create()."""

    def test_create_returns_campaign_with_id(self, all_repos: Repos):
        """create returns Campaign with id populated."""
        campaign = Campaign(repo_path="/tmp/test")
        result = all_repos.campaign_repo.create(campaign)

        assert result.id is not None
        assert isinstance(result.id, int)
        assert result.id > 0

    def test_create_sets_timestamps(self, all_repos: Repos):
        """create sets created_at and updated_at."""
        campaign = Campaign(repo_path="/tmp/test")
        result = all_repos.campaign_repo.create(campaign)

        assert result.created_at is not None
        assert result.updated_at is not None
        assert isinstance(result.created_at, datetime)
        assert isinstance(result.updated_at, datetime)

    def test_create_preserves_all_fields(self, all_repos: Repos):
        """create preserves all provided field values."""
        campaign = Campaign(
            repo_path="/tmp/test",
            status=CampaignStatus.ACTIVE,
        )
        result = all_repos.campaign_repo.create(campaign)

        assert result.repo_path == "/tmp/test"
        assert result.status == CampaignStatus.ACTIVE


class TestCampaignRepositoryGet:
    """Tests for CampaignRepository.get() and get_by_repo_path()."""

    def test_get_existing_returns_campaign(self, all_repos: Repos):
        """get with existing id returns Campaign."""
        campaign = Campaign(repo_path="/tmp/test")
        created = all_repos.campaign_repo.create(campaign)

        result = all_repos.campaign_repo.get(created.id)

        assert result is not None
        assert result.id == created.id
        assert result.repo_path == "/tmp/test"

    def test_get_nonexistent_returns_none(self, all_repos: Repos):
        """get with nonexistent id returns None."""
        result = all_repos.campaign_repo.get(999)
        assert result is None

    def test_get_by_repo_path_existing(self, all_repos: Repos):
        """get_by_repo_path with existing path returns Campaign."""
        campaign = Campaign(repo_path="/tmp/test")
        created = all_repos.campaign_repo.create(campaign)

        result = all_repos.campaign_repo.get_by_repo_path("/tmp/test")

        assert result is not None
        assert result.id == created.id

    def test_get_by_repo_path_nonexistent(self, all_repos: Repos):
        """get_by_repo_path with nonexistent path returns None."""
        result = all_repos.campaign_repo.get_by_repo_path("/nonexistent")
        assert result is None


class TestCampaignRepositoryUpdate:
    """Tests for CampaignRepository.update_status()."""

    def test_update_status_changes_status(self, all_repos: Repos):
        """update_status changes the status field."""
        campaign = Campaign(repo_path="/tmp/test")
        created = all_repos.campaign_repo.create(campaign)

        all_repos.campaign_repo.update_status(created.id, CampaignStatus.ACTIVE)

        result = all_repos.campaign_repo.get(created.id)
        assert result.status == CampaignStatus.ACTIVE


class TestCampaignRepositoryBriefing:
    """Tests for CampaignRepository briefing operations."""

    def test_create_briefing_returns_with_id(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_briefing returns Briefing with id."""
        briefing = Briefing(
            campaign_id=sample_campaign.id,
            summary="Test summary",
            tech_stack=["Python"],
            security_posture="Good",
        )
        result = all_repos.campaign_repo.create_briefing(briefing)

        assert result.id is not None
        assert result.campaign_id == sample_campaign.id

    def test_get_briefing_returns_created_briefing(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_briefing returns briefing matching all fields."""
        briefing = Briefing(
            campaign_id=sample_campaign.id,
            summary="Test summary",
            tech_stack=["Python", "TypeScript"],
            security_posture="Moderate",
        )
        created = all_repos.campaign_repo.create_briefing(briefing)

        result = all_repos.campaign_repo.get_briefing(sample_campaign.id)

        assert result is not None
        assert result.id == created.id
        assert result.summary == "Test summary"
        assert result.tech_stack == ["Python", "TypeScript"]
        assert result.security_posture == "Moderate"

    def test_update_briefing_changes_summary(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """update_briefing changes the summary field."""
        briefing = Briefing(
            campaign_id=sample_campaign.id,
            summary="Original",
            tech_stack=[],
            security_posture="Good",
        )
        created = all_repos.campaign_repo.create_briefing(briefing)

        created.summary = "Updated"
        all_repos.campaign_repo.update_briefing(created)

        result = all_repos.campaign_repo.get_briefing(sample_campaign.id)
        assert result.summary == "Updated"


class TestModuleRepository:
    """Tests for ModuleRepository CRUD operations."""

    def test_create_returns_module_with_id(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create returns Module with id populated."""
        module = Module(
            campaign_id=sample_campaign.id,
            name="test_module",
            path="src/test.py",
            description="Test module",
        )
        result = all_repos.module_repo.create(module)

        assert result.id is not None
        assert result.name == "test_module"

    def test_create_preserves_category_relevance(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create preserves category_relevance JSON round-trip."""
        cats = [InvestigationCategory.ARCHITECTURE, InvestigationCategory.DATA_FLOW]
        module = Module(
            campaign_id=sample_campaign.id,
            name="test",
            path="test.py",
            description="Test",
            category_relevance=cats,
        )
        created = all_repos.module_repo.create(module)
        result = all_repos.module_repo.get(created.id)

        assert result.category_relevance == cats

    def test_create_many_returns_all_with_ids(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_many returns all modules with ids populated."""
        modules = [
            Module(
                campaign_id=sample_campaign.id,
                name=f"module_{i}",
                path=f"src/module_{i}.py",
                description=f"Module {i}",
            )
            for i in range(3)
        ]
        results = all_repos.module_repo.create_many(modules)

        assert len(results) == 3
        for result in results:
            assert result.id is not None

    def test_create_many_duplicate_path_no_longer_fragile(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_many correctly assigns unique IDs even with duplicate
        paths
        """
        modules = [
            Module(
                campaign_id=sample_campaign.id,
                name="module_1",
                path="src/same.py",
                description="First module",
            ),
            Module(
                campaign_id=sample_campaign.id,
                name="module_2",
                path="src/same.py",
                description="Second module",
            ),
        ]
        results = all_repos.module_repo.create_many(modules)

        assert len(results) == 2
        # IDs are auto-generated and unique, not hardcoded
        assert results[0].id is not None
        assert results[1].id is not None
        assert results[0].id != results[1].id

    def test_get_by_campaign_returns_ordered_by_path(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_campaign returns modules ordered by path."""
        modules = [
            Module(
                campaign_id=sample_campaign.id,
                name=f"m{i}",
                path=f"src/{c}.py",
                description="",
            )
            for i, c in enumerate(["z", "a", "m"])
        ]
        all_repos.module_repo.create_many(modules)

        results = all_repos.module_repo.get_by_campaign(sample_campaign.id)

        assert len(results) == 3
        assert results[0].path == "src/a.py"
        assert results[1].path == "src/m.py"
        assert results[2].path == "src/z.py"

    def test_get_by_path_returns_correct_module(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_path returns module with matching path."""
        module = Module(
            campaign_id=sample_campaign.id,
            name="test",
            path="src/target.py",
            description="Target module",
        )
        all_repos.module_repo.create(module)

        result = all_repos.module_repo.get_by_path(sample_campaign.id, "src/target.py")

        assert result is not None
        assert result.name == "test"

    def test_get_by_path_nonexistent_returns_none(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_path returns None for nonexistent path."""
        result = all_repos.module_repo.get_by_path(sample_campaign.id, "nonexistent.py")
        assert result is None


class TestMissionRepositoryCRUD:
    """Tests for MissionRepository basic CRUD."""

    def test_create_returns_mission_with_id(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create returns Mission with id populated."""
        mission = Mission(
            campaign_id=sample_campaign.id,
            title="Test Mission",
            title_plain="Test Mission",
            description="Description",
            category=InvestigationCategory.ARCHITECTURE,
            difficulty=MissionDifficulty.QUICK,
            time_estimate_minutes=15,
            completion_condition="Done",
        )
        result = all_repos.mission_repo.create(mission)

        assert result.id is not None
        assert result.title == "Test Mission"

    def test_create_many_returns_exact_count(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_many returns exact count with all ids populated."""
        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"Mission {i}",
                title_plain=f"Mission {i}",
                description="",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            )
            for i in range(5)
        ]
        results = all_repos.mission_repo.create_many(missions)

        assert len(results) == 5
        assert all(m.id is not None for m in results)

    def test_get_by_status_filters_correctly(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_status returns only missions with matching status."""
        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"M{i}",
                title_plain=f"M{i}",
                description="",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
                status=(
                    MissionStatus.AVAILABLE if i % 2 == 0 else MissionStatus.LOCKED
                ),
            )
            for i in range(4)
        ]
        all_repos.mission_repo.create_many(missions)

        available = all_repos.mission_repo.get_by_status(
            sample_campaign.id, MissionStatus.AVAILABLE
        )

        assert len(available) == 2
        assert all(m.status == MissionStatus.AVAILABLE for m in available)

    def test_get_by_category_filters_correctly(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_category returns only missions with matching category."""
        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"M{i}",
                title_plain=f"M{i}",
                description="",
                category=(
                    InvestigationCategory.ARCHITECTURE
                    if i < 2
                    else InvestigationCategory.TESTING
                ),
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            )
            for i in range(4)
        ]
        all_repos.mission_repo.create_many(missions)

        arch = all_repos.mission_repo.get_by_category(
            sample_campaign.id, InvestigationCategory.ARCHITECTURE
        )

        assert len(arch) == 2

    def test_update_status_changes_status_and_completion(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """update_status changes status and completion_method."""
        mission = Mission(
            campaign_id=sample_campaign.id,
            title="Test",
            title_plain="Test",
            description="",
            category=InvestigationCategory.ARCHITECTURE,
            difficulty=MissionDifficulty.QUICK,
            time_estimate_minutes=15,
            completion_condition="",
        )
        created = all_repos.mission_repo.create(mission)

        all_repos.mission_repo.update_status(
            created.id,
            MissionStatus.COMPLETED,
            CompletionMethod.SELF_REPORT,
        )

        result = all_repos.mission_repo.get(created.id)
        assert result.status == MissionStatus.COMPLETED
        assert result.completion_method == CompletionMethod.SELF_REPORT

    def test_create_many_duplicate_title_plain_not_fragile(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_many correctly assigns unique IDs even with duplicate title_plain"""
        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title="Test",
                title_plain="Test",
                description="first test mission",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            ),
            Mission(
                campaign_id=sample_campaign.id,
                title="Test 2",
                title_plain="Test",
                description="second test mission",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            ),
        ]
        results = all_repos.mission_repo.create_many(missions)

        assert len(results) == 2
        # IDs are auto-generated and unique, not hardcoded
        assert results[0].id is not None
        assert results[1].id is not None
        assert results[0].id != results[1].id


class TestMissionRepositoryFileRefs:
    """Tests for MissionRepository file reference operations."""

    def test_add_file_refs_stores_all(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """add_file_refs stores all references."""
        mission = Mission(
            campaign_id=sample_campaign.id,
            title="Test",
            title_plain="Test",
            description="",
            category=InvestigationCategory.ARCHITECTURE,
            difficulty=MissionDifficulty.QUICK,
            time_estimate_minutes=15,
            completion_condition="",
        )
        created = all_repos.mission_repo.create(mission)

        refs = [
            MissionFileRef(
                mission_id=created.id,
                file_path="src/a.py",
                line_start=1,
                line_end=10,
                description="Section A",
                file_hash="hash_a",
            ),
            MissionFileRef(
                mission_id=created.id,
                file_path="src/b.py",
                line_start=None,
                line_end=None,
                description="Whole file B",
                file_hash="hash_b",
            ),
        ]
        all_repos.mission_file_ref_repo.add_file_refs(created.id, refs)

        result = all_repos.mission_file_ref_repo.get_file_refs(created.id)

        assert len(result) == 2
        paths = {r.file_path for r in result}
        assert paths == {"src/a.py", "src/b.py"}


class TestMissionRepositoryPrerequisites:
    """Tests for MissionRepository prerequisite graph operations."""

    def test_add_prerequisites_creates_edges(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """add_prerequisites creates prerequisite edges."""
        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"M{i}",
                title_plain=f"M{i}",
                description="",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            )
            for i in range(3)
        ]
        created = all_repos.mission_repo.create_many(missions)

        all_repos.mission_file_ref_repo.add_prerequisites(
            created[2].id, [created[0].id, created[1].id]
        )

        prereqs = all_repos.mission_file_ref_repo.get_prerequisites(created[2].id)
        assert len(prereqs) == 2
        assert created[0].id in prereqs
        assert created[1].id in prereqs

    def test_get_dependents_returns_reverse_edges(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_dependents returns missions that depend on given mission."""
        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"M{i}",
                title_plain=f"M{i}",
                description="",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            )
            for i in range(3)
        ]
        created = all_repos.mission_repo.create_many(missions)

        all_repos.mission_file_ref_repo.add_prerequisites(
            created[1].id, [created[0].id]
        )
        all_repos.mission_file_ref_repo.add_prerequisites(
            created[2].id, [created[0].id]
        )

        dependents = all_repos.mission_file_ref_repo.get_dependents(created[0].id)

        assert len(dependents) == 2
        assert created[1].id in dependents
        assert created[2].id in dependents


class TestMissionRepositoryModuleLinks:
    """Tests for MissionRepository module-mission link operations."""

    def test_link_modules_creates_associations(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """link_modules creates module-mission associations."""
        mission = Mission(
            campaign_id=sample_campaign.id,
            title="Test",
            title_plain="Test",
            description="",
            category=InvestigationCategory.ARCHITECTURE,
            difficulty=MissionDifficulty.QUICK,
            time_estimate_minutes=15,
            completion_condition="",
        )
        created_mission = all_repos.mission_repo.create(mission)

        modules = [
            Module(
                campaign_id=sample_campaign.id,
                name=f"mod_{i}",
                path=f"mod_{i}.py",
                description="",
            )
            for i in range(2)
        ]
        created_modules = all_repos.module_repo.create_many(modules)

        links = [
            ModuleMissionLink(
                module_id=created_modules[0].id,
                mission_id=created_mission.id,
                is_primary=True,
            ),
            ModuleMissionLink(
                module_id=created_modules[1].id,
                mission_id=created_mission.id,
                is_primary=False,
            ),
        ]
        all_repos.mission_link_repo.link_modules(created_mission.id, links)

        result = all_repos.mission_link_repo.get_linked_modules(created_mission.id)
        assert len(result) == 2


class TestChainRepository:
    """Tests for ChainRepository CRUD and mission ordering."""

    def test_create_returns_chain_with_id(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create returns MissionChain with id populated."""
        chain = MissionChain(
            campaign_id=sample_campaign.id,
            name="Learning Path",
            name_plain="Learning Path",
            description="A path",
            category=InvestigationCategory.ARCHITECTURE,
        )
        result = all_repos.chain_repo.create(chain)

        assert result.id is not None

    def test_add_missions_preserves_position_order(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """add_missions stores links with correct position ordering."""
        chain = MissionChain(
            campaign_id=sample_campaign.id,
            name="Test Chain",
            name_plain="Test Chain",
            description="",
            category=InvestigationCategory.ARCHITECTURE,
        )
        created_chain = all_repos.chain_repo.create(chain)

        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"M{i}",
                title_plain=f"M{i}",
                description="",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            )
            for i in range(3)
        ]
        created_missions = all_repos.mission_repo.create_many(missions)

        links = [
            ChainMissionLink(
                chain_id=created_chain.id,
                mission_id=created_missions[i].id,
                position=i,
            )
            for i in range(3)
        ]
        all_repos.chain_repo.add_missions(created_chain.id, links)

        result = all_repos.chain_repo.get_chain_missions(created_chain.id)

        assert len(result) == 3
        # Verify order by position
        assert result[0].position == 0
        assert result[1].position == 1
        assert result[2].position == 2

    def test_get_chains_for_mission_returns_chain_ids(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_chains_for_mission returns chain IDs containing the mission."""
        chain = MissionChain(
            campaign_id=sample_campaign.id,
            name="Test",
            name_plain="Test",
            description="",
            category=InvestigationCategory.ARCHITECTURE,
        )
        created_chain = all_repos.chain_repo.create(chain)

        mission = Mission(
            campaign_id=sample_campaign.id,
            title="M",
            title_plain="M",
            description="",
            category=InvestigationCategory.ARCHITECTURE,
            difficulty=MissionDifficulty.QUICK,
            time_estimate_minutes=15,
            completion_condition="",
        )
        created_mission = all_repos.mission_repo.create(mission)

        all_repos.chain_repo.add_missions(
            created_chain.id,
            [
                ChainMissionLink(
                    chain_id=created_chain.id, mission_id=created_mission.id, position=0
                )
            ],
        )

        chains = all_repos.chain_repo.get_chains_for_mission(created_mission.id)

        assert len(chains) == 1
        assert chains[0] == created_chain.id

    def test_create_many_duplicate_name_plain_not_fragile(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_many correctly assigns unique IDs even with duplicate name_plain"""
        chains = [
            MissionChain(
                campaign_id=sample_campaign.id,
                name="Test 1",
                name_plain="Test",
                description="first test chain",
                category=InvestigationCategory.ARCHITECTURE,
            ),
            MissionChain(
                campaign_id=sample_campaign.id,
                name="Test 2",
                name_plain="Test",
                description="second test chain",
                category=InvestigationCategory.ARCHITECTURE,
            ),
        ]
        results = all_repos.chain_repo.create_many(chains)

        assert len(results) == 2
        # IDs are auto-generated and unique, not hardcoded
        assert results[0].id is not None
        assert results[1].id is not None
        assert results[0].id != results[1].id


class TestFileSnapshotRepository:
    """Tests for FileSnapshotRepository operations."""

    def test_create_many_stores_all(self, sample_campaign: Campaign, all_repos: Repos):
        """create_many stores all snapshots."""
        snapshots = [
            FileSnapshot(
                campaign_id=sample_campaign.id,
                file_path=f"file_{i}.py",
                file_hash=f"hash_{i}",
                last_analyzed=datetime.now(),
            )
            for i in range(3)
        ]
        all_repos.file_snapshot_repo.create_many(snapshots)

        results = all_repos.file_snapshot_repo.get_by_campaign(sample_campaign.id)

        assert len(results) == 3

    def test_upsert_inserts_new_record(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """upsert inserts when record doesn't exist."""
        snapshot = FileSnapshot(
            campaign_id=sample_campaign.id,
            file_path="new_file.py",
            file_hash="initial_hash",
            last_analyzed=datetime.now(),
        )
        all_repos.file_snapshot_repo.upsert(snapshot)

        results = all_repos.file_snapshot_repo.get_by_campaign(sample_campaign.id)
        assert len(results) == 1
        assert results[0].file_hash == "initial_hash"

    def test_upsert_updates_existing_record(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """upsert updates when record exists with same campaign_id and path."""
        snapshot = FileSnapshot(
            campaign_id=sample_campaign.id,
            file_path="existing.py",
            file_hash="old_hash",
            last_analyzed=datetime.now(),
        )
        all_repos.file_snapshot_repo.upsert(snapshot)

        # Upsert with new hash
        updated = FileSnapshot(
            campaign_id=sample_campaign.id,
            file_path="existing.py",
            file_hash="new_hash",
            last_analyzed=datetime.now(),
        )
        all_repos.file_snapshot_repo.upsert(updated)

        results = all_repos.file_snapshot_repo.get_by_campaign(sample_campaign.id)
        # Exact 1 row for same (campaign_id, file_path)
        assert len(results) == 1
        assert results[0].file_hash == "new_hash"


class TestMissionRepositoryGetMissionsForModule:
    """Tests for MissionRepository.get_missions_for_module()."""

    def test_returns_linked_mission_ids(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_missions_for_module returns IDs of missions linked to a module."""
        module = Module(
            campaign_id=sample_campaign.id,
            name="core",
            path="src/core.py",
            description="Core module",
        )
        created_module = all_repos.module_repo.create(module)

        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"Mission {i}",
                title_plain=f"Mission {i}",
                description="",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="",
            )
            for i in range(2)
        ]
        created_missions = all_repos.mission_repo.create_many(missions)

        links = [
            ModuleMissionLink(
                module_id=created_module.id,
                mission_id=m.id,
                is_primary=True,
            )
            for m in created_missions
        ]
        all_repos.mission_link_repo.link_modules(created_missions[0].id, [links[0]])
        all_repos.mission_link_repo.link_modules(created_missions[1].id, [links[1]])

        result = all_repos.mission_link_repo.get_missions_for_module(created_module.id)

        assert len(result) == 2
        assert created_missions[0].id in result
        assert created_missions[1].id in result

    def test_returns_empty_for_unlinked_module(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_missions_for_module returns empty list for module with no links."""
        module = Module(
            campaign_id=sample_campaign.id,
            name="orphan",
            path="src/orphan.py",
            description="No missions",
        )
        created_module = all_repos.module_repo.create(module)

        result = all_repos.mission_link_repo.get_missions_for_module(created_module.id)

        assert result == []


class TestFileSnapshotGetByPath:
    """Tests for FileSnapshotRepository.get_by_path()."""

    def test_returns_correct_snapshot(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_path returns the snapshot matching campaign and path."""
        snapshots = [
            FileSnapshot(
                campaign_id=sample_campaign.id,
                file_path="src/main.py",
                file_hash="abc123",
                last_analyzed=datetime.now(),
            ),
            FileSnapshot(
                campaign_id=sample_campaign.id,
                file_path="src/utils.py",
                file_hash="def456",
                last_analyzed=datetime.now(),
            ),
        ]
        all_repos.file_snapshot_repo.create_many(snapshots)

        result = all_repos.file_snapshot_repo.get_by_path(
            sample_campaign.id, "src/main.py"
        )

        assert result is not None
        assert result.file_path == "src/main.py"
        assert result.file_hash == "abc123"
        assert result.campaign_id == sample_campaign.id

    def test_returns_none_for_nonexistent_path(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_path returns None when no snapshot matches."""
        result = all_repos.file_snapshot_repo.get_by_path(
            sample_campaign.id, "nonexistent.py"
        )

        assert result is None

    def test_returns_correct_campaign_snapshot(self, all_repos: Repos):
        """get_by_path scopes correctly to campaign_id."""
        # Create two campaigns
        campaign_a = all_repos.campaign_repo.create(Campaign(repo_path="/tmp/repo_a"))
        campaign_b = all_repos.campaign_repo.create(Campaign(repo_path="/tmp/repo_b"))

        # Same path in different campaigns
        all_repos.file_snapshot_repo.create_many(
            [
                FileSnapshot(
                    campaign_id=campaign_a.id,
                    file_path="src/main.py",
                    file_hash="hash_a",
                    last_analyzed=datetime.now(),
                ),
            ]
        )
        all_repos.file_snapshot_repo.create_many(
            [
                FileSnapshot(
                    campaign_id=campaign_b.id,
                    file_path="src/main.py",
                    file_hash="hash_b",
                    last_analyzed=datetime.now(),
                ),
            ]
        )

        result_a = all_repos.file_snapshot_repo.get_by_path(
            campaign_a.id, "src/main.py"
        )
        result_b = all_repos.file_snapshot_repo.get_by_path(
            campaign_b.id, "src/main.py"
        )

        assert result_a.file_hash == "hash_a"
        assert result_b.file_hash == "hash_b"


class TestCampaignRepositoryGenericUpdate:
    """Tests for CampaignRepository.update() (generic full-record update)."""

    def test_update_changes_fields(self, all_repos: Repos):
        """update() persists changed fields."""
        campaign = Campaign(repo_path="/tmp/test")
        created = all_repos.campaign_repo.create(campaign)

        created.status = CampaignStatus.ACTIVE
        created.theme = Theme.FILM_NOIR
        all_repos.campaign_repo.update(created)

        result = all_repos.campaign_repo.get(created.id)
        assert result.status == CampaignStatus.ACTIVE
        assert result.theme == Theme.FILM_NOIR

    def test_update_preserves_unchanged_fields(self, all_repos: Repos):
        """update() does not alter fields that weren't changed."""
        campaign = Campaign(repo_path="/tmp/test_preserve")
        created = all_repos.campaign_repo.create(campaign)
        original_repo_path = created.repo_path

        created.status = CampaignStatus.CLOSED
        all_repos.campaign_repo.update(created)

        result = all_repos.campaign_repo.get(created.id)
        assert result.repo_path == original_repo_path

    def test_update_changes_updated_at(self, all_repos: Repos):
        """update() advances the updated_at timestamp."""
        campaign = Campaign(repo_path="/tmp/test_ts")
        created = all_repos.campaign_repo.create(campaign)
        original_updated = created.updated_at

        created.status = CampaignStatus.ACTIVE
        all_repos.campaign_repo.update(created)

        result = all_repos.campaign_repo.get(created.id)
        assert result.updated_at >= original_updated


class TestModuleRepositoryUpdate:
    """Tests for ModuleRepository.update()."""

    def test_update_changes_category_relevance(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """update() persists category_relevance changes."""
        module = Module(
            campaign_id=sample_campaign.id,
            name="core",
            path="core.py",
            description="Core module",
            category_relevance=[],
        )
        created = all_repos.module_repo.create(module)

        created.category_relevance = [
            InvestigationCategory.ARCHITECTURE,
            InvestigationCategory.TESTING,
        ]
        all_repos.module_repo.update(created)

        result = all_repos.module_repo.get_by_path(sample_campaign.id, "core.py")
        assert result is not None
        assert InvestigationCategory.ARCHITECTURE in result.category_relevance
        assert InvestigationCategory.TESTING in result.category_relevance

    def test_update_preserves_unchanged_fields(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """update() does not alter fields that weren't changed."""
        module = Module(
            campaign_id=sample_campaign.id,
            name="stable",
            path="stable.py",
            description="Original desc",
        )
        created = all_repos.module_repo.create(module)

        created.file_count = 42
        all_repos.module_repo.update(created)

        result = all_repos.module_repo.get_by_path(sample_campaign.id, "stable.py")
        assert result.name == "stable"
        assert result.description == "Original desc"
        assert result.file_count == 42


class TestChainRepositoryCrudGaps:
    """Tests for ChainRepository.create_many(), get(), get_by_campaign()."""

    def test_create_many_returns_all_with_ids(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_many returns all chains with IDs set."""
        chains = [
            MissionChain(
                campaign_id=sample_campaign.id,
                name=f"Chain {i}",
                name_plain=f"Chain {i}",
                description=f"Description {i}",
                category=InvestigationCategory.ARCHITECTURE,
                sort_order=i,
            )
            for i in range(3)
        ]
        results = all_repos.chain_repo.create_many(chains)

        assert len(results) == 3
        for chain in results:
            assert chain.id is not None
            assert chain.id > 0
            assert chain.campaign_id == sample_campaign.id

    def test_create_many_preserves_fields(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """create_many preserves all input fields."""
        chains = [
            MissionChain(
                campaign_id=sample_campaign.id,
                name="Alpha Path",
                name_plain="Alpha Path",
                description="First path",
                category=InvestigationCategory.TESTING,
                sort_order=0,
            ),
        ]
        results = all_repos.chain_repo.create_many(chains)

        assert results[0].name == "Alpha Path"
        assert results[0].name_plain == "Alpha Path"
        assert results[0].description == "First path"
        assert results[0].category == InvestigationCategory.TESTING
        assert results[0].sort_order == 0

    def test_get_returns_chain_by_id(self, sample_campaign: Campaign, all_repos: Repos):
        """get() returns the correct chain by ID."""
        chain = MissionChain(
            campaign_id=sample_campaign.id,
            name="Target",
            name_plain="Target",
            description="Find me",
            category=InvestigationCategory.ARCHITECTURE,
        )
        created = all_repos.chain_repo.create(chain)

        result = all_repos.chain_repo.get(created.id)

        assert result is not None
        assert result.id == created.id
        assert result.name == "Target"
        assert result.description == "Find me"
        assert result.campaign_id == sample_campaign.id

    def test_get_returns_none_for_nonexistent(self, all_repos: Repos):
        """get() returns None for nonexistent ID."""
        result = all_repos.chain_repo.get(999)
        assert result is None

    def test_get_by_campaign_returns_ordered(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_campaign returns chains ordered by sort_order."""
        chains = [
            MissionChain(
                campaign_id=sample_campaign.id,
                name=f"Chain {i}",
                name_plain=f"Chain {i}",
                description="",
                category=InvestigationCategory.ARCHITECTURE,
                sort_order=2 - i,  # Reverse order to test sorting
            )
            for i in range(3)
        ]
        all_repos.chain_repo.create_many(chains)

        results = all_repos.chain_repo.get_by_campaign(sample_campaign.id)

        assert len(results) == 3
        # Should be sorted by sort_order ascending
        assert results[0].sort_order <= results[1].sort_order
        assert results[1].sort_order <= results[2].sort_order

    def test_get_by_campaign_empty(self, sample_campaign: Campaign, all_repos: Repos):
        """get_by_campaign returns empty list when no chains exist."""
        results = all_repos.chain_repo.get_by_campaign(sample_campaign.id)
        assert results == []


class TestMissionCountMethods:
    """Tests for mission repository count methods."""

    def test_count_by_campaign_returns_total(self, tmp_path: Path):
        """count_by_campaign returns total mission count."""
        db = open_campaign_db(tmp_path)

        # Create campaign
        campaign = db.campaign_repo.create(
            Campaign(
                repo_path=str(tmp_path),
                status=CampaignStatus.ACTIVE,
            )
        )

        # Initially no missions
        assert db.mission_repo.count_by_campaign(campaign.id) == 0

        # Add some missions
        for i in range(3):
            db.mission_repo.create(
                Mission(
                    campaign_id=campaign.id,
                    title=f"Mission {i}",
                    title_plain=f"Mission {i}",
                    description="Test",
                    category=InvestigationCategory.ARCHITECTURE,
                    difficulty=MissionDifficulty.QUICK,
                    status=MissionStatus.AVAILABLE,
                    time_estimate_minutes=5,
                    completion_condition="Complete the test",
                )
            )

        assert db.mission_repo.count_by_campaign(campaign.id) == 3

    def test_count_completed_returns_zero_initially(self, tmp_path: Path):
        """count_completed returns 0 when no missions completed."""
        db = open_campaign_db(tmp_path)

        campaign = db.campaign_repo.create(
            Campaign(
                repo_path=str(tmp_path),
                status=CampaignStatus.ACTIVE,
            )
        )

        assert db.mission_repo.count_completed(campaign.id) == 0

    def test_count_completed_increments(self, tmp_path: Path):
        """count_completed returns correct count after completions."""
        db = open_campaign_db(tmp_path)

        campaign = db.campaign_repo.create(
            Campaign(
                repo_path=str(tmp_path),
                status=CampaignStatus.ACTIVE,
            )
        )

        # Create and complete missions
        for i in range(5):
            mission = db.mission_repo.create(
                Mission(
                    campaign_id=campaign.id,
                    title=f"Mission {i}",
                    title_plain=f"Mission {i}",
                    description="Test",
                    category=InvestigationCategory.ARCHITECTURE,
                    difficulty=MissionDifficulty.QUICK,
                    status=MissionStatus.AVAILABLE,
                    time_estimate_minutes=5,
                    completion_condition="Complete the test",
                )
            )
            if i < 3:  # Complete first 3
                db.mission_repo.update_status(mission.id, MissionStatus.COMPLETED)

        assert db.mission_repo.count_completed(campaign.id) == 3


class TestMissionRepositoryGetByIds:
    """Tests for MissionRepository.get_by_ids() — GAP-001.

    These tests would have caught the triple bug in get_by_ids:
    - self.conn (AttributeError) → should be self._conn
    - FROM missions (OperationalError) → should be FROM mission
    - self._row_to_mission (AttributeError) → should be _row_to_mission
    """

    def test_empty_list_returns_empty_list(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_ids with empty list returns empty list."""
        result = all_repos.mission_repo.get_by_ids([])
        assert result == []

    def test_single_id_returns_single_mission(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_ids with single valid ID returns that mission."""
        mission = Mission(
            campaign_id=sample_campaign.id,
            title="Test Mission",
            title_plain="Test Mission",
            description="Description",
            category=InvestigationCategory.ARCHITECTURE,
            difficulty=MissionDifficulty.QUICK,
            time_estimate_minutes=15,
            completion_condition="Done",
        )
        created = all_repos.mission_repo.create(mission)

        result = all_repos.mission_repo.get_by_ids([created.id])

        assert len(result) == 1
        assert result[0].id == created.id
        assert result[0].title == "Test Mission"

    def test_multiple_ids_returns_all_matching(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_ids with multiple valid IDs returns all matching missions."""
        missions = [
            Mission(
                campaign_id=sample_campaign.id,
                title=f"Mission {i}",
                title_plain=f"Mission {i}",
                description="Description",
                category=InvestigationCategory.ARCHITECTURE,
                difficulty=MissionDifficulty.QUICK,
                time_estimate_minutes=15,
                completion_condition="Done",
            )
            for i in range(3)
        ]
        created = all_repos.mission_repo.create_many(missions)
        ids = [m.id for m in created]

        result = all_repos.mission_repo.get_by_ids(ids)

        assert len(result) == 3
        result_ids = {m.id for m in result}
        assert result_ids == set(ids)

    def test_nonexistent_ids_return_empty_list(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_ids with only nonexistent IDs returns empty list."""
        result = all_repos.mission_repo.get_by_ids([99999, 88888])
        assert result == []

    def test_mixed_valid_and_invalid_returns_only_valid(
        self, sample_campaign: Campaign, all_repos: Repos
    ):
        """get_by_ids with mixed IDs returns only valid missions."""
        mission = Mission(
            campaign_id=sample_campaign.id,
            title="Valid Mission",
            title_plain="Valid Mission",
            description="Description",
            category=InvestigationCategory.ARCHITECTURE,
            difficulty=MissionDifficulty.QUICK,
            time_estimate_minutes=15,
            completion_condition="Done",
        )
        created = all_repos.mission_repo.create(mission)

        result = all_repos.mission_repo.get_by_ids([created.id, 99999])

        assert len(result) == 1
        assert result[0].id == created.id
