"""프로젝트 범위 모델 — PostgreSQL 단일 데이터베이스에 저장."""

from sqlalchemy import Boolean, CheckConstraint, Column, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import deferred
from sqlalchemy.sql import expression
from app.core.database import Base
from app.core.file_paths import ImagePathType


class Episode(Base):
    __tablename__ = "episode"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_number = Column(Integer, nullable=False)
    title = Column(Text, nullable=False)
    source_filename = Column(Text, nullable=False)
    source_path = Column(Text, nullable=False)
    fulltext = deferred(Column(Text))
    language = Column(Text, default="ko")
    page_count = Column(Integer)
    # uploaded|analyzing|analyzed|error|stopped
    # stopped = 운영자가 세운 것 (`POST .../steps/cancel`). error 와 가른 이유는
    # 「깨진 것」과 「사람이 멈춘 것」이 다르기 때문이고, analyzing 으로 두지
    # 않는 이유는 그 값이 재실행을 409 로 막기 때문이다.
    status = Column(Text, default="uploaded")
    analysis_error = Column(Text)
    summary = Column(Text)               # 에피소드 상세 요약 (분석 시 생성)
    created_at = Column(Text, nullable=False)
    updated_at = Column(Text, nullable=False)


class EntityCanon(Base):
    __tablename__ = "entity_canon"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    short_id = Column(Text)  # C01, L03, P05, O01 — LLM 통신용 짧은 ID
    entity_type = Column(Text, nullable=False)  # character|location|prop|outlook
    name = Column(Text, nullable=False)
    description = Column(Text)
    stable_traits = Column(Text, default="{}")
    # D6: location entity 의 space_profile 등 free-form metadata.
    # stable_traits 의 list 계약 (visual_traits) 보존을 위한 별도 column.
    metadata_json = Column(Text, nullable=False, server_default="{}", default="{}")
    t2i_prompt = Column(Text)  # 시각적 보통명사 기반 T2I 프롬프트
    status = Column(Text, default="active")
    created_at = Column(Text, nullable=False)
    updated_at = Column(Text, nullable=False)


class GroundingResearchRevision(Base):
    """GROUNDING-V2 §4a — 조사 기록의 **정본**. ★append-only.

    ★**왜 별도 테이블인가.** 계약이 「조사는 별도 durable record」라고 못박았다 —
    canon 평문 칸이나 `review_notes` 에 쓰면 뒤 단계가 그 칸을 덮어 **감사가
    사라진다.** 실제로 그 판이 한 번 있었다.

    ★**신원이 셋이다** (Codex). 하나로 뭉치면 같은 입력의 두 결과를 못 남긴다:

    | 칸 | 무엇의 신원인가 |
    |---|---|
    | `research_input_hash` | **무엇을 조사했는가** — subject+시대+지역+팩+정책 |
    | `revision_content_hash` | **무엇이 나왔는가** — claims/gaps/delta |
    | `attempt_id` | **몇 번째 시도인가** — 같은 입력·같은 결과라도 다른 행 |

    같은 입력인데 결과가 다를 수 있다(`delta=yes` vs `no`). 그건 **다른 시도**이지
    다른 조사가 아니다 — 유일성을 입력에만 걸면 두 번째가 안 들어간다.

    ★체크포인트는 **projection** 이다 — id 와 hash 를 참조만 하고 payload 를
    복사하지 않는다. 복사하면 둘이 갈리고 정본이 사라진다.
    """

    __tablename__ = "grounding_research_revision"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_id = Column(Text, nullable=False)
    research_subject_id = Column(Text, nullable=False)

    # ★신원 셋 — 뭉치지 않는다.
    research_input_hash = Column(Text, nullable=False)
    revision_content_hash = Column(Text, nullable=False)
    attempt_id = Column(Text, nullable=False)

    era = Column(Text, nullable=False, server_default="", default="")
    region = Column(Text, nullable=False, server_default="", default="")
    claims_pack_version = Column(Text, nullable=False)
    policy_version = Column(Text, nullable=False)
    #: ★★입력 신원 12칸의 **정본 bytes**. 합성 hash 는 역산이 안 되므로 이게
    #:  없으면 이 행이 정말 그 입력이었는지 영원히 확인할 수 없다 (Codex).
    #:  `research_input_hash` 도 **이 문자열에서** 계산된다.
    #:  ★위 era·region·pack·policy 칼럼은 조회용이고, 정본은 이 칸 하나다.
    research_input_json = Column(Text, nullable=False)

    delta = Column(Text, nullable=False)          # yes | no | unresolved
    delta_reason = Column(Text, nullable=False, server_default="", default="")
    # ★`unresolved` 하나로 뭉치면 **시간에 잘린 것**과 **다 보고도 못 정한 것**이
    #  같아진다. 앞의 것은 다음 판에서 다시 사야 한다.
    status = Column(Text, nullable=False)  # completed|unresolved_terminal|retryable

    claims_json = Column(Text, nullable=False, server_default="[]", default="[]")
    gaps_json = Column(Text, nullable=False, server_default="[]", default="[]")
    audit_json = Column(Text, nullable=False, server_default="{}", default="{}")
    # ★**누가·무엇으로 냈나.** 빈 칸을 허용하면 그 행은 영원히 못 되짚는다.
    #  `revision_content_hash` 에는 안 들어간다 — 같은 claims 는 생산자가
    #  달라도 같은 semantic hash 가 맞다.
    provenance_json = Column(Text, nullable=False)

    created_at = Column(Text, nullable=False)

    __table_args__ = (
        # ★같은 **시도**를 두 번 안 넣는다. 입력에만 걸면 같은 입력의 다른
        #  결과가 막히고, 결과에만 걸면 어느 조사의 것인지 모른다.
        UniqueConstraint("project_id", "episode_id", "research_subject_id",
                         "research_input_hash", "attempt_id",
                         name="uq_grounding_research_revision_attempt"),
    )


