# ==============================================================================
# PORTED FROM STARSEND: orchestrator/step_types.py
# DATE: 2026-03-29
# ==============================================================================
"""
step_types.py — Type definitions for the unified StepRunner.

Frozen dataclasses for immutability. These are the contracts between
StepRunner and its callers.
"""

from dataclasses import dataclass, field
from typing import Callable, Optional, Any
from pathlib import Path


class InvalidStateError(Exception):
    """Raised when a shot is not in the expected state for an operation."""

    def __init__(self, shot_id: str, expected: str | set[str], actual: str):
        self.shot_id = shot_id
        self.expected = expected
        self.actual = actual
        expected_str = (
            expected if isinstance(expected, str) else " | ".join(sorted(expected))
        )
        super().__init__(f"Shot {shot_id}: expected state {expected_str}, got {actual}")


@dataclass(frozen=True)
class ProjectPaths:
    """Immutable path bundle for a project+episode combination.

    This is the EPISODE-SCOPED variant. For project-scoped paths, use
    recoil.core.paths.ProjectPaths directly. The two are intentionally
    different:
      - core.paths.ProjectPaths(project_root) → project-level paths
      - execution.step_types.ProjectPaths.for_episode(project, ep) →
        episode-resolved paths bundle (frames, video, plans for a specific ep)

    Post project-paths-refactor-v2 (2026-05-26): episode dirs moved from
    output/{frames,previs,video}/ep_NNN/ to:
      - frames: sequences/ep_NNN/        (keyframes + previz both land here)
      - previs: sequences/ep_NNN/        (same dir — previz is intermediate)
      - video:  renders/ep_NNN/
    """

    project: str
    project_root: Path
    frames_dir: Path
    video_dir: Path
    plans_dir: Path
    coverage_passes_dir: Path = None
    previs_dir: Path = None

    @classmethod
    def for_episode(cls, project: str, episode: int) -> "ProjectPaths":
        from recoil.core.paths import ProjectPaths as CoreProjectPaths
        core = CoreProjectPaths.for_project(project)
        return cls(
            project=project,
            project_root=core.project_root,
            frames_dir=core.episode_prep_dir(episode),
            video_dir=core.episode_renders_dir(episode),
            plans_dir=core.plans_dir,
            coverage_passes_dir=core.coverage_passes_dir,
            previs_dir=core.episode_prep_dir(episode),
        )


@dataclass(frozen=True)
class GateVerdict:
    """Result of a single QC gate evaluation.

    When deferred=True, the pipeline continues (passed is effectively True)
    but the shot is flagged for mandatory human review before final export.
    Used by Gate 3 video drift and as a fail-open fallback when gate APIs fail.
    """

    passed: bool
    gate_name: str
    reason: str
    details: dict[str, Any] = field(default_factory=dict)
    cost: float = 0.0
    retriable: bool = True
    deferred: bool = False


# Type alias: a gate function takes (output_path, shot_dict) and returns GateVerdict
GateFunction = Callable[[Path, dict], GateVerdict]


@dataclass(frozen=True)
class StepResult:
    """Immutable result of a single generation step."""

    shot_id: str
    success: bool
    final_state: str
    output_path: Optional[str]
    cost_usd: float
    error: Optional[str]
    take_index: int
    gate_verdict: Optional[GateVerdict]
    model: str = ""
    pipeline: str = ""
    take_id: Optional[str] = None  # written to shot JSON for i2v parent linkage


@dataclass(frozen=True)
class SegmentResult:
    """Result of a single segment within a coverage pass."""

    source_shot_id: str
    segment_index: int
    timestamp_start_s: float
    timestamp_end_s: float
    boundary_frame_path: Optional[str] = None
    identity_score: Optional[float] = None
    usable: bool = True
    gate_error: Optional[str] = None


@dataclass(frozen=True)
class PassResult:
    """Result of a coverage pass generation."""

    pass_id: str
    success: bool
    video_path: Optional[str]
    cost_usd: float
    segment_results: tuple = ()  # tuple[SegmentResult, ...] — immutable like StepResult
    model: str = ""
    pipeline: str = "coverage_pass"
    error: Optional[str] = None
    take_index: int = -1
    expected_cuts: int = 0
    detected_cuts: int = 0
    # Provider-side metadata for billing reconciliation (provider, tier,
    # request_id, duration, resolution, rate, etc.). Sourced from the
    # underlying GenerationResult.metadata. See api_client.SeedDanceClient.
    api_metadata: dict = field(default_factory=dict)


__all__ = [
    # Public symbols (Phase D — MF-3 + DEBT-9).
    # Type alias.
    "GateFunction",
    # Frozen result dataclasses.
    "GateVerdict",
    "PassResult",
    "ProjectPaths",
    "SegmentResult",
    "StepResult",
    # Exceptions.
    "InvalidStateError",
]
