"""사람이 볼 화면 — 유료 산출을 **한 자리에서** 보고 거부한다. ★유료 0.

Codex 가 못박았다 (2026-08-31): C 에서 **자동으로 재는 것**은 schema/runtime
enum · 씬 제약 · 격리 · 장부/재개 · Opik/provider 대조뿐이다. **샷 ID 가 의미상
맞나 · 두 축(hard·notice) · 엔티티/facet 품질은 사람만** 본다.

★VLM 을 평가자로 안 쓴다. 이 도구는 **판을 차릴 뿐** 판정하지 않는다.

## 한 화면에 무엇을 놓나

    원문 인용 (그 대상을 부른 자리)
    + 고른 샷 ID
    + **각 샷의 전문 description**   ← 자르지 않는다. 이게 판단 근거다
    + owner / 관계 / 두 축 / 등록 사유
    + 격리된 행과 그 raw
    + **merge 가 삭제를 거부한 행**과 그 사유
    + ★**그 샷의 실제 이미지**(있으면). 글로만 보면 「이 대상이 저 샷에 있나」를
      제대로 못 본다 — 사용자 지적 2026-08-31

## ★★「어긋남 0」을 어디서 잰 것인가

Codex 가 잡았다 (2026-08-31). `automatic_problems` 는 **격리를 통과한 행**에서만
잰다. 그래서 0 은 **「모델이 계약을 다 지켰다」가 아니라 「검증기가 위반을
막아냈다」**는 뜻이다. 이 구분을 안 적으면 결과를 거꾸로 읽는다.

    모델이 낸 행  =  살아남은 행  +  격리된 행     ← 격리도 **모델 산출**이다
    잔존 어긋남 0 =  격리 **뒤** 에 남은 것

    python tools/grounding_audit/cc_veto_review.py <run.json> <out.html> [episode_id]
"""
from __future__ import annotations

import html
import json
import sys
from pathlib import Path
from typing import Any, Dict, Sequence

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))


def _e(x: Any) -> str:
    return html.escape(str(x if x is not None else ""))


#: 샷 ID → 이미지 상대경로. ★렌더 직전에 채운다.
_SHOT_IMAGES: Dict[str, str] = {}
#: 이미지 파일을 URL 로 만드는 접두. ★서버가 저장소 뿌리를 뿌린다.
IMAGE_URL_PREFIX = "/"


def _shot_li(sid: str, shots: Dict[str, Dict[str, Any]]) -> str:
    """샷 하나 — ID · **이미지** · 전문 description."""
    path = _SHOT_IMAGES.get(str(sid))
    img = (f'<img class=shot loading=lazy src="{IMAGE_URL_PREFIX}{_e(path)}" '
           f'alt="{_e(sid)}">' if path else
           '<div class=noimg>이 샷은 아직 이미지가 없습니다</div>')
    return ("<li><b>{}</b>{}<div class=d>{}</div></li>".format(
        _e(sid), img, _e((shots.get(sid) or {}).get("description"))))


