"""Tests for campaign orchestrator — main entry point for campaign lifecycle.

Tests verify:
- create_campaign orchestrates analysis → briefing → pipeline → persistence
- create_campaign validates git repository
- create_campaign calls progress callback at each stage
- create_campaign deletes existing campaigns before recreation
- resume_campaign returns None when no active campaigns exist
- resume_campaign returns most recent ACTIVE campaign
"""

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

import pytest

from unfamiliar.campaign.orchestrator import (
    CampaignOrchestrationError,
    create_campaign,
    resume_campaign,
)
from unfamiliar.campaign.types import CampaignInfo
from unfamiliar.config import UnfamiliarConfig
from unfamiliar.constants import CampaignStatus
from unfamiliar.models.campaign import Campaign
from unfamiliar.tui.themes.film_noir.theme import FILM_NOIR_THEME


class TestCreateCampaign:
    """Tests for create_campaign orchestration function."""

    def test_creates_campaign_with_active_status_on_success(self, tmp_path: Path):
        """Full orchestration returns Campaign with status=ACTIVE."""
        # Setup: create a fake git repo
        repo_path = tmp_path / "my-repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()

        config = UnfamiliarConfig(
            provider="anthropic",
            token_budget=10000,
            theme="film_noir",
        )

        # Mock all subsystems
        mock_analysis = MagicMock()
        mock_analysis.files = []
        mock_analysis.modules = []

        mock_briefing_result = MagicMock()
        mock_briefing_result.modules = []

        # Create a mock category response with missions list
        mock_category_response = MagicMock()
        mock_category_response.missions = [MagicMock()]  # 1 mission

        mock_pipeline_result = MagicMock()
        mock_pipeline_result.completed_categories = ["architecture"]
        mock_pipeline_result.category_missions = {
            "architecture": mock_category_response
        }

        mock_persist_result = MagicMock()
        mock_persist_result.mission_count = 5
        mock_persist_result.chain_count = 1
        mock_persist_result.warning_messages = []

        # Mock DB and repos
        mock_db = MagicMock()
        mock_db.__enter__ = MagicMock(return_value=mock_db)
        mock_db.__exit__ = MagicMock(return_value=False)
        mock_db.campaign_repo.get_by_repo_path.return_value = None

        created_campaign = Campaign(
            id=42,
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.INITIALIZING,
        )
        mock_db.campaign_repo.create.return_value = created_campaign

        # After status update, get returns ACTIVE campaign
        active_campaign = Campaign(
            id=42,
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.ACTIVE,
        )

        def get_campaign_side_effect(campaign_id):
            if campaign_id == 42:
                return active_campaign
            return None

        mock_db.campaign_repo.get.side_effect = get_campaign_side_effect

        campaign_dir = tmp_path / "campaigns" / "my-repo-abc123"

        with (
            patch(
                "unfamiliar.campaign.orchestrator.analyze_repository"
            ) as mock_analyze,
            patch(
                "unfamiliar.campaign.orchestrator.generate_briefing"
            ) as mock_briefing,
            patch(
                "unfamiliar.campaign.orchestrator.run_mission_pipeline"
            ) as mock_pipeline,
            patch("unfamiliar.campaign.orchestrator.open_campaign_db") as mock_open_db,
            patch(
                "unfamiliar.campaign.orchestrator.create_chat_model"
            ) as mock_create_model,
            patch("unfamiliar.campaign.orchestrator.get_campaign_dir") as mock_get_dir,
            patch(
                "unfamiliar.campaign.orchestrator.persist_briefing_data"
            ) as mock_persist_briefing,
            patch(
                "unfamiliar.campaign.orchestrator.persist_pipeline_data"
            ) as mock_persist_pipeline,
        ):
            mock_analyze.return_value = mock_analysis
            mock_briefing.return_value = mock_briefing_result
            mock_pipeline.return_value = mock_pipeline_result
            mock_open_db.return_value = mock_db
            mock_create_model.return_value = MagicMock()
            mock_get_dir.return_value = campaign_dir
            mock_persist_briefing.return_value = []
            mock_persist_pipeline.return_value = mock_persist_result

            result = create_campaign(repo_path, config, theme=FILM_NOIR_THEME)

        # Assert all fields on returned campaign
        assert result.id == 42
        assert result.repo_path == str(repo_path.resolve())
        assert result.status == CampaignStatus.ACTIVE

        # Verify orchestration sequence was called
        mock_analyze.assert_called_once_with(repo_path)
        mock_briefing.assert_called_once()
        mock_pipeline.assert_called_once()
        mock_db.campaign_repo.update_status.assert_called_once_with(
            42, CampaignStatus.ACTIVE
        )

    def test_calls_progress_callback_at_each_stage(self, tmp_path: Path):
        """Progress callback receives messages for each orchestration stage."""
        # Setup: create a fake git repo
        repo_path = tmp_path / "progress-repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()

        config = UnfamiliarConfig(
            provider="anthropic",
            token_budget=10000,
            theme="film_noir",
        )

        # Capture all callback messages
        callback_messages: list[str] = []

        def progress_callback(message: str) -> None:
            callback_messages.append(message)

        # Mock all subsystems
        mock_analysis = MagicMock()
        mock_analysis.files = []
        mock_analysis.modules = []

        mock_briefing_result = MagicMock()
        mock_briefing_result.modules = []

        # Create a mock category response with missions list
        mock_category_response = MagicMock()
        mock_category_response.missions = [MagicMock()]  # 1 mission

        mock_pipeline_result = MagicMock()
        mock_pipeline_result.completed_categories = ["architecture"]
        mock_pipeline_result.category_missions = {
            "architecture": mock_category_response
        }

        mock_persist_result = MagicMock()
        mock_persist_result.mission_count = 3
        mock_persist_result.chain_count = 0
        mock_persist_result.warning_messages = []

        # Mock DB and repos
        mock_db = MagicMock()
        mock_db.__enter__ = MagicMock(return_value=mock_db)
        mock_db.__exit__ = MagicMock(return_value=False)
        mock_db.campaign_repo.get_by_repo_path.return_value = None

        created_campaign = Campaign(
            id=1,
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.INITIALIZING,
        )
        mock_db.campaign_repo.create.return_value = created_campaign

        active_campaign = Campaign(
            id=1,
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.ACTIVE,
        )
        mock_db.campaign_repo.get.return_value = active_campaign

        campaign_dir = tmp_path / "campaigns" / "progress-repo-abc"

        with (
            patch(
                "unfamiliar.campaign.orchestrator.analyze_repository"
            ) as mock_analyze,
            patch(
                "unfamiliar.campaign.orchestrator.generate_briefing"
            ) as mock_briefing,
            patch(
                "unfamiliar.campaign.orchestrator.run_mission_pipeline"
            ) as mock_pipeline,
            patch("unfamiliar.campaign.orchestrator.open_campaign_db") as mock_open_db,
            patch(
                "unfamiliar.campaign.orchestrator.create_chat_model"
            ) as mock_create_model,
            patch("unfamiliar.campaign.orchestrator.get_campaign_dir") as mock_get_dir,
            patch(
                "unfamiliar.campaign.orchestrator.persist_briefing_data"
            ) as mock_persist_briefing,
            patch(
                "unfamiliar.campaign.orchestrator.persist_pipeline_data"
            ) as mock_persist_pipeline,
        ):
            mock_analyze.return_value = mock_analysis
            mock_briefing.return_value = mock_briefing_result
            mock_pipeline.return_value = mock_pipeline_result
            mock_open_db.return_value = mock_db
            mock_create_model.return_value = MagicMock()
            mock_get_dir.return_value = campaign_dir
            mock_persist_briefing.return_value = []
            mock_persist_pipeline.return_value = mock_persist_result

            create_campaign(
                repo_path,
                config,
                progress_callback=progress_callback,
                theme=FILM_NOIR_THEME,
            )

        # Verify callback received expected messages in order
        assert len(callback_messages) == 9

        # Check each stage message (using startswith for paths that vary)
        assert callback_messages[0].startswith("Checking repository:")
        assert callback_messages[1].startswith("Campaign directory:")
        assert callback_messages[2] == "Analyzing repository..."
        assert callback_messages[3] == "Connecting to LLM provider..."
        assert callback_messages[4] == "Generating codebase briefing..."
        assert callback_messages[5] == "Saving briefing data..."
        assert callback_messages[6] == "Generating investigation missions..."
        assert callback_messages[7] == "Saving mission data..."
        assert callback_messages[8].startswith("Campaign created:")

    def test_non_git_directory_raises_orchestration_error(self, tmp_path: Path):
        """Path without .git/ directory raises CampaignOrchestrationError."""
        # Setup: directory without .git
        repo_path = tmp_path / "not-a-repo"
        repo_path.mkdir()
        # No .git directory created

        config = UnfamiliarConfig(
            provider="anthropic",
            token_budget=10000,
            theme="film_noir",
        )

        with pytest.raises(CampaignOrchestrationError) as exc_info:
            create_campaign(repo_path, config, theme=FILM_NOIR_THEME)

        error_message = str(exc_info.value)
        assert "not a git repository" in error_message.lower()
        assert str(repo_path) in error_message

    def test_existing_campaign_is_deleted_before_recreation(self, tmp_path: Path):
        """When campaign exists for repo, it is deleted before new one created."""
        # Setup: create a fake git repo
        repo_path = tmp_path / "existing-repo"
        repo_path.mkdir()
        (repo_path / ".git").mkdir()

        config = UnfamiliarConfig(
            provider="anthropic",
            token_budget=10000,
            theme="film_noir",
        )

        # Mock all subsystems
        mock_analysis = MagicMock()
        mock_analysis.files = []
        mock_analysis.modules = []

        mock_briefing_result = MagicMock()
        mock_briefing_result.modules = []

        mock_pipeline_result = MagicMock()
        mock_pipeline_result.completed_categories = []
        mock_pipeline_result.category_missions = {}

        mock_persist_result = MagicMock()
        mock_persist_result.mission_count = 0
        mock_persist_result.chain_count = 0
        mock_persist_result.warning_messages = []

        # Mock DB with existing campaign
        mock_db = MagicMock()
        mock_db.__enter__ = MagicMock(return_value=mock_db)
        mock_db.__exit__ = MagicMock(return_value=False)

        # Existing campaign that should be deleted
        existing_campaign = Campaign(
            id=99,
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.ACTIVE,
        )
        mock_db.campaign_repo.get_by_repo_path.return_value = existing_campaign

        # Mock mission counts (no completed missions, so overwrite proceeds)
        mock_db.mission_repo.count_completed.return_value = 0
        mock_db.mission_repo.count_by_campaign.return_value = 5

        # New campaign created after deletion
        new_campaign = Campaign(
            id=100,
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.INITIALIZING,
        )
        mock_db.campaign_repo.create.return_value = new_campaign

        active_campaign = Campaign(
            id=100,
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.ACTIVE,
        )
        mock_db.campaign_repo.get.return_value = active_campaign

        campaign_dir = tmp_path / "campaigns" / "existing-repo-abc"

        with (
            patch(
                "unfamiliar.campaign.orchestrator.analyze_repository"
            ) as mock_analyze,
            patch(
                "unfamiliar.campaign.orchestrator.generate_briefing"
            ) as mock_briefing,
            patch(
                "unfamiliar.campaign.orchestrator.run_mission_pipeline"
            ) as mock_pipeline,
            patch("unfamiliar.campaign.orchestrator.open_campaign_db") as mock_open_db,
            patch(
                "unfamiliar.campaign.orchestrator.create_chat_model"
            ) as mock_create_model,
            patch("unfamiliar.campaign.orchestrator.get_campaign_dir") as mock_get_dir,
            patch(
                "unfamiliar.campaign.orchestrator.persist_briefing_data"
            ) as mock_persist_briefing,
            patch(
                "unfamiliar.campaign.orchestrator.persist_pipeline_data"
            ) as mock_persist_pipeline,
        ):
            mock_analyze.return_value = mock_analysis
            mock_briefing.return_value = mock_briefing_result
            mock_pipeline.return_value = mock_pipeline_result
            mock_open_db.return_value = mock_db
            mock_create_model.return_value = MagicMock()
            mock_get_dir.return_value = campaign_dir
            mock_persist_briefing.return_value = []
            mock_persist_pipeline.return_value = mock_persist_result

            result = create_campaign(repo_path, config, theme=FILM_NOIR_THEME)

        # Verify delete was called with existing campaign ID
        mock_db.campaign_repo.delete.assert_called_once_with(99)

        # Verify new campaign was created
        mock_db.campaign_repo.create.assert_called_once()

        # Verify result is the new campaign
        assert result.id == 100
        assert result.status == CampaignStatus.ACTIVE


