#!/usr/bin/env python3
"""최종 선정 스틸 일괄 i2i 시네마틱 변환 파일럿 — 프로덕션 무접촉 (#103/#104).

배경: 최종 이미지 모델 grok 2.0 확정 전 3안 대조(nb2 원본 조립 vs grok
컴팩트 조립 vs "nb2 선정본을 grok 이 i2i 재구성"). 이 스크립트가 셋째 안.
mai_image_pilot.py 의 요청·기록 패턴 재사용 + 23장 루프.

계약:
- 변환 지시는 짧은 범용 문안 하나(시나리오 고유명사·샷별 분기 없음) —
  원본 조립 프롬프트를 쓰지 않는다. 문안 SOT 는 <out>/prompt.txt 로 남긴다.
- 입력 = recipe 디렉토리의 S{scene}sh*_sel.png (대상 씬 인자).
- 산출 = <out>/img/<tag>_cine.<ext> + calls.json append(실패도 기록).
- 재개 안전: 산출이 이미 있으면 건너뛴다(재실행=실패분만 재시도).

사용:
  python3 grok_cine_batch.py                      # 기본값으로 전체 실행
  python3 grok_cine_batch.py --scenes 1,4 --limit 2   # 부분 실측
"""
from __future__ import annotations

import argparse
import base64
import json
import mimetypes
import re
import time
from datetime import datetime, timezone
from pathlib import Path

import requests

ROOT = Path(__file__).resolve().parent
MODEL = "x-ai/grok-imagine-image-2.0"
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
TIMEOUT_S = 420

# 팩 v17 cinematic_finish + broll_composition_variation 문안 기반 —
# 장소·인물·순간은 원본 스틸이 고정하고, 프레이밍·조명만 영화적으로 재구성.
TRANSFORM_PROMPT = """Rework this still into a cinematic key frame from a live-action film.
Keep the place, the people, their wardrobe, props and the exact moment unchanged — the source still fixes WHAT is seen. Do not add, remove or replace people or objects, and do not change the era the scene depicts.
Reframe and relight like a film director choosing a stronger setup: you may change the camera angle, height, distance or foreground layering — the source still does not fix where the camera stands. Use motivated practical light with soft falloff; depth layering of foreground, midground and background; subtle air and atmosphere; restrained film grain. It must not read as a posed photograph.
Do not render any text, captions, subtitles, watermarks or logos in the image.
"""


def load_api_key() -> str:
    for line in (ROOT / ".env").read_text(encoding="utf-8").splitlines():
        if line.startswith("OPENROUTER_API_KEY="):
            return line.split("=", 1)[1].strip()
    raise SystemExit("OPENROUTER_API_KEY 가 backend/.env 에 없다")


def image_part(path: Path) -> dict:
    mime = mimetypes.guess_type(str(path))[0] or "image/png"
    b64 = base64.b64encode(path.read_bytes()).decode()
    return {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}


def shot_key(tag: str) -> tuple[int, int]:
    m = re.match(r"S(\d+)sh(\d+)$", tag)
    return (int(m.group(1)), int(m.group(2)))


def append_record(calls: Path, rec: dict) -> None:
    hist = json.loads(calls.read_text(encoding="utf-8")) if calls.is_file() else []
    hist.append(rec)
    calls.write_text(json.dumps(hist, ensure_ascii=False, indent=1), encoding="utf-8")


