"""Pydantic SSOT for engine entities exposed over HTTP.

These shapes are codegen'd into @recoil/contracts/src/generated.ts via
`pydantic-to-typescript`. Phase 17 swaps the desktop + mobile data import
sites from @recoil/fixtures to @recoil/http-adapter, which uses these
generated types as the wire contract.

Per Law 7 + ADR-0010 every shape carries a frozen schema_version. Per Law 4
fields without a sane default are REQUIRED — Project.aspect has no default,
on purpose. Older projects that pre-date this format raise
LegacyProjectFormatError at the adapter boundary; the route surfaces them as
warning events and excludes them from the project list.
"""

from __future__ import annotations

from datetime import datetime
from typing import Any, Literal, Optional

from pydantic import BaseModel, ConfigDict, Field

from recoil.api.schemas._base import SCHEMA_VERSION, _Versioned

__all__ = ["SCHEMA_VERSION", "_Versioned"]


# ── Enums (Literal aliases) ────────────────────────────────────────────────
AspectRatio = Literal["9_16", "16_9", "1_1", "4_3"]
# Literal alias (not the core enum) so pydantic2ts codegen emits a union type; the core enum stays canonical.
ProjectType = Literal["microdrama", "client_deliverable", "client_video"]
EvalState = Literal["pass", "fail", "pending", "errored", "skip", "miss"]
TakeStatus = Literal["queued", "running", "succeeded", "failed"]
MediaKind = Literal["video", "still", "audio"]
BeatStatus = Literal["pending", "running", "blocked", "locked", "draft"]
EventSeverity = Literal["failure", "warning", "fallback", "success", "info"]


# ── Hierarchy: Beat → Scene → Episode → Project ───────────────────────────
class Beat(_Versioned):
    id: str
    name: str
    status: BeatStatus
    takes: int
    primary: Optional[str] = None
    score: Optional[float] = None
    focused: bool = False


class Scene(_Versioned):
    id: str
    name: str
    status: BeatStatus
    score: Optional[float] = None
    beat_list: list[Beat] = Field(default_factory=list, alias="beatList")
    # P3 — true when the Scene is synthesized at request time from the shot
    # index rather than read from a first-class engine entity. Surfaced in
    # the navigator as a faint "(synthesized)" suffix.
    synthesized: bool = False


class Episode(_Versioned):
    id: str
    name: str
    status: BeatStatus
    score: Optional[float] = None
    scenes: list[Scene] = Field(default_factory=list)
    # P3 — true when the Episode is synthesized at request time from
    # set(shot.episode_id) rather than read from a first-class engine entity.
    synthesized: bool = False


class Project(_Versioned):
    id: str
    name: str
    aspect: AspectRatio  # REQUIRED (no default — Law 4 fail-loud on missing)
    aspect_synthesized: bool = Field(
        default=False, alias="aspectSynthesized"
    )  # Phase 13 — True if aspect was derived via legacy migration; frontend shows a badge.
    project_type: ProjectType = Field(default="microdrama", alias="projectType")  # default so legacy fixture data without the field still parses
    score: Optional[float] = None
    episodes: list[Episode] = Field(default_factory=list)


# ── Take ───────────────────────────────────────────────────────────────────
class Take(_Versioned):
    id: str
    beat_id: str = Field(alias="beatId")
    idx: int
    status: TakeStatus
    eval_state: EvalState = Field(alias="evalState")
    score: Optional[float] = None
    model: Optional[str] = None
    cost_gen: Optional[float] = Field(default=None, alias="costGen")
    cost_eval: float = Field(alias="costEval")
    ts: datetime
    eta: Optional[str] = None
    progress: Optional[float] = None
    primary: bool = False
    circled: bool = False
    hidden: bool = False
    media: Optional[MediaKind] = None
    failure_mode: Optional[str] = Field(default=None, alias="failureMode")
    warnings: list[str] = Field(default_factory=list)
    aspect: Optional[AspectRatio] = None
    url: Optional[str] = None


# ── Lineage ────────────────────────────────────────────────────────────────
LineageNodeKind = Literal[
    "input",
    "note",
    "prompt",
    "params",
    "eval",
    "step",
    "sibling",
    "output",
    "deriv",
    "more",
    "ref",
    "parent_take",
    "bible",
]
LineageMediaKind = Literal["video", "image", "audio", "text"]
LineageEdgeKind = Literal["data", "note", "fallback"]


class LineageNode(_Versioned):
    id: str
    beat_id: str = Field(alias="beatId")
    kind: LineageNodeKind
    label: str
    sub: Optional[str] = None
    col: int
    row: int
    media_kind: Optional[LineageMediaKind] = Field(default=None, alias="mediaKind")
    model: Optional[str] = None
    cost: Optional[float] = None
    time: Optional[str] = None
    failed: bool = False
    prompt_body: Optional[str] = Field(default=None, alias="promptBody")
    params_body: Optional[list[list[str]]] = Field(default=None, alias="paramsBody")
    eval_detail: Optional[dict[str, Any]] = Field(default=None, alias="evalDetail")
    url: Optional[str] = None
    # ── manifest-mode fields (Optional with defaults so all legacy consumers
    # continue to parse pre-manifest Lineage payloads) ──
    ref_role: Optional[str] = Field(default=None, alias="refRole")
    ref_hash: Optional[str] = Field(default=None, alias="refHash")
    parent_take_id: Optional[str] = Field(default=None, alias="parentTakeId")
    bible_file: Optional[str] = Field(default=None, alias="bibleFile")
    bible_sections: Optional[list[str]] = Field(default=None, alias="bibleSections")