class TestResumeCampaign:
    """Tests for resume_campaign function."""

    def test_returns_none_when_no_campaigns_exist(self, tmp_path: Path):
        """Empty campaigns directory returns None."""
        with patch(
            "unfamiliar.campaign.orchestrator.discover_campaigns"
        ) as mock_discover:
            mock_discover.return_value = []

            result = resume_campaign(base_dir=tmp_path)

        assert result is None

    def test_returns_most_recent_active_campaign(self, tmp_path: Path):
        """Returns most recent ACTIVE/INITIALIZING campaign, skips orphaned."""

        # Create mock campaign infos - discover_campaigns returns sorted by
        # updated_at desc
        # Most recent - ACTIVE, not orphaned - should be picked
        newest_active = CampaignInfo(
            campaign_dir=tmp_path / "campaigns" / "newest-active",
            repo_path=tmp_path / "repos" / "newest",
            status=CampaignStatus.ACTIVE,
            is_orphaned=False,
            created_at=datetime(2024, 1, 1),
            updated_at=datetime(2024, 1, 20, 12, 0, 0),
            mission_count=10,
            completed_count=5,
        )

        older_active = CampaignInfo(
            campaign_dir=tmp_path / "campaigns" / "older-active",
            repo_path=tmp_path / "repos" / "older",
            status=CampaignStatus.ACTIVE,
            is_orphaned=False,
            created_at=datetime(2024, 1, 1),
            updated_at=datetime(2024, 1, 10, 12, 0, 0),
            mission_count=5,
            completed_count=2,
        )

        # Orphaned - should be skipped even though ACTIVE
        orphaned = CampaignInfo(
            campaign_dir=tmp_path / "campaigns" / "orphaned",
            repo_path=None,
            status=CampaignStatus.ACTIVE,
            is_orphaned=True,
            created_at=datetime(2024, 1, 18),
            updated_at=datetime(2024, 1, 18, 12, 0, 0),
            mission_count=3,
            completed_count=1,
        )

        # Mock DB to return campaign for the newest_active
        mock_db = MagicMock()
        mock_db.__enter__ = MagicMock(return_value=mock_db)
        mock_db.__exit__ = MagicMock(return_value=False)

        expected_campaign = Campaign(
            id=55,
            repo_path=str(tmp_path / "repos" / "newest"),
            status=CampaignStatus.ACTIVE,
        )
        mock_db.campaign_repo.get_by_repo_path.return_value = expected_campaign

        with (
            patch(
                "unfamiliar.campaign.orchestrator.discover_campaigns"
            ) as mock_discover,
            patch("unfamiliar.campaign.orchestrator.open_campaign_db") as mock_open_db,
        ):
            # Return sorted by updated_at desc (as discover_campaigns does)
            mock_discover.return_value = [
                newest_active,  # Jan 20 - most recent, ACTIVE, not orphaned
                orphaned,  # Jan 18 - ACTIVE but orphaned, skipped
                older_active,  # Jan 10 - older ACTIVE
            ]
            mock_open_db.return_value = mock_db

            result = resume_campaign(base_dir=tmp_path)

        # Verify correct campaign returned
        assert result is not None
        assert result.id == 55
        assert result.status == CampaignStatus.ACTIVE

        # Verify DB was opened for the correct campaign directory
        mock_open_db.assert_called_once_with(newest_active.campaign_dir)


class TestCampaignExistsError:
    """Tests for CampaignExistsError exception."""

    def test_error_message_includes_progress(self, tmp_path: Path):
        """CampaignExistsError message includes completed/total counts."""
        from unfamiliar.campaign.types import CampaignExistsError

        error = CampaignExistsError(
            campaign_dir=Path("/fake/campaign-dir"),
            completed_count=5,
            total_count=10,
        )

        msg = str(error)
        assert "5/10" in msg
        assert "campaign-dir" in msg
        assert "--force" in msg

    def test_error_attributes_accessible(self, tmp_path: Path):
        """CampaignExistsError exposes attributes for CLI use."""
        from unfamiliar.campaign.types import CampaignExistsError

        error = CampaignExistsError(
            campaign_dir=Path("/fake/path"),
            completed_count=3,
            total_count=7,
        )

        assert error.campaign_dir == Path("/fake/path")
        assert error.completed_count == 3
        assert error.total_count == 7
