"""s13 — 블록 세트장 v2 (사용자 피드백 반영, 2026-07-05):

v1(s12) 대비 변경:
  1. grounding 확장 — **관련 씬 원문 전체**(scene_save, 자르기 금지) + 멤버 + 전 샷.
     창/문 개수는 텍스트 근거와 일치해야 함(개구부마다 인용 필수). 건물 창문 포함
     (텍스트 근거 없으면 certainty=inferred 로 정직하게 표기).
  2. 마커 = 원문자 — 구조/영역=알파벳(Ⓐ…), 개구부/설비=번호(①…). 도면 위에는
     마커만, "무엇인지"는 LLM 산출 텍스트(desc_ko)를 갤러리에 그대로 게시.
  3. 3차원 레이어 = 색상 — LLM 이 높이 레이어(rel_height+한국어 설명)를 정의,
     코드가 높이 순 램프 색으로 채움. 색상 의미 설명도 LLM 텍스트 그대로 게시.
  4. 크기/배치 = 여전히 LLM 상대 판단(100×100 캔버스).
사용: .venv/bin/python s13_blockset_v2.py [--only plan|render|html]
산출: plans/blockset_layout_v2.json, out/blockset/blockset_v2.png, blockset.html
"""
import argparse
import html as _html
import json
import math
import sys
from pathlib import Path

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

OUTB = F.OUT / "blockset"
PAGE = F.EXP / "blockset.html"

BLOCK_KINDS = ["building_mass", "upper_shell", "terrace", "yard", "road",
               "alley", "stair", "wall", "neighbor_mass"]
POINT_KINDS = ["door", "window", "gate", "fixture_round", "fixture_rect",
               "equipment"]
KINDS = BLOCK_KINDS + POINT_KINDS

V2_SYSTEM = """You are a set-layout analyst for film pre-production. You get one
real-world place's member descriptions, the FULL ORIGINAL TEXT of every scene
that plays there, and the full content of every shot. Build ONE top-down BLOCK
LAYOUT of the whole property as structured data for a code renderer — plain
blocks for structural understanding (relative sizes and positions), not art.

Canvas: 100 wide x 100 tall, x rightward, y downward. Use most of the canvas.
Sizes and positions are YOUR relative judgement, but every spatial relation the
texts imply (what is beside/under/above what, what connects what, which side an
entrance/stair/window is on) must be honoured.

HEIGHT LAYERS (3-6): you define them. Each layer = layer_id, name_en (1-3
words), desc_ko (one Korean sentence: what physical height this is, e.g. 지면 /
건물 옥상면 / 옥탑 지붕면), rel_height (number, 0 = ground, increasing upward).
Every element references one layer_id — the renderer colours by layer, because
a top-down drawing cannot show height. Neighbour buildings also get the layer
matching their described height.

Elements:
- kinds: building_mass, upper_shell (an enclosed upper-level unit — outline
  only, never interior), terrace, yard, road, alley, stair, wall, neighbor_mass
  (BLOCK kinds — marker must be a capital LETTER A,B,C...), and door, window,
  gate, fixture_round, fixture_rect, equipment (POINT kinds — marker must be a
  NUMBER 1,2,3...). Markers unique.
- shape "rect": x,y = top-left, w,h sizes, rot_deg rotates around centre.
  shape "circle": x,y = centre, w = h = diameter, rot_deg 0.
- doors/windows/gates sit ON their host block's edge line. "repeat" = how many
  identical openings the strip contains (1 for a single opening).
- COUNT-CRITICAL: the number and count of openings must match the texts. An
  upper_shell element's windows: mark exactly as many as the texts support, on
  the sides the texts support. The main building's facade windows: include them;
  if the texts give no exact count, choose a modest count and set
  certainty="inferred" (otherwise certainty="text").
- Simplify ALL neighbouring buildings to plain neighbor_mass rects (no detail,
  no openings). Include the road/alley approach around the street entrance.
- Include permanent fixtures the scenes rely on (round free-standing unit ->
  fixture_round, low rectangular unit -> fixture_rect, machine unit ->
  equipment). NO story props, no vehicles, no people, nothing temporary.
- 10 to 26 elements. name_en: 1-3 generic English words, no proper names.
  desc_ko: one short Korean sentence — what it PHYSICALLY is, its form and
  key spatial relation, present tense, PHYSICAL VISIBLE FACTS ONLY: never
  events, actions, characters or animals, usage stories, moments of motion,
  or authorial phrasing (verbatim shown to the human reviewer and fed to
  drawing stages — narrative causes hallucination).
- evidence: source = scene index / shot key / member id, quote = exact phrase
  copied from the given texts (original language). certainty="text" needs a
  real quote; "inferred" states the nearest supporting line.

Also output layout_notes_ko: 3-6 Korean sentences describing the overall
arrangement logic (verbatim shown to the human reviewer)."""

