#!/usr/bin/env python3
"""s29 — TOP 체인 v2 (사용자 "그대로 다시, 최종 수정된 것 기반, 맵 완성까지",
2026-07-09. 커밋 금지).

s20 ⓪ TOP 체인 재실행 — 이번엔 프롬프트가 최종 자산 기반:
s27 상세 스펙(공간 단서 그래프 저작 15항목) + SCALE & MASSING(3층·8-10m·
다단 철제 계단=제작자 정정) + ID-free 범례(nb2 라벨 습성 대응, x_nb2_r2 검증).

체인: [1]photos nb2 3롤(참조 0) → [2]judge GPT/Gemini VLM 0-10 합산 선택
(동점=Gemini 순위 우선 — 물리 결함 감지 우세 판정 지식) → [3]fix 양쪽 VLM
결함 질문·코드 취합(중복 판단 없음)→nb2 i2i 수정(결함 0=생략) →
[4]map 수정본 기반 탑다운 맵 작도 — 경찰서 FP 스타일(s27 STYLE_MAP)
+ 마커(P–W, s27 detail items) + 수직 재투영 계약(s20 FPFLAT_VERTICAL_NOTE_V2
판정 지식) — 사진→수직 재투영은 gpt 전담(판정 지식).
사용: backend/.venv/bin/python s29_top_v2.py [--only photos|judge|fix|map|html|all]
산출: out/forest_map/top2/*.png + plans/top2_place_v1.json + top2.html
"""
import argparse
import html as _html
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import forest_lib as F  # noqa: E402
import s27_forest_map as S27  # noqa: E402 (스펙/스타일 계약 재사용)

OUTT = S27.OUT / "top2"
PAGE = F.EXP / "top2.html"
PLAN = "top2_place_v1"

CANDIDATES = [("A", "top2_photo_nb2_a.png"),
              ("B", "top2_photo_nb2_b.png"),
              ("C", "top2_photo_nb2_c.png")]
FIXED = "top2_photo_fixed_nb2.png"
MAP_OUT = "top2_map_gpt.png"


def _idfree_legend(spec):
    # nb2 라벨 습성 대응 — 코드 없는 순수 서술 (s23/x_nb2_r2 검증 지식)
    return "\n".join(f"- {it['name_en']} — {it['placement_en']}"
                     for it in spec["detail"]["items"])


def _photo_prompt(spec):
    """x_nb2_r2 검증본과 동일 구성 (ID-free)."""
    d = spec["detail"]
    return "\n\n".join([
        "Create ONE PHOTOREALISTIC aerial location still of a filming",
        "property, from a 45-degree high aerial camera looking across the",
        "yard toward the building (whole property in frame). No reference",
        "image is attached — build the scene from this text alone.",
        "LAYOUT (ground truth):\n" + d["layout_narration_en"],
        "ELEMENTS (render each as the real thing, in its listed place):\n"
        + _idfree_legend(spec),
        "SCALE & MASSING: the main building is a three-storey aged",
        "multi-family villa, roof deck roughly 8-10 m above the yard, with",
        "the small rooftop-room unit as a fourth-level volume on the deck;",
        "the steel retrofit stair climbs the full three storeys as a",
        "multi-flight zig-zag run with intermediate landings; each facade",
        "storey shows a few modest windows of the regional multi-family",
        "type; surrounding blocks are a dense low-rise Korean town, fully",
        "real (windows, roofs, wires, pavement).",
        S27.NO_ANNOTATION,
        S27.REAL_FACTS,
    ])


# ── s20 TOP 체인 계약 포트 (검증된 원문 그대로, 파일 결합만 상이) ──
JUDGE_SYSTEM = "\n".join([
    "You are given ONE image-generation prompt and THREE candidate",
    "images, labelled A, B, C in the order attached, all generated",
    "from that exact prompt.",
    "Pick the ONE candidate that most faithfully realises the prompt.",
    "Judge ONLY fidelity to the prompt: its stated structure (what",
    "contains or carries what, counts, connections, placements), the",
    "camera it asks for, materials and state, and its exclusions (no",
    "people, no readable text). Ignore generic aesthetic appeal.",
    "Check every candidate against the prompt point by point before",
    "deciding; base every verdict only on what is visible.",
    "Also give every candidate an integer score 0-10 for that same",
    "fidelity.",
    "Output: winner, ranking best-to-worst, and per candidate a score",
    "plus a one-line Korean verdict citing the decisive prompt points.",
])