def _rows_block(rows: Sequence[Dict[str, Any]],
                shots: Dict[str, Dict[str, Any]],
                registered: Dict[str, Any],
                part_of: Sequence[Dict[str, Any]]) -> str:
    parent = {str(x.get("part")): str(x.get("whole")) for x in part_of or ()}
    out = []
    for r in rows:
        lid = str(r.get("local_id"))
        rec = registered.get(lid) or {}
        quotes = "".join(
            f"<li><code>{_e(o.get('source_span', {}).get('segment_id'))}</code> "
            f"{_e(o.get('source_quote'))}</li>"
            for o in (r.get("occurrences") or []))
        # ★샷을 그리는 자리는 **한 곳**(`_shot_li`)이다. 여기서 따로 조립하면
        #  이미지가 있어도 본문 카드에는 **절대 안 나온다** (Codex 2026-08-31).
        picked = "".join(_shot_li(sid, shots)
                         for sid in (r.get("shot_appearance_ids") or []))
        ev = "".join(f"<li>{_e(q)}</li>"
                     for q in (r.get("evidence_quotes") or []))
        reg = rec.get("registered")
        badge = ("등록" if reg is True else
                 "미등록" if reg is False else "미확정")
        out.append(f"""
<article class="row {'reg' if reg is True else 'unk' if reg is None else 'no'}">
  <header>
    <span class=own>{_e(r.get('owner_type'))}</span>
    <b>{_e(r.get('surface_form'))}</b>
    <span class=id>{_e(lid)}{' → ' + _e(rec.get('final_id'))
                              if rec.get('final_id') else ''}</span>
    <span class=badge>{badge} · {_e(rec.get('reason'))}</span>
  </header>
  <div class=cols>
    <section><h4>부른 자리</h4><ul>{quotes or '<li>—</li>'}</ul></section>
    <section><h4>고른 샷 · <span class=hint>결속 {_e(
        r.get('shot_binding_status'))}</span></h4>
      <ul class=shots>{picked or '<li>—</li>'}</ul></section>
    <section><h4>겉모습 근거</h4><ul>{ev or '<li>—</li>'}</ul></section>
    <section><h4>두 축 <span class=hint>★사람이 본다</span></h4>
      <p>만들기 어려움 <b>{_e(r.get('hard_to_generate'))}</b> ·
         알아챔 <b>{_e(r.get('viewers_would_notice'))}</b></p>
      <p class=hint>부모: {_e(parent.get(lid) or '—')}</p>
      <p class=hint>질의: {_e(', '.join(r.get('search_terms_native') or []))
                          or '—'}</p>
    </section>
  </div>
</article>""")
    return "".join(out)


def load_shot_images(episode_id: str) -> Dict[str, str]:
    """샷 → 이미지 파일 상대경로. ★못 읽으면 **빈 표**(화면은 그대로 뜬다).

    ★★글 설명만 보고 「이 대상이 저 샷에 있나」를 판단할 수는 없다. 이미지가
    있으면 그것이 근거다.

    ★`scene_still` 의 `(scene_index, shot_index)` 가 catalog 의 샷 ID 와 같은
    좌표다 — 이름으로 짝짓지 않는다.
    """
    if not episode_id:
        return {}
    try:
        from sqlalchemy import text

        from app.core.database import SessionLocal

        with SessionLocal() as db:
            rows = db.execute(text("""
                SELECT ss.scene_index, ss.shot_index, ia.file_path
                  FROM scene_still ss
                  JOIN image_asset ia ON ia.still_id = ss.id
                 WHERE ss.episode_id = :eid
                   -- ★`is_primary` 는 **integer** 다. boolean 으로 견주면
                   --  `DatatypeMismatch` 로 터지고, 위 `except` 가 그것을
                   --  **빈 표로 삼켜** 「이미지가 없다」로 보인다.
                   AND COALESCE(ia.is_primary, 0) = 1
                 ORDER BY ia.created_at
            """), {"eid": episode_id}).fetchall()
    except Exception as exc:                       # noqa: BLE001
        print(f"  ★이미지를 못 읽었다 ({exc}) — **없다는 뜻이 아니다**")
        return {}
    out: Dict[str, str] = {}
    for scene, shot, path in rows:
        if path:
            out[f"s{scene}#{shot}"] = str(path)
    return out


def _tally(run: Dict[str, Any]) -> Dict[str, Any]:
    """세는 자리 **한 곳**. ★화면과 보고가 각자 세면 두 수가 갈린다.

    ★내가 앞 보고에서 **등록 안 된 행까지 섞어** 「반복 축 87」로 냈다
    (Codex 2026-08-31). 실제 등록 92 = 반복 68 + 예외 24 다. 사유는
    **등록된 행에서만** 센다.
    """
    from collections import Counter

    red = run.get("reduced") or {}
    pre = run.get("rows") or []
    q = run.get("quarantined") or []
    kinds = Counter(p.get("kind") for x in q for p in (x.get("problems") or ()))
    reg = {k: v for k, v in (red.get("registered") or {}).items()
           if v.get("registered") is True}
    why = Counter(str(v.get("reason") or "").split("=")[0]
                  for v in reg.values())
    disp = Counter(str(v.get("disposition"))
                   for v in (red.get("registered") or {}).values())
    return {
        "model_rows": len(pre) + len(q), "survived": len(pre),
        "quarantined": len(q), "events": sum(kinds.values()),
        "kinds": dict(sorted(kinds.items())),
        "reduced": len(red.get("rows") or []),
        "merged": (red.get("counts") or {}).get("merged"),
        "part_of": len(red.get("part_of") or []),
        "refused": red.get("refused") or {},
        "registered": len(reg), "why": dict(sorted(why.items())),
        "disposition": dict(sorted(disp.items())),
        "residual": run.get("automatic_problems") or [],
    }


