"""주행 결과 갤러리 — 「오늘 고친 것이 그림에 닿았나」를 먼저 보여 준다.

usage:  build_run_gallery.py <episode_id> [출력디렉토리]

## 왜 이 순서인가

이 저장소에서 여러 번, **안 닿는 것을 고치고 효과를 기다렸다.** 그래서 이
갤러리는 그림보다 먼저 **닿았는지**를 잰다 — 프롬프트 전문에서 문자열로
찾고, 못 찾으면 못 찾았다고 적는다.

재는 것:
- **B-2b** lane 샷 roll 프롬프트에 `- FRAMING SCALE:` 이 있나 (PR #39)
- **B-2b** lane 스케치 라벨이 새 판(`sketch_label_lane`)인가
- **#97** era 캐시 신원이 `canonical` 인가 `identity_fallback` 인가 (08-27 수정)
- **A-1 ③** 도면 렌더 유료 호출에 `project_id`·`episode_id` 가 남았나 (PR #36)
- **#40** Gemini 정수 enum 손질 뒤 beat/shot/camera_flow 가 실제로 돌았나

## lane 을 가르는 법 — 두 번 틀렸다

1. **표기용 `ref_mode` 로 가르지 않는다.** 그것은 조립 시점의 `lane_used` 와
   다르다(2026-08-28 에 그걸로 한 번 잘못 셌다).
2. **옛 문구 `ONLY the camera framing` 으로도 못 가른다** (Codex #41 BLOCK).
   저장소 실측: `conti_label.md` **6판본** · `sketch_label.md` **5판본**에
   같이 있다. 그걸 「옛 lane 라벨」로 쓰면 **일반 콘티 샷**을 lane 으로 세어
   양쪽 분모를 오염시킨다.

→ v41 `sketch_label_lane.md` **한 곳에만** 있는 `ONLY the figure placement`
  하나로만 가른다. 그 문구가 0이면 **「lane 이 없다」가 아니라 「새 계약 도달
  증거가 없다」**로 적는다 — 프롬프트만으로는 lane 부재와 옛 라벨 회귀를
  구분할 수 없다.

## #97 행이 증명하는 것

`identity: canonical` 이 **도달했나**만 본다. **적중률(cache hit rate)의
종결 근거가 아니다** — 그건 같은 장소를 두 번 만나는 주행이 있어야 잰다.
"""
from __future__ import annotations

import html
import json
import pathlib
import subprocess
import sys
from collections import Counter

ROOT = pathlib.Path(__file__).resolve().parents[2]
PORT = 8940


def q(sql: str) -> list[list[str]]:
    """★자격 증명을 여기 박지 않는다 — 앱 자신의 `settings.database_url` 로 붙는다.

    처음엔 `PGPASSWORD` 를 소스에 적었다가 GitGuardian 이 잡았다(PR #41).
    그 비밀번호가 `config.py` 기본값에 이미 있다는 것이 **또 한 벌 박아도
    된다는 뜻은 아니다.**
    """
    from sqlalchemy import create_engine, text

    # ★`settings` 는 `.env` 를 **상대 경로**로 읽는다 — backend 를 경로에
    #  넣고 거기서 읽어야 설정이 통째로 비지 않는다.
    if str(ROOT / "backend") not in sys.path:
        sys.path.insert(0, str(ROOT / "backend"))
    from app.core.config import settings

    if not hasattr(q, "_eng"):
        q._eng = create_engine(settings.database_url)   # type: ignore[attr-defined]
    with q._eng.connect() as conn:                      # type: ignore[attr-defined]
        return [["" if v is None else str(v) for v in row]
                for row in conn.execute(text(sql)).fetchall()]


def esc(s) -> str:
    return html.escape(str(s or ""))