class GroundingReferenceFidelityReview(Base):
    """사람이 **그 사진이 그 시대·그 지역 것인지** 정한 기록. ★DB 가 정본.

    ★★★왜 별도 테이블인가 (Codex 계약 2026-09-02). coarse 심판은
    「무엇인가 · 보이는가」만 본다 — 시대·나라·정확성은 **안 묻는다**
    (사용자 확정 08-31). 그러니 `selected` 는 「그 종류의 사진을 골랐다」일
    뿐이고, **그 시대·그 지역 것이 맞는지는 사람만** 정한다.
    조사 revision 칸이나 획득 CP 칸을 재사용하지 않는다 — 뒤 단계가 덮으면
    감사가 사라진다.

    ★★**체크포인트는 projection 이다.** 사람이 CP 나 산출 JSON 을 직접
    고치는 길은 만들지 않는다. 결정은 인증된 backend 를 지나 이 표로만
    들어온다.

    ★★★**입력 신원이 정본이다.** 사진이 바뀌거나(SHA) 다른 판으로
    다시 샀거나(acquisition identity) 요구 좌표가 바뀌면, 옛 결정은
    **절대 따라가지 않는다** — 그때 본 것과 지금 것이 다르기 때문이다.

    append-only 다. 정정은 `UPDATE` 가 아니라 `supersedes_id` 를 든 **새 행**.
    """

    __tablename__ = "grounding_reference_fidelity_review"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"),
                        nullable=False)
    episode_id = Column(Text, nullable=False)

    # ★무엇을 보고 정했나 — 조회용 칸들. **정본은 아래 `review_input_json`**.
    research_subject_id = Column(Text, nullable=False)
    final_id = Column(Text, nullable=False, server_default="", default="")
    purpose = Column(Text, nullable=False, server_default="", default="")
    acquisition_identity = Column(Text, nullable=False)
    image_sha256 = Column(Text, nullable=False)
    #: 요구된 좌표 **원문**. 없으면 빈 값 — **만들지 않는다**.
    era = Column(Text, nullable=False, server_default="", default="")
    region = Column(Text, nullable=False, server_default="", default="")

    #: ★★입력 신원의 **정본 bytes**. 합성 hash 는 역산이 안 되므로 이것이
    #:  없으면 이 행이 정말 그 입력이었는지 영원히 확인할 수 없다.
    #:  `review_input_hash` 도 **이 문자열에서** 계산된다.
    review_input_json = Column(Text, nullable=False)
    review_input_hash = Column(Text, nullable=False)

    #: verified | rejected. ★닫힌 목록이다 — 모르는 값은 미확인으로 접힌다.
    verdict = Column(Text, nullable=False)
    #: 왜 그렇게 봤나 — 사람이 적는다. 빈 칸을 허용한다(거절 사유는 권장).
    reason = Column(Text, nullable=False, server_default="", default="")

    #: ★**서버의 인증 주체**에서 받는다 — 클라이언트가 임의로 못 보낸다.
    reviewer_actor = Column(Text, nullable=False)
    reviewed_at = Column(Text, nullable=False)
    #: 어느 화면·어느 계약으로 봤나. 화면이 바뀌면 판이 바뀐다.
    review_contract_version = Column(Text, nullable=False)
    #: ★사람이 **실제로 본** 좌표와 사진 SHA. 위 칸과 다르면 그 결정은 못 쓴다.
    observed_json = Column(Text, nullable=False, server_default="{}",
                           default="{}")

    #: 정정이면 앞 행의 id. ★앞 행은 **안 지운다**.
    supersedes_id = Column(Text, nullable=True)
    #: 같은 것을 두 번 보내도 **같은 행**이 나온다.
    idempotency_key = Column(Text, nullable=False)

    created_at = Column(Text, nullable=False)

    __table_args__ = (
        UniqueConstraint("project_id", "episode_id", "idempotency_key",
                         name="uq_grounding_fidelity_review_idem"),
    )