def _tally_block(t: Dict[str, Any]) -> str:
    pct = (t["quarantined"] / t["model_rows"] * 100) if t["model_rows"] else 0
    kinds = " · ".join(f"{k} {n}" for k, n in t["kinds"].items()) or "—"
    why = " + ".join(f"{k} {n}" for k, n in t["why"].items()) or "—"
    disp = " · ".join(f"{k} {n}" for k, n in t["disposition"].items())
    return f"""
<table class=tally>
 <tr><th>모델이 낸 행</th><td><b>{t['model_rows']}</b></td>
     <td class=hint>살아남은 {t['survived']} + 격리 {t['quarantined']}
         ({pct:.1f}%) — <b>격리도 모델 산출이다</b></td></tr>
 <tr><th>격리 사유 event</th><td><b>{t['events']}</b></td>
     <td class=hint>{_e(kinds)} <span class=hint>(한 행이 여럿 가질 수
     있다)</span></td></tr>
 <tr><th>merge 뒤 행</th><td><b>{t['reduced']}</b></td>
     <td class=hint>합쳐진 것 {t['merged']} · part_of {t['part_of']} ·
         <b>삭제 거부 {len(t['refused'])}</b></td></tr>
 <tr class=key><th>격리 <u>뒤</u> 잔존 어긋남</th>
     <td><b>{len(t['residual'])}</b></td>
     <td class=hint>★<b>모델이 계약을 다 지켰다는 뜻이 아니다</b> —
         위 {t['quarantined']}행을 <b>검증기가 막아낸 뒤</b> 남은 수다</td></tr>
 <tr><th>등록</th><td><b>{t['registered']}</b></td>
     <td class=hint>{_e(why)} <span class=hint>(★등록된 행에서만 센다)</span>
     </td></tr>
 <tr><th>처분</th><td>—</td><td class=hint>{_e(disp)}</td></tr>
</table>"""


def _side(r: Dict[str, Any], shots: Dict[str, Dict[str, Any]],
          role: str) -> str:
    """한 쪽을 그린다. ★**두 쪽이 같은 함수**를 쓴다 — 따로 적으면 한쪽만
    고쳐지고, 사람은 「덜 보여 준 쪽」이 나쁜 줄 안다."""
    if not r:
        return (f"<section class=side><h4>{_e(role)}</h4>"
                "<p class=hint>★기록에서 이 행을 못 찾았다 — "
                "**없다는 뜻이 아니다**. 이름으로 짐작해 잇지 않는다.</p>"
                "</section>")
    quotes = "".join(f"<li>{_e(o.get('source_quote'))}</li>"
                     for o in (r.get("occurrences") or ()))
    picked = "".join(_shot_li(sid, shots) for sid in
                     (r.get("shot_appearance_ids") or ()))
    return f"""<section class=side>
  <h4>{_e(role)}</h4>
  <p><span class=own>{_e(r.get('owner_type'))}</span>
     <b>{_e(r.get('surface_form'))}</b>
     <span class=id>{_e(r.get('local_id'))}</span></p>
  <h4>부른 자리</h4><ul>{quotes or '<li>—</li>'}</ul>
  <h4>고른 샷 <span class=hint>결속 {_e(r.get('shot_binding_status'))}</span></h4>
  <ul class=shots>{picked or '<li>—</li>'}</ul>
</section>"""


