"""갤러리 빌더 v2 — 파이프라인 순서 정렬 + 단계 라벨 + 프롬프트 전수 표시.

사용: .venv/bin/python gallery.py  → forest_exp/gallery.html (8897 서빙)
"""
import html
import json
import shutil
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import forest_lib as F  # noqa: E402

EXP = F.EXP


def load_runlog():
    """out 경로 → 생성 이벤트. skip 이벤트가 prompt 있는 원본을 덮지 않게."""
    by_out = {}
    if F.RUNLOG.exists():
        for line in F.RUNLOG.read_text().splitlines():
            try:
                e = json.loads(line)
            except Exception:
                continue
            out = e.get("out")
            if not out:
                continue
            if out not in by_out or e.get("prompt"):
                by_out[out] = e
    return by_out


def build_usage_map():
    """참조 이미지 절대경로 → 그것을 소비한 생성 호출 tag 목록 (runlog SOT)."""
    used = {}
    if F.RUNLOG.exists():
        for line in F.RUNLOG.read_text().splitlines():
            try:
                e = json.loads(line)
            except Exception:
                continue
            if e.get("kind") != "img" or e.get("skip"):
                continue
            for r in (e.get("refs") or []):
                used.setdefault(str(r), []).append(e.get("tag") or "?")
    return used


def usage_note(path: str, used: dict) -> str:
    tags = used.get(str(path)) or []
    if not tags:
        return "❌ 이번 실험의 어떤 생성 호출에도 참조로 사용되지 않음(보유 자산)"
    uniq = sorted(set(tags))
    return "✅ 사용된 호출: " + ", ".join(uniq[:8]) + (" …" if len(uniq) > 8 else "")


def cell(path: Path, step_no: str, role: str, by_out, w=390, note=""):
    """이미지 셀 — 단계번호+역할 라벨 + 프롬프트/참조 접기."""
    if not path.exists():
        return ""
    rel = path.relative_to(EXP)
    e = by_out.get(str(path)) or {}
    meta = []
    if e.get("engine"):
        meta.append(f"{e['engine']}")
    if e.get("dur_s"):
        meta.append(f"{e['dur_s']}s")
    det = ""
    if e.get("prompt"):
        labels = e.get("ref_labels") or []
        ref_names = [Path(r).name for r in (e.get("refs") or [])]
        if labels and len(labels) == len(ref_names):
            refs = "\n".join(f"[{l}]\n  → {n}" for l, n in zip(labels, ref_names))
        else:
            refs = "\n".join(ref_names)
        det = ("<details><summary>prompt/refs (실제 호출 기록)</summary><pre>"
               + html.escape(e["prompt"]) + "</pre>"
               + (f"<div class='refs'>참조 이미지(역할 라벨 포함):\n{html.escape(refs)}</div>"
                  if refs else "")
               + "</details>")
    nt = f"<div class='note'>{html.escape(note)}</div>" if note else ""
    return (f"<div class='cell'><div class='steplbl'><b>{html.escape(step_no)}</b> "
            f"{html.escape(role)} <span class='meta'>{html.escape(' · '.join(meta))}</span></div>"
            f"<a href='{rel}' target='_blank'><img src='{rel}' width='{w}'></a>{nt}{det}</div>")


def json_details(title: str, path: Path):
    if not path.exists():
        return ""
    return (f"<details class='json'><summary>{html.escape(title)}</summary><pre>"
            + html.escape(path.read_text()) + "</pre></details>")


def row(title: str, cells: list) -> str:
    cells = [c for c in cells if c]
    if not cells:
        return ""
    return (f"<div class='rowtitle'>{html.escape(title)}</div>"
            f"<div class='row'>{''.join(cells)}</div>")


def copy_into(src: Path, dst: Path) -> Path:
    if src and Path(src).exists() and not dst.exists():
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(src, dst)
    return dst


