"""고증 판정 **화면**을 만든다. ★결정은 여기 안 남는다 — DB 가 정본이다.

    python tools/grounding_audit/fidelity_review_page.py \
        --cp <reference_acquisition/manifest.json> \
        --world <visual_world_rules/manifest.json> \
        --project-id … --episode-id … --out artifact/<날짜_설명>

★★★이 산출은 **지워도 된다**. 사람이 누른 것은 인증된 backend 를 지나
PostgreSQL 의 append-only 표로 간다 (Codex 계약 2026-09-02).

★유료 호출 **0** — 모델도, 검색도, 다운로드도 안 부른다. 이미 받아 둔
사진과 얼어붙은 CP 만 읽는다.
"""
from __future__ import annotations

import hashlib
import html
import json
from urllib.parse import urlparse
import sys
from pathlib import Path
from typing import Any, Dict, List, Sequence

BACKEND = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND))

from app.modules.pipeline import grounding_fidelity_review as fr  # noqa: E402

#: 화면 계약의 판. ★보여 주는 것이 바뀌면 올린다 — 결정 신원에 접힌다.
PAGE_CONTRACT_VERSION = "1.202609020900"


def sha_of_file(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def rows_for_page(cp: Dict[str, Any], world: Dict[str, Any], *,
                  project_id: str, episode_id: str,
                  projects_root: Path) -> List[Dict[str, Any]]:
    """화면에 그릴 카드들. ★신원은 `grounding_fidelity_review` 가 만든다."""
    data = (world or {}).get("data") or world or {}
    coords = {"era": str(data.get("era") or "").strip(),
              "region": str(data.get("region") or "").strip()}

    def _sha(row):
        rel = str(((row.get("acquisition") or {}).get("chosen") or {}
                   ).get("path") or "")
        p = projects_root / rel
        return sha_of_file(p) if rel and p.is_file() else ""

    got = fr.review_inputs_for(cp, project_id=project_id,
                               episode_id=episode_id,
                               coordinates=coords, sha_of=_sha)
    return cards_from(
        got,
        exists_of=lambda x: (projects_root / x["chosen_path"]).is_file(),
        key_of=lambda x: x["chosen_path"])


def provenance_blocks(rounds) -> List[Dict[str, Any]]:
    """라운드들 → 화면이 그릴 **질의 출처 블록**. ★한 곳이 만든다.

    ★적혀 있으면 그것을 쓰고, 없으면 **그 라운드가 보낸 검색어와 대조해서**
    가른다 — 짐작이 아니라 대조다(같은 문자열이면 우리 것, 아니면 검색 도구가
    더한 것). 계약 이전에 얼어붙은 판도 사람이 갈라 볼 수 있어야 한다.
    ★보낸 검색어조차 없으면 그때는 **모름**이다.
    """
    from app.modules.pipeline.reference_acquisition_rounds import (
        query_provenance)

    out: List[Dict[str, Any]] = []
    for rd in rounds or ():
        q = (rd or {}).get("query_provenance") or {}
        if q.get("queries"):
            out.append(q)
            continue
        if not rd.get("queries"):
            continue
        sent = list(rd.get("terms_native") or ())
        if sent:
            out.append(query_provenance(sent, rd["queries"]))
        else:
            flat = [str(x) for g in rd["queries"]
                    for x in (g if isinstance(g, list) else [g])]
            out.append({"queries": [{"text": t, "origin": "unknown"}
                                    for t in flat],
                        "provided": 0, "expanded": 0})
    return out


def card_of(one: Dict[str, Any], *, exists: bool, key: str,
            same_as: Sequence[str] = ()) -> Dict[str, Any]:
    """판정 하나를 **사람이 읽을 모양**으로. ★화면·CLI 가 같은 함수를 쓴다."""
    row = one["row"]
    led = row.get("ledger_row") or {}
    pp = led.get("grounding_producer_payload") or {}
    ev = led.get("source_evidence") or row.get("source_evidence") or {}
    return {
        "hash": one["hash"], "key": key, "exists": exists,
        "payload": one["payload"],
        "sha": one["payload"]["image_sha256"],
        # ★심판이 「무엇인가」로 본 이름 — 짧다
        "kind": str(pp.get("coarse_type_label") or ""),
        # ★원고가 쓴 **그 말** 과 어디에 있나
        "said": str(ev.get("surface_form") or ""),
        "anchor": str(ev.get("source_anchor") or ""),
        # ★원고가 그린 모습
        "brief": str(pp.get("visual_brief") or ""),
        "owner": str(led.get("owner_type") or ""),
        # ★사람이 확인하러 갈 곳은 **사진이 실린 쪽**이다. `url` 은 이미지
        #  파일 주소라 CDN 호스트가 찍힌다(실측: `minio.nculture.org`).
        "site_url": str(((one.get("candidate") or (row.get("acquisition") or {}).get("chosen") or {})
                         ).get("source_website_url") or ""),
        # ★후보별 카드 — 모델이 고른 것인지, 몇 라운드 몇 번째인지 (표시일 뿐 문이 아니다)
        "candidate": {"index": (one.get("candidate") or {}).get("index"),
                      "round_no": (one.get("candidate") or {}).get("round_no"),
                      "model_pick": bool(one.get("model_pick")),
                      "model_eligible": (one.get("candidate") or {}).get("model_eligible")},
        "same_as": list(same_as),
        "terms": [t for rd in one["rounds"]
                  for t in (rd.get("terms_native") or ())],
        "provenance": provenance_blocks(one["rounds"]),
    }


def cards_from(inputs: Sequence[Dict[str, Any]], *,
               exists_of=None, key_of=None) -> List[Dict[str, Any]]:
    """후보들 → 카드들. ★**같은 사진**을 쓴 것끼리 서로 알려 준다."""
    by_sha: Dict[str, List[str]] = {}
    for x in inputs:
        led = (x["row"].get("ledger_row") or {})
        by_sha.setdefault(x["payload"]["image_sha256"], []).append(
            str(led.get("final_id") or x["payload"]["research_subject_id"]))
    out = []
    for x in inputs:
        led = (x["row"].get("ledger_row") or {})
        me = str(led.get("final_id") or x["payload"]["research_subject_id"])
        twins = [n for n in by_sha.get(x["payload"]["image_sha256"], [])
                 if n != me]
        out.append(card_of(
            x,
            exists=(exists_of(x) if exists_of else
                    bool(x["payload"]["image_sha256"])),
            key=(key_of(x) if key_of else x["hash"]),
            same_as=twins))
    return out


_CSS = """
.target{max-width:1100px;margin:0 auto 26px;padding:16px 24px;background:#fff;border:1px solid var(--line);border-radius:12px}
.choices{display:flex;flex-wrap:wrap;gap:12px}
.choice{display:flex;flex-direction:column;gap:6px;width:220px;padding:8px;border:2px solid var(--line);border-radius:10px;cursor:pointer}
.choice img{width:100%;height:160px;object-fit:cover;border-radius:6px}
.choice.chosen{border-color:var(--ok)}
.choice.missing{opacity:.5}
.choice.none{justify-content:center;background:var(--warm)}
.cap{font-size:13px;color:var(--dim)}
.act{margin-top:10px;display:flex;gap:10px;align-items:center}

:root{--ink:#1c1917;--dim:#78716c;--line:#e7e5e4;--bg:#faf9f7;
      --ok:#166534;--no:#9f1239;--warm:#fef7ed}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
     font:16px/1.75 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo",
     "Noto Sans KR",system-ui,sans-serif}
header{max-width:900px;margin:0 auto;padding:48px 24px 8px}
h1{font-size:27px;line-height:1.35;margin:0 0 14px;letter-spacing:-.01em}
.lede{font-size:17px;color:#44403c;margin:0 0 22px}
.lede b{color:var(--ink)}
.rules{background:#fff;border:1px solid var(--line);border-radius:12px;
       padding:18px 22px;margin:0 0 10px}
.rules p{margin:0 0 8px}.rules p:last-child{margin:0}
.rules .yes{color:var(--ok);font-weight:600}
.rules .no{color:var(--no);font-weight:600}
.coords{display:flex;gap:26px;flex-wrap:wrap;margin:18px 0 0;
        padding:14px 22px;background:var(--warm);border-radius:12px;
        border:1px solid #fed7aa}
.coords div{min-width:0}
.coords .k{font-size:12px;color:#9a3412;letter-spacing:.04em}
.coords .v{font-size:16px;font-weight:600}
main{max-width:900px;margin:0 auto;padding:26px 24px 80px}
.count{color:var(--dim);font-size:14px;margin:0 0 16px}
.card{background:#fff;border:1px solid var(--line);border-radius:14px;
      overflow:hidden;margin:0 0 26px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.shot{background:#1c1917;display:flex;align-items:center;
      justify-content:center;min-height:220px}
.shot img{max-width:100%;max-height:520px;display:block}
.shot a{display:block;line-height:0}
.broken{color:#fda4af;padding:70px 20px;text-align:center;font-size:15px}
.body{padding:20px 24px 22px}
.kind{font-size:20px;font-weight:700;margin:0 0 2px;letter-spacing:-.01em}
.who{color:var(--dim);font-size:13.5px;margin:0 0 14px}
.said{background:#f5f5f4;border-left:3px solid #d6d3d1;padding:10px 14px;
      margin:0 0 14px;border-radius:0 8px 8px 0}
.said .q{font-size:16.5px;font-family:inherit;background:none;padding:0;
         margin:0;line-height:1.6}
.said .where{color:var(--dim);font-size:12.5px;margin-top:2px}
.brief{margin:0 0 16px;color:#44403c}
.meta{display:grid;grid-template-columns:86px 1fr;gap:6px 14px;
      font-size:14px;margin:0 0 16px}
.meta dt{color:var(--dim)}
.meta dd{margin:0;word-break:break-all}
.twin{background:#eff6ff;border:1px solid #bfdbfe;color:#1e40af;
      padding:9px 14px;border-radius:8px;font-size:14px;margin:0 0 16px}
details{border-top:1px solid var(--line);padding-top:12px;margin:0 0 18px}
summary{cursor:pointer;color:var(--dim);font-size:14px;
        list-style:none;user-select:none}
summary::-webkit-details-marker{display:none}
summary::before{content:"▸ ";color:#a8a29e}
details[open] summary::before{content:"▾ "}
.qlist{margin:12px 0 0}
.qgroup{margin:0 0 12px}
.qgroup h4{margin:0 0 6px;font-size:13px;font-weight:600;color:var(--dim)}
.q{font-size:13.5px;padding:4px 10px;border-radius:6px;margin:0 0 3px;
   font-family:ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.5}
.q.provided{background:#ecfdf5;color:#065f46}
.q.provider_expanded{background:#fff7ed;color:#7c2d12}
.q.unknown{background:#f5f5f4;color:#57534e}
.verdict{border-top:1px solid var(--line);padding-top:16px}
.verdict .ask{font-size:15px;margin:0 0 10px}
.why{width:100%;padding:9px 12px;border:1px solid #d6d3d1;border-radius:8px;
     font:14px inherit;margin:0 0 10px}
button{font:15px inherit;font-weight:600;padding:9px 22px;border-radius:9px;
       border:1.5px solid;background:#fff;cursor:pointer;margin-right:8px}
button.ok{border-color:var(--ok);color:var(--ok)}
button.ok:hover:not([disabled]){background:var(--ok);color:#fff}
button.no{border-color:var(--no);color:var(--no)}
button.no:hover:not([disabled]){background:var(--no);color:#fff}
button[disabled]{opacity:.35;cursor:not-allowed}
.said-back{margin-left:6px;font-size:14px}
.done{background:#f0fdf4;border-color:#86efac !important}
.chosen{outline:3px solid #2563eb}
button.pick{border-color:#2563eb;color:#2563eb}
button.pick:hover:not([disabled]){background:#2563eb;color:#fff}
.picked{margin-left:6px;font-size:14px;color:#1d4ed8}
.pick-ask{margin-top:12px}
.pick-word{font-weight:700;color:#2563eb}
"""

_JS = """
async // ★서버 렌더 때 is_file() 만 보면 HTTP 로 403/404 가 나도 단추가 살아 있다 — 브라우저에서 **실제로 안 뜨면** 그때 끈다.
function brokenPhoto(img){
  const l=img.closest('label'); if(!l) return;
  const b=l.querySelector('input[type=radio]'); if(b){ b.disabled=true; b.checked=false; }
  l.classList.add('missing');
  const cap=l.querySelector('.cap'); if(cap && !/사진을 못 열어/.test(cap.textContent)) cap.textContent += ' · 사진을 못 열어 고를 수 없다';
}
function decide(btn){
  // ★대상당 한 물음 — 고른 사진이면 /select, 「없음」이면 기계 선택에 rejected 판정.
  const sec=btn.closest('.target');
  const sid=sec.dataset.subject;
  const line=sec.querySelector('.said-back');
  const why=(sec.querySelector('.why')||{}).value||'';
  const chosen=sec.querySelector('input[type=radio]:checked');
  if(!chosen){ line.textContent='하나를 고르거나 「없음」을 고르세요'; return; }
  btn.disabled=true; line.textContent='적는 중…';
  try{
    let r, j;
    if(chosen.value==='__none__'){
      const mp=btn.dataset.modelPick;
      if(!mp){ line.textContent='기계가 고른 장이 없어 거절할 것이 없다 — 이미 참조 없음'; btn.disabled=false; return; }
      const p=P[mp];
      r=await fetch(API+'/verdict',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',
        body:JSON.stringify({project_id:p.project_id, episode_id:p.episode_id, review_input_hash:mp, verdict:'rejected',
          reason:why, idempotency_key:mp+':rejected:'+Date.now(),
          observed:{image_sha256:p.image_sha256, era:p.era, region:p.region}})});
      j=await r.json();
      if(r.ok){ line.textContent='「없음」으로 적었다 — 기계 선택을 거절'; sec.querySelectorAll('.choice').forEach(x=>x.classList.remove('chosen')); }
      else{ line.textContent='서버가 거절했다 — '+((j.error&&j.error.message)||JSON.stringify(j)); }
    }else{
      const p=P[chosen.value];
      r=await fetch(API+'/select',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',
        body:JSON.stringify({project_id:p.project_id, episode_id:p.episode_id, review_input_hash:chosen.value,
          idempotency_key:chosen.value+':select:'+Date.now()})});
      j=await r.json();
      if(r.ok){
        sec.querySelectorAll('.choice').forEach(x=>x.classList.remove('chosen'));
        chosen.closest('.choice').classList.add('chosen');
        line.textContent='이 대상에 쓸 사진으로 골랐다'+(j.replaced?' — 앞 선택을 대신한다':'');
      }else{ line.textContent='서버가 거절했다 — '+((j.error&&j.error.message)||JSON.stringify(j)); }
    }
  }catch(err){ line.textContent='못 보냈다: '+err; }
  btn.disabled=false;
}
"""


def render(rows: List[Dict[str, Any]], *, api_base: str, image_base: str,
           coords: Dict[str, str], selected: Sequence[str] = ()) -> str:
    """검증용 갤러리 — **대상당 한 물음**: 「이 대상에 쓸 사진 하나」 또는 「없음」.

    ★HITL 0 (사용자 2026-09-03): 이 화면은 실서비스의 문이 아니다. 자동 선택(모델이 고른 한 장)이 그대로
    붙어서 그림까지 간다. 여기서 고른 것은 canary·A/B·사후 평가에서 **덮어쓰기**로만 쓰인다.
    ★앞 판은 후보 76장 × 버튼 셋으로 사람에게 같은 물음을 75번 물었다 — 사용자 지적. 물음은 대상당 하나다.
    `selected` = 지금 살아 있는 선택의 입력 신원들 — 그 후보에 「골라져 있다」를 적는다."""
    e = html.escape
    picked = set(selected or ())
    era = coords.get("era") or ""
    region = coords.get("region") or ""
    # ★대상(research_subject_id)별로 묶는다 — 물음은 대상당 하나
    groups: Dict[str, List[Dict[str, Any]]] = {}
    order: List[str] = []
    for r in rows:
        sid = str(r["payload"]["research_subject_id"])
        if sid not in groups:
            groups[sid] = []
            order.append(sid)
        groups[sid].append(r)
    out = ['<meta charset="utf-8">',
           '<meta name="viewport" content="width=device-width,initial-scale=1">',
           "<title>참고 사진 고르기 (검증용)</title>",
           f"<style>{_CSS}</style>",
           "<header>",
           "<h1>대상마다 한 번 — 쓸 사진 하나를 고르거나, 없으면 「없음」</h1>",
           '<p class="lede">이 화면은 <b>검증용 도구</b>입니다. 실서비스는 기계가 고른 한 장을 그대로 씁니다 — '
           '사람이 아무것도 안 눌러도 그림은 그려집니다. 여기서 고른 것은 A/B·사후 평가에서만 그 선택을 대신합니다.</p>',
           '<div class="rules">',
           '<p><b>이 사진을 쓴다</b> — 그 대상의 후보 가운데 <b>한 장</b>을 고릅니다. 기계가 고른 장에는 표시가 있습니다.</p>',
           '<p><b>없음</b> — 후보 가운데 그 시대·그 곳 것이 <b>하나도 없다</b>고 적습니다(기계 선택을 거절합니다).</p>',
           "</div>",
           f'<p class="coords">요구한 시대 <b>{e(era) or "(없음)"}</b> · 요구한 지역 <b>{e(region) or "(없음)"}</b> · 대상 {len(order)} · 후보 {len(rows)}</p>',
           "</header>", "<main>"]
    for sid in order:
        cards = groups[sid]
        first = cards[0]
        p0 = first["payload"]
        subj = f'{e(p0.get("final_id") or sid)} · {e(first.get("kind") or "")}'
        said = first.get("said") or ""
        picked_here = next((c["hash"] for c in cards if c["hash"] in picked), None)
        model_pick = next((c["hash"] for c in cards if (c.get("candidate") or {}).get("model_pick")), None)
        out.append(f'<section class="target" data-subject="{e(sid)}">')
        out.append(f'<h2>{subj}</h2>')
        if said:
            out.append(f'<p class="said">원고: 「{e(said)}」</p>')
        out.append('<div class="choices">')
        for c in cards:
            p = c["payload"]
            cand = c.get("candidate") or {}
            tag = " · 기계가 고른 장" if cand.get("model_pick") else ""
            site = ""
            if c.get("site_url"):
                try:
                    site = urlparse(c["site_url"]).netloc
                except Exception:              # noqa: BLE001
                    site = ""
            disabled = "" if c["exists"] else " disabled"
            checked = " checked" if c["hash"] == picked_here else ""
            out.append(
                f'<label class="choice{" chosen" if c["hash"] == picked_here else ""}{" missing" if not c["exists"] else ""}">'
                f'<input type="radio" name="pick-{e(sid)}" value="{e(c["hash"])}"{disabled}{checked}>'
                f'<img src="{e(image_base)}{e(c["key"])}" alt="" onerror="brokenPhoto(this)">'
                f'<span class="cap">r{cand.get("round_no")}#{cand.get("index")}{e(tag)}'
                + (f' · <a href="{e(c["site_url"])}" target="_blank" rel="noreferrer">{e(site)}</a>' if site else "")
                + ("" if c["exists"] else " · 사진을 못 열어 고를 수 없다")
                + '</span></label>')
        out.append(
            f'<label class="choice none"><input type="radio" name="pick-{e(sid)}" value="__none__">'
            '<span class="cap">없음 — 맞는 사진이 하나도 없다</span></label>')
        out.append('</div>')
        # ★우리가 준 질의와 검색 도구가 스스로 더한 질의를 **갈라** 보여 준다 (Codex 2026-09-02) —
        #  갈라 보여 주지 않으면 사람이 무엇을 보고 정하는지 모른다. 대상당 한 번(후보들이 같은 라운드를 공유).
        blocks = []
        seen_q = set()
        for c in cards:
            for b in (c.get("provenance") or ()):
                for q in (b.get("queries") or ()):
                    key = (str(q.get("origin") or "unknown"), str(q.get("text") or ""))
                    if key in seen_q:
                        continue
                    seen_q.add(key)
                    blocks.append(key)
        if blocks:
            ours = [t for o, t in blocks if o == "provided"]
            theirs = [t for o, t in blocks if o == "provider_expanded"]
            unknown = [t for o, t in blocks if o not in ("provided", "provider_expanded")]
            qhtml = "".join(f'<span class="q provided">{e(t)}</span>' for t in ours)
            if theirs:
                qhtml += '<p class="qhead">검색 도구가 스스로 더한 질의</p>' + "".join(
                    f'<span class="q provider_expanded">{e(t)}</span>' for t in theirs)
            if unknown:
                qhtml += '<p class="qhead">어디서 왔는지 모르는 질의</p>' + "".join(
                    f'<span class="q unknown">{e(t)}</span>' for t in unknown)
            out.append(f'<details><summary>이 사진들을 찾은 검색어 보기 (우리 것 {len(ours)} · 도구가 더한 것 {len(theirs)})</summary>'
                       f'<div class="qlist">{qhtml}</div></details>')
        out.append(
            f'<div class="act"><input class="why" placeholder="왜 그렇게 보셨는지 (안 적으셔도 됩니다)">'
            f'<button class="submit" onclick="decide(this)" data-model-pick="{e(model_pick or "")}">이 대상은 이렇게</button>'
            f'<span class="said-back">{"골라져 있다" if picked_here else ""}</span></div>')
        out.append('</section>')
    payloads = {r["hash"]: r["payload"] for r in rows}
    out.append("</main>")
    out.append("<script>\n"
               f"const API={json.dumps(api_base)};\n"
               f"const P={json.dumps(payloads, ensure_ascii=False)};\n"
               f"{_JS}</script>")
    return "\n".join(out)


def main() -> int:
    import argparse

    ap = argparse.ArgumentParser(add_help=True)
    ap.add_argument("--cp", required=True)
    ap.add_argument("--world", required=True)
    ap.add_argument("--project-id", required=True)
    ap.add_argument("--episode-id", required=True)
    ap.add_argument("--projects-root", required=True,
                    help="`chosen.path` 의 기준 뿌리")
    ap.add_argument("--api-base",
                    default="/api/v1/grounding-fidelity")
    ap.add_argument("--image-base", default="./img/",
                    help="사진 주소의 **앞머리**(디렉토리가 아니라 prefix)")
    ap.add_argument("--out", required=True)
    a = ap.parse_args()

    cp = json.loads(Path(a.cp).read_text(encoding="utf-8"))
    world = json.loads(Path(a.world).read_text(encoding="utf-8"))
    rows = rows_for_page(cp, world, project_id=a.project_id,
                         episode_id=a.episode_id,
                         projects_root=Path(a.projects_root))
    data = (world or {}).get("data") or world or {}
    out = Path(a.out)
    out.mkdir(parents=True, exist_ok=True)
    page = out / "review.html"
    page.write_text(render(rows, api_base=a.api_base,
                           image_base=a.image_base,
                           coords={"era": str(data.get("era") or ""),
                                   "region": str(data.get("region") or "")}),
                    encoding="utf-8")
    print(f"■ 고증 판정 화면 · 볼 것 {len(rows)}장 · 판 "
          f"{PAGE_CONTRACT_VERSION}")
    print(f"  못 여는 사진 {sum(1 for r in rows if not r['exists'])}장")
    print(f"  적었다: {page}")
    return 0


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