def lane_bindings(epi: str) -> dict[str, str]:
    """`outdoor_lane_plan` 이 샷마다 고른 lane — `{"S2sh3": "none", …}`.

    ★경로를 **끝까지** 짚는다: `data/groups/<그룹>/plan/shot_bindings`.
     「깊이 몇 이내에서 첫 dict 리스트」 식으로 찾으면 못 찾고, 그러면
     **없는 것으로 읽힌다**(2026-08-28 에 그 실수를 했다).
    """
    out: dict[str, str] = {}
    for p in ROOT.glob(
            f"projects/*/checkpoints/episodes/{epi}/outdoor_lane_plan/manifest.json"):
        m = json.loads(p.read_text(encoding="utf-8"))
        groups = ((m.get("data") or {}).get("groups") or {})
        for g in groups.values():
            for b in ((g.get("plan") or {}).get("shot_bindings") or []):
                if not isinstance(b, dict):
                    continue
                key = f"S{b.get('scene_index')}sh{b.get('shot_index')}"
                out[key] = str(b.get("lane") or "?")
    return out


def find_records(epi: str) -> dict:
    for p in ROOT.glob(f"projects/*/images/{epi}/scene/recipe/records.json"):
        return json.loads(p.read_text(encoding="utf-8"))
    return {}


# ─────────────────────────────────────────── 닿았나