def transform_one(api_key: str, src: Path, out_img: Path, tag: str) -> dict:
    content = [
        {"type": "text", "text": TRANSFORM_PROMPT},
        {"type": "text", "text": "SOURCE STILL"},
        image_part(src),
    ]
    body = {
        "model": MODEL,
        "messages": [{"role": "user", "content": content}],
        "modalities": ["image"],
    }
    rec: dict = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "tag": tag,
        "model": MODEL,
        "prompt_chars": len(TRANSFORM_PROMPT),
        "src": src.name,
        "src_bytes": src.stat().st_size,
    }
    t0 = time.monotonic()
    try:
        resp = requests.post(
            ENDPOINT,
            headers={"Authorization": f"Bearer {api_key}",
                     "Content-Type": "application/json"},
            json=body, timeout=TIMEOUT_S)
        rec["latency_s"] = round(time.monotonic() - t0, 1)
        rec["http_status"] = resp.status_code
        try:
            data = resp.json()
        except ValueError:
            rec["error"] = f"비JSON 응답 (앞 300자): {resp.text[:300]!r}"
            return rec
        if "error" in data:
            rec["error"] = json.dumps(data["error"], ensure_ascii=False)[:500]
        choices = data.get("choices") or []
        msg = (choices[0].get("message") or {}) if choices else {}
        rec["finish_reason"] = choices[0].get("finish_reason") if choices else None
        rec["usage"] = data.get("usage")
        images = msg.get("images") or []
        rec["n_images"] = len(images)
        for im in images:
            url = ((im.get("image_url") or {}).get("url")
                   if isinstance(im, dict) else "") or ""
            if not url.startswith("data:"):
                rec.setdefault("error", f"data URI 아님: {url[:120]}")
                continue
            header, b64 = url.split(",", 1)
            raw = base64.b64decode(b64)
            ext = ".png" if "png" in header else ".jpg"
            fp = out_img / f"{tag}_cine{ext}"
            fp.write_bytes(raw)
            rec["saved"] = {"file": fp.name, "bytes": len(raw)}
            break  # 첫 이미지만 — 변환은 1장 계약
        if images and "saved" not in rec:
            rec.setdefault("error", "이미지 파트 저장 실패")
        if not images and "error" not in rec:
            rec["error"] = "응답에 이미지 0장"
    except requests.Timeout:
        rec["latency_s"] = round(time.monotonic() - t0, 1)
        rec["error"] = f"timeout {TIMEOUT_S}s"
    except Exception as exc:  # 실측 기록이 목적 — 종류 불문 남긴다
        rec["latency_s"] = round(time.monotonic() - t0, 1)
        rec["error"] = f"{type(exc).__name__}: {exc}"[:500]
    return rec


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--recipe-dir", default=str(
        ROOT.parent / "projects" / "c7e3b2e7-c545-4516-93b2-62a51a74d794"
        / "images" / "7c902020-4451-4967-9eb6-1e53c2b9b717" / "scene" / "recipe"))
    ap.add_argument("--scenes", default="1,4,5,9,37,39,56,64,65",
                    help="대상 씬 번호 쉼표 목록")
    ap.add_argument("--out", default=str(
        ROOT.parent / "artifact" / "20260813_grok시네마틱변환23"))
    ap.add_argument("--limit", type=int, default=0, help="앞 N장만 (0=전체)")
    args = ap.parse_args()

    recipe = Path(args.recipe_dir)
    scenes = {int(s) for s in args.scenes.split(",") if s.strip()}
    out_dir = Path(args.out)
    out_img = out_dir / "img"
    out_img.mkdir(parents=True, exist_ok=True)
    calls = out_dir / "calls.json"
    (out_dir / "prompt.txt").write_text(TRANSFORM_PROMPT, encoding="utf-8")

    targets = sorted(
        (p.name[:-len("_sel.png")] for p in recipe.glob("S*_sel.png")
         if shot_key(p.name[:-len("_sel.png")])[0] in scenes),
        key=shot_key)
    if args.limit:
        targets = targets[:args.limit]

    api_key = load_api_key()
    done = failed = skipped = 0
    for i, tag in enumerate(targets, 1):
        existing = list(out_img.glob(f"{tag}_cine.*"))
        if existing:
            skipped += 1
            print(f"[{i}/{len(targets)}] {tag} skip (기존 {existing[0].name})",
                  flush=True)
            continue
        rec = transform_one(api_key, recipe / f"{tag}_sel.png", out_img, tag)
        append_record(calls, rec)
        if rec.get("saved"):
            done += 1
            print(f"[{i}/{len(targets)}] {tag} ok {rec['latency_s']}s "
                  f"{rec['saved']['bytes']}B", flush=True)
        else:
            failed += 1
            print(f"[{i}/{len(targets)}] {tag} FAIL {rec.get('error')}",
                  flush=True)
    print(f"완료 {done} · 건너뜀 {skipped} · 실패 {failed} / 대상 {len(targets)}")
    if failed:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