V2_SCHEMA = {
    "type": "object",
    "properties": {
        "canvas": {"type": "object", "properties": {
            "width": {"type": "number"}, "height": {"type": "number"},
            "orientation_note": {"type": "string"}},
            "required": ["width", "height", "orientation_note"],
            "additionalProperties": False},
        "layers": {"type": "array", "items": {"type": "object", "properties": {
            "layer_id": {"type": "string"},
            "name_en": {"type": "string"},
            "desc_ko": {"type": "string"},
            "rel_height": {"type": "number"}},
            "required": ["layer_id", "name_en", "desc_ko", "rel_height"],
            "additionalProperties": False}},
        "elements": {"type": "array", "items": {"type": "object", "properties": {
            "marker": {"type": "string"},
            "name_en": {"type": "string"},
            "desc_ko": {"type": "string"},
            "kind": {"type": "string", "enum": KINDS},
            "layer_id": {"type": "string"},
            "shape": {"type": "string", "enum": ["rect", "circle"]},
            "x": {"type": "number"}, "y": {"type": "number"},
            "w": {"type": "number"}, "h": {"type": "number"},
            "rot_deg": {"type": "number"},
            "repeat": {"type": "integer"},
            "importance": {"type": "string", "enum": ["major", "minor"]},
            "certainty": {"type": "string", "enum": ["text", "inferred"]},
            "evidence": {"type": "array", "items": {"type": "object",
                "properties": {"source": {"type": "string"},
                               "quote": {"type": "string"}},
                "required": ["source", "quote"],
                "additionalProperties": False}}},
            "required": ["marker", "name_en", "desc_ko", "kind", "layer_id",
                         "shape", "x", "y", "w", "h", "rot_deg", "repeat",
                         "importance", "certainty", "evidence"],
            "additionalProperties": False}},
        "layout_notes_ko": {"type": "string"},
    },
    "required": ["canvas", "layers", "elements", "layout_notes_ko"],
    "additionalProperties": False,
}


def scene_texts(recon):
    """관련 씬 원문 전체 — scene_save 에서 그룹 샷들이 속한 씬만, 자르기 금지."""
    idxs = sorted({int(k.split("_")[0][1:]) for k in recon["shots"]})
    segs = F.load_step("scene_save")["segments"]
    by_idx = {s["scene_index"]: s for s in segs}
    blocks = []
    for i in idxs:
        s = by_idx.get(i)
        if s:
            blocks.append(f"=== SCENE {i} FULL TEXT ===\n{s['text']}")
    return idxs, "\n\n".join(blocks)