JUDGE_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {
        "winner": {"type": "string", "enum": ["A", "B", "C"]},
        "ranking": {"type": "array",
                    "items": {"type": "string", "enum": ["A", "B", "C"]}},
        "verdicts": {
            "type": "array",
            "items": {
                "type": "object", "additionalProperties": False,
                "properties": {
                    "label": {"type": "string", "enum": ["A", "B", "C"]},
                    "score": {"type": "integer"},
                    "verdict_ko": {"type": "string"},
                },
                "required": ["label", "score", "verdict_ko"],
            },
        },
    },
    "required": ["winner", "ranking", "verdicts"],
}

CRITIQUE_SYSTEM = "\n".join([
    "You are given the image-generation prompt and ONE photograph",
    "generated from it. List what is WRONG with the photograph:",
    "- fidelity: any visible deviation from the prompt's stated",
    "  structure (what contains or carries what, counts, connections,",
    "  placements), its camera, materials and state, and its",
    "  exclusions (no people, no readable text),",
    "- physical coherence: any construction that could not physically",
    "  work in reality (broken, floating or overlapping structure,",
    "  unreachable routes, impossible geometry).",
    "Base every finding only on what is visible. Ignore generic",
    "aesthetic taste.",
    "issues: each finding as issue_ko (one short Korean line) plus",
    "fix_en (ONE imperative English edit instruction that fixes",
    "exactly that finding while changing nothing else). Empty array",
    "if nothing is wrong.",
])

CRITIQUE_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {
        "issues": {
            "type": "array",
            "items": {
                "type": "object", "additionalProperties": False,
                "properties": {
                    "issue_ko": {"type": "string"},
                    "fix_en": {"type": "string"},
                },
                "required": ["issue_ko", "fix_en"],
            },
        },
    },
    "required": ["issues"],
}

FIX_HEAD = "\n".join([
    "Edit this photograph. Fix ONLY the issues listed below, each in",
    "place, changing nothing else about the property:",
])

PRESERVE_TAIL = "\n".join([
    "PRESERVE EVERYTHING ELSE EXACTLY as in the original photograph —",
    "buildings, roof objects, yard, surroundings, lighting, camera,",
    "framing. No people, no readable text anywhere.",
])

FIX_LABEL = ("PHOTOGRAPH TO EDIT — fix ONLY the issues the prompt lists;"
             " preserve everything else exactly.")

# s20 판정 지식: 사진→수직 재투영 계약(v2) — 산출 매체 선언+재투영+입면 금지
VERTICAL_NOTE = "\n".join([
    "OUTPUT MEDIUM: a flat 2D architectural SITE-PLAN DRAWING on plain",
    "drawing paper — NOT a photograph, NOT a rendering of the scene.",
    "The attached photograph is ONLY a source of layout information;",
    "do NOT reproduce or imitate its camera angle or its look —",
    "RE-PROJECT everything to a TRUE ORTHOGRAPHIC PLAN seen from",
    "DIRECTLY ABOVE (90 degrees, straight-down nadir).",
    "Absolutely NO 3D of any kind: no perspective, no foreshortening,",
    "no axonometric or oblique tilt, no facades, no wall surfaces, no",
    "volume sides, no cast shadows, no photographic texture — every",
    "element appears ONLY as its pure plan geometry (roof outlines,",
    "ground shapes, openings marked in wall lines) in uniform flat",
    "fills and clean drafting linework.",
])


def _map_prompt(spec):
    d = spec["detail"]
    return "\n\n".join([
        "Draw ONE detailed top-down architectural SITE PLAN of the",
        "filming property shown in the attached photograph — the",
        "property inside its boundary wall (building, rooftop deck",
        "contents, external stair, yard, gate, approach) plus a thin",
        "strip of the immediate surroundings as simple flat context",
        "blocks.",
        VERTICAL_NOTE,
        "CIRCLED MARKERS (all of them, nothing else): place each as a",
        "small white circle with a thin black ring containing the code,",
        "exactly on its element as located in the photograph:\n"
        + "\n".join(f"- ({it['code']}) {it['name_en']} — {it['placement_en']}"
                    for it in d["items"]),
        "Allowed zone labels: " + ", ".join(d["zone_labels_en"]),
        S27.STYLE_MAP,
        S27.STYLE_REF_NOTE,
    ])


def load_plan_or(name, default=None):
    try:
        return F.load_plan(name)
    except Exception:
        return default if default is not None else {}


def stage_photos():
    spec = F.load_plan(S27.SPEC)
    p = _photo_prompt(spec)
    F.save_plan(PLAN, {"photo_prompt_en": p,
                       "candidates": dict(CANDIDATES)})
    for lab, fn in CANDIDATES:
        F.img_nb2(f"s29_top2_photo_{lab.lower()}", p, [],
                  aspect_ratio="1:1", out_path=OUTT / fn)
    print("[photos] nb2 3롤 완료 →", OUTT)