class GroundingReferenceSelection(Base):
    """사람이 한 대상(research_subject)에 **쓸 사진 한 장**을 고른 기록. ★DB 가 정본 · append-only.

    ★★★왜 판정과 따로인가 (Codex BLOCK 2026-09-02 밤): 판정(`GroundingReferenceFidelityReview`)은
    후보 **한 장마다** 「그 시대·그 곳 것인가」다 — 둘 다 맞으면 둘 다 verified 가 옳다. 어느 장을
    **쓸지**는 다른 물음이라 같은 단추에 합치면 A 를 고른 뒤 B 로 바꾸는 정상 행동이 「두 장 동시
    승인」이 되어 대상 전체가 미확인으로 접힌다. 이 표는 대상당 살아 있는 줄 하나가 선택이고,
    새 선택은 앞 선택을 `supersedes_id` 로 대신한다(UPDATE 없음). 선택만으로는 안 붙는다 —
    그 사진의 판정이 verified 여야 `chosen` 으로 올라간다(`apply_reviews`).
    """
    __tablename__ = "grounding_reference_selection"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_id = Column(Text, nullable=False)
    research_subject_id = Column(Text, nullable=False)
    review_input_hash = Column(Text, nullable=False)
    image_sha256 = Column(Text, nullable=False, server_default="", default="")
    selected_by = Column(Text, nullable=False)
    selected_at = Column(Text, nullable=False)
    supersedes_id = Column(Text, nullable=True)
    idempotency_key = Column(Text, nullable=False)
    created_at = Column(Text, nullable=False)

    __table_args__ = (
        UniqueConstraint("project_id", "episode_id", "idempotency_key",
                         name="uq_grounding_reference_selection_idem"),
        Index("ix_grounding_reference_selection_scope",
              "project_id", "episode_id", "research_subject_id"),
    )


class CharacterOutlook(Base):
    __tablename__ = "character_outlook"

    id = Column(Text, primary_key=True)
    character_id = Column(Text, ForeignKey("entity_canon.id"), nullable=False)
    outlook_id = Column(Text, ForeignKey("entity_canon.id"), nullable=False)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    # ★어느 화의 배정인가. 없으면(=NULL) 화 범위를 모르는 legacy 행이다.
    #  종전에는 이 칸이 아예 없어서, 같은 인물이 다음 화에 다른 옷을 입으면
    #  delta sync 가 **앞 화 관계를 지웠다** (alembic 014).
    #  ★legacy 행은 추측해 채우지 않는다 — 쓰면서 제 화로 이어받는다.
    episode_id = Column(Text, nullable=True)
    created_at = Column(Text, nullable=False)