def gen_layout(recon):
    members = F.members_block(recon["members"])
    idxs, scenes = scene_texts(recon)
    shot_blocks = "\n\n".join(
        F.shot_block(k, s) for k, s in sorted(recon["shots"].items()))
    user = ("PLACE MEMBERS:\n" + members
            + "\n\nFULL ORIGINAL SCENE TEXTS (every scene at this place):\n\n"
            + scenes
            + "\n\nALL SHOTS AT THIS PLACE (full content):\n\n" + shot_blocks
            + "\n\nBuild the block layout now.")
    layout = F.llm("forest_blockset_layout_v2", V2_SYSTEM, user, V2_SCHEMA,
                   model="gpt")
    _fix_markers(layout)
    F.save_plan("blockset_layout_v2", layout)
    print(f"v2: scenes {idxs} | layers {len(layout['layers'])} |"
          f" elements {len(layout['elements'])}")
    for e in layout["elements"]:
        print(f"  ({e['marker']}) {e['name_en']} [{e['kind']}/{e['layer_id']}"
              f"/{e['certainty']}] rep={e['repeat']}")
    return layout


def _fix_markers(layout):
    """마커 유일성/형식 보정 (결정론) — 블록=A~Z, 포인트=1~99."""
    used = set()
    letters = [chr(65 + i) for i in range(26)]
    numbers = [str(i) for i in range(1, 100)]
    for e in layout["elements"]:
        pool = letters if e["kind"] in BLOCK_KINDS else numbers
        m = str(e.get("marker") or "").strip()
        ok = m in pool and m not in used
        if not ok:
            m = next(c for c in pool if c not in used)
            e["marker"] = m
        used.add(m)


# ── 결정론 렌더러 v2 — 색=높이 레이어, 마커=원문자 ──

RAMP = ["#ece7d9", "#d9cfb6", "#bfb08e", "#9c8a66", "#75664a", "#4f4534"]
OPEN_STYLE = {"door": ("#7a3b2e", "#3a1c14"), "gate": ("#6b4f3a", "#3a2c20"),
              "window": ("#ffffff", "#2e5670")}


def _rot(cx, cy, deg, pts):
    a = math.radians(deg)
    out = []
    for x, y in pts:
        dx, dy = x - cx, y - cy
        out.append((cx + dx * math.cos(a) - dy * math.sin(a),
                    cy + dx * math.sin(a) + dy * math.cos(a)))
    return out


def layer_colors(layout):
    lay = sorted(layout["layers"], key=lambda l: l["rel_height"])
    n = max(1, len(lay))
    cols = {}
    for i, l in enumerate(lay):
        idx = round(i * (len(RAMP) - 1) / max(1, n - 1)) if n > 1 else 0
        cols[l["layer_id"]] = RAMP[idx]
    return cols, lay