def _refused_block(refused: Dict[str, Any],
                   rows: Sequence[Dict[str, Any]],
                   decisions: Sequence[Dict[str, Any]],
                   shots: Dict[str, Dict[str, Any]]) -> str:
    """★merge 가 **삭제를 거부한** 짝. fail-closed 가 든 자리라 사람이 본다.

    ★★**두 행을 나란히** 놓는다 (Codex 2026-08-31). 앞 판은 지울 쪽만 냈는데,
    사람이 판단할 것은 「모델이 같은 실물이라고 이은 **두 행 중 어느 owner·
    표현이 맞나**」다. 상대를 141행 아래에서 ID 도 모른 채 찾게 하면 「어느 쪽이
    맞는지는 사람이 본다」는 계약을 수행할 근거가 없다.

    ★잇는 것은 **기록된 decision 의 ID** 뿐이다 — 이름·부분문자열로 새로
    짝짓지 않는다. 못 찾으면 **못 찾았다고 적는다**.
    """
    if not refused:
        return ""
    by = {str(r.get("local_id")): r for r in rows}
    keeper = {str(d.get("remove_local_id")): d for d in (decisions or ())
              if d.get("remove_local_id")}
    out = []
    for lid, why in sorted(refused.items()):
        dec = keeper.get(str(lid)) or {}
        kid = str(dec.get("keep_local_id") or "")
        out.append(f"""
<article class="row ref">
  <header><span class=badge2>merge 삭제 거부</span>
    <b>{_e(lid)}</b><span class=hint>를 지우자던 것을 안 지웠다</span>
    <span class=id>relation={_e(dec.get('relation') or '—')}</span>
    <span class=badge>{_e(why)}</span></header>
  <div class=cols>
    {_side(by.get(str(lid)), shots, '지우자던 쪽 (remove)')}
    {_side(by.get(kid) if kid else None, shots,
           f'남기자던 쪽 (keep · {kid or "기록 없음"})')}
  </div>
  <p class=foot>★모델은 이 둘을 <b>같은 실물</b>이라고 이었지만 갈래가 달라
     안 합쳤습니다. <b>둘 중 어느 owner·표현이 맞는지는 사람이 봅니다.</b>
     이 화면은 판정하지 않습니다.</p>
</article>""")
    return f"<h3>merge 가 삭제를 거부한 {len(refused)}짝</h3>" + "".join(out)