class EntityAlias(Base):
    __tablename__ = "entity_alias"

    id = Column(Text, primary_key=True)
    canon_id = Column(Text, ForeignKey("entity_canon.id"), nullable=False)
    alias = Column(Text, nullable=False)

    __table_args__ = (UniqueConstraint("canon_id", "alias"),)


class RelationFact(Base):
    __tablename__ = "relation_fact"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    relation_family = Column(Text, nullable=False)
    relation_type = Column(Text, nullable=False)
    directionality = Column(Text, nullable=False)
    temporal_scope = Column(Text, nullable=False)
    continuity_priority = Column(Text, nullable=False)
    continuity_reason = Column(Text)
    created_at = Column(Text, nullable=False)


class RelationParticipant(Base):
    __tablename__ = "relation_participant"

    id = Column(Text, primary_key=True)
    relation_id = Column(Text, ForeignKey("relation_fact.id"), nullable=False)
    canon_id = Column(Text, ForeignKey("entity_canon.id"), nullable=False)
    participant_role = Column(Text, nullable=False)
    participant_order = Column(Integer, default=1)


class SceneStill(Base):
    __tablename__ = "scene_still"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_id = Column(Text, ForeignKey("episode.id"), nullable=False)
    still_index = Column(Integer, nullable=False)
    screenplay_scene_heading = Column(Text)
    beat_title = Column(Text)
    still_frame_prompt = Column(Text)
    camera_json = Column(Text, default="{}")
    lighting_json = Column(Text, default="{}")
    visible_entities_json = Column(Text, default="[]")
    t2i_prompt_cinematic = Column(Text)    # A) wide/establishing 버전 (대표 T2I)
    t2i_prompt_closeup = Column(Text)      # B) 인물 클로즈업 버전
    t2i_variations_json = Column(Text)     # N개 T2I 변형 JSON: [{theme, theme_label, t2i_prompt}]
    t2i_composer_version = Column(Text)    # 사용된 composer 프롬프트 버전
    segment_start_char = Column(Integer)   # 시나리오 원문 세그먼트 시작 위치
    segment_end_char = Column(Integer)     # 시나리오 원문 세그먼트 끝 위치
    variation_a_type = Column(Text)        # "angle" | "color" | "angle+color" | "none"
    variation_a_angle = Column(Text)       # JSON: {"horizontal": 45, "vertical": 0, "zoom": 1.0}
    variation_a_color = Column(Text)       # color edit prompt
    variation_a_reason = Column(Text)      # LLM recommendation reason
    variation_b_type = Column(Text)
    variation_b_angle = Column(Text)
    variation_b_color = Column(Text)
    variation_b_reason = Column(Text)
    recommended_variant = Column(Text)     # "original" | "A" | "B"
    selected_variant = Column(Text)        # user override (null = use recommended)
    dependent_scene_id = Column(Text)      # 앞쪽 의존 씬 ID (같은 장소 등)
    shot_type_1 = Column(Text)             # 촬영 기법 1 (scene_cinematography)
    shot_type_2 = Column(Text)             # 촬영 기법 2 (scene_cinematography)
    scene_index = Column(Integer)           # v4: 원본 씬 번호 (그룹핑용)
    shot_index = Column(Integer)           # v4: shot 인덱스 (씬 내)
    shot_description = Column(Text)        # v4: shot 설명
    based_on_beat = Column(Integer)        # v4: 근거 beat 인덱스
    scene_type = Column(Text, default="normal")  # normal|montage|flashback|dream|voiceover|transition
    scene_summary = Column(Text)                    # 씬 요약 (200자)
    audio_entity_ids = Column(Text, default="[]")   # V/A/H: 소리만 들리는 엔티티 [short_id]
    hallucination_entity_ids = Column(Text, default="[]")  # V/A/H: 비물리적 표현 [short_id]
    is_selected = Column(Boolean, default=True)     # shot-more: shot_selection 결과, True=이미지 생성 대상
    image_generated = Column(Boolean, default=False)  # shot-more: 이미지 파이프라인 완료 여부
    status = Column(Text, default="pending")        # pending|completed|stale (force 재실행 시 stale)
    created_at = Column(Text, nullable=False)