def baseline_cells(sk, recon, by_out):
    s = recon["shots"][sk]
    dst_dir = EXP / "out" / "baseline" / sk
    cells_ = []
    try:
        paths = F.query_production_stills(s["scene_index"], s["shot_index"])
    except Exception:
        paths = []
    for i, p in enumerate(paths[:2]):
        dst = copy_into(Path(p), dst_dir / f"prod_{i}.png")
        cells_.append(cell(dst, "비교", f"production 최종 스틸 #{i}", by_out, w=330))
    return cells_


def indoor_shot_block(sk, recon, by_out):
    s = recon["shots"][sk]
    d = F.OUT / "indoor" / sk
    st = s.get("staging") or {}
    out = [f"<h3>{sk} <small>loc={','.join(s['loc_ids'])} · framing="
           f"{st.get('framing_scale')} · 인물={', '.join(s.get('characters') or ['없음'])}"
           f"</small></h3><p class='desc'>{html.escape(str(s.get('description')))}</p>"]
    out.append(row("생성 순서 ① control/임시", [
        cell(d / "fpvar.png", "①", "fp변형 (카메라 콘+인물 서클) [CONTROL]", by_out),
        cell(d / "temp.png", "②", "임시샷 β (ENV=탑레이어+CONTROL)", by_out),
        cell(d / "temp_bg.png", "②'", "임시배경 α (실험변수)", by_out),
        cell(d / "temp_alpha.png", "②''", "임시샷 α (ENV=임시배경)", by_out),
        cell(d / "contour.png", "③", "윤곽 (temp→라인아트)", by_out),
    ]))
    out.append(row("경로 A — 직행 (temp 없이 최종)", [
        cell(d / "final_A.png", "A", "최종샷 (ENV+CONTROL+passport)", by_out),
    ]))
    out.append(row("경로 B — 임시샷→VLM 구도 텍스트→배경 재생성→최종", [
        cell(d / "bg_B.png", "B1", "재생성 배경 (VLM comp 기반)", by_out),
        cell(d / "final_B.png", "B2", "최종샷", by_out),
    ]))
    out.append(row("경로 C — 임시샷→i2i 윤곽→배경 재생성→최종", [
        cell(d / "bg_C.png", "C1", "재생성 배경 (윤곽 기반)", by_out),
        cell(d / "final_C.png", "C2", "최종샷", by_out),
        cell(d / "bg_C_m.png", "m1", "재생성 배경+마네킹", by_out),
        cell(d / "final_C_m.png", "m2", "최종샷 (마네킹 경유)", by_out),
    ]))
    comp = F.PLANS / f"comp_{sk}.json"
    out.append(json_details(f"{sk} VLM 구도 분석 JSON (경로 B 입력)", comp))
    bl = baseline_cells(sk, recon, by_out)
    if bl:
        out.append(row("production 비교", bl))
    return "\n".join(out)


def outdoor_shot_block(sk, recon, by_out, lane, tagv=""):
    s = recon["shots"][sk]
    d = F.OUT / lane / sk
    st = s.get("staging") or {}
    out = [f"<h3>{sk} <small>loc={','.join(s['loc_ids'])} · framing="
           f"{st.get('framing_scale')} · 인물={', '.join(s.get('characters') or ['없음'])}"
           f"</small></h3><p class='desc'>{html.escape(str(s.get('description')))}</p>"]
    out.append(row("생성 순서 ① control/plate/임시", [
        cell(d / "root_marked.png", "①", "뿌리 마킹 (카메라 콘+인물 서클) [CONTROL]", by_out),
        cell(d / "sketch.png", "①'", "camview 라인아트 (sketch-hop)", by_out),
        cell(d / "plate.png", "②", "빈 배경 plate [ENV]", by_out),
        cell(d / "temp.png", "③", "임시샷", by_out),
        cell(d / "temp_v31.png", "③'", "임시샷 v3.1 (CONTROL=sketch)", by_out),
        cell(d / "contour.png", "④", "윤곽", by_out),
    ]))
    out.append(row("경로 A — 직행", [
        cell(d / "final_A.png", "A", "최종샷 (plate+CONTROL+passport)", by_out),
    ]))
    out.append(row("경로 B — VLM 구도 텍스트", [
        cell(d / "bg_B.png", "B1", "재생성 배경", by_out),
        cell(d / "final_B.png", "B2", "최종샷", by_out),
        cell(d / "bg_B_v31.png", "B1'", "재생성 배경 v3.1", by_out),
        cell(d / "final_B_v31.png", "B2'", "최종샷 v3.1", by_out),
    ]))
    out.append(row("경로 C — i2i 윤곽", [
        cell(d / "bg_C.png", "C1", "재생성 배경", by_out),
        cell(d / "final_C.png", "C2", "최종샷", by_out),
        cell(d / "bg_C_m.png", "m1", "재생성 배경+마네킹", by_out),
        cell(d / "final_C_m.png", "m2", "최종샷 (마네킹 경유)", by_out),
    ]))
    comp = F.PLANS / f"comp_{sk}{tagv}.json"
    out.append(json_details(f"{sk}{tagv} VLM 구도 분석 JSON", comp))
    bl = baseline_cells(sk, recon, by_out)
    if bl:
        out.append(row("production 비교", bl))
    return "\n".join(out)