def stage_judge():
    plan = F.load_plan(PLAN)
    parts = [{"type": "text",
              "text": ("THE PROMPT (all three candidates were generated"
                       " from this):\n" + plan["photo_prompt_en"])}]
    for label, fn in CANDIDATES:
        f = OUTT / fn
        assert f.exists(), f"후보 이미지 없음: {f}"
        parts.append({"type": "text", "text": f"Candidate {label}:"})
        parts.append(F.png_data_url(f))
    judge = {}
    for model, tag in (("gpt", "s29_top2_judge_gpt"),
                       ("gemini-pro", "s29_top2_judge_gemini")):
        judge[model] = F.llm(tag, JUDGE_SYSTEM, parts, JUDGE_SCHEMA,
                             model=model)
        print(f"judge[{model}]: winner={judge[model]['winner']}"
              f" ranking={judge[model]['ranking']}")
    totals = {lab: sum(
        next(v["score"] for v in judge[m]["verdicts"] if v["label"] == lab)
        for m in ("gpt", "gemini-pro")) for lab, _ in CANDIDATES}
    best = max(totals.values())
    tied = [lab for lab, t in totals.items() if t == best]
    gem_rank = judge["gemini-pro"]["ranking"]
    selected = min(tied, key=lambda lab: gem_rank.index(lab)
                   if lab in gem_rank else 99)
    plan.update({"judge": judge, "totals": totals, "selected": selected,
                 "selected_file": dict(CANDIDATES)[selected]})
    F.save_plan(PLAN, plan)
    print(f"judge: totals={totals} selected={selected}")


def stage_fix():
    plan = F.load_plan(PLAN)
    src = OUTT / plan["selected_file"]
    assert src.exists(), f"선택본 없음: {src}"
    parts = [{"type": "text",
              "text": ("THE PROMPT (the photograph was generated from"
                       " this):\n" + plan["photo_prompt_en"])},
             {"type": "text", "text": "Photograph to examine:"},
             F.png_data_url(src)]
    critique = {}
    for model, tag in (("gpt", "s29_top2_critique_gpt"),
                       ("gemini-pro", "s29_top2_critique_gemini")):
        critique[model] = F.llm(tag, CRITIQUE_SYSTEM, parts,
                                CRITIQUE_SCHEMA, model=model)
        print(f"critique[{model}]: issues={len(critique[model]['issues'])}")
    plan["critique"] = critique
    fix_lines = [f"- ({title}) {i['fix_en']}"
                 for model, title in (("gpt", "GPT VLM"),
                                      ("gemini-pro", "Gemini VLM"))
                 for i in critique[model]["issues"]]
    if not fix_lines:
        plan["fixed_file"] = plan["selected_file"]
        plan["fix_skipped"] = True
        F.save_plan(PLAN, plan)
        print("fix: 결함 0 — 수정 생략")
        return
    p = "\n\n".join([FIX_HEAD, "ISSUES TO FIX:\n" + "\n".join(fix_lines),
                     PRESERVE_TAIL])
    plan["fix_prompt"] = p
    plan["fixed_file"] = FIXED
    F.save_plan(PLAN, plan)
    F.img_nb2("s29_top2_photo_fixed", p, [(FIX_LABEL, src)],
              aspect_ratio="1:1", out_path=OUTT / FIXED)
    print("[fix] 수정본 완료 →", OUTT / FIXED)


def stage_map():
    spec = F.load_plan(S27.SPEC)
    plan = F.load_plan(PLAN)
    src = OUTT / plan["fixed_file"]
    assert src.exists(), f"수정본 없음: {src}"
    p = _map_prompt(spec)
    plan["map_prompt"] = p
    plan["map_file"] = MAP_OUT
    F.save_plan(PLAN, plan)
    # 사진→수직 재투영은 gpt 전담 (s20 판정 지식). 스타일 참조=경찰서 FP.
    F.img_gpt("s29_top2_map_gpt", p, refs=[src, S27.STYLE_FP],
              size="1024x1024", out_path=OUTT / MAP_OUT)
    print("[map] 완료 →", OUTT / MAP_OUT)