class EntityEpisodeLink(Base):
    __tablename__ = "entity_episode_link"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    canon_id = Column(Text, ForeignKey("entity_canon.id"), nullable=False)
    episode_id = Column(Text, ForeignKey("episode.id"), nullable=False)
    source = Column(Text, default="extracted")
    t2i_appearance_count = Column(Integer, default=0)
    # ★★「이 화에서 어떤 자리인가」 — active | shelved (alembic 014).
    #  저빈도로 걸러진 요소를 **지우는 대신** 여기 적는다. 프로젝트 전역
    #  상태가 아니다: 1화에서 저빈도였다고 5화에서도 저빈도가 아니다.
    #  값은 `app.core.entity_identity.PRESENCE_*` 가 정본이다.
    presence_status = Column(Text, nullable=False,
                             server_default="active", default="active")
    # ★이번 화에서 뽑힌 묘사·특징(JSON). 재사용하는 canon 의 안정 칸을 덮지
    #  않으려고 여기 둔다 — 버리지도, 덮지도 않는다.
    episode_notes_json = Column(Text, nullable=True)

    __table_args__ = (UniqueConstraint("canon_id", "episode_id"),)


class ImageAsset(Base):
    __tablename__ = "image_asset"
    __table_args__ = (
        # 절대 경로 차단 — ImagePathType bind 에서 자동 상대화 하지만 raw SQL/Core
        # insert 우회 시 절대 경로가 들어가는 사고를 DB 차원에서 마지막 차단.
        # alembic 005 가 production 에 동일 constraint 추가; 모델 정의는 fresh
        # ``Base.metadata.create_all`` (test/dev) 환경에 동등 보장.
        # 표현식은 alembic 005 CHECK_EXPR + database.py `_migrations` DO block 과 동일 — 동기화 필수.
        CheckConstraint(
            "file_path = '' OR file_path NOT LIKE '/%'",
            name="ck_image_asset_file_path_relative",
        ),
    )

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    asset_type = Column(Text, nullable=False)  # 'reference' | 'scene'
    entity_id = Column(Text)  # for reference images
    still_id = Column(Text)   # for scene images
    episode_id = Column(Text)
    file_path = Column(ImagePathType(), nullable=False)  # bind=상대 / result=절대 (Phase 2 architectural fix)
    prompt_used = Column(Text)
    generation_model = Column(Text)
    width = Column(Integer)
    height = Column(Integer)
    status = Column(Text, default="generated")  # generated|approved|needs_fix|regenerating
    review_notes = Column(Text, default="")
    validation_score = Column(Integer)       # LVM validation score 0-100
    validation_result = Column(Text)         # JSON string of full validation result
    sanitization_strategy = Column(Text)     # null=원본, "film_previs"|"movie_poster"|"aftermath"
    original_prompt = Column(Text)           # 수정 전 원본 프롬프트 (수정된 경우에만)
    sanitization_note = Column(Text)         # GPT가 어떻게 수정했는지 설명
    variant_type = Column(Text)              # "original" | "variant_a" | "variant_b"
    angle_applied = Column(Text)             # JSON of angle params applied
    color_applied = Column(Text)             # color prompt applied
    source_image_id = Column(Text)           # parent image ID for i2i chain
    is_primary = Column(Integer, default=0)  # 1 = representative image for PDF/display
    prompt_type = Column(Text)               # "cinematic" | "closeup" | "original" | None
    code_version = Column(Text)              # from version_registry at generation time
    prompt_file_version = Column(Text)       # "t2i_composer/v1" or "prompt_sanitizer/v1" etc.
    # Task 5 (single-vs-batch reference contract §4.4): lineage refs (visible_entities
    # character/prop UUID list, NOT actual LLM-attached refs).
    # For actual refs see llm_call_log.reference_image_ids.
    reference_image_ids = Column(Text, default="[]")  # (lineage) JSON array of visible_entities character/prop UUIDs
    parent_image_id = Column(Text, ForeignKey("image_asset.id"))
    shot_index = Column(Integer)              # v4: shot 인덱스
    theme_label = Column(Text)               # v5: 변형 주제 라벨 (예: "인물 중심", "상황 중심")
    # Phase 5 — outlook 패턴 backgrounds 확장
    variant_index = Column(Integer, nullable=False, default=0, server_default="0")  # 0=floor_plan, 1+=chain_bg
    variant_label = Column(String(255), nullable=False, default="v00", server_default="v00")  # "v00"|"v01"|... (chain_bg group_id 등 LLM 생성 라벨 수용 — alembic 004)
    t2i_guide = Column(Text)                 # shot t2i 주입용 가이드 (Phase 6에서 scene_detail consumer)
    # ── 전 과정 생성 이미지 영속화 (alembic 008) ──────────────────────────────
    # 중간 생성물(가이드/스케치/항공뷰/블로킹/거부 후보 등)을 image_asset 행으로
    # 영속화하기 위한 신규 메타. 모두 nullable — 기존 최종물 행에는 부재(NULL).
    # ★ reference_image_ids 는 절대 재사용 금지(char/prop lineage 유지). 중간물의
    #   전체 입력 lineage 는 신규 input_image_ids 에 저장한다.
    input_image_ids = Column(Text)           # (lineage) JSON array of input image_asset UUIDs (i2i/ref 입력)
    stage = Column(Text)                     # 파이프라인 단계명(step): "pose_guide" | "background_render" | ...
    pipeline_role = Column(Text)             # 용도: aerial_base|shot_blocking|pose_guide|candidate|...
    is_intermediate = Column(Boolean, default=False, nullable=False, server_default=expression.false())  # 중간물 여부(캔버스 필터/접기)
    generation_call_id = Column(Text)        # → llm_call_log 조인(프롬프트/검증 SOT)
    candidate_index = Column(Integer)        # 같은 호출 내 후보 번호(거부 후보 구분)
    pipeline_metadata_json = Column(Text)    # group_id/checkpoint key/rejection reason/model params 등 확장 JSON
    # ── true-intermediate 생애 메타 (alembic 009) ──────────────────────────────
    disposition = Column(Text)               # accepted|rejected|diagnostic|cache_hit_source (NULL=미상/최종물)
    attempt_index = Column(Integer)          # 같은 논리 생성의 재시도 회차(0-base, candidate_index 와 직교)
    created_at = Column(Text, nullable=False)