def render(layout, out_path, markers=True):
    from PIL import Image, ImageDraw, ImageFont
    cw = float(layout["canvas"]["width"]) or 100.0
    ch = float(layout["canvas"]["height"]) or 100.0
    S, M, LEG = 11, 50, 470
    W, H = int(cw * S + M * 2 + LEG), int(ch * S + M * 2 + 40)
    img = Image.new("RGB", (W, H), "#f7f5f0")
    d = ImageDraw.Draw(img)

    def font(sz, bold=False):
        for p in ("/System/Library/Fonts/Helvetica.ttc",
                  "/System/Library/Fonts/Supplemental/Arial.ttf"):
            try:
                return ImageFont.truetype(p, sz, index=1 if bold else 0)
            except Exception:
                continue
        return ImageFont.load_default()

    f_title, f_mark, f_leg, f_min = font(26, True), font(15, True), font(14), font(11)

    def px(x, y):
        return M + x * S, M + 30 + y * S

    d.text((M, 12), "BLOCK SET LAYOUT v2 — colour = height layer,"
           " circled markers (structure only, code-rendered)",
           font=f_title, fill="#333")
    # 희미한 10단위 그리드 (상대 좌표 판독 보조)
    for g in range(0, int(cw) + 1, 10):
        d.line([px(g, 0), px(g, ch)], fill="#e6e1d3", width=1)
    for g in range(0, int(ch) + 1, 10):
        d.line([px(0, g), px(cw, g)], fill="#e6e1d3", width=1)
    d.rectangle([px(0, 0), px(cw, ch)], outline="#8a857a", width=2)

    cols, lay_sorted = layer_colors(layout)
    els = list(layout["elements"])
    blocks = sorted([e for e in els if e["kind"] in BLOCK_KINDS],
                    key=lambda e: (
                        next((l["rel_height"] for l in layout["layers"]
                              if l["layer_id"] == e["layer_id"]), 0),
                        -(e["w"] * e["h"])))
    points = [e for e in els if e["kind"] not in BLOCK_KINDS]

    def poly(e):
        x, y, w, h = e["x"], e["y"], e["w"], e["h"]
        cx, cy = x + w / 2, y + h / 2
        corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
        return [px(*p) for p in _rot(cx, cy, e.get("rot_deg", 0) or 0, corners)]

    def draw_block(e):
        fill = cols.get(e["layer_id"], "#ddd")
        oc, ow = "#3a3a3a", 4 if e["kind"] in ("building_mass", "upper_shell") \
            else 2
        if e["shape"] == "circle":
            cxp, cyp = px(e["x"], e["y"])
            r = e["w"] * S / 2
            d.ellipse([cxp - r, cyp - r, cxp + r, cyp + r], fill=fill,
                      outline=oc, width=ow)
            return
        pts = poly(e)
        outline = "#9a9a9a" if e["kind"] == "neighbor_mass" else oc
        d.polygon(pts, fill=fill, outline=outline)
        d.line(pts + [pts[0]], fill=outline,
               width=1 if e["kind"] == "neighbor_mass" else ow, joint="curve")
        if e["kind"] == "stair":
            x, y, w, h = e["x"], e["y"], e["w"], e["h"]
            cx, cy = x + w / 2, y + h / 2
            n = max(4, int(max(w, h)))
            for i in range(1, n):
                t = i / n
                p1, p2 = ((x + w * t, y), (x + w * t, y + h)) if w >= h else \
                    ((x, y + h * t), (x + w, y + h * t))
                q1, q2 = _rot(cx, cy, e.get("rot_deg", 0) or 0, [p1, p2])
                d.line([px(*q1), px(*q2)], fill="#3a3a3a", width=2)

    def draw_point(e):
        k = e["kind"]
        if k in OPEN_STYLE:
            fill, oc = OPEN_STYLE[k]
            x, y, w, h = e["x"], e["y"], e["w"], e["h"]
            rep = max(1, int(e.get("repeat") or 1))
            along_x = w >= h
            for i in range(rep):
                t0 = (i + 0.22) / rep
                t1 = (i + 0.78) / rep
                if along_x:
                    seg = [(x + w * t0, y), (x + w * t1, y + h)]
                else:
                    seg = [(x, y + h * t0), (x + w, y + h * t1)]
                p0, p1 = px(*seg[0]), px(*seg[1])
                d.rectangle([p0, p1], fill=fill, outline=oc, width=2)
        elif e["shape"] == "circle":
            cxp, cyp = px(e["x"], e["y"])
            r = e["w"] * S / 2
            d.ellipse([cxp - r, cyp - r, cxp + r, cyp + r],
                      fill=cols.get(e["layer_id"], "#cfcfcf"),
                      outline="#3a3a3a", width=3)
        else:
            d.polygon(poly(e), fill=cols.get(e["layer_id"], "#cfcfcf"),
                      outline="#3a3a3a")
            d.line(poly(e) + [poly(e)[0]], fill="#3a3a3a", width=3)

    for e in blocks:
        draw_block(e)
    for e in points:
        draw_point(e)

    # 원문자 마커 — 요소 중심(작으면 우측 바깥+리더선)
    def marker_pos(e):
        if e["shape"] == "circle":
            return px(e["x"], e["y"]), None
        cx, cy = e["x"] + e["w"] / 2, e["y"] + e["h"] / 2
        small = e["w"] * S < 34 or e["h"] * S < 34
        if small:
            edge = px(e["x"] + e["w"], cy)
            return (edge[0] + 18, edge[1] - 12), px(e["x"] + e["w"] / 2, cy)
        return px(cx, cy), None

    if markers:
        for e in els:
            (mx, my), leader = marker_pos(e)
            r = 13 if len(e["marker"]) < 2 else 15
            if leader:
                d.line([leader, (mx, my)], fill="#3a3a3a", width=1)
            d.ellipse([mx - r, my - r, mx + r, my + r], fill="#ffffff",
                      outline="#222", width=2)
            d.text((mx, my), e["marker"], font=f_mark, fill="#111", anchor="mm")

    # 우측 범례 — ① 높이 레이어(색), ② 마커 (name_en 만 — 상세는 페이지)
    lx = int(cw * S + M + 26)
    ly = M + 34
    d.text((lx, ly - 24), "HEIGHT LAYERS (colour)", font=f_mark, fill="#333")
    for l in lay_sorted:
        d.rectangle([lx, ly, lx + 30, ly + 20], fill=cols[l["layer_id"]],
                    outline="#3a3a3a", width=2)
        d.text((lx + 38, ly + 3), f"h={l['rel_height']:g}  {l['name_en']}",
               font=f_leg, fill="#333")
        ly += 28
    ly += 8
    d.text((lx, ly), "openings: door/gate=dark tick, window=white pane",
           font=f_min, fill="#666")
    ly += 26
    d.text((lx, ly - 4), "MARKERS", font=f_mark, fill="#333")
    ly += 18
    col2 = False
    ly0 = ly
    for e in els:
        tag = f"({e['marker']}) {e['name_en']}"
        if e["certainty"] == "inferred":
            tag += " *"
        d.text((lx + (220 if col2 else 0), ly), tag, font=f_leg, fill="#333")
        ly += 22
        if ly > H - 60 and not col2:
            col2, ly = True, ly0
    d.text((lx, H - 34), "* = inferred (텍스트 근거 없는 추정) — 상세 설명은 페이지",
           font=f_min, fill="#666")

    out_path.parent.mkdir(parents=True, exist_ok=True)
    img.save(out_path)
    F.runlog({"kind": "code_render", "tag": "blockset_v2", "out": str(out_path),
              "elements": len(els), "layers": len(layout["layers"])})
    print("render ->", out_path)


