"""엔티티 및 씬 스틸 API 라우터."""

import json
import logging

logger = logging.getLogger(__name__)

from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session as OrmSession
from sqlalchemy import func

from app.api.deps import get_db, get_current_user, verify_project_access
from app.core.errors import AppError
from app.core.name_matcher import build_name_index, lookup_name
from app.i18n.loader import t
from app.logging.activity_logger import ActivityLogger
from app.models.catalog import UserAccount
from app.models.project import (
    EntityCanon,
    EntityAlias,
    EntityEpisodeLink,
    Episode,
    RelationFact,
    RelationParticipant,
    SceneStill,
)
from app.schemas.entity import (
    EntityResponse,
    EntityDetailResponse,
    EntityUpdate,
    SceneStillResponse,
    SceneStillUpdate,
)


router = APIRouter(
    prefix="/api/v1/projects/{project_id}",
    tags=["entities"],
)


def _mark_checkpoint_user_edited(project_id: str, episode_id: str, scene_index, shot_index, *, updated_fields: dict | None = None):
    """scene_detail 체크포인트에서 해당 shot 결과에 _user_edited=true 마킹 + 편집값 반영 (atomic write)."""
    try:
        from pathlib import Path
        from app.core.config import settings
        import json as _json
        import tempfile
        import os

        cp_path = (
            Path(settings.projects_dir) / project_id
            / "checkpoints" / "episodes" / episode_id
            / "scene_detail" / "manifest.json"
        )
        if not cp_path.exists():
            return
        cp = _json.loads(cp_path.read_text(encoding="utf-8"))
        for s in cp.get("data", {}).get("scenes", []):
            if s.get("scene_index") == scene_index:
                cp_shot = s.get("_shot_index")
                if shot_index != cp_shot:  # None == None is correct for legacy
                    continue
                s["_user_edited"] = True
                if updated_fields:
                    s.update(updated_fields)
                break
        # atomic write: temp → rename
        fd, tmp = tempfile.mkstemp(dir=str(cp_path.parent), suffix=".tmp")
        try:
            os.write(fd, _json.dumps(cp, ensure_ascii=False, indent=2).encode("utf-8"))
            os.close(fd)
            os.replace(tmp, str(cp_path))
        except Exception:
            os.close(fd) if not os.get_inheritable(fd) else None
            if os.path.exists(tmp):
                os.unlink(tmp)
            raise
    except Exception:
        pass  # 체크포인트 마킹 실패는 무시 (DB 업데이트는 이미 완료)


def _entity_to_response(canon: EntityCanon, episode_count: int) -> dict:
    return {
        "id": canon.id,
        "short_id": canon.short_id,
        "entity_type": canon.entity_type,
        "name": canon.name,
        "description": canon.description,
        "stable_traits": canon.stable_traits or "{}",
        "t2i_prompt": canon.t2i_prompt,
        "status": canon.status,
        "episode_count": episode_count,
        "created_at": canon.created_at,
    }