def stage_html():
    spec = F.load_plan(S27.SPEC)
    plan = load_plan_or(PLAN)

    def esc(t):
        return _html.escape(str(t))

    def fig(fn, cap, width=31):
        rel = f"out/forest_map/top2/{fn}"
        return (f"<figure style='width:{width}%'><a href='{rel}'>"
                f"<img src='{rel}' loading='lazy'></a>"
                f"<figcaption>{esc(cap)}</figcaption></figure>")

    judge = plan.get("judge", {})
    totals = plan.get("totals", {})
    jrows = ""
    for m in ("gpt", "gemini-pro"):
        if m not in judge:
            continue
        for v in judge[m]["verdicts"]:
            jrows += (f"<tr><td>{m}</td><td>{v['label']}</td>"
                      f"<td>{v['score']}</td><td>{esc(v['verdict_ko'])}"
                      f"</td></tr>")
    crows = ""
    for m, c in (plan.get("critique") or {}).items():
        for i in c["issues"]:
            crows += (f"<tr><td>{m}</td><td>{esc(i['issue_ko'])}</td>"
                      f"<td>{esc(i['fix_en'])}</td></tr>")

    doc = f"""<!doctype html><html lang=ko><head><meta charset=utf-8>
<title>s29 TOP 체인 v2 — 실사 3롤→VLM 판정→수정→맵</title><style>
body{{font-family:'Apple SD Gothic Neo',sans-serif;margin:24px;max-width:1500px}}
figure{{display:inline-block;margin:1%;vertical-align:top}}
img{{width:100%;border:1px solid #ccc}} figcaption{{font-size:13px;text-align:center}}
pre{{font-size:11px;background:#f7f7f7;border:1px solid #ddd;padding:8px;
white-space:pre-wrap;max-height:320px;overflow:auto}}
table{{border-collapse:collapse;font-size:12px;width:100%}}
td,th{{border:1px solid #ccc;padding:4px 6px;text-align:left}}
h2{{border-bottom:2px solid #333;padding-bottom:4px;margin-top:36px}}
.sel{{outline:4px solid #2a7ae2}}</style></head><body>
<h1>s29 — TOP 체인 v2 (2026-07-09)</h1>
<p>프롬프트 기반=최종 자산: s27 상세 스펙+SCALE &amp; MASSING(3층·철제 계단
정정)+ID-free. 체인=nb2 3롤 → GPT/Gemini VLM 0-10 합산 선택(동점=Gemini
우선) → 결함 취합 nb2 i2i 수정 → 경찰서 FP 스타일 탑다운 맵(gpt 재투영).</p>

<h2>① 후보 3롤 (nb2, 참조 0) — 선택={esc(plan.get('selected','?'))}
(합산 {esc(totals)})</h2>
{"".join(fig(fn, f"후보 {lab}" + (" ★선택" if plan.get("selected") == lab else ""))
         for lab, fn in CANDIDATES)}
<table><tr><th>VLM</th><th>후보</th><th>score</th><th>한줄 판정</th></tr>
{jrows}</table>
<details><summary>실전송 프롬프트(3롤 공통)</summary>
<pre>{esc(plan.get('photo_prompt_en',''))}</pre></details>

<h2>② 결함 취합 → nb2 i2i 수정 {"(결함 0 — 생략)" if plan.get("fix_skipped") else ""}</h2>
<table><tr><th>VLM</th><th>issue</th><th>fix_en</th></tr>{crows}</table>
{fig(plan.get('fixed_file', ''), '수정본(fixed)' if not plan.get('fix_skipped') else '선택본 그대로(결함 0)', 44) if plan.get('fixed_file') else ''}
<details><summary>수정 프롬프트</summary><pre>{esc(plan.get('fix_prompt','(없음)'))}</pre></details>

<h2>③ 맵 형태 완성 — 수정 실사 → 경찰서 FP 스타일 탑다운 (gpt 재투영, 마커 P–W)</h2>
{fig(plan.get('map_file',''), '최종 맵 (top2_map_gpt)', 44) if plan.get('map_file') else '<p>(미생성)</p>'}
<details><summary>맵 프롬프트</summary><pre>{esc(plan.get('map_prompt','(없음)'))}</pre></details>
</body></html>"""
    PAGE.write_text(doc, encoding="utf-8")
    print(f"[html] {PAGE}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", default="all",
                    choices=["photos", "judge", "fix", "map", "html", "all"])
    a = ap.parse_args()
    OUTT.mkdir(parents=True, exist_ok=True)
    if a.only in ("photos", "all"):
        stage_photos()
    if a.only in ("judge", "all"):
        stage_judge()
    if a.only in ("fix", "all"):
        stage_fix()
    if a.only in ("map", "all"):
        stage_map()
    if a.only in ("html", "all"):
        stage_html()
