#!/usr/bin/env python3
"""CAMERA 절이 없으면 그림이 어떻게 되나 — 참조를 그대로 붙이고 A/B.

무엇을 묻나: `shot_staging` 이 쓴 `camera_direction` 이 실제로 그림을 잡고
있는가. 없으면 무엇이 달라지는가.

    arm A  기록된 프롬프트 그대로
    arm B  카메라 축 셋만 제거
             - `- CAMERA: …`           (shot_staging 의 camera_direction)
             - `- FRAMING SCALE: …`
             - `Compose the frame exactly as specified above — camera angle,
                subject scale and screen placement.`  (한 문장만)

★`KEY BACKGROUND ELEMENTS` 는 남긴다 — 그것은 카메라 지시가 아니라
  「무엇이 화면에 있나」다. 같이 빼면 카메라를 없앤 효과와 내용을 없앤 효과가
  섞여 무엇 때문에 달라졌는지 못 가른다.
★뒤따르는 `Keep true physical scale …` 도 남긴다 — 물리 크기 규칙이지
  카메라 지시가 아니다.

★★참조 이미지는 기록된 라벨 그대로 붙인다. 빼고 돌리면 장소·인물이
  통째로 달라져 카메라 차이가 그 소음에 묻힌다.

usage: ab_camera_clause.py [--dry]
"""
import base64
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, "/Users/manta/Documents/Projects/TheRoad-I1/scratchpad")
import _opik_env  # noqa: E402,F401  ★cwd 를 backend 로 고정

import psycopg2  # noqa: E402

ROOT = Path("/Users/manta/Documents/Projects/TheRoad-I1")
PROJ = "da049582-2c6d-492c-979d-f468d61bab6e"
EPI = "fb7a883f-baac-4145-9131-732ce628d474"
IMG = ROOT / "projects" / PROJ / "images" / EPI
OUT = ROOT / "artifact" / "20260825_camera_clause_ab"

# ── 카메라 축 셋만 걷어내는 규칙 ──────────────────────────────────────
RE_HEAD = re.compile(r"^CAMERA & FRAME \(follow exactly[^)]*\):\s*$", re.M)
RE_CAM = re.compile(r"^- CAMERA: .*$", re.M)
RE_SCALE = re.compile(r"^- FRAMING SCALE: .*$", re.M)
RE_COMPOSE = re.compile(
    r"Compose the frame exactly as specified above — camera angle, "
    r"subject scale and screen placement\.\s*")


def _dsn() -> str:
    """DB 접속 문자열은 `backend/.env` 에서 읽는다.

    ★코드에 적어 두면 저장소에 자격 증명이 남는다(GitGuardian).
     `_opik_env` 가 cwd 를 backend 로 옮겨 두므로 설정이 채워진다.
    """
    from app.core.config import settings

    url = getattr(settings, "database_url", "") or ""
    if not url:
        raise SystemExit("[설정 미로드] database_url 이 비었다 — "
                         "backend/.env 를 못 읽었다")
    return url


def strip_camera(p: str) -> tuple[str, dict]:
    """카메라 축 셋을 걷어낸 프롬프트와, 무엇이 몇 자 빠졌는지."""
    removed = {}
    m = RE_CAM.search(p)
    removed["CAMERA"] = len(m.group(0)) if m else 0
    m2 = RE_SCALE.search(p)
    removed["FRAMING SCALE"] = len(m2.group(0)) if m2 else 0
    m3 = RE_COMPOSE.search(p)
    removed["Compose 지시"] = len(m3.group(0)) if m3 else 0

    out = RE_CAM.sub("", p)
    out = RE_SCALE.sub("", out)
    out = RE_COMPOSE.sub("", out)
    # 헤더는 남은 내용(배경 요소)에 맞게 갈아 끼운다 — 지우면 목록이 떠 버린다
    out = RE_HEAD.sub("BACKGROUND ELEMENTS PRESENT IN THIS SHOT:", out)
    out = re.sub(r"\n{3,}", "\n\n", out)
    return out, removed


