"""Pipeline data persister — transform LLM output to database models.

This module bridges the gap between the LLM-generated schemas
(BriefingResult, PipelineResult) and the database models
(Briefing, Module, Mission, etc.).

Two main functions:
- persist_briefing_data: Store briefing and modules
- persist_pipeline_data: Store missions, chains, and relationships
"""

import hashlib
import logging
from pathlib import Path

from unfamiliar.analysis.models import AnalysisResult
from unfamiliar.campaign.types import PersistResult
from unfamiliar.constants import (
    InvestigationCategory,
    MissionDifficulty,
    MissionStatus,
)
from unfamiliar.db import CampaignDB
from unfamiliar.llm.briefing.generator import BriefingResult
from unfamiliar.llm.missions.pipeline import PipelineResult
from unfamiliar.llm.missions.schemas import (
    ChainResponse,
    MissionFileRefResponse,
    MissionItemResponse,
)
from unfamiliar.models.analysis import Module
from unfamiliar.models.campaign import Briefing
from unfamiliar.models.mission import (
    ChainMissionLink,
    Mission,
    MissionChain,
    MissionFileRef,
    ModuleMissionLink,
)

logger = logging.getLogger(__name__)


def persist_briefing_data(
    db: CampaignDB,
    campaign_id: int,
    briefing_result: BriefingResult,
    analysis: AnalysisResult | None = None,
) -> list[Module]:
    """Persist the briefing and module descriptions to the database.

    Creates a Briefing record and Module records for each module description
    in the briefing result. Returns persisted modules with IDs populated,
    which are needed for mission-module linking during pipeline persistence.

    ModuleDescription (from LLM) provides name, description, and
    security_relevant. Structural data (path, file_count, category_relevance)
    comes from the AnalysisResult's DetectedModule objects when available.

    Args:
        db: Open campaign database with all repositories.
        campaign_id: ID of the campaign to associate with.
        briefing_result: Output from generate_briefing().
        analysis: Optional AnalysisResult for structural module data
            (path, file_count, category_hints). When None, defaults are used.

    Returns:
        List of persisted Module objects with IDs populated, in the same
        order as briefing_result.modules (for index-based linking).

    Raises:
        DatabaseError: If campaign doesn't exist or briefing already exists.
    """
    # Create the briefing record
    briefing = Briefing(
        campaign_id=campaign_id,
        summary=briefing_result.briefing.summary,
        tech_stack=briefing_result.briefing.tech_stack,
        security_posture=briefing_result.briefing.security_posture,
    )
    db.campaign_repo.create_briefing(briefing)
    logger.info(
        "Created briefing for campaign %d with %d tech stack items",
        campaign_id,
        len(briefing.tech_stack),
    )

    # Create module records
    # LLM provides: name, description, security_relevant
    # Analysis provides: primary_path, file_count, category_hints
    detected_modules = analysis.modules if analysis else []

    modules_to_create: list[Module] = []
    for idx, module_desc in enumerate(briefing_result.modules):
        # Pull structural data from analysis when available
        if idx < len(detected_modules):
            detected = detected_modules[idx]
            path = detected.primary_path
            file_count = detected.file_count
            category_relevance = list(detected.category_hints)
        else:
            path = ""
            file_count = 0
            category_relevance = []

        module = Module(
            campaign_id=campaign_id,
            name=module_desc.name,
            path=path,
            description=module_desc.description,
            file_count=file_count,
            category_relevance=category_relevance,
            security_relevant=module_desc.security_relevant,
        )
        modules_to_create.append(module)

    persisted_modules = db.module_repo.create_many(modules_to_create)
    logger.info(
        "Created %d modules for campaign %d",
        len(persisted_modules),
        campaign_id,
    )

    return persisted_modules