class ProjectSettings(Base):
    __tablename__ = "project_settings"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False, unique=True)
    composer_system_prompt = Column(Text)  # Override for T2I composer system prompt
    composer_user_prompt = Column(Text)    # Override for T2I composer user prompt
    style_rules_json = Column(Text)        # 프로젝트 단위 스타일 규칙 JSON
    world_summary = Column(Text)           # 프로젝트 단위 세계관 요약
    scene_split_threshold = Column(Integer, default=600)  # 씬 분할 글자수 임계값
    llm_config_json = Column(Text, default="{}")  # 단계별 LLM 모델 설정 JSON
    updated_at = Column(Text, nullable=False)


class WorldGuide(Base):
    __tablename__ = "world_guide"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_id = Column(Text, ForeignKey("episode.id"))
    guide_json = Column(Text, nullable=False)
    source_hash = Column(Text)  # MD5 hash of inputs (fulltext + entities + stills count)
    created_at = Column(Text, nullable=False)


class WebbookPackage(Base):
    __tablename__ = "webbook_package"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_id = Column(Text, ForeignKey("episode.id"))
    package_json = Column(Text, nullable=False)
    prompt_version = Column(Text)
    created_at = Column(Text, nullable=False)


class OperationLog(Base):
    """모든 파이프라인 작업의 프로비저닝 기록 — Opik 호환 구조."""
    __tablename__ = "operation_log"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    operation_type = Column(Text, nullable=False)  # entity_extraction / scene_still_extraction / image_generation / webbook_generation / pdf_rendering / validation
    episode_id = Column(Text)
    module_name = Column(Text, nullable=False)      # from version_registry
    module_version = Column(Text, nullable=False)
    prompt_name = Column(Text)                       # e.g., "entity_extraction/v5"
    prompt_version = Column(Text)                    # e.g., "v5"
    prompt_hash = Column(Text)                       # SHA256 of actual prompt content
    input_summary = Column(Text)                     # JSON: key inputs (truncated)
    output_summary = Column(Text)                    # JSON: key outputs (truncated)
    status = Column(Text, nullable=False)            # success / error / partial
    error_message = Column(Text)
    duration_ms = Column(Integer)
    token_usage = Column(Text, default="{}")         # JSON: {input_tokens, output_tokens, cost_usd}
    metadata_json = Column(Text, default="{}")       # JSON: extra context
    created_at = Column(Text, nullable=False)