def render(run: Dict[str, Any], shots: Dict[str, Dict[str, Any]]) -> str:
    red = run.get("reduced") or {}
    rows = red.get("rows") or []
    q = run.get("quarantined") or []
    t = _tally(run)
    qb = "".join(f"""
<article class="row qua">
  <header><span class=own>{_e(x.get('owner_type'))}</span>
    <b>{_e(x.get('surface_form'))}</b>
    <span class=id>{_e(x.get('local_id'))}</span>
    <span class=badge>격리</span></header>
  <div class=cols><section><h4>왜</h4><ul>{
      ''.join('<li>' + _e(p.get('kind')) + ' — ' + _e(p.get('why')) + '</li>'
              for p in (x.get('problems') or []))}</ul></section>
    <section><h4>모델이 낸 것(raw)</h4><pre>{
      _e(json.dumps(x.get('raw_mentions'), ensure_ascii=False,
                    indent=1))}</pre></section></div>
</article>""" for x in q)

    return f"""<meta charset="utf-8">
<title>C canary 사람 검토</title>
<style>
 body{{font:14px/1.6 system-ui,-apple-system,sans-serif;margin:0;
   background:#f7f7f8;color:#1a1a1a}}
 .wrap{{max-width:1200px;margin:0 auto;padding:24px}}
 h1{{font-size:20px;margin:0 0 4px}}
 .note{{background:#fff8e1;border:1px solid #f0d98c;padding:12px 14px;
   border-radius:8px;margin:16px 0;font-size:13px}}
 .row{{background:#fff;border:1px solid #e3e3e6;border-radius:10px;
   margin:12px 0;overflow:hidden}}
 .row.reg{{border-left:4px solid #2e7d32}}
 .row.no{{border-left:4px solid #bdbdbd}}
 .row.unk{{border-left:4px solid #ef6c00}}
 .row.qua{{border-left:4px solid #c62828}}
 .row.ref{{border-left:4px solid #6a1b9a}}
 .side{{border-right:1px solid #eee}}
 .badge2{{font-size:11px;background:#ede7f6;color:#4527a0;padding:2px 8px;
   border-radius:99px}}
 .foot{{margin:0;padding:8px 14px;background:#faf7ff;font-size:12px;
   border-top:1px solid #f0f0f0}}
 .tally{{margin:14px 0;width:100%}}
 .tally th{{width:150px;background:#fafafa}}
 .tally .key td{{background:#fff8e1}}
 header{{display:flex;gap:10px;align-items:center;flex-wrap:wrap;
   padding:10px 14px;background:#fafafa;border-bottom:1px solid #eee}}
 .own{{font-size:11px;background:#eceff1;padding:2px 8px;border-radius:99px}}
 .id{{font-family:ui-monospace,monospace;font-size:11px;color:#777}}
 .badge{{margin-left:auto;font-size:12px;color:#555}}
 .cols{{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));
   gap:0}}
 section{{padding:10px 14px;border-right:1px solid #f0f0f0}}
 h4{{margin:0 0 6px;font-size:12px;color:#666;font-weight:600}}
 ul{{margin:0;padding-left:16px}} li{{margin:2px 0}}
 .shots li{{list-style:none;margin:6px 0 6px -16px}}
 img.shot{{display:block;width:100%;max-width:320px;border-radius:8px;
   margin:4px 0;background:#eee}}
 .noimg{{font-size:11px;color:#999;padding:10px;background:#fafafa;
   border:1px dashed #ddd;border-radius:8px;margin:4px 0;max-width:320px}}
 .d{{color:#444;font-size:12px;background:#fafafa;padding:6px 8px;
   border-radius:6px;margin-top:2px;white-space:pre-wrap}}
 .hint{{color:#888;font-size:11px;font-weight:400}}
 pre{{white-space:pre-wrap;font-size:11px;background:#fafafa;padding:8px;
   border-radius:6px;margin:0}}
 code{{background:#eef;padding:1px 5px;border-radius:4px;font-size:11px}}
 table{{border-collapse:collapse;font-size:13px}}
 td,th{{border:1px solid #e3e3e6;padding:5px 10px;text-align:left}}
</style>
<div class=wrap>
<h1>C canary — 사람 검토</h1>
<div class=note>
 <b>★이 화면은 판정하지 않습니다.</b> 자동으로 잰 것은 schema·runtime enum·
 씬 제약·격리·장부/재개·Opik 대조뿐입니다.<br>
 <b>샷 ID 가 의미상 맞는지 · 두 축(만들기 어려움 · 알아챔) · 엔티티와 결속의
 품질은 사람이 봅니다.</b> VLM 을 평가자로 쓰지 않습니다.<br>
 이 주행은 <b>shot-aware 경로 가능성 진단</b>이고 production PASS 가 아닙니다.
 <br><b>★「잔존 어긋남 0」은 모델이 계약을 다 지켰다는 뜻이 아닙니다</b> —
 아래 표의 격리된 행을 <b>검증기가 막아낸 뒤</b> 남은 수입니다.
</div>
<table>
 <tr><th>논리 호출</th><td>{_e(run.get('logical'))}</td>
     <th>산 것</th><td>{_e(run.get('bought'))}</td>
     <th>재사용</th><td>{_e(run.get('reused'))}</td>
     <th>후처리 계약</th><td>{_e(run.get('processing_contract'))}</td></tr>
</table>
{_tally_block(t)}
{_refused_block(t['refused'], rows, run.get('decisions') or [], shots)}
<h3>merge 뒤 {len(rows)}행</h3>
{_rows_block(rows, shots, red.get('registered') or {}, red.get('part_of') or [])}
{'<h3>격리 ' + str(len(q)) + '행</h3>' + qb if q else ''}
</div>"""


def main() -> int:
    if len(sys.argv) < 3:
        print(__doc__)
        return 2
    run = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
    shots: Dict[str, Dict[str, Any]] = {}
    for c in run.get("shot_catalog") or []:
        shots[str(c.get("id"))] = c

    # ★★**실제로 채운다.** 앞 판은 `load_shot_images` 를 만들어 놓고 **아무도
    #  안 불러서** `_SHOT_IMAGES` 가 늘 비어 있었다 (Codex 2026-08-31).
    eid = str(run.get("episode_id") or "")
    if not eid and len(sys.argv) > 3:
        eid = sys.argv[3]
    _SHOT_IMAGES.update(load_shot_images(eid))
    print(f"  샷 이미지 {len(_SHOT_IMAGES)}장 실었다"
          + ("" if _SHOT_IMAGES else
             " — ★한 장도 없으면 이 화면은 **시각 검토용이 아니다**"))

    out = Path(sys.argv[2])
    out.write_text(render(run, shots), encoding="utf-8")
    print(f"■ 적었다: {out}")
    print("  ★이 화면은 **판을 차릴 뿐** 판정하지 않는다.")
    return 0


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