#!/usr/bin/env python3
"""유료 주행 전 **무료** preflight — 굽을 샷의 의상 배정이 다 풀리는가.

2026-08-29 실측: `scene_image_pipeline` 7샷 중 5샷이 아래 한 줄로 죽어
37분 유료 주행이 partial 로 끝났다.

    still_recipe S1sh8: outfit_assignments invalid: unknown outlook_id: 'O02' — fail-closed

원인은 `entity_episode_link` 에 outlook 행이 없어 `entity_lookup` 에 안 실린
것이었다. **주행 전에 무료로 30초면 보이는 것**을 유료로 쟀다.

## 거는 시점

`scene_still` 이 생긴 직후가 아니라, **OutlookSyncService 까지 포함한 전체
checkpoint sync 가 commit 된 뒤 · 유료 `scene_image_pipeline` 앞**이다
(2026-08-29 Codex). sync 전에 재면 아직 안 채워진 link 를 결함으로 읽는다.

## 재구현하지 않는다 — 판단도, 모집단도

여기서 다시 짜면 재는 쪽과 도는 쪽이 같이 틀린다. 프로덕션과 **같은 함수**를
부른다:

    ScenePersistenceService.load_episode_entity_dicts   entity_lookup 원천
    ScenePersistenceService.load_episode_still_dicts    **어느 샷을 굽는가**
    still_recipe.build_short_id_resolver                short id → UUID
    still_recipe.extract_outfit_assignments             배정 파싱 + fail-closed

★모집단을 넓게 잡으면 **안 굽는 샷 때문에 멀쩡한 주행을 막는다.** 프로덕션은
 `is_selected` · `still_index>=0` · `status!='stale'` 로 거른다
 (2026-08-29 Codex BLOCK — 처음엔 에피소드의 scene_still 을 전부 읽었다).

usage:  preflight_outfit_resolution.py <project_id> <episode_id>
exit:   0 통과 · 1 어긋남 · 2 미확정(잴 것을 못 찾음)
"""
from __future__ import annotations

import pathlib
import sys

HERE = pathlib.Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[2]))          # backend

OK, NG, NA, OUT = "✓", "★", "·", "—"


def evaluate(db, pid: str, eid: str) -> int:
    """exit code 를 돌려준다 — 시험이 부를 수 있도록 CLI 와 갈라 둔다.

    ★모집단 규칙에는 양성 확인이 따로 필요하다(2026-08-29 Codex): 선택된 행이
    멀쩡하고 **미선택·stale 행이 깨져 있어도** 통과해야 한다. 그것을 재려면
    이 판단이 argv·SessionLocal 과 묶여 있으면 안 된다.
    """
    from app.modules.pipeline.still_recipe import (
        build_short_id_resolver,
        extract_outfit_assignments,
    )
    from app.services.scene_persistence_service import ScenePersistenceService

    svc = ScenePersistenceService(db, pid)

    ents = svc.load_episode_entity_dicts(eid)
    by_type: dict = {}
    for e in ents:
        by_type.setdefault(e["entity_type"], []).append(e["short_id"])
    print(f"entity_lookup {len(ents)}건 — "
          + " · ".join(f"{k} {sorted(v)}" for k, v in sorted(by_type.items())))
    if not ents:
        print(f"{NA} 이 에피소드에 연결된 entity 가 **하나도 없다** — "
              f"내 조회로는 못 찾았다. 미확정")
        return 2
    if "outlook" not in by_type:
        print(f"{NG} outlook 이 entity_lookup 에 **하나도 없다** — "
              f"entity_episode_link 누락(2026-08-29 결함과 같은 모양)")

    norm = build_short_id_resolver({e["id"]: e for e in ents})

    # ★프로덕션이 쓰는 그 함수 — 세 필터를 여기서 다시 쓰지 않는다.
    _, stills = svc.load_episode_still_dicts(eid)
    if not stills:
        print(f"{NA} 굽을 샷(selected)이 없다 — 아직 잴 단계가 아니다. 미확정")
        return 2

    bad, checked, empty = [], 0, 0
    for s in stills:
        res = extract_outfit_assignments(
            getattr(s, "t2i_variations_json", None), norm)
        tag = (f"S{getattr(s, 'scene_index', '?')}"
               f"sh{getattr(s, 'shot_index', '?')}")
        if "__invalid__" in res:
            bad.append(f"{tag}: {res['__invalid__']}")
        elif "__conflict__" in res:
            bad.append(f"{tag}: 배정 충돌 {res['__conflict__']}")
        elif res:
            checked += 1
        else:
            empty += 1

    print(f"굽을 샷(selected) {len(stills)}개 — 배정 있고 다 풀림 {checked} · "
          f"배정 없음 {empty} · 못 푼 것 {len(bad)}")
    if bad:
        for line in bad:
            print(f"   {NG} {line}")
        print(f"\n{NG} 어긋남 {len(bad)}건 — 이대로 태우면 그 샷들이 "
              f"fail-closed 로 죽는다")
        return 1
    if checked == 0:
        print(f"\n{NA} 배정이 실린 샷이 **하나도 없다** — 통과가 아니라 미확정")
        return 2
    print(f"\n{OK} 배정이 실린 {checked}샷이 모두 해소됐다")
    return 0


def main() -> int:
    if len(sys.argv) < 3:
        print(__doc__)
        return 2

    from app.core.database import SessionLocal

    db = SessionLocal()
    try:
        return evaluate(db, sys.argv[1], sys.argv[2])
    finally:
        db.close()


if __name__ == "__main__":
    raise SystemExit(main())