class PipelineProgress(Base):
    """파이프라인 진행률 추적 — UI에서 폴링하여 표시."""
    __tablename__ = "pipeline_progress"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_id = Column(Text, nullable=False)
    operation = Column(Text, nullable=False)  # analysis / image_generation / webbook / pdf_render
    status = Column(Text, default="running")  # running / completed / error
    current_step = Column(Text, default="")   # e.g., "엔티티 추출 중", "참조 이미지 생성 (5/26)"
    total_steps = Column(Integer, default=0)
    completed_steps = Column(Integer, default=0)
    error_message = Column(Text)
    started_at = Column(Text, nullable=False)
    updated_at = Column(Text, nullable=False)
    completed_at = Column(Text)


class LLMCallLog(Base):
    """모든 LLM 호출의 입력/출력 기록 — 추적 및 재활용."""
    __tablename__ = "llm_call_log"

    id = Column(Text, primary_key=True)
    project_id = Column(Text)
    episode_id = Column(Text)
    operation_type = Column(Text)       # entity_extraction / scene_analysis / outlook / image_gen / ...
    step_name = Column(Text)            # turn0 / scene_10 / translate / select_best / ...
    model_name = Column(Text, nullable=False)
    system_prompt = Column(Text)
    user_prompt = Column(Text, nullable=False)
    output_text = Column(Text)
    # Task 5 (single-vs-batch reference contract §4.4): actual ref labels attached
    # to LLM call (e.g. "character C01O02 in outfit", "BACKGROUND chain reference").
    # image_asset.reference_image_ids 는 lineage — 두 컬럼 의미가 다름.
    reference_image_ids = Column(Text, default="[]")  # (actual) JSON array of attached ref labels
    duration_ms = Column(Integer)
    input_tokens = Column(Integer)
    output_tokens = Column(Integer)
    status = Column(Text, nullable=False)  # success / error
    error_message = Column(Text)
    # Phase 4 iter 7 W3+I1 — free-form trace metadata (JSON serialized).
    # scene_image_gen / ref_image_gen 호출 시 scene_index / shot_index /
    # still_id / entity_id 같은 fan-out 단위 추적 정보를 보존. alembic 006.
    metadata_json = Column(Text)
    created_at = Column(Text, nullable=False)


class ScenePlan(Base):
    """씬 분할 계획 — 사용자 승인 전 미리보기용."""
    __tablename__ = "scene_plan"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    episode_id = Column(Text, ForeignKey("episode.id"), nullable=False)
    split_threshold = Column(Integer, default=600)
    segments_json = Column(Text, nullable=False)  # JSON array of segment dicts
    total_scenes = Column(Integer, nullable=False)
    status = Column(Text, default="pending")  # pending|approved|rejected
    created_at = Column(Text, nullable=False)


class GenerationTrace(Base):
    __tablename__ = "generation_trace"

    id = Column(Text, primary_key=True)
    project_id = Column(Text, ForeignKey("project_registry.id"), nullable=False)
    image_asset_id = Column(Text)  # nullable — may not have asset yet
    still_id = Column(Text)
    entity_id = Column(Text)
    attempt_number = Column(Integer, nullable=False, default=1)
    prompt_used = Column(Text, nullable=False)
    prompt_version = Column(Text, nullable=False, default="original")  # original/sanitized_v1/sanitized_v2/alternative
    model_name = Column(Text)
    status = Column(Text, nullable=False)  # success/moderation_blocked/error/timeout
    block_reason = Column(Text)  # SAFETY, HARM, etc.
    block_categories = Column(Text, default="[]")  # JSON array
    response_time_ms = Column(Integer)
    sanitizer_feedback = Column(Text)  # GPT's explanation of changes
    created_at = Column(Text, nullable=False)