# ── 라벨 → 실제 파일 ──────────────────────────────────────────────────
def build_ref_index(cur) -> dict:
    """엔티티 이름별 참조 파일. composite 가 있으면 그것(얼굴+복장), 없으면 face."""
    cur.execute("""
        SELECT ia.pipeline_role, coalesce(ec.name,''), ia.file_path
        FROM image_asset ia LEFT JOIN entity_canon ec ON ec.id = ia.entity_id
        WHERE ia.episode_id=%s AND ia.pipeline_role IN
              ('reference_face','reference_outlook','reference_composite',
               'background_render','outdoor_canon_photo')
    """, (EPI,))
    by_name, backgrounds = {}, []
    for role, name, path in cur.fetchall():
        p = ROOT / path if not path.startswith("/") else Path(path)
        if not p.exists():
            p2 = IMG / path
            p = p2 if p2.exists() else p
        if role in ("background_render", "outdoor_canon_photo"):
            backgrounds.append(p)
            continue
        cur_role = by_name.get(name, (None, None))[0]
        # composite > face > outlook
        rank = {"reference_composite": 3, "reference_face": 2, "reference_outlook": 1}
        if cur_role is None or rank[role] > rank[cur_role]:
            by_name[name] = (role, p)
    return {"by_name": by_name, "backgrounds": sorted(backgrounds)}


def resolve(label: str, sh: str, idx: dict, prev_still: Path | None) -> Path | None:
    """기록된 라벨 하나를 실제 파일로."""
    head = label.split("—")[0].strip().split("(")[0].strip()
    if head in ("CHARACTER REFERENCE", "PROP REFERENCE"):
        m = re.search(r"—\s*([^:]+):", label)
        if m:
            name = m.group(1).strip()
            hit = idx["by_name"].get(name)
            if hit:
                return hit[1]
        return None
    if head == "PREVIOUS SHOT STILL":
        return prev_still
    if head == "LAYOUT SKETCH":
        p = IMG / "conti" / f"conti_{sh}.png"
        return p if p.exists() else None
    if head == "SHOT BACKGROUND":
        # ★샷별로 다르다 — 전 샷에 같은 배경을 주면 LOCATION 절이 그것을
        #  「이 장소의 확정된 진실」로 읽어 장면이 통째로 바뀐다(실측 사고).
        p = IMG / "scene" / "recipe" / f"{sh}__bgfirst_bg.png"
        return p if p.exists() else None
    if head == "LOCATION PHOTOGRAPH":
        # 장소 사진 — 실내(background_render)/실외(outdoor_canon_photo)가 섞여 있어
        # 샷의 LOCATION 문안으로 가른다
        bgs = idx["backgrounds"]
        return bgs[0] if bgs else None
    return None