def reach_checks(epi: str, rec: dict) -> list[tuple[str, str, str, str]]:
    """(항목, 판정, 수치, 설명) — 판정은 ok / no / n/a."""
    out = []

    rolls = [(k, r, t) for k, v in rec.items() if isinstance(v, dict)
             for r, t in (v.get("roll_prompts") or {}).items()
             if isinstance(t, str)]
    # ★lane 롤은 **v41 고유 문구 하나로만** 가른다 (Codex #41 BLOCK).
    #
    #  첫 판은 옛 문구 `ONLY the camera framing` 을 「옛 lane 라벨」로 썼다.
    #  그건 lane 신호가 아니다 — 저장소 실측으로 `conti_label.md` **6판본**과
    #  `sketch_label.md` **5판본**에 같이 들어 있다. 즉 **일반 콘티 샷**을
    #  옛 lane 으로 세어 lane 분모와 일반 롤 분모를 **양쪽에서** 오염시키고,
    #  한 프롬프트에 둘 다 있으면 중복 집계까지 된다.
    #
    #  `ONLY the figure placement` 는 v41 `sketch_label_lane.md` **한 곳**뿐이라
    #  이 주행의 도달 증거로 쓸 수 있는 유일한 문구다.
    lane_new = [t for _, _, t in rolls if "ONLY the figure placement" in t]

    if not rolls:
        out.append(("roll 프롬프트", "n/a", "0건", "아직 스틸 단계 전이다"))
    elif not lane_new:
        # ★프롬프트만 보면 **lane 부재**와 **옛 라벨 회귀**를 못 가른다.
        #  그래서 `outdoor_lane_plan` 체크포인트를 열어 **실제 판정**을 읽는다.
        #
        #  2026-08-28: 처음엔 이 자리에서 「lane 경로가 안 열렸다 / plan 산출
        #  0행」이라고 적었다. **틀렸다.** 내 조사 코드가 매니페스트에서 깊이
        #  3 이내의 dict 리스트만 찾았는데 실제 경로는
        #  `data/groups/<그룹>/plan/shot_bindings` 로 더 깊었다 —
        #  **못 찾은 것을 「없다」로 읽었다**(Codex 가 잡았다).
        lanes = lane_bindings(epi)
        if lanes:
            n_map = sum(1 for v in lanes.values() if v == "map_marker")
            verdict = " · ".join(f"{k}={v}" for k, v in sorted(lanes.items()))
            if n_map:
                # ★여기가 **초록이면 안 된다** (Codex #41 2차 지적, 수용).
                #  lane plan 이 map_marker 를 골랐는데 v41 고유 문구가 롤
                #  프롬프트에 **한 건도 없다** — 그건 「골랐으니 됐다」가
                #  아니라 **PR #39 가 그 샷에 안 닿았다**는 가장 강한 증거다.
                #  종전 코드는 `"ok" if n_map else "n/a"` 라 이 자리를 초록으로
                #  칠했다. 내 도구가 미도달을 도달로 표시하던 것이다.
                out.append((
                    "B-2b · map_marker 샷에 v41 계약이 닿았나", "no",
                    f"map_marker {n_map}/{len(lanes)} · v41 문구 0",
                    "lane plan 은 map_marker 를 **골랐는데** v41 "
                    "`sketch_label_lane` 고유 문구가 롤 프롬프트에 한 건도 "
                    f"없다 — PR #39 **미도달**. 판정: {verdict}"))
            else:
                out.append((
                    "B-2b · lane 이 map_marker 를 골랐나", "n/a",
                    f"map_marker 0/{len(lanes)}",
                    "lane plan 은 **정상 동작**했다 — 판정: " + verdict
                    + ". map_marker 를 한 번도 안 골랐으니 PR #39 는 "
                    "**미검증**이다(결함 아님)"))
        else:
            out.append((
                "B-2b · lane 판정", "n/a", "바인딩 0",
                "`outdoor_lane_plan` 체크포인트에 샷 바인딩이 없다 — "
                "실외 그룹 자체가 없었거나 스텝이 안 돌았다"))
    else:
        n_scale = sum(1 for t in lane_new if "- FRAMING SCALE:" in t)
        out.append((
            "B-2b · lane 롤에 typed FRAMING SCALE",
            "ok" if n_scale == len(lane_new) else "no",
            f"{n_scale}/{len(lane_new)}",
            "PR #39 이전에는 lane 샷에 샷 크기가 한 글자도 안 실렸다"))
        out.append((
            "B-2b · lane 스케치 라벨이 새 판(v41)",
            "ok", f"{len(lane_new)}/{len(lane_new)} 롤",
            "v41 `sketch_label_lane` 고유 문구로만 셌다 — 옛 문구는 "
            "일반 콘티 라벨에도 있어 lane 판별에 못 쓴다"))

    # ★일반 롤 분모에서 **v41 고유 롤만** 뺀다. 옛 문구로 빼면 일반 콘티 롤이
    #  같이 빠져 분모가 줄고, 한 롤을 두 번 세는 일도 생긴다.
    n_nonlane = len(rolls) - len(lane_new)
    if n_nonlane:
        bad = sum(1 for _, _, t in rolls
                  if "ONLY the figure placement" not in t
                  and "- FRAMING SCALE:" not in t)
        out.append((
            "일반 롤에 FRAMING SCALE (리그레션 확인)",
            "ok" if bad == 0 else "no", f"빠진 롤 {bad}/{n_nonlane}",
            "lane 밖 샷은 종전대로 CAMERA & FRAME 절이 그 줄을 갖는다"))

    # era 캐시 신원 (#97) — 08-27 수정의 첫 표본
    era = [v for k, v in rec.items()
           if k.startswith("era_assess::") and isinstance(v, dict)]
    if era:
        ident = Counter(
            "canonical" if v.get("identity") == "canonical"
            else ("fallback" if v.get("identity_fallback") else "무표시")
            for v in era)
        n_canon = ident.get("canonical", 0)
        out.append((
            "#97 · era 캐시 신원이 정본",
            "ok" if n_canon == len(era) else ("no" if n_canon == 0 else "일부"),
            f"{n_canon}/{len(era)} · {dict(ident)}",
            "08-27 수정 전 기록은 전부 「무표시」였다 — 이 주행이 첫 표본. "
            "★신원이 **도달했나**만 본다. 적중률 종결 근거가 아니다"))
    else:
        out.append(("#97 · era 캐시 신원", "n/a", "0건", "era 조사가 아직 없다"))

    # A-1 ③ 도면 렌더 유료 호출의 계보 (PR #36)
    # ★에피소드로 못 거른다 — 결함이 바로 그 두 칸을 NULL 로 남기는 것이다.
    #  그래서 「최근 24시간」으로 잡는다. 이 주행이 그 창의 유일한 도면 렌더다.
    # ★`step_name` 은 `step:floor_plan_render` 다 — 이름은 DB 에서
    #  그대로 가져온다(`'floor_plan'` 로 잡으면 0건이 나와 n/a 로 샌다).
    fp_all = q("SELECT count(*) FILTER (WHERE project_id IS NULL OR "
               "episode_id IS NULL), count(*) FROM llm_call_log "
               "WHERE step_name LIKE 'step%floor_plan%' "
               "AND CAST(created_at AS timestamptz) > "
               "now() - interval '1 day'")
    if fp_all and fp_all[0][1] != "0":
        null_n, tot = int(fp_all[0][0]), int(fp_all[0][1])
        out.append((
            "A-1 ③ · 도면 렌더 유료 호출의 계보",
            "ok" if null_n == 0 else "no", f"NULL {null_n}/{tot} (최근 24시간)",
            "PR #36 이전에는 133건이 project_id·episode_id 둘 다 NULL"))
    else:
        out.append(("A-1 ③ · 도면 렌더 계보", "n/a", "0건",
                    "최근 24시간에 도면 렌더 호출이 없다"))

    # #40 Gemini 정수 enum 손질 — 그 세 스텝이 돌았나
    steps = {r[0]: r[1] for r in q(
        f"SELECT step_id, status FROM step_run WHERE episode_id='{epi}'")}
    trio = ["beat_extract", "shot_extract", "scene_camera_flow"]
    okc = sum(1 for s in trio if steps.get(s) == "completed")
    out.append((
        "#40 · 정수 enum 을 쓰던 세 스텝",
        "ok" if okc == 3 else "no", f"{okc}/3 completed",
        " · ".join(f"{s}={steps.get(s, '없음')}" for s in trio)))
    return out