CSS = """<meta charset='utf-8'><title>forest_exp gallery</title><style>
body{font-family:sans-serif;background:#171717;color:#eee;margin:20px;max-width:1780px}
.row{display:flex;flex-wrap:wrap;gap:10px;margin:4px 0 14px}
.cell{background:#222;padding:8px;border-radius:8px;max-width:430px}
.cell img{border-radius:4px;max-width:100%}
.steplbl{font-size:13px;margin-bottom:4px;color:#fd9}
.meta{color:#8ac;font-size:11px}.note{color:#f88;font-size:12px}
.desc{color:#bbb;max-width:1200px;font-size:13px}
.rowtitle{color:#fc6;font-weight:bold;margin-top:12px;font-size:14px}
details pre{white-space:pre-wrap;font-size:11px;color:#9c9;max-height:300px;overflow:auto;background:#1b1b1b;padding:6px}
details.json pre{color:#9ac}
.refs{font-size:10px;color:#789;white-space:pre-wrap}
h2{border-bottom:2px solid #555;padding-bottom:4px;margin-top:40px;background:#20242c;padding:8px}
h3{margin-top:22px;color:#dfd}
.verdict{background:#26301f;border-left:4px solid #8c4;padding:8px 12px;margin:8px 0;font-size:13px;max-width:1200px}
.warn{background:#33261f;border-left:4px solid #c84}
.guide{background:#1e2430;padding:12px 16px;border-radius:8px;font-size:13px;max-width:1200px;line-height:1.6}
</style>"""


