"""Mission validation and auto-fix utilities.

Validates LLM-generated missions against the filesystem and prerequisite graph.
Auto-fixes safe issues (line ranges, missing prerequisites) and logs warnings.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path  # noqa: TC003 (used at runtime for validation)

from unfamiliar.constants import (
    MAX_PREREQUISITE_DEPTH,
    MIN_ROOT_MISSIONS_PER_CATEGORY,
    InvestigationCategory,
)
from unfamiliar.llm.missions.schemas import (
    ChainResponse,
    MissionFileRefResponse,
    MissionItemResponse,
)

logger = logging.getLogger(__name__)


@dataclass
class ValidationResult:
    """Result of validating a category's generated missions.

    Attributes:
        missions: Post-validation mission list (may be modified from original).
        chains: Post-validation chain list (may be modified from original).
        warnings: All issues found, whether auto-fixed or not.
        fixes_applied: Auto-fix actions taken.
        valid: True if at least 1 mission exists and no critical issues remain.
    """

    missions: list[MissionItemResponse]
    chains: list[ChainResponse]
    warnings: list[str] = field(default_factory=list)
    fixes_applied: list[str] = field(default_factory=list)
    valid: bool = True


def validate_category_missions(
    missions_response: MissionItemResponse | list[MissionItemResponse],
    repo_path: Path,
    category: InvestigationCategory,
    chains: list[ChainResponse] | None = None,
) -> ValidationResult:
    """Orchestrate all validation passes on a category's generated missions.

    Args:
        missions_response: Single mission or list of missions from LLM output.
        repo_path: Absolute path to the repository root.
        category: The investigation category being validated.
        chains: Optional list of chain groupings from LLM output.

    Returns:
        ValidationResult with validated/modified missions and any warnings.
    """
    # Normalize input to list
    if isinstance(missions_response, MissionItemResponse):
        missions = [missions_response]
    else:
        missions = list(missions_response)

    chains = chains or []
    warnings: list[str] = []
    fixes_applied: list[str] = []

    # Check for empty mission list
    if not missions:
        return ValidationResult(
            missions=[],
            chains=[],
            warnings=["No missions generated for category"],
            fixes_applied=[],
            valid=False,
        )

    # Step 1: Validate file references for each mission
    for mission in missions:
        validated_refs, ref_warnings = validate_file_refs(mission.file_refs, repo_path)
        mission.file_refs = validated_refs
        warnings.extend(ref_warnings)
        if ref_warnings:
            fixes_applied.append(f"Fixed file refs in '{mission.title_plain}'")

    # Step 2: Validate prerequisites (cycle detection, depth, roots)
    missions, prereq_warnings, prereq_fixes = validate_prerequisites(missions, category)
    warnings.extend(prereq_warnings)
    fixes_applied.extend(prereq_fixes)

    # Step 2.5: Deduplicate thematic titles
    missions, dedup_warnings, dedup_fixes = _deduplicate_titles(missions)
    warnings.extend(dedup_warnings)
    fixes_applied.extend(dedup_fixes)

    # Step 3: Validate chains reference existing missions
    validated_chains, chain_warnings = _validate_chains(missions, chains)
    chains = validated_chains
    warnings.extend(chain_warnings)

    # Final validity check
    valid = len(missions) > 0

    return ValidationResult(
        missions=missions,
        chains=chains,
        warnings=warnings,
        fixes_applied=fixes_applied,
        valid=valid,
    )


def validate_file_refs(
    file_refs: list[MissionFileRefResponse],
    repo_path: Path,
) -> tuple[list[MissionFileRefResponse], list[str]]:
    """Validate file references from LLM output against the actual filesystem.

    Args:
        file_refs: List of file references from LLM output.
        repo_path: Absolute path to the repository root.

    Returns:
        Tuple of (validated refs, warnings list).
        Invalid file paths are removed from the output.
        Line ranges are clamped to actual file bounds.
    """
    validated: list[MissionFileRefResponse] = []
    warnings: list[str] = []

    for ref in file_refs:
        file_path = repo_path / ref.file_path

        # Check if file exists
        if not file_path.exists():
            warnings.append(f"File not found: {ref.file_path} (removed from mission)")
            continue

        # Check for path traversal (file should be under repo_path)
        try:
            file_path.resolve().relative_to(repo_path.resolve())
        except ValueError:
            warnings.append(
                f"Path traversal detected: {ref.file_path} (removed from mission)"
            )
            continue

        # Skip binary/unreadable files
        try:
            content = file_path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError) as e:
            warnings.append(
                f"Could not read file {ref.file_path}: {e} (removed from mission)"
            )
            continue

        # Validate and clamp line ranges
        line_count = len(content.splitlines())
        line_start = ref.line_start
        line_end = ref.line_end

        # Fix: line_start = 0 → 1 (lines are 1-indexed)
        if line_start is not None and line_start < 1:
            line_start = 1
            warnings.append(
                f"Fixed line_start in {ref.file_path}: 0 → 1 (lines are 1-indexed)"
            )

        # Fix: line_end = 0 → 1 (lines are 1-indexed)
        if line_end is not None and line_end < 1:
            line_end = 1
            warnings.append(
                f"Fixed line_end in {ref.file_path}: 0 → 1 (lines are 1-indexed)"
            )

        # Fix: line_start > line_end → swap
        if line_start is not None and line_end is not None and line_start > line_end:
            line_start, line_end = line_end, line_start
            warnings.append(
                f"Swapped line range in {ref.file_path}: now {line_start}-{line_end}"
            )

        # Fix: line_start > file_line_count → clamp to last line
        if line_start is not None and line_start > line_count:
            line_start = line_count
            line_end = line_count
            warnings.append(f"Clamped line range in {ref.file_path} to {line_count}")

        # Fix: line_end > file_line_count → clamp
        if line_end is not None and line_end > line_count:
            line_end = line_count
            warnings.append(f"Clamped line_end in {ref.file_path} to {line_count}")

        # Create validated ref (may have modified line numbers)
        validated.append(
            MissionFileRefResponse(
                file_path=ref.file_path,
                line_start=line_start,
                line_end=line_end,
                description=ref.description,
            )
        )

    return validated, warnings


def validate_prerequisites(
    missions: list[MissionItemResponse],
    category: InvestigationCategory,
) -> tuple[list[MissionItemResponse], list[str], list[str]]:
    """Validate and fix prerequisite relationships within a mission set.

    Args:
        missions: List of mission items from LLM output for one category.
        category: The investigation category being validated.

    Returns:
        Tuple of (modified missions, warnings, fixes_applied).
    """
    warnings: list[str] = []
    fixes_applied: list[str] = []

    if not missions:
        return missions, ["Empty mission list"], []

    # Build title → mission mapping
    title_to_mission: dict[str, MissionItemResponse] = {
        m.title_plain: m for m in missions
    }

    # Step 1: Remove prerequisites that reference non-existent missions
    for mission in missions:
        valid_prereqs: list[str] = []
        for prereq_title in mission.prerequisite_titles:
            if prereq_title in title_to_mission:
                valid_prereqs.append(prereq_title)
            else:
                warnings.append(
                    f"Removed nonexistent prerequisite '{prereq_title}' "
                    f"from '{mission.title_plain}'"
                )
                fixes_applied.append(f"Removed bad prereq in '{mission.title_plain}'")
        mission.prerequisite_titles = valid_prereqs

    # Step 2: Detect and break cycles
    missions, cycle_warnings, cycle_fixes = _break_cycles(missions)
    warnings.extend(cycle_warnings)
    fixes_applied.extend(cycle_fixes)

    # Step 3: Enforce max prerequisite depth
    missions, depth_warnings, depth_fixes = _enforce_max_depth(missions)
    warnings.extend(depth_warnings)
    fixes_applied.extend(depth_fixes)

    # Step 4: Ensure minimum root missions exist
    missions, root_warnings, root_fixes = _ensure_root_missions(missions)
    warnings.extend(root_warnings)
    fixes_applied.extend(root_fixes)

    # Step 5: Verify all missions reachable from a root
    missions, reach_warnings, reach_fixes = _ensure_reachability(missions)
    warnings.extend(reach_warnings)
    fixes_applied.extend(reach_fixes)

    return missions, warnings, fixes_applied


def _break_cycles(
    missions: list[MissionItemResponse],
) -> tuple[list[MissionItemResponse], list[str], list[str]]:
    """Detect and break circular prerequisite dependencies.

    Uses DFS to detect cycles. When found, removes one edge from the cycle.
    """
    warnings: list[str] = []
    fixes_applied: list[str] = []

    # Build adjacency list
    title_to_idx = {m.title_plain: i for i, m in enumerate(missions)}

    # Track visited and in-progress for cycle detection
    visited: set[int] = set()
    in_progress: set[int] = set()
    cycle_edges: list[tuple[int, str]] = []  # (mission_idx, prereq_title)

    def dfs(idx: int, path: list[int]) -> None:
        if idx in in_progress:
            # Found a cycle - mark the edge that closes the cycle
            cycle_start = path.index(idx)
            cycle_path = path[cycle_start:] + [idx]
            # Remove the last edge in the cycle
            last_mission_idx = cycle_path[-2]
            prereq_title = missions[cycle_path[-1]].title_plain
            cycle_edges.append((last_mission_idx, prereq_title))
            return
        if idx in visited:
            return

        in_progress.add(idx)
        path.append(idx)

        mission = missions[idx]
        for prereq_title in mission.prerequisite_titles:
            if prereq_title in title_to_idx:
                dfs(title_to_idx[prereq_title], path)

        path.pop()
        in_progress.remove(idx)
        visited.add(idx)

    # Run DFS from each mission
    for i in range(len(missions)):
        if i not in visited:
            dfs(i, [])

    # Remove cycle-closing edges
    for mission_idx, prereq_title in cycle_edges:
        mission = missions[mission_idx]
        if prereq_title in mission.prerequisite_titles:
            mission.prerequisite_titles.remove(prereq_title)
            warnings.append(
                f"Broke cycle by removing prereq '{prereq_title}' "
                f"from '{mission.title_plain}'"
            )
            fixes_applied.append(f"Broke cycle in '{mission.title_plain}'")

    return missions, warnings, fixes_applied


def _enforce_max_depth(
    missions: list[MissionItemResponse],
) -> tuple[list[MissionItemResponse], list[str], list[str]]:
    """Ensure no prerequisite chain exceeds MAX_PREREQUISITE_DEPTH."""
    warnings: list[str] = []
    fixes_applied: list[str] = []

    title_to_idx = {m.title_plain: i for i, m in enumerate(missions)}

    def get_depth(idx: int, visited: set[int]) -> int:
        """Calculate depth of mission from root (memoized by visited set)."""
        if idx in visited:
            return 0  # Cycle (should be broken already)
        visited.add(idx)

        mission = missions[idx]
        if not mission.prerequisite_titles:
            return 0

        max_prereq_depth = 0
        for prereq_title in mission.prerequisite_titles:
            if prereq_title in title_to_idx:
                prereq_depth = get_depth(title_to_idx[prereq_title], visited.copy())
                max_prereq_depth = max(max_prereq_depth, prereq_depth + 1)

        return max_prereq_depth

    # Check each mission's depth
    for i, mission in enumerate(missions):
        depth = get_depth(i, set())
        too_deep = depth > MAX_PREREQUISITE_DEPTH
        has_prereqs = bool(mission.prerequisite_titles)
        if too_deep and has_prereqs:
            removed = mission.prerequisite_titles.pop()
            warnings.append(
                f"Trimmed prereq '{removed}' from '{mission.title_plain}' "
                f"(depth {depth} > max {MAX_PREREQUISITE_DEPTH})"
            )
            fixes_applied.append(f"Trimmed depth in '{mission.title_plain}'")

    return missions, warnings, fixes_applied


def _ensure_root_missions(
    missions: list[MissionItemResponse],
) -> tuple[list[MissionItemResponse], list[str], list[str]]:
    """Ensure at least MIN_ROOT_MISSIONS_PER_CATEGORY missions have no prereqs."""
    warnings: list[str] = []
    fixes_applied: list[str] = []

    roots = [m for m in missions if not m.prerequisite_titles]

    if len(roots) >= MIN_ROOT_MISSIONS_PER_CATEGORY:
        return missions, warnings, fixes_applied

    # Need to create root missions by removing prerequisites
    needed = MIN_ROOT_MISSIONS_PER_CATEGORY - len(roots)

    # Sort non-roots by difficulty (quick < medium < deep_dive)
    difficulty_order = {"quick": 0, "medium": 1, "deep_dive": 2}
    non_roots = [m for m in missions if m.prerequisite_titles]
    non_roots.sort(key=lambda m: difficulty_order.get(m.difficulty, 1))

    for i in range(min(needed, len(non_roots))):
        mission = non_roots[i]
        removed = mission.prerequisite_titles.copy()
        mission.prerequisite_titles = []
        warnings.append(
            f"Created root mission '{mission.title_plain}' "
            f"by removing prereqs: {removed}"
        )
        fixes_applied.append(f"Created root '{mission.title_plain}'")

    return missions, warnings, fixes_applied


def _ensure_reachability(
    missions: list[MissionItemResponse],
) -> tuple[list[MissionItemResponse], list[str], list[str]]:
    """Ensure all missions are reachable from at least one root."""
    warnings: list[str] = []
    fixes_applied: list[str] = []

    if not missions:
        return missions, warnings, fixes_applied

    # Find all roots
    roots = {m.title_plain for m in missions if not m.prerequisite_titles}

    if not roots:
        return missions, warnings, fixes_applied  # Handled by _ensure_root_missions

    # BFS from roots to find reachable missions
    # Use deque for O(1) popleft (list.pop(0) is O(n))
    from collections import deque

    reachable: set[str] = set()
    queue = deque(roots)

    while queue:
        current = queue.popleft()
        if current in reachable:
            continue
        reachable.add(current)

        # Find missions that have current as a prerequisite
        for mission in missions:
            has_current = current in mission.prerequisite_titles
            not_reached = mission.title_plain not in reachable
            if has_current and not_reached:
                queue.append(mission.title_plain)

    # Check for unreachable missions
    unreachable = [m for m in missions if m.title_plain not in reachable]

    for mission in unreachable:
        # Make it a root
        mission.prerequisite_titles = []
        warnings.append(f"Made unreachable mission '{mission.title_plain}' a root")
        fixes_applied.append(f"Made '{mission.title_plain}' reachable")

    return missions, warnings, fixes_applied


def _validate_chains(
    missions: list[MissionItemResponse],
    chains: list[ChainResponse],
) -> tuple[list[ChainResponse], list[str]]:
    """Validate chain references to existing missions.

    Normalizes Film Noir titles to title_plain values automatically.
    """
    warnings: list[str] = []
    valid_chains: list[ChainResponse] = []

    # Build lookup sets/maps
    mission_titles = {m.title_plain for m in missions}
    title_to_plain = {m.title: m.title_plain for m in missions}

    for chain in chains:
        # Normalize and validate all mission references in chain
        normalized_titles: list[str] = []
        for title in chain.mission_titles:
            if title in mission_titles:
                # Already a valid title_plain
                normalized_titles.append(title)
            elif title in title_to_plain:
                # Film Noir title - normalize to title_plain
                plain_title = title_to_plain[title]
                normalized_titles.append(plain_title)
                warnings.append(
                    f"Normalized Film Noir title '{title}' to title_plain "
                    f"'{plain_title}' in chain '{chain.name_plain}'"
                )
            # else: title not found in either - will be caught in missing check

        # Check for missing missions (after normalization)
        missing = (
            set(chain.mission_titles)
            - set(normalized_titles)
            - set(title_to_plain.keys())
        )
        if missing:
            warnings.append(
                f"Chain '{chain.name_plain}' references missing missions: {missing}"
            )

        # Require at least 2 missions in a chain
        if len(normalized_titles) < 2:
            warnings.append(
                f"Dropped chain '{chain.name_plain}' (needs 2+ valid missions)"
            )
            continue

        # Create validated chain with normalized titles
        valid_chains.append(
            ChainResponse(
                name=chain.name,
                name_plain=chain.name_plain,
                description=chain.description,
                mission_titles=normalized_titles,
            )
        )

    return valid_chains, warnings


def _deduplicate_titles(
    missions: list[MissionItemResponse],
) -> tuple[list[MissionItemResponse], list[str], list[str]]:
    """Find and deduplicate thematic titles that appear more than once.

    When two missions have the same `title` (thematic), but different `title_plain`,
    appends a disambiguation suffix like " (II)", " (III)", etc.

    Args:
        missions: List of mission items from LLM output for one category.

    Returns:
        Tuple of (modified missions, warnings, fixes_applied).
    """
    warnings: list[str] = []
    fixes_applied: list[str] = []

    # Count title occurrences
    title_counts: dict[str, int] = {}
    for mission in missions:
        title_counts[mission.title] = title_counts.get(mission.title, 0) + 1

    # Find duplicates (count > 1)
    duplicates = {t for t, c in title_counts.items() if c > 1}
    if not duplicates:
        return missions, warnings, fixes_applied
    # Deduplicate
    roman_numerals = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
    for dup_title in duplicates:
        # Find all missions with this title
        dup_missions = [m for m in missions if m.title == dup_title]
        for i in range(1, len(dup_missions)):
            mission = dup_missions[i]
            suffix = roman_numerals[i] if i < len(roman_numerals) else f" ({i + 1})"
            new_title = f"{mission.title} {suffix}"
            logger.warning(
                "Deduplicating mission title '%s' -> '%s'",
                mission.title,
                new_title,
            )
            warnings.append(
                f"Deduplicated mission title '{mission.title}' -> '{new_title}'"
            )
            fixes_applied.append(f"Deduplicated title in '{mission.title_plain}'")
            mission.title = new_title
    return missions, warnings, fixes_applied