def persist_pipeline_data(
    db: CampaignDB,
    campaign_id: int,
    pipeline_result: PipelineResult,
    modules: list[Module],
    repo_path: Path,
) -> PersistResult:
    """Transform and persist all mission pipeline output to the database.

    For each completed category in the pipeline result:
    1. Creates Mission records from MissionItemResponse
    2. Creates MissionFileRef entries
    3. Resolves prerequisite_titles to mission IDs and creates MissionPrerequisite
    4. Maps module_index to module ID and creates ModuleMissionLink
    5. Creates MissionChain records from ChainResponse

    Root missions (no prerequisites) are set to AVAILABLE status.
    All other missions are set to LOCKED status.

    Args:
        db: Open campaign database with all repositories.
        campaign_id: ID of the campaign.
        pipeline_result: Output from run_mission_pipeline().
        modules: Persisted Module objects (with IDs) from persist_briefing_data().
        repo_path: Path to the repository (for file hash computation).

    Returns:
        PersistResult with counts and any warning messages.
    """
    warnings: list[str] = []
    total_missions = 0
    total_chains = 0

    # Build a lookup for missions by title_plain within each category
    # This allows prerequisite resolution after all missions are persisted
    category_mission_lookup: dict[str, dict[str, int]] = {}

    # Process each completed category
    for category_name in pipeline_result.completed_categories:
        category_missions = pipeline_result.category_missions.get(category_name)
        if category_missions is None:
            continue

        try:
            category = InvestigationCategory(category_name)
        except ValueError:
            warnings.append(f"Unknown category '{category_name}', skipping")
            continue

        # Persist missions for this category
        mission_id_map = _persist_category_missions(
            db=db,
            campaign_id=campaign_id,
            category=category,
            missions=category_missions.missions,
            modules=modules,
            repo_path=repo_path,
            warnings=warnings,
        )
        category_mission_lookup[category_name] = mission_id_map
        total_missions += len(mission_id_map)

        # Persist chains for this category
        chain_count = _persist_category_chains(
            db=db,
            campaign_id=campaign_id,
            category=category,
            chains=category_missions.chains,
            mission_id_map=mission_id_map,
            warnings=warnings,
            missions=category_missions.missions,
        )
        total_chains += chain_count

    # Now resolve prerequisites (second pass after all missions exist)
    _resolve_all_prerequisites(
        db=db,
        pipeline_result=pipeline_result,
        category_mission_lookup=category_mission_lookup,
        warnings=warnings,
    )

    logger.info(
        "Persisted %d missions and %d chains for campaign %d",
        total_missions,
        total_chains,
        campaign_id,
    )

    return PersistResult(
        mission_count=total_missions,
        chain_count=total_chains,
        warning_messages=warnings,
    )


def _persist_category_missions(
    db: CampaignDB,
    campaign_id: int,
    category: InvestigationCategory,
    missions: list[MissionItemResponse],
    modules: list[Module],
    repo_path: Path,
    warnings: list[str],
) -> dict[str, int]:
    """Persist all missions for a category.

    Returns a mapping from title_plain to mission ID for prerequisite resolution.
    """
    mission_id_map: dict[str, int] = {}

    for mission_resp in missions:
        # Map difficulty string to enum
        difficulty = _parse_difficulty(mission_resp.difficulty)

        # Determine initial status: root missions are AVAILABLE, others LOCKED
        is_root = len(mission_resp.prerequisite_titles) == 0
        initial_status = MissionStatus.AVAILABLE if is_root else MissionStatus.LOCKED

        # Create the mission
        mission = Mission(
            campaign_id=campaign_id,
            title=mission_resp.title,
            title_plain=mission_resp.title_plain,
            description=mission_resp.description,
            category=category,
            difficulty=difficulty,
            time_estimate_minutes=difficulty.time_minutes,
            completion_condition=mission_resp.completion_condition,
            security_tag=mission_resp.security_tag,
            standard_tag=mission_resp.standard_tag,
            status=initial_status,
            narrative_intro=mission_resp.narrative_intro,
            narrative_outro=mission_resp.narrative_outro,
        )

        persisted = db.mission_repo.create(mission)
        mission_id_map[mission_resp.title_plain] = persisted.id

        # Create file references
        file_refs = _create_file_refs(
            mission_id=persisted.id,
            file_ref_responses=mission_resp.file_refs,
            repo_path=repo_path,
        )
        if file_refs:
            db.mission_file_ref_repo.add_file_refs(persisted.id, file_refs)

        # Create module link (primary only for now)
        module_index = mission_resp.module_index
        if module_index < 0 or module_index >= len(modules):
            warnings.append(
                f"Mission '{mission_resp.title_plain}' has module_index "
                f"{module_index} out of range (0-{len(modules) - 1}), defaulting to 0"
            )
            module_index = 0

        link = ModuleMissionLink(
            module_id=modules[module_index].id,
            mission_id=persisted.id,
            is_primary=True,
        )
        db.mission_link_repo.link_modules(persisted.id, [link])

    return mission_id_map