# ─────────────────────────────────────────── 본체

def main() -> int:
    if len(sys.argv) < 2:
        print(__doc__)
        return 2
    epi = sys.argv[1]
    outdir = pathlib.Path(sys.argv[2]) if len(sys.argv) > 2 else (
        ROOT / "artifact" / "20260828_minimal_run")
    outdir.mkdir(parents=True, exist_ok=True)

    rec = find_records(epi)
    rows = q(f"""SELECT step_id, status, coalesce(applicable_count,0),
                        coalesce(completed_count,0), coalesce(failed_count,0),
                        coalesce(error_message,'')
                 FROM step_run WHERE episode_id='{epi}' ORDER BY started_at""")
    # ★`text()` 안에 `'HH24:MI'` 를 쓰면 SQLAlchemy 가 `:MI` 를 **바인드 인자**로
    #  읽어 죽는다. 시각 서식은 파이썬에서 만든다.
    # ★같은 이유로 `::timestamptz` 도 못 쓴다 — 두 콜론을 바인드로
    #  읽는다. `CAST(x AS timestamptz)` 로 적는다.
    span = q(f"""SELECT min(CAST(started_at AS timestamptz)) + interval '9 hour',
                        max(CAST(completed_at AS timestamptz)) + interval '9 hour',
                        round(extract(epoch from (max(CAST(completed_at AS timestamptz))
                              - min(CAST(started_at AS timestamptz))))/60)
                 FROM step_run WHERE episode_id='{epi}'""")
    if span and span[0][0]:
        span = [[span[0][0][11:16], (span[0][1] or "")[11:16], span[0][2]]]
    assets = q(f"""SELECT asset_type, count(*) FROM image_asset
                   WHERE episode_id='{epi}' GROUP BY 1 ORDER BY 2 DESC""")
    stills = q(f"""SELECT id, scene_index, shot_index,
                          coalesce(shot_description,''), coalesce(status,'')
                   FROM scene_still WHERE episode_id='{epi}'
                     AND is_selected IS TRUE
                   ORDER BY scene_index, shot_index""")
    imgs: dict[str, list[tuple[str, str]]] = {}
    for sid, atype, path in q(
            f"""SELECT coalesce(still_id,''), asset_type, file_path
                FROM image_asset WHERE episode_id='{epi}'
                  AND still_id IS NOT NULL ORDER BY created_at"""):
        imgs.setdefault(sid, []).append((atype, path))

    st = Counter(r[1] for r in rows)
    fails = [r for r in rows if r[1] in ("failed", "partial")]

    P = ['<meta charset="utf-8">',
         "<title>골목 끝 — 최소 검증판 주행 (2026-08-28)</title>", "<style>",
         ":root{color-scheme:dark}",
         "body{background:#111;color:#ddd;font:15px/1.65 -apple-system,"
         "'Apple SD Gothic Neo',sans-serif;margin:0;padding:28px 32px;"
         "max-width:1500px}",
         "h1{font-size:24px;margin:0 0 6px}",
         "h2{font-size:18px;margin:38px 0 12px;border-bottom:1px solid #333;"
         "padding-bottom:6px}",
         ".n{color:#8a8a8a}", "code{color:#9cdcfe;font-size:13px}",
         "table{border-collapse:collapse;margin:10px 0;font-size:14px}",
         "td,th{border:1px solid #333;padding:6px 10px;text-align:left;"
         "vertical-align:top}", "th{background:#1b1b1b}",
         ".ok{color:#6fcf6f;font-weight:700} .no{color:#ff7676;font-weight:700}",
         ".na{color:#888} .part{color:#e8c26a;font-weight:700}",
         ".shot{border:1px solid #2a2a2a;border-radius:8px;padding:14px;"
         "margin:14px 0;background:#161616}",
         ".imgs{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px}",
         ".imgs figure{margin:0;max-width:340px}",
         ".imgs img{width:100%;border-radius:6px;border:1px solid #333}",
         ".imgs figcaption{font-size:12px;color:#888;margin-top:4px}",
         "</style>",
         "<h1>골목 끝 — 최소 검증판 주행</h1>",
         f'<div class="n">에피소드 <code>{esc(epi)}</code> · '
         f'{esc(span[0][0] if span else "?")}~{esc(span[0][1] if span else "?")} KST '
         f'({esc(span[0][2] if span else "?")}분) · 오늘 병합분을 실제로 태운 첫 주행</div>']

    # ── 닿았나
    P.append("<h2>① 오늘 고친 것이 닿았나</h2>")
    P.append('<div class="n">그림보다 먼저 이것을 잰다 — 이 저장소에서 여러 번 '
             '<b>안 닿는 것을 고치고 효과를 기다렸다</b>. 프롬프트 전문에서 '
             '문자열로 찾고, 못 찾으면 못 찾았다고 적는다.</div>')
    P.append("<table><tr><th>항목</th><th>판정</th><th>수치</th><th>왜 재나</th></tr>")
    for name, verdict, num, why in reach_checks(epi, rec):
        cls = {"ok": "ok", "no": "no", "일부": "part"}.get(verdict, "na")
        P.append(f"<tr><td>{esc(name)}</td>"
                 f'<td class="{cls}">{esc(verdict)}</td>'
                 f"<td>{esc(num)}</td><td class='n'>{esc(why)}</td></tr>")
    P.append("</table>")

    # ── 주행
    P.append("<h2>② 주행</h2>")
    P.append("<table><tr>"
             + "".join(f"<th>{esc(k)}</th>" for k in st)
             + "</tr><tr>"
             + "".join(f"<td>{v}</td>" for v in st.values()) + "</tr></table>")
    if assets:
        P.append('<div class="n">자산 '
                 + " · ".join(f"{esc(a)} {n}" for a, n in assets) + "</div>")
    if fails:
        P.append("<table><tr><th>실패·부분</th><th>상태</th><th>사유</th></tr>")
        for r in fails:
            P.append(f"<tr><td>{esc(r[0])}</td><td class='no'>{esc(r[1])}</td>"
                     f"<td class='n'>{esc(r[5][:200])}</td></tr>")
        P.append("</table>")
    else:
        P.append('<div class="ok">실패 0</div>')

    # ── 샷
    P.append(f"<h2>③ 선정 샷 {len(stills)}개</h2>")
    for sid, si, shi, desc, status in stills:
        P.append('<div class="shot">')
        P.append(f"<b>S{esc(si)}sh{esc(shi)}</b> "
                 f'<span class="n">{esc(status)}</span><br>'
                 f'<span class="n">{esc(desc)[:400]}</span>')
        got = imgs.get(sid, [])
        if got:
            P.append('<div class="imgs">')
            for atype, path in got:
                rel = path if path.startswith("projects/") else path
                P.append(f'<figure><a href="/{esc(rel)}" target="_blank">'
                         f'<img src="/{esc(rel)}" loading="lazy"></a>'
                         f"<figcaption>{esc(atype)}</figcaption></figure>")
            P.append("</div>")
        else:
            P.append('<div class="n">이미지 없음</div>')
        P.append("</div>")

    (outdir / "index.html").write_text("\n".join(P), encoding="utf-8")
    ip = subprocess.run(["ipconfig", "getifaddr", "en0"],
                        capture_output=True, text=True).stdout.strip()
    rel = (outdir / "index.html").relative_to(ROOT)
    print(f"만듦: {outdir/'index.html'}")
    print(f"주소: http://{ip}:{PORT}/{rel}")
    return 0


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