class LineageEdge(BaseModel):
    """Plain edge — no schemaVersion. Edges are siblings of nodes inside Lineage."""

    model_config = ConfigDict(populate_by_name=True, frozen=True)

    from_id: str = Field(alias="from")
    to_id: str = Field(alias="to")
    kind: Optional[LineageEdgeKind] = None


class Lineage(_Versioned):
    beat_id: str = Field(alias="beatId")
    root_take: str = Field(alias="rootTake")
    nodes: list[LineageNode] = Field(default_factory=list)
    edges: list[LineageEdge] = Field(default_factory=list)


# ── Memory ────────────────────────────────────────────────────────────────
MemoryKind = Literal["learning", "anti-pattern", "fallback"]


class MemoryEntry(_Versioned):
    id: str
    kind: MemoryKind
    scope: str
    text: str
    confidence: float
    hits: int
    on: bool


# ── Engine event ──────────────────────────────────────────────────────────
class EngineEvent(_Versioned):
    id: str
    ts: datetime
    severity: EventSeverity
    scope: str
    summary: str
    detail: Optional[str] = None
    payload: Optional[dict[str, Any]] = None


# P4 — re-export SystemStatus + WorkerPoolCounts so the codegen entry point
# (`recoil.api.schemas.engine`) picks them up. The shapes themselves live in
# recoil/api/system_status.py beside the FastAPI router, but pydantic2ts only
# discovers models inside ONE module — keeping this file as the single
# codegen surface avoids splitting the generator across modules.
#
# Import is at the BOTTOM so the `_Versioned` + `SCHEMA_VERSION` bindings that
# system_status.py imports from this module are already defined when the
# import resolves (no circular-import hazard).
from recoil.api.system_status import (  # noqa: E402  # bottom-import is intentional
    SystemStatus,
    WorkerPoolCounts,
)

__all__ = [
    "SCHEMA_VERSION",
    "AspectRatio",
    "ProjectType",
    "EvalState",
    "TakeStatus",
    "MediaKind",
    "BeatStatus",
    "EventSeverity",
    "Beat",
    "Scene",
    "Episode",
    "Project",
    "Take",
    "LineageNode",
    "LineageEdge",
    "LineageNodeKind",
    "LineageMediaKind",
    "LineageEdgeKind",
    "Lineage",
    "MemoryEntry",
    "MemoryKind",
    "EngineEvent",
    "SystemStatus",
    "WorkerPoolCounts",
    "FallbackMeta",
    "EnvelopeWithMeta",
    "MissingCanonicalField",
    "MissingCanonicalFieldError",
]


# ── Build A Convergence: canonical-absence response shapes ────────────────────
# Per LOCK 1 of console-v2-meta-diagnosis SYNTHESIS: split shapes.
# - 200 + EnvelopeWithMeta when a legitimate (sanctioned) fallback fired.
# - 422 + MissingCanonicalField when a canonical field is structurally absent.
# Two parsers, two UI affordances. Do NOT collapse to a unified `_meta` shape.

class FallbackMeta(BaseModel):
    """Carried on 200 responses that fired a legitimate fallback (18 entries)."""
    degraded: Literal[True]
    fallbacks_fired: list[str]  # canonical names from sanctioned_fallbacks registry


class EnvelopeWithMeta(BaseModel):
    """Wrapper for routes that may degrade. Used ONLY when fallbacks_fired is
    non-empty. Routes that did not fire any fallback return the bare list/object —
    no wrapper."""
    data: Any
    meta: FallbackMeta = Field(..., alias="_meta")


class MissingCanonicalField(BaseModel):
    """422 body for canonical-field absence (the 3 deletion-targets become this)."""
    error: Literal["missing_canonical_field"]
    field: str  # "episode_id" | "scene_id" | "episodes"
    project_id: str
    remediation: str  # human-readable + the canonical fix command
    fix_cli: str  # exact CLI invocation


class MissingCanonicalFieldError(Exception):
    """Raised inside adapters when a canonical field is structurally absent.
    Caught at the route boundary and converted to a 422 HTTPException with a
    MissingCanonicalField body."""

    def __init__(self, *, field: str, project_id: str, remediation: str = "", fix_cli: str = ""):
        self.field = field
        self.project_id = project_id
        self.remediation = remediation or f"Run backfill for {project_id} — {field} is missing."
        self.fix_cli = fix_cli or f"python3 recoil/pipeline/cli/backfill_state.py {project_id}"
        super().__init__(f"missing canonical field {field!r} in project {project_id!r}")