def build_html(layout):
    png = OUTB / "blockset_v2.png"
    rel = png.relative_to(F.EXP)
    cols, lay_sorted = layer_colors(layout)
    layer_rows = "".join(
        f"<tr><td><span class='sw' style='background:{cols[l['layer_id']]}'></span></td>"
        f"<td>{_html.escape(l['layer_id'])}</td><td>{_html.escape(l['name_en'])}</td>"
        f"<td>h={l['rel_height']:g}</td>"
        f"<td class='ko'>{_html.escape(l['desc_ko'])}</td></tr>"
        for l in lay_sorted)
    rows = ""
    for e in layout["elements"]:
        ev = "<br>".join(
            f"<span class='src'>{_html.escape(v['source'])}</span> "
            f"{_html.escape(v['quote'])}" for v in e["evidence"])
        cert = "" if e["certainty"] == "text" else \
            " <span class='inf'>추정</span>"
        rows += (f"<tr><td class='mk'>({e['marker']})</td>"
                 f"<td>{_html.escape(e['name_en'])}{cert}</td>"
                 f"<td class='ko'>{_html.escape(e['desc_ko'])}</td>"
                 f"<td>{e['kind']}</td><td>{_html.escape(e['layer_id'])}</td>"
                 f"<td>{'×' + str(e['repeat']) if e['repeat'] > 1 else ''}</td>"
                 f"<td>({e['x']:g},{e['y']:g}) {e['w']:g}×{e['h']:g}</td>"
                 f"<td class='ev'>{ev}</td></tr>")
    doc = f"""<meta charset='utf-8'><title>블록 세트장 v2 — 코드 렌더</title><style>
body{{font-family:sans-serif;background:#171717;color:#eee;margin:24px;max-width:1900px}}
h1{{font-size:22px}} h3{{color:#fd9}} img{{width:100%;max-width:1560px;border-radius:8px;background:#fff}}
.guide{{background:#1e2430;padding:12px 16px;border-radius:8px;font-size:13px;line-height:1.7;margin-bottom:14px}}
.notes{{background:#20301e;border-left:4px solid #4a4;padding:10px 14px;font-size:13px;line-height:1.7;margin:12px 0}}
table{{border-collapse:collapse;font-size:12px;margin-top:10px;width:100%}}
td,th{{border:1px solid #333;padding:5px 8px;vertical-align:top}}
th{{background:#222;color:#fd9}} .src{{color:#8ac}} .ev{{color:#9c9;max-width:460px}}
.ko{{color:#ffd;max-width:340px}} .mk{{font-weight:bold;color:#fd9}}
.sw{{display:inline-block;width:26px;height:16px;border:1px solid #999}}
.inf{{color:#fa5;font-size:11px}}
details{{margin-top:20px}} summary{{cursor:pointer;color:#8ac}}
pre{{white-space:pre-wrap;font-size:11px;color:#9c9;background:#1b1b1b;padding:8px;max-height:300px;overflow:auto}}
</style>
<h1>블록 세트장 v2 — LLM 구조화(씬 원문 전체 grounding) → 코드 렌더</h1>
<div class='guide'>색상=<b>높이 레이어</b>(위에서 본 도면의 3차원 보완 — 아래 레이어 표의
한국어 설명은 LLM 산출 그대로), 원문자=<b>구조(알파벳)/개구부·설비(번호)</b> — 도면에는
마커만, 의미는 아래 마커 표의 desc(LLM 산출 그대로). 창/문 개수는 씬 원문 인용 근거
필수(근거 없는 건물 창은 '추정' 표기). 크기/배치=LLM 상대 판단(100×100). 옆집=민네모.</div>
<a href='{rel}' target='_blank'><img src='{rel}'></a>
<div class='notes'><b>배치 논리 (LLM layout_notes_ko 원문):</b><br>
{_html.escape(layout.get('layout_notes_ko', ''))}</div>
<h3>① 높이 레이어 (색상 의미 — LLM 텍스트 원문)</h3>
<table><tr><th>색</th><th>ID</th><th>이름</th><th>상대 높이</th><th>설명 (desc_ko 원문)</th></tr>
{layer_rows}</table>
<h3>② 마커 (LLM 텍스트 원문)</h3>
<table><tr><th>마커</th><th>이름</th><th>설명 (desc_ko 원문)</th><th>kind</th>
<th>레이어</th><th>반복</th><th>좌표/크기</th><th>근거 인용</th></tr>{rows}</table>
<details><summary>orientation / canvas</summary>
<pre>{_html.escape(json.dumps(layout.get('canvas', {}), ensure_ascii=False, indent=1))}</pre></details>
<details><summary>(이력) v1 — 이름 라벨 직접 표기 방식 (사용자 피드백으로 대체)</summary>
<img src='out/blockset/blockset.png' style='max-width:1200px'>
<pre>v1 한계: 마커 대신 이름 텍스트 라벨(겹침), 높이 미표현, 창 개수 근거 부족(멤버+샷 요약만 사용).</pre></details>
"""
    PAGE.write_text(doc)
    print("page ->", PAGE)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", default="all",
                    choices=["all", "plan", "render", "html"])
    args = ap.parse_args()
    recon = F.load_recon()
    if args.only in ("all", "plan"):
        layout = gen_layout(recon)
    else:
        layout = F.load_plan("blockset_layout_v2")
    if args.only in ("all", "render"):
        render(layout, OUTB / "blockset_v2.png")
    if args.only in ("all", "html"):
        build_html(layout)
    F.runlog({"kind": "stage", "stage": "s13_blockset_v2", "done": args.only})


if __name__ == "__main__":
    main()
