"""CARRIED 가 **그 샷 PEOPLE 목록 밖 인물**을 말하는가 — 감사 1-B.

보고서가 「7샷 중 5샷」이라 적었다. 이 저장소에서 그 형태의 수치가 재현
안 된 전례가 있어(표기 저작 1-A 의 「5/7」) **직접 잰다**.

한 프롬프트 안에서 두 절이 맞선다:

    PEOPLE: … IF a person appears, they must be one of: {허용 목록}
            — never anyone else …
    CARRIED STATE (persist exactly …): {씬 단위로 저작된 문안}

허용 목록은 **프롬프트 안에 데이터로 들어 있다** — 코드가 인물 이름을
알 필요가 없다. 목록을 그대로 모델에게 넘기고 「이 문안이 말하는 사람이
이 목록 안에 있나」만 묻는다. 이름 대조를 글자로 하지 않는 이유는 대명사·
직함·묘사구를 못 잡기 때문이다.

    $ .venv/bin/python tools/prompt_measure/audit_carried_offscreen_people.py [--limit N]
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path

sys.path.insert(0, "/Users/manta/Documents/Projects/TheRoad-I1/scratchpad")
import _opik_env  # noqa: E402,F401  ★cwd 를 backend 로 고정

ROOT = Path("/Users/manta/Documents/Projects/TheRoad-I1")
OUT = Path(__file__).resolve().parent / "_carried_offscreen.json"

P_PEOPLE = re.compile(
    r"^PEOPLE:.*?they must be one of: (.+?) — never anyone else", re.M | re.S)
P_CARRIED = re.compile(
    r"^CARRIED STATE \(persist exactly[^)]*\): (.+?)$", re.M)
P_NOPEOPLE = re.compile(r"NO PEOPLE", re.M)

SYS = """A storyboard prompt names exactly which people may appear in one
shot. A separate CARRIED STATE line, written for the whole scene rather
than for this one shot, says what must stay consistent.

You are given both. Decide whether the CARRIED STATE line describes any
living person who is NOT in the allowed list. A person shown inside a
photograph, a painting or a screen is part of that object, not a person in
the shot — do not count them.