def main():
    recon = F.load_recon()
    by_out = load_runlog()
    subset = F.load_plan("subset")
    P = [CSS, f"<h1>forest_exp — '{recon['group_id']}' 총괄 파이프라인 실험</h1>"]

    # ── 읽는 법 ──
    P.append("""<div class='guide'><b>읽는 법</b> — 흐름: 0.입력(fp/배치도) → 1.뿌리(조감 스케치) →
2.실내 탑레이어 → 3.실내 샷별 체인 → 4.실외 샷별 체인(v1→v2→v3 버전순).<br>
각 샷 블록은 <b>생성 순서(①②③…)</b> 행 다음에 <b>경로별 결과(A=직행 / B=VLM구도 / C=i2i윤곽 / m=마네킹)</b> 행.
모든 셀 하단 <i>prompt/refs</i> 를 펼치면 실제 사용 프롬프트와 참조 이미지가 나옵니다.
실외 버전: <b>v1</b>=계약 없음(실패 증거) → <b>v2</b>=인원/시점/마커 계약 3줄 → <b>v3</b>=sketch-hop(라인아트 경유) → <b>v3.1</b>=CONTROL을 스케치로 교체.</div>""")

    # ── 0. 입력 데이터 (실제 생성에 사용된 것만 — 미사용 자산은 미표시) ──
    used = build_usage_map()
    P.append("<h2>0. 입력 데이터 — 실제 생성에 참조로 사용된 것만</h2>")
    P.append("<div class='guide'>각 셀 주석 = runlog 기준 그 이미지를 <b>실제 소비한 생성 호출 목록</b>. "
             "사용되지 않은 보유 자산은 표시하지 않음.</div>")
    fp_cells = []
    for loc, fps in sorted((recon.get("fp_by_loc") or {}).items()):
        for fp in fps:
            if not used.get(str(fp["png_path"])):
                continue  # 미사용 자산 미표시 (사용자 지시)
            src = Path(fp["png_path"])
            dst = copy_into(src, F.OUT / "inputs" / src.name)
            fp_cells.append(cell(dst, "입력", f"{loc} floor plan — {fp['fp_id']}",
                                 by_out, w=330, note=usage_note(fp["png_path"], used)))
    if recon.get("baseline_aerial") and used.get(str(recon["baseline_aerial"])):
        dst = copy_into(Path(recon["baseline_aerial"]), F.OUT / "root" / "baseline_aerial.png")
        fp_cells.append(cell(dst, "입력", "production 통합 배치도 (root_B의 베이스)", by_out, w=330,
                             note=usage_note(recon["baseline_aerial"], used)
                             + " · ⚠ fp_l03 과 배치 불일치+실내 노출(3라운드 진단 원인)"))
    P.append(row("floor plans + production 배치도", fp_cells))
    # passport (최종샷 IDENTITY ref)
    pp_cells = []
    for sid, ppath in sorted((recon.get("passports") or {}).items()):
        tags = used.get(str(ppath)) or []
        if not tags:
            continue  # 사용된 passport 만 표시 (미사용 나열은 소음)
        dst = copy_into(Path(ppath), F.OUT / "inputs" / f"passport_{sid}.png")
        pp_cells.append(cell(dst, "입력", f"passport {sid}", by_out, w=200,
                             note=usage_note(ppath, used)))
    if pp_cells:
        P.append(row("캐릭터 passport (최종샷 IDENTITY 참조로 실사용된 것만)", pp_cells))

    # ── 1. 뿌리 ──
    P.append("<h2>1. 뿌리 — 통합 조감 스케치 (전 샷 포괄, 원기호+영어 범례)</h2>")
    P.append("<div class='verdict'>판정: 기호/범례 렌더는 A·B 모두 신뢰(오탈자 0, readback 10/10). "
             "그러나 <b>기하가 문제</b> — root_A 는 자체 발명, root_B 는 production 배치도의 틀린 배치"
             "(fp_l03 대비 방 축소·계단/물탱크 위치 오류)를 상속 → 실외 하류 전체가 '오두막' 으로 붕괴. "
             "3라운드: fp_l03 을 기하 SOT 로 앵커한 root_C 검증.</div>")
    P.append(row("root 변형", [
        cell(F.OUT / "root" / "root_A.png", "R-A", "순수 T2I (기하 자체발명 — 결격)", by_out, w=440),
        cell(F.OUT / "root" / "root_B.png", "R-B", "배치도 I2I (1-2라운드 뿌리 — 틀린 기하 상속)", by_out, w=440),
        cell(F.OUT / "root" / "root_C.png", "R-C", "fp_l03 기하 앵커 (3라운드)", by_out, w=440),
    ]))
    P.append(json_details("R1 요소 선정 JSON (뿌리 텍스트 계약)", F.PLANS / "root_plan.json"))
    for v in ("A", "B", "C"):
        P.append(json_details(f"root_{v} VLM readback", F.PLANS / f"root_readback_{v}.json"))

    # ── 1.5 뿌리 v2 (4라운드 — 사용자 재정의: 건물 전체) ──
    if (F.OUT / "root_v2" / "root_v2.png").exists():
        P.append("<h2>1.5 뿌리 v2 — 건물 전체 통합 도면 (4라운드, 사용자 재정의)</h2>")
        P.append("<div class='verdict'>사용자 의도 반영: 옥탑방 레벨만이 아니라 <b>건물 전체</b> — "
                 "지상 출입구/마당(인물 도착 지점), 외부 계단, 옥상 레벨 — 를 한 장 top-down 으로. "
                 "기하 앵커 = 그룹 실외 fp 전부(옥상+마당+계단, 이전 미사용이던 fp_l10 2장 포함).</div>")
        P.append(row("root v2", [
            cell(F.OUT / "root_v2" / "root_v2.png", "R-v2",
                 "건물 전체 통합 도면 (실외 fp 3장 앵커)", by_out, w=560),
        ]))
        P.append(json_details("R1v2 요소 선정 JSON", F.PLANS / "root_plan_v2.json"))
        P.append(json_details("root_v2 VLM readback", F.PLANS / "root_readback_v2.json"))
        d_dir = F.OUT / "outdoor_d"
        if d_dir.exists():
            for skd in sorted(p.name for p in d_dir.iterdir() if p.is_dir()):
                sd = recon["shots"].get(skd) or {}
                P.append(f"<h3>실외샷 (무마킹·무인) — {skd} "
                         f"<small>loc={','.join(sd.get('loc_ids') or [])}</small></h3>"
                         f"<p class='desc'>{html.escape(str(sd.get('description')))}</p>")
                P.append(row("root v2 → camview → 빈 plate", [
                    cell(d_dir / skd / "sketch.png", "①", "camview 라인아트 (마킹 없음, staging 텍스트)", by_out),
                    cell(d_dir / skd / "plate.png", "②", "빈 배경 plate (무인·무마커)", by_out),
                ]))

    # ── 2. 실내 탑레이어 ──
    P.append("<h2>2. 실내 탑레이어 배경 (zone/material master)</h2>")
    P.append("<div class='verdict'>판정: 재질/무드 연속(체이닝 성공) but TL2 가 TL1 구도까지 복사(구도 수렴) "
             "— 침실 마스터 부재. 하류는 fpvar 가 카메라 SOT 를 쥐고 있어 버텨냄.</div>")
    tl = F.OUT / "indoor" / "top_layers"
    P.append(row("탑레이어 (생성 순서)", [
        cell(tl / "TL1_bedroom_bathroom.png", "TL1", "1번째 마스터 (fp 첨부=LLM 판단 ON)", by_out),
        cell(tl / "TL1_bedroom_bathroom_fpOFF.png", "TL1'", "강제 fp OFF 비교쌍", by_out),
        cell(tl / "TL2_living_kitchen_entry.png", "TL2", "2번째 마스터 (TL1 체이닝)", by_out),
    ]))
    P.append(json_details("I0 실내 계획 JSON (배경 리스팅→탑레이어 병합/순서/fp판단)",
                          F.PLANS / "indoor_plan.json"))

    # ── 3. 실내 샷 체인 ──
    P.append("<h2>3. 실내 샷별 체인</h2>")
    P.append("<div class='verdict'>판정: 전 경로 성립 — fp 기하 정합, 정체성 교차샷 일관, 마커/마네킹 누출 0.</div>")
    for sk in subset.get("indoor") or []:
        P.append(indoor_shot_block(sk, recon, by_out))

    # ── 4. 실외 ──
    P.append("<h2>4. 실외 샷별 체인 — 버전순 (v1 → v2 → v3)</h2>")
    P.append("<div class='verdict warn'>판정: v1=실패 증거(항공 고착+군중+지붕 소실). v2=계약 3줄로 군중/topology 해소. "
             "v3=sketch-hop 으로 plate 눈높이 성공, 단 임시샷(nb2) 경유 시 시점 재상승(2회 재현) → "
             "<b>wide 무인물 establishing 정답 = sketch-hop + 직행(임시샷 생략)</b>. "
             "그리고 모든 버전이 뿌리(root_B)의 틀린 기하(작은 오두막)를 물려받음 — 근본 해결은 root_C 몫.</div>")
    for lane, sub in (("outdoor", "v1 — 계약 없음 (실패 증거 보존)"),
                      ("outdoor_v2", "v2 — 인원/시점/마커 계약 3줄"),
                      ("outdoor_v3", "v3 — sketch-hop-before-plate (+v3.1 CONTROL=sketch)")):
        keys = [k for k in (subset.get("outdoor") or []) if (F.OUT / lane / k).exists()]
        if not keys:
            continue
        P.append(f"<h2 style='font-size:17px'>4.{lane[-1] if lane[-1].isdigit() else '1'} 실외 {sub}</h2>")
        for sk in keys:
            P.append(outdoor_shot_block(sk, recon, by_out, lane,
                                        tagv=("_v3" if lane == "outdoor_v3" else
                                              ("_v2" if lane == "outdoor_v2" else ""))))

    # ── 4.4 root_C 체인 재검증 ──
    if (F.OUT / "outdoor_c").exists():
        P.append("<h2 style='font-size:17px'>4.4 실외 root_C 체인 재검증 "
                 "(fp 앵커 뿌리 → 마킹 → sketch-hop → plate)</h2>")
        for sk in sorted(p.name for p in (F.OUT / "outdoor_c").iterdir()
                         if p.is_dir()):
            P.append(outdoor_shot_block(sk, recon, by_out, "outdoor_c"))

    # ── 부록: LLM 단계 프롬프트 (실제 사용 — 시스템=코드 상수, user=데이터 조립) ──
    P.append("<h2>부록 — LLM 단계 프롬프트 (실제 사용 원문)</h2>")
    P.append("<div class='guide'>이미지 셀의 prompt/refs 는 전부 호출 시점 runlog 기록. "
             "LLM(텍스트) 단계의 system 프롬프트는 아래 코드 상수가 그대로 사용되었고, "
             "user 프롬프트는 §0-§1 의 데이터(멤버/샷 원문 전체+계획 JSON)를 자르지 않고 "
             "조립한 것(조립 규칙 포함). 이후 호출부터는 runlog 에 원문도 기록.</div>")
    try:
        import s1_root as _s1
        import s2_indoor as _s2
        for title, txt in (
            ("R1 요소 선정 system prompt (s1_root.R1_SYSTEM)", _s1.R1_SYSTEM),
            ("I0 실내 계획 system prompt (s2_indoor.I0_SYSTEM)", _s2.I0_SYSTEM),
            ("VLM 구도 분석 지시문 (forest_chain.vlm_composition)",
             "Analyse this film still's composition precisely. Describe the camera,"
             " horizon/depth, major visible surfaces, each subject's screen position /"
             " depth / pose / support contact, occlusion order, and what an EMPTY"
             " background plate (no people) must contain so the people could be"
             " re-inserted with the same composition."),
            ("root readback 지시문 (s1_root.readback)",
             "This is a top-down site sketch with circled capital letters and a"
             " legend panel. List every circled capital letter you can find, list"
             " the legend entries as written, and say whether any interior room"
             " layout is exposed."),
        ):
            P.append(f"<details class='json'><summary>{html.escape(title)}</summary>"
                     f"<pre>{html.escape(txt)}</pre></details>")
    except Exception as exc:  # 게시 실패해도 갤러리는 유지
        P.append(f"<div class='note'>프롬프트 상수 로드 실패: {html.escape(str(exc))}</div>")
    # runlog 에 기록된 llm 원문(신규 호출)
    llm_logged = []
    for line in F.RUNLOG.read_text().splitlines() if F.RUNLOG.exists() else []:
        try:
            e = json.loads(line)
        except Exception:
            continue
        if e.get("kind") == "llm" and e.get("system_prompt"):
            llm_logged.append(e)
    for e in llm_logged:
        P.append(f"<details class='json'><summary>llm 호출 기록 — {html.escape(e.get('step',''))}"
                 f" ({html.escape(e.get('model',''))})</summary><pre>SYSTEM:\n"
                 + html.escape(e["system_prompt"]) + "\n\nUSER:\n"
                 + html.escape(e.get("user_prompt") or "") + "</pre></details>")

    (EXP / "gallery.html").write_text("\n".join(P))
    print(f"gallery -> {EXP / 'gallery.html'}")


if __name__ == "__main__":
    main()