def _create_file_refs(
    mission_id: int,
    file_ref_responses: list[MissionFileRefResponse],
    repo_path: Path,
) -> list[MissionFileRef]:
    """Convert MissionFileRefResponse to MissionFileRef models."""
    refs: list[MissionFileRef] = []

    for ref_resp in file_ref_responses:
        # Compute file hash
        file_path = repo_path / ref_resp.file_path
        file_hash = ""
        try:
            if file_path.exists():
                file_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()[:16]
        except OSError as exc:
            logger.debug("Could not hash file %s: %s", file_path, exc)

        refs.append(
            MissionFileRef(
                mission_id=mission_id,
                file_path=ref_resp.file_path,
                line_start=ref_resp.line_start,
                line_end=ref_resp.line_end,
                description=ref_resp.description,
                file_hash=file_hash,
            )
        )

    return refs


def _persist_category_chains(
    db: CampaignDB,
    campaign_id: int,
    category: InvestigationCategory,
    chains: list[ChainResponse],
    mission_id_map: dict[str, int],
    warnings: list[str],
    missions: list[MissionItemResponse] | None = None,
) -> int:
    """Persist all chains for a category.

    Args:
        db: Open campaign database with all repositories.
        campaign_id: ID of the campaign.
        category: The investigation category.
        chains: List of chain responses to persist.
        mission_id_map: Mapping from title_plain to mission ID.
        warnings: List to append warning messages.
        missions: Optional list of missions for Film Noir title fallback lookup.

    Returns the number of chains created.
    """
    count = 0

    # Build fallback map from Film Noir title to mission ID
    title_fallback: dict[str, int] = {}
    if missions:
        for m in missions:
            if m.title_plain in mission_id_map:
                title_fallback[m.title] = mission_id_map[m.title_plain]

    for chain_resp in chains:
        # Create the chain

        chain = MissionChain(
            campaign_id=campaign_id,
            name=chain_resp.name,
            name_plain=chain_resp.name_plain,
            description=chain_resp.description,
            category=category,
            sort_order=count,
        )
        persisted_chain = db.chain_repo.create(chain)

        # Resolve mission titles to IDs and create links
        links: list[ChainMissionLink] = []
        for position, title in enumerate(chain_resp.mission_titles):
            # Primary lookup: title_plain
            mission_id = mission_id_map.get(title)

            # Fallback: Film Noir title (if missions provided)
            if mission_id is None and title in title_fallback:
                mission_id = title_fallback[title]
                warning_msg = (
                    f"Chain '{chain_resp.name_plain}' linked via "
                    f"Film Noir title fallback: '{title}'"
                )
                logger.warning(warning_msg)
                warnings.append(warning_msg)

            if mission_id is None:
                warnings.append(
                    f"Chain '{chain_resp.name_plain}' references "
                    f"unknown mission '{title}', skipping"
                )
                continue

            links.append(
                ChainMissionLink(
                    chain_id=persisted_chain.id,
                    mission_id=mission_id,
                    position=position,
                )
            )

        if links:
            db.chain_repo.add_missions(persisted_chain.id, links)

        count += 1

    return count


def _resolve_all_prerequisites(
    db: CampaignDB,
    pipeline_result: PipelineResult,
    category_mission_lookup: dict[str, dict[str, int]],
    warnings: list[str],
) -> None:
    """Create prerequisite relationships for all persisted missions.

    This is a second pass after all missions are persisted, so we can
    resolve prerequisite_titles to mission IDs.
    """
    for category_name, mission_id_map in category_mission_lookup.items():
        category_missions = pipeline_result.category_missions.get(category_name)
        if category_missions is None:
            continue

        for mission_resp in category_missions.missions:
            if not mission_resp.prerequisite_titles:
                continue

            dependent_id = mission_id_map.get(mission_resp.title_plain)
            if dependent_id is None:
                continue

            prerequisite_ids: list[int] = []
            for prereq_title in mission_resp.prerequisite_titles:
                prereq_id = mission_id_map.get(prereq_title)
                if prereq_id is None:
                    warnings.append(
                        f"Mission '{mission_resp.title_plain}' has "
                        f"unknown prerequisite '{prereq_title}', skipping"
                    )
                    continue
                prerequisite_ids.append(prereq_id)

            if prerequisite_ids:
                db.mission_file_ref_repo.add_prerequisites(
                    dependent_id, prerequisite_ids
                )


def _parse_difficulty(value: str) -> MissionDifficulty:
    """Parse a difficulty string to MissionDifficulty enum."""
    try:
        return MissionDifficulty(value)
    except ValueError:
        logger.warning("Unknown difficulty '%s', defaulting to MEDIUM", value)
        return MissionDifficulty.MEDIUM