Answer with the persons it describes and whether each one is in the list."""

SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "persons_described": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "properties": {
                    "who_en": {"type": "string"},
                    "in_allowed_list": {"type": "boolean"},
                },
                "required": ["who_en", "in_allowed_list"],
            },
        },
        "has_person_outside_list": {"type": "boolean"},
        "reason_ko": {"type": "string"},
    },
    "required": ["persons_described", "has_person_outside_list", "reason_ko"],
}


# ★양성 대조 — 「0건」과 「꺼진 판정기」를 가른다.
#  이 저장소가 같은 함정에 두 번 빠졌다(era 에서 17/17 「비대상」, 표기
#  저작에서 0/12). 명백히 목록 밖인 짝을 지어 함께 태운다.
POSITIVE_CONTROLS = [
    ("PC-clear",
     "the woman in the blue coat (Korean adult female)",
     "The woman in the blue coat stands by the window, and the old man"
     " remains seated at the table behind her."),
    ("PC-pronoun",
     "the young man (Korean adult male)",
     "The young man still holds the lantern; his sister keeps kneeling"
     " beside the well."),
]
# 음성 대조 — 사진 **속** 인물은 사물의 일부다. 이걸 「목록 밖」이라
# 하면 판정기가 너무 헐겁다.
NEGATIVE_CONTROLS = [
    ("NC-photo",
     "the young man (Korean adult male)",
     "The old wallet is open, with the black-and-white photograph of a"
     " school-uniformed girl fixed inside its transparent sleeve."),
    ("NC-objects",
     "the young man (Korean adult male)",
     "The rusty iron entrance door remains half open, exposing the dark"
     " interior."),
]


def collect(only_project: str = ""):
    """(허용 목록, CARRIED 문안) 짝을 **고유하게** 모은다."""
    pairs: "dict[tuple[str, str], list]" = {}
    for f in sorted(ROOT.glob("projects/*/images/*/scene/recipe/records.json")):
        if only_project and not f.parts[-6].startswith(only_project):
            continue
        try:
            data = json.loads(f.read_text(encoding="utf-8"))
        except Exception:
            continue
        data = data.get("data", data)
        proj = f.parts[-6][:8]

        def walk(v, key):
            if isinstance(v, str):
                if "CARRIED STATE" not in v:
                    return
                if P_NOPEOPLE.search(v):
                    return           # 무인 샷은 별건(다른 도구가 잰다)
                mp = P_PEOPLE.search(v)
                mc = P_CARRIED.search(v)
                if not (mp and mc):
                    return
                allowed = re.sub(r"\s+", " ", mp.group(1)).strip()
                carried = mc.group(1).strip()
                pairs.setdefault((allowed, carried), []).append(
                    (proj, key))
            elif isinstance(v, dict):
                for x in v.values():
                    walk(x, key)
            elif isinstance(v, list):
                for x in v:
                    walk(x, key)

        for key, rec in data.items():
            if isinstance(rec, dict):
                walk(rec, key)
    return pairs


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--limit", type=int, default=0)
    ap.add_argument("--model", default="gemini-flash")
    ap.add_argument("--project", default="",
                    help="프로젝트 id 앞자리 — 한 판만 잰다")
    a = ap.parse_args()

    pairs = collect(a.project)
    items = sorted(pairs.items(), key=lambda x: -len(x[1]))
    if a.limit:
        items = items[:a.limit]
    n_shot = len({s for v in pairs.values() for s in v})
    print(f"■ 인물 샷에서 (허용 목록, CARRIED) 고유 짝 {len(pairs)}개"
          f" · 샷 {n_shot}개")
    print(f"  {len(items)}개를 {a.model} 로 판정한다\n")

    from app.modules.llm.llm_client import call_structured

    tag = "carried_offscreen_probe"
    pc = {tag: {"model": a.model}}

    def judge(allowed: str, carried: str):
        body = (f"ALLOWED PEOPLE FOR THIS SHOT:\n{allowed}\n\n"
                f"CARRIED STATE LINE:\n{carried}")
        return call_structured(tag, SYS, [{"type": "text", "text": body}],
                               SCHEMA, project_config=pc, schema_name=tag)

    # ★대조를 **먼저** 태운다 — 판정기가 꺼져 있으면 본 표본을 살 이유가
    #  없다. 어긋나면 여기서 멈춘다.
    print("■ 대조 — 「0건」이 「꺼진 판정기」가 아님을 먼저 본다")
    bad = []
    for name, al, ca in POSITIVE_CONTROLS:
        g = judge(al, ca)
        ok = bool(g.get("has_person_outside_list"))
        print(f"    {'✓' if ok else '✗'} {name}: 목록밖={ok}"
              f"  {str(g.get('reason_ko'))[:60]}")
        if not ok:
            bad.append(name)
    for name, al, ca in NEGATIVE_CONTROLS:
        g = judge(al, ca)
        ok = not g.get("has_person_outside_list")
        print(f"    {'✓' if ok else '✗'} {name}: 목록밖="
              f"{bool(g.get('has_person_outside_list'))}"
              f"  {str(g.get('reason_ko'))[:60]}")
        if not ok:
            bad.append(name)
    if bad:
        print(f"\n  ★대조가 어긋났다 ({bad}) — 본 표본을 재지 않는다.")
        return 1
    print()

    out, c = [], Counter()
    for i, ((allowed, carried), shots) in enumerate(items):
        try:
            got = judge(allowed, carried)
        except Exception as exc:  # noqa: BLE001 — 측정은 계속한다
            c["error"] += 1
            out.append({"allowed": allowed, "carried": carried,
                        "shots": shots[:5], "n": len(shots),
                        "error": f"{type(exc).__name__}: {exc}"[:200]})
            continue
        outside = bool(got.get("has_person_outside_list"))
        persons = got.get("persons_described") or []
        kind = ("목록밖" if outside
                else "목록안" if persons else "사람없음")
        c[kind] += 1
        c[f"shot:{kind}"] += len(shots)
        out.append({"allowed": allowed, "carried": carried,
                    "shots": shots[:5], "n": len(shots), "kind": kind,
                    "persons": persons, "reason_ko": got.get("reason_ko")})
        if (i + 1) % 20 == 0:
            print(f"  … {i+1}/{len(items)}")

    n = sum(c[k] for k in ("목록밖", "목록안", "사람없음")) or 1
    ns = sum(c[f"shot:{k}"] for k in ("목록밖", "목록안", "사람없음")) or 1
    print(f"\n■ 결과 — 고유 짝 {n}개 / 샷 {ns}개")
    for k in ("목록밖", "목록안", "사람없음"):
        print(f"    {k:<5} 짝 {c[k]:>4}개 ({100.0*c[k]/n:.0f}%)"
              f"   샷 {c[f'shot:{k}']:>4}개 ({100.0*c[f'shot:{k}']/ns:.0f}%)")
    if c["error"]:
        print(f"    ★실패 {c['error']}")

    print("\n■ 목록 밖 인물을 말한 표본")
    shown = 0
    for r in out:
        if r.get("kind") != "목록밖" or shown >= 8:
            continue
        shown += 1
        who = ", ".join(p["who_en"] for p in r["persons"]
                        if not p.get("in_allowed_list"))
        print(f"\n  ×{r['n']} {r['shots'][0][0]}/{r['shots'][0][1]}")
        print(f"    허용 : {r['allowed'][:110]}")
        print(f"    문안 : {r['carried'][:160]}")
        print(f"    목록밖: {who[:110]}")

    OUT.write_text(json.dumps(out, ensure_ascii=False, indent=1),
                   encoding="utf-8")
    print(f"\n→ {OUT.name}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
