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

This is the glue layer that connects:
- Analysis (analyze_repository → AnalysisResult)
- LLM (generate_briefing → BriefingResult, run_mission_pipeline → PipelineResult)
- Database (open_campaign_db → CampaignDB with repositories)

Provides create_campaign() and resume_campaign() as the primary entry points.
"""

import logging
from collections.abc import Callable
from pathlib import Path

from unfamiliar.analysis import analyze_repository
from unfamiliar.campaign.discovery import discover_campaigns
from unfamiliar.campaign.persister import persist_briefing_data, persist_pipeline_data
from unfamiliar.campaign.types import CampaignExistsError
from unfamiliar.config import UnfamiliarConfig, get_campaign_dir
from unfamiliar.constants import CampaignStatus
from unfamiliar.db import CampaignDB, open_campaign_db
from unfamiliar.llm import create_chat_model, generate_briefing
from unfamiliar.llm.missions.pipeline import run_mission_pipeline
from unfamiliar.models.campaign import Campaign
from unfamiliar.tui.themes.base import ThemeConfig

logger = logging.getLogger(__name__)


class CampaignOrchestrationError(Exception):
    """Raised when campaign orchestration fails."""


def create_campaign(
    repo_path: Path,
    config: UnfamiliarConfig,
    progress_callback: Callable[[str], None] | None = None,
    *,
    force: bool = False,
    theme: ThemeConfig,
) -> Campaign:
    """Orchestrate full campaign creation: analyze → briefing → missions → persist.

    Steps:
    1. Compute campaign_dir via get_campaign_dir()
    2. Create campaign_dir if it doesn't exist
    3. Open campaign DB
    4. Check for existing campaign — raise CampaignExistsError if ACTIVE with progress
    5. Create Campaign record (status=INITIALIZING)
    6. Run analyze_repository(repo_path)
    7. Create LLM model via create_chat_model(config)
    8. Generate briefing via generate_briefing()
    9. Persist briefing data (creates modules)
    10. Run mission pipeline via run_mission_pipeline()
    11. Persist pipeline data
    12. Update campaign status to ACTIVE
    13. Return campaign

    On partial failure (some categories failed), campaign status is ACTIVE
    with partial missions. On total failure (auth error), raises and
    campaign stays INITIALIZING.

    Args:
        repo_path: Path to the repository to investigate. Must have .git/ directory.
        config: User configuration with provider settings.
        progress_callback: Optional callback for progress updates.
            Receives status strings like "Analyzing repository...",
            "Generating briefing...", etc.
        force: If True, overwrite existing campaign without prompting.
            If False and ACTIVE campaign exists with completed missions,
            raises CampaignExistsError.
        theme: Theme configuration for campaign briefing and missions.

    Returns:
        Campaign record with status=ACTIVE.

    Raises:
        CampaignOrchestrationError: If analysis fails.
        CampaignExistsError: If ACTIVE campaign exists with progress and force=False.
        AuthenticationError: If API key is invalid (propagated from LLM layer).
        AnalysisError: If repository analysis fails.
    """
    _notify(progress_callback, f"Checking repository: {repo_path}")

    # Validate git repo
    if not (repo_path / ".git").exists():
        raise CampaignOrchestrationError(
            f"'{repo_path}' is not a git repository (no .git directory found)"
        )

    # Get campaign directory
    campaign_dir = get_campaign_dir(repo_path)
    logger.debug(
        "Campaign directory for %s: %s (exists=%s)",
        repo_path,
        campaign_dir,
        campaign_dir.exists(),
    )
    _notify(progress_callback, f"Campaign directory: {campaign_dir}")

    # Open database (creates directory and marker atomically)
    logger.debug("Opening campaign database for %s", repo_path)
    db = open_campaign_db(campaign_dir, str(repo_path.resolve()))

    with db:
        # Check for existing campaign
        existing = db.campaign_repo.get_by_repo_path(str(repo_path.resolve()))
        if existing is not None:
            # Check if we should prompt before overwriting
            if not force and existing.status == CampaignStatus.ACTIVE:
                # Count completed missions
                completed_count = db.mission_repo.count_completed(existing.id)
                total_count = db.mission_repo.count_by_campaign(existing.id)
                if completed_count > 0:
                    raise CampaignExistsError(
                        campaign_dir=campaign_dir,
                        completed_count=completed_count,
                        total_count=total_count,
                    )

            logger.info(
                "Deleting existing %s campaign for %s (will recreate)",
                existing.status,
                repo_path,
            )
            db.campaign_repo.delete(existing.id)

        # Create new campaign
        campaign = Campaign(
            repo_path=str(repo_path.resolve()),
            status=CampaignStatus.INITIALIZING,
        )
        campaign = db.campaign_repo.create(campaign)
        logger.info("Created campaign %d for %s", campaign.id, repo_path)

        return _run_initialization(
            db=db,
            campaign=campaign,
            repo_path=repo_path,
            config=config,
            campaign_dir=campaign_dir,
            progress_callback=progress_callback,
            theme=theme,
        )


def _run_initialization(
    db: CampaignDB,
    campaign: Campaign,
    repo_path: Path,
    config: UnfamiliarConfig,
    campaign_dir: Path,
    progress_callback: Callable[[str], None] | None,
    theme: ThemeConfig,
) -> Campaign:
    """Run the full initialization pipeline for a new campaign."""
    # Step 1: Analyze repository
    _notify(progress_callback, "Analyzing repository...")
    analysis = analyze_repository(repo_path)
    logger.info(
        "Analysis complete: %d files, %d modules detected",
        len(analysis.files),
        len(analysis.modules),
    )

    # Step 2: Create LLM model
    _notify(progress_callback, "Connecting to LLM provider...")
    model = create_chat_model(config)

    # Step 3: Generate briefing
    _notify(progress_callback, "Generating codebase briefing...")
    briefing_result = generate_briefing(
        analysis=analysis,
        model=model,
        token_budget=config.token_budget,
        provider_name=config.provider,
    )
    logger.info(
        "Briefing generated: %d modules described",
        len(briefing_result.modules),
    )

    # Step 4: Persist briefing and modules
    _notify(progress_callback, "Saving briefing data...")
    modules = persist_briefing_data(
        db=db,
        campaign_id=campaign.id,
        briefing_result=briefing_result,
        analysis=analysis,
    )

    # Step 5: Run mission pipeline
    _notify(progress_callback, "Generating investigation missions...")

    pipeline_result = run_mission_pipeline(
        analysis=analysis,
        briefing_result=briefing_result,
        model=model,
        campaign_dir=campaign_dir,
        token_budget=config.token_budget,
        provider_name=config.provider,
        theme_config=theme,
    )
    logger.info(
        "Mission pipeline complete: %d categories, %d missions",
        len(pipeline_result.completed_categories),
        sum(
            len(pipeline_result.category_missions[c].missions)
            for c in pipeline_result.completed_categories
        ),
    )

    # Step 6: Persist pipeline data
    _notify(progress_callback, "Saving mission data...")
    persist_result = persist_pipeline_data(
        db=db,
        campaign_id=campaign.id,
        pipeline_result=pipeline_result,
        modules=modules,
        repo_path=repo_path,
    )
    logger.info(
        "Persisted %d missions, %d chains",
        persist_result.mission_count,
        persist_result.chain_count,
    )

    if persist_result.warning_messages:
        for warning in persist_result.warning_messages:
            logger.warning("Persistence warning: %s", warning)

    # Step 7: Update campaign status to ACTIVE
    db.campaign_repo.update_status(campaign.id, CampaignStatus.ACTIVE)
    campaign = db.campaign_repo.get(campaign.id)

    _notify(
        progress_callback,
        f"Campaign created: {persist_result.mission_count} missions across "
        f"{len(pipeline_result.completed_categories)} categories",
    )

    return campaign


def resume_campaign(
    repo_path: Path | None = None,
    base_dir: Path | None = None,
) -> Campaign | None:
    """Find and return the most recently active campaign, or a specific one.

    If repo_path provided: looks up that specific campaign.
    If no repo_path: finds the most recently updated ACTIVE or INITIALIZING campaign.

    Returns None if no matching campaign found.
    If campaign is INITIALIZING, returns it (caller decides whether to continue).

    Args:
        repo_path: Optional path to look up a specific campaign.
        base_dir: Base directory for unfamiliar data. Defaults to ~/.unfamiliar.

    Returns:
        Campaign record, or None if not found.
    """
    if repo_path is not None:
        # Look up specific campaign
        campaign_dir = get_campaign_dir(repo_path, base_dir)
        if not campaign_dir.exists():
            return None

        db = open_campaign_db(campaign_dir, str(repo_path.resolve()))
        with db:
            campaign = db.campaign_repo.get_by_repo_path(str(repo_path.resolve()))
            return campaign

    # Find most recent active/initializing campaign
    campaigns = discover_campaigns(base_dir)

    # Filter to ACTIVE or INITIALIZING, sort by updated_at desc
    active_campaigns = [
        c
        for c in campaigns
        if c.status in (CampaignStatus.ACTIVE, CampaignStatus.INITIALIZING)
        and not c.is_orphaned
    ]

    if not active_campaigns:
        return None

    # Get the most recent one
    most_recent = active_campaigns[0]

    # Open and return the campaign record
    db = open_campaign_db(most_recent.campaign_dir)
    with db:
        if most_recent.repo_path is None:
            return None
        campaign = db.campaign_repo.get_by_repo_path(str(most_recent.repo_path))
        return campaign


def _notify(
    callback: Callable[[str], None] | None,
    message: str,
) -> None:
    """Send a progress notification if callback is provided."""
    if callback is not None:
        callback(message)