def main() -> int:
    dry = "--dry" in sys.argv
    cn = psycopg2.connect(_dsn())
    cur = cn.cursor()
    cur.execute("""
        SELECT metadata_json::json->>'multiroll_tag', user_prompt,
               reference_image_ids, created_at
        FROM llm_call_log
        WHERE episode_id=%s AND operation_type='still_recipe_roll'
          AND model_name LIKE 'gemini%%'
        ORDER BY created_at
    """, (EPI,))
    # 샷별로 _a 후보의 **최신** 것 (b 는 COMPOSITION VARIATION 이 붙어 순수 비교가 안 된다)
    picked: dict[str, tuple] = {}
    for tag, prompt, refs, ts in cur.fetchall():
        if not tag or not tag.endswith("_a"):
            continue
        sh = tag[len("still_"):-2]
        picked[sh] = (prompt, json.loads(refs or "[]"), ts)

    idx = build_ref_index(cur)
    print(f"샷 {len(picked)}개 · 참조 인덱스 {len(idx['by_name'])}엔티티 "
          f"· 배경 {len(idx['backgrounds'])}장\n")

    OUT.mkdir(parents=True, exist_ok=True)
    plan = []
    order = sorted(picked, key=lambda s: [int(x) for x in re.findall(r"\d+", s)])
    for i, sh in enumerate(order):
        prompt, labels, _ = picked[sh]
        stripped, removed = strip_camera(prompt)
        # ★`PREVIOUS SHOT STILL` 은 **앞 샷**이다 — 자기 자신을 주면 모델이
        #  그것을 그대로 베껴 A/B 가 양쪽 다 그 그림으로 수렴한다.
        #  라벨이 「같은 장소의 앞 샷」이라 하므로 같은 씬의 직전 샷을 쓴다.
        scene = re.match(r"S(\d+)", sh).group(1)
        prev = None
        for back in range(i - 1, -1, -1):
            cand = order[back]
            if re.match(r"S(\d+)", cand).group(1) != scene:
                break
            f = IMG / "scene" / "recipe" / f"{cand}_cine.png"
            if f.exists():
                prev = f
                break
        if prev is None and i > 0:   # 씬 안에 앞 샷이 없으면 바로 앞 샷
            f = IMG / "scene" / "recipe" / f"{order[i-1]}_cine.png"
            prev = f if f.exists() else None
        files, missing = [], []
        for lb in labels:
            p = resolve(lb, sh, idx, prev)
            (files if p and p.exists() else missing).append((lb.split("—")[0].strip()[:28], p))
        # ★마네킹 치환형은 카메라 권위가 **첫 이미지**에 있다
        #  ("KEEP EXACTLY: … the camera framing"). 텍스트 절을 빼도 카메라는
        #  안 움직인다 — A/B 를 그대로 돌리되 그 사실을 표시한다.
        kind = "마네킹치환" if prompt.startswith("Replace every grey") else "일반 t2i"
        plan.append((sh, prompt, stripped, removed, labels, files, missing, kind))
        print(f"{sh}  [{kind}] {len(prompt):>6,}자 → {len(stripped):>6,}자 "
              f"(−{len(prompt)-len(stripped)})  참조 {len(files)}/{len(labels)}"
              + (f"  ★못찾음 {[m[0] for m in missing]}" if missing else ""))
        print(f"      뺀 것: " + " · ".join(f"{k} {v}자" for k, v in removed.items())
              + ("   ★카메라는 첫 이미지가 정한다 — 이 샷은 절을 빼도 안 움직인다"
                 if kind == "마네킹치환" else ""))

    if dry:
        print("\n--dry 라 생성은 안 한다.")
        (OUT / "_plan.json").write_text(json.dumps(
            [{"shot": s, "removed": r, "labels": lb, "kind": k,
              "refs": [str(f[1]) for f in fs], "missing": [m[0] for m in ms]}
             for s, _, _, r, lb, fs, ms, k in plan], ensure_ascii=False, indent=2),
            encoding="utf-8")
        print(f"계획 → {OUT}/_plan.json")
        return 0

    from app.modules.llm.gemini_image_client import GeminiImageClient
    client = GeminiImageClient()
    for sh, pa, pb, _, labels, files, _, _kind in plan:
        labeled = [(lb, p.read_bytes()) for (lb, p) in
                   [(l, f[1]) for l, f in zip(labels, files)]]
        for arm, text in (("A", pa), ("B", pb)):
            dst = OUT / f"{sh}_{arm}.png"
            if dst.exists():
                print(f"  {sh}_{arm} 이미 있음 — 건너뜀")
                continue
            try:
                img, ms = client.generate_image(text, labeled_references=labeled)
                dst.write_bytes(img)
                print(f"  {sh}_{arm} ✔ {len(img):,}B {ms}ms")
            except Exception as e:
                print(f"  {sh}_{arm} ✘ {e}")
    print(f"\n→ {OUT}")
    return 0


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