@router.get("/entities", response_model=list[EntityResponse])
def list_entities(
    type: str | None = None,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    query = db.query(EntityCanon).filter(EntityCanon.project_id == project_id)
    if type:
        query = query.filter(EntityCanon.entity_type == type)
    canons = query.order_by(EntityCanon.name).all()

    results = []
    for canon in canons:
        ep_count = (
            db.query(func.count(EntityEpisodeLink.id))
            .filter(EntityEpisodeLink.canon_id == canon.id)
            .scalar()
        ) or 0
        results.append(_entity_to_response(canon, ep_count))
    return results


@router.get("/entities/{entity_id}", response_model=EntityDetailResponse)
def get_entity(
    entity_id: str,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    canon = (
        db.query(EntityCanon)
        .filter(EntityCanon.id == entity_id, EntityCanon.project_id == project_id)
        .first()
    )
    if not canon:
        raise AppError(
            code="entity.not_found",
            message=t("entity.not_found"),
            status_code=404,
        )

    ep_count = (
        db.query(func.count(EntityEpisodeLink.id))
        .filter(EntityEpisodeLink.canon_id == canon.id)
        .scalar()
    ) or 0

    aliases = [
        a.alias
        for a in db.query(EntityAlias)
        .filter(EntityAlias.canon_id == canon.id)
        .all()
    ]

    # Build relations list
    participations = (
        db.query(RelationParticipant)
        .filter(RelationParticipant.canon_id == canon.id)
        .all()
    )
    relations = []
    seen_relation_ids = set()
    for p in participations:
        if p.relation_id in seen_relation_ids:
            continue
        seen_relation_ids.add(p.relation_id)

        fact = db.query(RelationFact).filter(RelationFact.id == p.relation_id).first()
        if not fact:
            continue

        all_participants = (
            db.query(RelationParticipant)
            .filter(RelationParticipant.relation_id == fact.id)
            .order_by(RelationParticipant.participant_order)
            .all()
        )

        participant_list = []
        for rp in all_participants:
            participant_canon = (
                db.query(EntityCanon)
                .filter(EntityCanon.id == rp.canon_id)
                .first()
            )
            participant_list.append({
                "entity_name": participant_canon.name if participant_canon else rp.canon_id,
                "entity_id": rp.canon_id,
                "role": rp.participant_role,
                "order": rp.participant_order,
            })

        relations.append({
            "id": fact.id,
            "relation_family": fact.relation_family,
            "relation_type": fact.relation_type,
            "directionality": fact.directionality,
            "temporal_scope": fact.temporal_scope,
            "continuity_priority": fact.continuity_priority,
            "continuity_reason": fact.continuity_reason,
            "participants": participant_list,
        })

    # Build episodes list
    ep_links = (
        db.query(EntityEpisodeLink)
        .filter(EntityEpisodeLink.canon_id == canon.id)
        .all()
    )
    episodes = []
    for link in ep_links:
        ep = db.query(Episode).filter(Episode.id == link.episode_id).first()
        if ep:
            episodes.append({
                "episode_id": ep.id,
                "episode_number": ep.episode_number,
                "title": ep.title,
            })

    base = _entity_to_response(canon, ep_count)
    base["aliases"] = aliases
    base["relations"] = relations
    base["episodes"] = episodes
    return base


@router.patch("/entities/{entity_id}", response_model=EntityResponse)
def update_entity(
    entity_id: str,
    body: EntityUpdate,
    request: Request,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    canon = (
        db.query(EntityCanon)
        .filter(EntityCanon.id == entity_id, EntityCanon.project_id == project_id)
        .first()
    )
    if not canon:
        raise AppError(
            code="entity.not_found",
            message=t("entity.not_found"),
            status_code=404,
        )

    ip = request.client.host if request.client else None
    before = {}
    after = {}

    if body.name is not None:
        before["name"] = canon.name
        canon.name = body.name
        after["name"] = body.name

    if body.description is not None:
        before["description"] = canon.description
        canon.description = body.description
        after["description"] = body.description

    if body.stable_traits is not None:
        before["stable_traits"] = canon.stable_traits
        canon.stable_traits = body.stable_traits
        after["stable_traits"] = body.stable_traits

    if body.t2i_prompt is not None:
        before["t2i_prompt"] = canon.t2i_prompt
        canon.t2i_prompt = body.t2i_prompt
        after["t2i_prompt"] = body.t2i_prompt

    from datetime import datetime, timezone
    canon.updated_at = datetime.now(timezone.utc).isoformat()
    db.commit()

    logger = ActivityLogger(db)
    logger.log(
        actor_id=current_user.id,
        action="entity.update",
        resource_type="entity",
        resource_id=entity_id,
        project_id=project_id,
        detail={"before": before, "after": after},
        ip_address=ip,
    )

    ep_count = (
        db.query(func.count(EntityEpisodeLink.id))
        .filter(EntityEpisodeLink.canon_id == canon.id)
        .scalar()
    ) or 0

    return _entity_to_response(canon, ep_count)


@router.get("/episodes/{episode_id}/stills")
def list_stills(
    episode_id: str,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    stills = (
        db.query(SceneStill)
        .filter(
            SceneStill.project_id == project_id,
            SceneStill.episode_id == episode_id,
            SceneStill.still_index >= 0,          # shot-more: stale row 제외 (legacy still_index=-1)
            SceneStill.status != "stale",          # shot-more: 명시적 stale 필터
        )
        .order_by(SceneStill.still_index)
        .all()
    )

    # Resolve visible entities with reference image info
    from app.models.project import EntityCanon, ImageAsset
    import re as _re

    # ── N+1 제거: 전체 entity ID 수집 후 벌크 로드 ──
    all_entity_ids = set()
    for still in stills:
        try:
            vis = json.loads(still.visible_entities_json or "[]")
        except json.JSONDecodeError:
            vis = []
        for v in vis:
            eid = (v.get("id") or v.get("entity_id", "")) if isinstance(v, dict) else ""
            if eid:
                all_entity_ids.add(eid)

    # Bulk load entities
    entity_map = {}
    if all_entity_ids:
        entities = db.query(EntityCanon).filter(EntityCanon.id.in_(all_entity_ids)).all()
        entity_map = {e.id: e for e in entities}
    # 이름+타입으로도 매칭 가능하도록 (entity_id 없는 visible_entities 대응)
    all_project_entities = db.query(EntityCanon).filter(EntityCanon.project_id == project_id).all()
    entity_name_map: dict = {}
    for e in all_project_entities:
        entity_name_map[(e.name, e.entity_type)] = e
        entity_name_map[(e.name, "")] = e  # 타입 없이도 매칭
        if e.id not in entity_map:
            entity_map[e.id] = e
        all_entity_ids.add(e.id)  # 이미지 bulk load에도 포함

    # Bulk load primary reference images for non-character/non-outlook entities
    primary_image_map = {}
    if all_entity_ids:
        primary_images = db.query(ImageAsset).filter(
            ImageAsset.entity_id.in_(all_entity_ids),
            ImageAsset.asset_type == "reference",
            ImageAsset.is_primary == 1,
        ).all()
        primary_image_map = {img.entity_id: img for img in primary_images}

    # Bulk load character face reference images (no outlook_id in prompt)
    char_entity_ids = {eid for eid in all_entity_ids if eid in entity_map and entity_map[eid].entity_type == "character"}
    char_face_image_map = {}
    if char_entity_ids:
        # Get all reference images for characters, then filter in Python
        char_ref_images = db.query(ImageAsset).filter(
            ImageAsset.entity_id.in_(char_entity_ids),
            ImageAsset.asset_type == "reference",
        ).order_by(ImageAsset.created_at.desc()).all()
        for img in char_ref_images:
            if img.entity_id not in char_face_image_map:
                if not img.prompt_used or ("outlook_id:" not in img.prompt_used and "outfit:" not in img.prompt_used and "composite:" not in img.prompt_used):
                    char_face_image_map[img.entity_id] = img

    # Bulk load character name→id map for outlook pairing (all project characters,
    # not just those in stills — T2I [[char]+[outlook]] may reference any character)
    all_project_chars = db.query(EntityCanon).filter(
        EntityCanon.project_id == project_id,
        EntityCanon.entity_type == "character",
    ).all()
    char_name_map = {c.name: c for c in all_project_chars}

    # Bulk load T2I 출현 횟수
    from app.models.project import EntityEpisodeLink
    _t2i_count_map: dict = {}  # canon_id → count
    _ep_links = db.query(EntityEpisodeLink).filter(
        EntityEpisodeLink.project_id == project_id,
        EntityEpisodeLink.episode_id == episode_id,
    ).all()
    for _lnk in _ep_links:
        _t2i_count_map[_lnk.canon_id] = _lnk.t2i_appearance_count or 0

    # ── request-scoped 캐시 (O(N·E) → O(N+E)) ──
    # 각 still 루프에서 build_name_index / 선형 탐색 반복을 막기 위해 사전 구축.
    # all_project_entities는 이미 line 371에서 1회 로드됨 — 그 결과만 재구성.
    _char_name_idx = build_name_index(
        [e for e in all_project_entities if e.entity_type == "character"],
        key_fn=lambda e: e.name,
    )
    _outlook_name_idx = build_name_index(
        [e for e in all_project_entities if e.entity_type == "outlook"],
        key_fn=lambda e: e.name,
    )
    # first-match 보존 (기존 next(...) 의미 유지). 동일 short_id 중복 시 첫 엔티티 유지.
    # 이상 데이터(entity merge 버그 등) 조기 감지 위해 중복 발견 시 warning.
    _short_id_map: dict = {}
    for _e in all_project_entities:
        if not _e.short_id:
            continue
        if _e.short_id in _short_id_map:
            logger.warning(
                "Duplicate short_id=%s in project %s: kept %s, skipped %s",
                _e.short_id, project_id, _short_id_map[_e.short_id].id, _e.id,
            )
            continue
        _short_id_map[_e.short_id] = _e

    result = []
    for still in stills:
        data = SceneStillResponse.model_validate(still).model_dump()

        # Parse visible_entities and enrich with entity info + ref image
        try:
            vis_list = json.loads(still.visible_entities_json or "[]")
        except json.JSONDecodeError:
            vis_list = []

        # [[char]+[outlook]] 파싱 — 씬 설명 + T2I 양쪽에서
        t2i_text = (still.still_frame_prompt or "") + " " + (still.t2i_prompt_cinematic or "")
        _t2i_vars: list = []
        if still.t2i_variations_json:
            try:
                _t2i_vars = json.loads(still.t2i_variations_json)
            except Exception as exc:
                logger.warning("t2i_variations_json parse failed for still %s: %s — 본문만 사용", still.id, exc)
        for _v in _t2i_vars:
            if isinstance(_v, dict):
                t2i_text += " " + (_v.get("t2i_prompt") or "")
        # character_name → outlook_name 매핑 (이 씬에서)
        # #6 fix (2026-07-02): SOT = t2i_variations_json 의 구조 필드
        # outfit_assignments([{"character_id":"C##","outlook_id":"O##"}]).
        # 현 파이프라인은 T2I 텍스트에 복합 ID(C##O##)를 넣지 않는 계약이라
        # 아래 토큰 파싱만으로는 캐릭터 썸네일이 통째로 비었다("썸네일 안
        # 나옴" 근본원인). 토큰 파싱은 구조 필드 없던 legacy still fallback.
        char_to_outlook = {}  # char_name -> outlook_name
        for _v in _t2i_vars:
            if not isinstance(_v, dict):
                continue
            for _oa in (_v.get("outfit_assignments") or []):
                if not isinstance(_oa, dict):
                    continue
                _c_ent = _short_id_map.get(str(_oa.get("character_id") or ""))
                _o_ent = _short_id_map.get(str(_oa.get("outlook_id") or ""))
                if _c_ent is not None and _o_ent is not None:
                    char_to_outlook.setdefault(_c_ent.name, _o_ent.name)
        # 레거시 [[char]+[outlook]] 패턴
        for _m in _re.finditer(r'\[\[([^\]]+)\]\+\[([^\]]+)\]\]', t2i_text):
            char_to_outlook.setdefault(_m.group(1), _m.group(2))
        # short_id C01O02 패턴 → 이름으로 역매핑 (request-scoped _short_id_map 사용)
        for _m in _re.finditer(r'(C\d{2,3})(O\d{2,3})', t2i_text):
            c_sid, o_sid = _m.group(1), _m.group(2)
            c_name = _short_id_map[c_sid].name if c_sid in _short_id_map else c_sid
            o_name = _short_id_map[o_sid].name if o_sid in _short_id_map else o_sid
            if c_name not in char_to_outlook:
                char_to_outlook[c_name] = o_name

        resolved_entities = []
        seen_chars = set()

        # T2I 프롬프트에서 사용된 short_id 파싱 (P##, L##)
        t2i_prop_sids = set(_re.findall(r'(?<![A-Z])P\d{2,3}(?!\d)', t2i_text))
        t2i_loc_sids = set(_re.findall(r'L\d{2,3}', t2i_text))

        # 1) T2I에서 파싱된 C##O## → composite entity
        for c_name, o_name in char_to_outlook.items():
            c_canon = lookup_name(_char_name_idx, c_name)
            o_canon = lookup_name(_outlook_name_idx, o_name)
            if c_canon and c_name not in seen_chars:
                seen_chars.add(c_name)
                composite_img = None
                if c_canon and o_canon:
                    composite_img = db.query(ImageAsset).filter(
                        ImageAsset.entity_id == c_canon.id,
                        ImageAsset.asset_type == "reference",
                        ImageAsset.prompt_used.like(f"%composite:{c_canon.id}:{o_canon.id}%"),
                    ).first()
                if not composite_img and c_canon:
                    composite_img = char_face_image_map.get(c_canon.id)
                resolved_entities.append({
                    "entity_id": c_canon.id,
                    "short_id": c_canon.short_id,
                    "outlook_short_id": o_canon.short_id if o_canon else None,
                    "entity_name": f"{c_name}+{o_name}",
                    "entity_type": "composite",
                    "display_name": c_name,
                    "outlook_id": o_canon.id if o_canon else None,
                    "t2i_prompt": c_canon.t2i_prompt or "",
                    "has_reference_image": composite_img is not None,
                    "reference_image_id": composite_img.id if composite_img else None,
                    "t2i_appearance_count": _t2i_count_map.get(c_canon.id, 0),
                })

        # 2) T2I에서 파싱된 P##, L## → prop/location entity (_short_id_map O(1))
        _seen_eids = {e["entity_id"] for e in resolved_entities}
        for sid in (t2i_prop_sids | t2i_loc_sids):
            entity = _short_id_map.get(sid)
            if not entity or entity.id in _seen_eids:
                continue
            _seen_eids.add(entity.id)
            ref_img = primary_image_map.get(entity.id)
            resolved_entities.append({
                "entity_id": entity.id,
                "short_id": entity.short_id,
                "entity_name": entity.name,
                "entity_type": entity.entity_type,
                "display_name": entity.name,
                "t2i_prompt": entity.t2i_prompt or "",
                "has_reference_image": ref_img is not None,
                "reference_image_id": ref_img.id if ref_img else None,
                "t2i_appearance_count": _t2i_count_map.get(entity.id, 0),
            })

        # 3) #6 fix (2026-07-02): VE(frame-visible SOT, scene_director 확정
        # 데이터에서 코드 구축) 기반 보충 — outfit 배정 없는 캐릭터는 face
        # 썸네일로, T2I 토큰에 안 잡힌 prop/location 도 표시. 구조 ID 조인만.
        for _ve in vis_list:
            if isinstance(_ve, str):
                _ve_ent = entity_map.get(_ve)
            elif isinstance(_ve, dict):
                _ve_ent = entity_map.get(_ve.get("entity_id") or _ve.get("id") or "")
                if _ve_ent is None and _ve.get("short_id"):
                    _ve_ent = _short_id_map.get(_ve.get("short_id"))
            else:
                _ve_ent = None
            if _ve_ent is None or _ve_ent.id in _seen_eids:
                continue
            if _ve_ent.entity_type == "character":
                if _ve_ent.name in seen_chars:
                    continue
                seen_chars.add(_ve_ent.name)
                _seen_eids.add(_ve_ent.id)
                _face = char_face_image_map.get(_ve_ent.id)
                resolved_entities.append({
                    "entity_id": _ve_ent.id,
                    "short_id": _ve_ent.short_id,
                    "entity_name": _ve_ent.name,
                    "entity_type": "character",
                    "display_name": _ve_ent.name,
                    "t2i_prompt": _ve_ent.t2i_prompt or "",
                    "has_reference_image": _face is not None,
                    "reference_image_id": _face.id if _face else None,
                    "t2i_appearance_count": _t2i_count_map.get(_ve_ent.id, 0),
                })
            elif _ve_ent.entity_type in ("prop", "location"):
                _seen_eids.add(_ve_ent.id)
                _ref = primary_image_map.get(_ve_ent.id)
                resolved_entities.append({
                    "entity_id": _ve_ent.id,
                    "short_id": _ve_ent.short_id,
                    "entity_name": _ve_ent.name,
                    "entity_type": _ve_ent.entity_type,
                    "display_name": _ve_ent.name,
                    "t2i_prompt": _ve_ent.t2i_prompt or "",
                    "has_reference_image": _ref is not None,
                    "reference_image_id": _ref.id if _ref else None,
                    "t2i_appearance_count": _t2i_count_map.get(_ve_ent.id, 0),
                })

        data["resolved_entities"] = resolved_entities

        # 의존 씬 — dependent_scene_id 기반 (분석 시 설정된 시각적 연관)
        dependent_scenes = []
        dep_scene_id = data.get("dependent_scene_id")
        if dep_scene_id:
            dep_still = db.query(SceneStill).filter(SceneStill.id == dep_scene_id).first()
            if dep_still:
                dep_img = (
                    db.query(ImageAsset)
                    .filter(
                        ImageAsset.still_id == dep_still.id,
                        ImageAsset.asset_type == "scene",
                        ImageAsset.is_primary == 1,
                    )
                    .first()
                )
                dependent_scenes.append({
                    "still_id": dep_still.id,
                    "still_index": dep_still.still_index,
                    "scene_index": dep_still.scene_index,
                    "shot_index": dep_still.shot_index,
                    "shot_description": dep_still.shot_description or "",
                    "beat_title": dep_still.beat_title or "",
                    "has_image": dep_img is not None,
                    "image_id": dep_img.id if dep_img else None,
                })
        data["dependent_scenes"] = dependent_scenes

        # 변형 추천을 동적 배열로 반환 (A/B 하드코딩 → variations[])
        variations = []
        for label_suffix in ["a", "b"]:
            vtype = data.get(f"variation_{label_suffix}_type")
            if vtype and vtype != "none":
                variations.append({
                    "label": label_suffix.upper(),
                    "type": vtype,
                    "angle": data.get(f"variation_{label_suffix}_angle"),
                    "color": data.get(f"variation_{label_suffix}_color"),
                    "reason": data.get(f"variation_{label_suffix}_reason"),
                })
        data["variations"] = variations

        result.append(data)

    # 아웃룩 단독 이미지 목록 (요소 추가 피커용)
    available_outfits = []
    outlook_entities = [e for e in all_project_entities if e.entity_type == "outlook"]
    for ol in outlook_entities:
        outfit_img = primary_image_map.get(ol.id)
        available_outfits.append({
            "outlook_id": ol.id,
            "outlook_name": ol.name,
            "has_image": outfit_img is not None,
            "image_id": outfit_img.id if outfit_img else None,
        })

    # v4: shot 정보 로드 (beat_extract, shot_extract, shot_selection 체크포인트)
    shots_data = {}
    try:
        from app.core.config import settings as _settings
        from pathlib import Path as _Path

        cp_base = _Path(_settings.projects_dir) / project_id / "checkpoints" / "episodes" / episode_id

        # shot_validator로 전환 — description이 '한 찰나' 원칙 적용된 최신본이 UI에 노출
        shot_cp_path = cp_base / "shot_validator" / "manifest.json"
        sel_cp_path = cp_base / "shot_selection" / "manifest.json"
        beat_cp_path = cp_base / "beat_extract" / "manifest.json"

        if shot_cp_path.exists():
            shot_cp_data = json.loads(shot_cp_path.read_text())
            sel_data = {}
            if sel_cp_path.exists():
                sel_cp_data = json.loads(sel_cp_path.read_text())
                for s in sel_cp_data.get("data", {}).get("scenes", []):
                    sel_data[s["scene_index"]] = set(s.get("selected_shot_indices", []))

            beat_data = {}
            if beat_cp_path.exists():
                beat_cp_data = json.loads(beat_cp_path.read_text())
                for s in beat_cp_data.get("data", {}).get("scenes", []):
                    beat_data[s["scene_index"]] = s.get("beats", [])

            for sc in shot_cp_data.get("data", {}).get("scenes", []):
                si = sc["scene_index"]
                selected = sel_data.get(si)
                scene_shots = []
                for sh in sc.get("shots", []):
                    scene_shots.append({
                        "shot_index": sh["shot_index"],
                        "description": sh["description"],
                        "characters": sh.get("characters", []),
                        "based_on_beat": sh.get("based_on_beat", 0),
                        "selected": selected is None or sh["shot_index"] in selected,
                    })
                shots_data[si] = {
                    "shots": scene_shots,
                    "beats": beat_data.get(si, []),
                    "total_shots": len(sc.get("shots", [])),
                    "selected_count": sum(1 for s in scene_shots if s.get("selected")),
                }
    except Exception as exc:
        logger.warning("Failed to load shot data: %s", exc)

    # Attach shot data to each still by scene_index
    for item in result:
        si = item.get("scene_index") if item.get("scene_index") is not None else item.get("still_index")
        if si is not None and si in shots_data:
            item["shots_info"] = shots_data[si]

    # v4: short_id → name 매핑 (전체 프로젝트 엔티티) — request-scoped _short_id_map 재사용
    entity_sid_map = {sid: e.name for sid, e in _short_id_map.items()}

    # 합성 이미지 목록 (인물+아웃룩)
    from app.models.project import CharacterOutlook
    available_composites = []
    co_links = db.query(CharacterOutlook).filter(CharacterOutlook.project_id == project_id).all()
    for co in co_links:
        char_ent = entity_map.get(co.character_id)
        outlook_ent = entity_map.get(co.outlook_id)
        if not char_ent or not outlook_ent:
            continue
        comp_img = db.query(ImageAsset).filter(
            ImageAsset.entity_id == co.character_id,
            ImageAsset.asset_type == "reference",
            ImageAsset.prompt_used.like(f"%composite:{co.character_id}:{co.outlook_id}%"),
        ).first()
        available_composites.append({
            "character_id": co.character_id,
            "character_name": char_ent.name,
            "outlook_id": co.outlook_id,
            "outlook_name": outlook_ent.name,
            "has_image": comp_img is not None,
            "image_id": comp_img.id if comp_img else None,
        })

    return {
        "stills": result,
        "available_outfits": available_outfits,
        "available_composites": available_composites,
        "entity_sid_map": entity_sid_map,
    }


@router.patch("/stills/{still_id}", response_model=SceneStillResponse)
def update_still(
    still_id: str,
    body: SceneStillUpdate,
    request: Request,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    """Update a scene still's prompt, beat_title, camera, or lighting."""
    still = (
        db.query(SceneStill)
        .filter(SceneStill.id == still_id, SceneStill.project_id == project_id)
        .first()
    )
    if not still:
        raise AppError(
            code="still.not_found",
            message=t("still.not_found"),
            status_code=404,
        )

    ip = request.client.host if request.client else None
    before = {}
    after = {}

    if body.still_frame_prompt is not None:
        before["still_frame_prompt"] = still.still_frame_prompt
        still.still_frame_prompt = body.still_frame_prompt
        after["still_frame_prompt"] = body.still_frame_prompt
        # v4: 체크포인트에 _user_edited 마킹 + 편집값 반영 (scene_detail 재실행 시 보존)
        _mark_checkpoint_user_edited(
            project_id, still.episode_id, still.scene_index, still.shot_index,
            updated_fields={"representative_moment": body.still_frame_prompt},
        )

    if body.beat_title is not None:
        before["beat_title"] = still.beat_title
        still.beat_title = body.beat_title
        after["beat_title"] = body.beat_title

    if body.camera_json is not None:
        before["camera_json"] = still.camera_json
        still.camera_json = body.camera_json
        after["camera_json"] = body.camera_json

    if body.lighting_json is not None:
        before["lighting_json"] = still.lighting_json
        still.lighting_json = body.lighting_json
        after["lighting_json"] = body.lighting_json

    if body.t2i_prompt_cinematic is not None:
        before["t2i_prompt_cinematic"] = still.t2i_prompt_cinematic
        still.t2i_prompt_cinematic = body.t2i_prompt_cinematic
        after["t2i_prompt_cinematic"] = body.t2i_prompt_cinematic
        # v4: 체크포인트에 _user_edited 마킹 (force 재실행 시 보존)
        _mark_checkpoint_user_edited(project_id, still.episode_id, still.scene_index, still.shot_index)

    if body.t2i_prompt_closeup is not None:
        before["t2i_prompt_closeup"] = still.t2i_prompt_closeup
        still.t2i_prompt_closeup = body.t2i_prompt_closeup
        after["t2i_prompt_closeup"] = body.t2i_prompt_closeup

    if body.visible_entities_json is not None:
        before["visible_entities_json"] = still.visible_entities_json
        still.visible_entities_json = body.visible_entities_json
        after["visible_entities_json"] = body.visible_entities_json

    if "dependent_scene_id" in (body.model_dump(exclude_unset=True) or {}):
        before["dependent_scene_id"] = still.dependent_scene_id
        still.dependent_scene_id = body.dependent_scene_id
        after["dependent_scene_id"] = body.dependent_scene_id

    db.commit()

    logger = ActivityLogger(db)
    logger.log(
        actor_id=current_user.id,
        action="still.update",
        resource_type="still",
        resource_id=still_id,
        project_id=project_id,
        detail={"before": before, "after": after},
        ip_address=ip,
    )

    return still


class T2IVariationUpdate(BaseModel):
    t2i_prompt: str


@router.patch("/stills/{still_id}/t2i-variation/{var_index}")
def update_t2i_variation(
    still_id: str,
    var_index: int,
    body: T2IVariationUpdate,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    """T2I 변형 프롬프트 개별 수정."""
    still = db.query(SceneStill).filter(
        SceneStill.id == still_id, SceneStill.project_id == project_id
    ).first()
    if not still:
        raise AppError(code="still.not_found", message="씬을 찾을 수 없습니다", status_code=404)

    variations = json.loads(still.t2i_variations_json or "[]")
    if var_index < 0 or var_index >= len(variations):
        raise AppError(code="still.invalid_index", message="잘못된 변형 인덱스", status_code=400)

    variations[var_index]["t2i_prompt"] = body.t2i_prompt
    still.t2i_variations_json = json.dumps(variations, ensure_ascii=False)
    db.commit()

    # v4: 체크포인트에도 _user_edited 마킹
    _mark_checkpoint_user_edited(project_id, still.episode_id, still.scene_index, still.shot_index)

    # T2I 수정 → 출현 횟수 재계산 (Phase 2.1 Service 경로)
    from app.services.checkpoint_sync import EpisodeProjectionService
    EpisodeProjectionService(db, project_id, still.episode_id).sync_t2i_appearance_counts()

    return {"ok": True, "variation_index": var_index}


# ── T2I 프롬프트 번역 API ──

class T2ITranslateRequest(BaseModel):
    t2i_prompt: str = Field(..., min_length=1, max_length=2000)


@router.post("/t2i/translate-to-korean")
def translate_t2i_to_korean(
    body: T2ITranslateRequest,
    project_id: str = Depends(verify_project_access),
    current_user: UserAccount = Depends(get_current_user),
):
    """T2I 프롬프트를 한국어로 번역 (엔티티 ID 보존)."""
    from app.modules.llm.llm_client import call_text

    result = call_text(
        step="t2i_translation",
        system_prompt=(
            "T2I 이미지 생성 프롬프트를 한국어로 번역하세요.\n"
            "중요 규칙:\n"
            "- C01O02, C13, P03, L05 같은 엔티티 ID 패턴은 절대 번역하지 마세요. 원본 그대로 유지하세요.\n"
            "- [L07: description] 같은 배경 표기도 그대로 유지하세요.\n"
            "- [Camera: ...] 같은 촬영 지시도 그대로 유지하세요.\n"
            "- 'Photorealistic cinematic still.' 같은 스타일 접두어도 그대로 유지하세요.\n"
            "- 나머지 영어 묘사만 자연스러운 한국어로 번역하세요."
        ),
        user_prompt=body.t2i_prompt,
        temperature=0.1,
    )
    return {"korean_prompt": result.strip() if isinstance(result, str) else body.t2i_prompt}


@router.post("/t2i/translate-to-english")
def translate_t2i_to_english(
    body: T2ITranslateRequest,
    project_id: str = Depends(verify_project_access),
    current_user: UserAccount = Depends(get_current_user),
):
    """한국어 T2I 프롬프트를 영어로 번역 (엔티티 ID 보존)."""
    from app.modules.llm.llm_client import call_text

    result = call_text(
        step="t2i_translation",
        system_prompt=(
            "한국어 T2I 이미지 생성 프롬프트를 영어로 번역하세요.\n"
            "중요 규칙:\n"
            "- C01O02, C13, P03, L05 같은 엔티티 ID 패턴은 절대 번역하지 마세요. 원본 그대로 유지하세요.\n"
            "- [L07: description] 같은 배경 표기도 그대로 유지하세요.\n"
            "- [Camera: ...] 같은 촬영 지시도 그대로 유지하세요.\n"
            "- 'Photorealistic cinematic still.' 같은 스타일 접두어도 그대로 유지하세요.\n"
            "- 나머지 한국어 묘사만 자연스러운 영어로 번역하세요."
        ),
        user_prompt=body.t2i_prompt,
        temperature=0.1,
    )
    return {"t2i_prompt": result.strip() if isinstance(result, str) else body.t2i_prompt}


# ── 프로젝트 스타일 규칙 API ──

@router.get("/style-rules")
def get_style_rules(
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    """프로젝트 단위 스타일 규칙 조회."""
    from app.models.project import ProjectSettings
    import json as _json
    ps = db.query(ProjectSettings).filter(ProjectSettings.project_id == project_id).first()
    if not ps or not ps.style_rules_json:
        return {"style_rules": None, "world_summary": None, "scene_split_threshold": 600}
    return {
        "style_rules": _json.loads(ps.style_rules_json) if ps.style_rules_json else None,
        "world_summary": ps.world_summary,
        "scene_split_threshold": ps.scene_split_threshold or 600,
    }


@router.patch("/style-rules")
async def update_style_rules(
    request: Request,
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    """프로젝트 스타일 규칙 수정."""
    from app.models.project import ProjectSettings
    import json as _json
    from datetime import datetime, timezone

    ps = db.query(ProjectSettings).filter(ProjectSettings.project_id == project_id).first()
    body = await request.json()

    if not ps:
        ps = ProjectSettings(
            id=str(__import__("uuid").uuid4()),
            project_id=project_id,
            updated_at=datetime.now(timezone.utc).isoformat(),
        )
        db.add(ps)

    if "style_rules" in body:
        ps.style_rules_json = _json.dumps(body["style_rules"], ensure_ascii=False)
    if "world_summary" in body:
        ps.world_summary = body["world_summary"]
    if "scene_split_threshold" in body:
        ps.scene_split_threshold = int(body["scene_split_threshold"])
    ps.updated_at = datetime.now(timezone.utc).isoformat()
    db.commit()

    return {
        "style_rules": _json.loads(ps.style_rules_json) if ps.style_rules_json else None,
        "world_summary": ps.world_summary,
        "scene_split_threshold": ps.scene_split_threshold or 600,
    }


@router.get("/character-outlooks")
def list_character_outlooks(
    project_id: str = Depends(verify_project_access),
    db: OrmSession = Depends(get_db),
    current_user: UserAccount = Depends(get_current_user),
):
    """인물+아웃룩 조합 목록 반환."""
    from app.models.project import CharacterOutlook, EntityCanon

    combos = (
        db.query(CharacterOutlook)
        .filter(CharacterOutlook.project_id == project_id)
        .all()
    )
    # Bulk load all referenced entities
    all_ids = {co.character_id for co in combos} | {co.outlook_id for co in combos}
    ent_map = {}
    if all_ids:
        ents = db.query(EntityCanon).filter(EntityCanon.id.in_(all_ids)).all()
        ent_map = {e.id: e for e in ents}

    result = []
    for co in combos:
        char_ent = ent_map.get(co.character_id)
        outlook_ent = ent_map.get(co.outlook_id)
        if char_ent and outlook_ent:
            result.append({
                "character_name": char_ent.name,
                "character_id": char_ent.id,
                "outlook_name": outlook_ent.name,
                "outlook_id": outlook_ent.id,
                "outlook_description": outlook_ent.description or "",
                "outlook_t2i_prompt": outlook_ent.t2i_prompt or "",
            })
    return result
