"""Opus 전담 파이프라인 실험 — 판정·지휘·수정 프롬프트 저작을 전부 Claude Opus 5 가 맡는다.

이미 생성된 후보 롤을 입력으로 다시 돌린다 — 롤 생성 비용은 들지 않고,
바뀌는 것은 **누가 고르고 · 무엇을 고치라 하고 · 무슨 문장으로 고치라 하는가**뿐이다.
이미지를 그리는 일은 그대로 이미지 모델(gemini-3.1-flash-image-preview)이 한다.

흐름
  ① 선정   Opus 가 후보 전부를 보고 하나를 고른다.
           ★후보마다 **방향 · 복잡 구조물 내부 공간 · 중요 엔티티 동일성**을
            문장으로 서술하게 강제한다. 기존 판정이 총구 방향(S88sh6)·
            룸미러 광학(S15sh5)·핸들 이중(S13sh3)·지폐 국적(S62sh4)을 전부
            놓친 것이 근거다 — 점수만 받으면 안 본 것을 안 본 채로 넘긴다.
  ② 결함   선정본의 결함을 찾고 **심각도**를 스스로 매긴다.
  ③ 지휘   `critical` 만 수정 대상으로 남긴다. 하나도 없으면 손대지 않는다.
           수정 프롬프트도 Opus 가 저작한다 — 고칠 것만 말하고, 건드리면
           안 되는 것을 함께 못 박게 한다(i2i 가 전체를 다시 그리기 때문).
  ④ 수정   i2i 1회.
  ⑤ 재판정 [수정 전 원본 vs 수정본] 블라인드 정순+역순. 개악이면 원본 유지.

사용
  .venv/bin/python opus_pilot.py <out.json> [--stems S3sh6,S13sh3,...] [--all]
"""
from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import sys
import threading
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

sys.path.insert(0, str(Path(__file__).resolve().parent))

from app.modules.llm.llm_client import call_structured  # noqa: E402
from app.modules.pipeline.multiroll_gemini import (  # noqa: E402
    SELECT_JUDGE_MODEL, make_nb2_gen_fn, png_part, ref_parts,
)
from app.modules.pipeline.multiroll_select import (  # noqa: E402
    _compose_critique_prompt,
)

PROJ = "e716bafb-24bb-42b7-aea0-fdb383844ee8"
EPI = "d6a9aa85-b75e-400c-980c-4ee7e876a15b"
ROOT = Path(__file__).resolve().parent.parent
RECIPE = ROOT / f"projects/{PROJ}/images/{EPI}/scene/recipe"

# 사용자가 육안으로 지목한 결함 샷 — 관찰 대상 표본.
SAMPLE_STEMS = [
    "S3sh6",    # 수정 단계가 서양 남자를 만들었다
    "S13sh3",   # 핸들 림 이중 / _b 는 인물이 조수석
    "S15sh5",   # 룸미러에 얼굴이 절반만
    "S18sh5",   # 선정·수정본 둘 다 좌석이 돌아가 있다
    "S42sh4",   # 선정본에서 폰이 허공에 뜬다 (손이 있는 후보가 실재)
    "S62sh4",   # 지폐가 미국 달러 (한국 지폐 후보가 실재)
    "S88sh6",   # 총구·시선 방향이 상대를 향하지 않는다
]

MODEL_CFG = {"model": SELECT_JUDGE_MODEL}

# ── ① 선정 ────────────────────────────────────────────────────────────
SELECT_SYS = """\
You choose which candidate photograph best realizes a film-still brief.

You must LOOK before you score. For each candidate, write out what you
actually see along three axes — these are the axes where scoring-without-
looking has failed in practice:

DIRECTION — where every gaze, weapon muzzle, pointed object, and travelling
body is aimed, and whether that aim lands on the thing the shot text says it
lands on. Name the target you see. "He aims at the man on the dock" is an
observation; "composition is good" is not.

BUILT SPACE — when the shot is inside or around a made structure (a car
cabin, a wheelhouse, a cockpit, a machine), count and place its parts. How
many steering wheels / control surfaces are visible, and is that the right
number? Which seat is each person actually occupying? Do seats, mirrors and
controls face the way that seat and that camera require? Is any reflection
optically possible from this camera position?

ENTITIES — for every object and person the brief names, say whether the thing
in the picture IS that thing. A banknote has a country. A pendant has a shape.
A person has an apparent ethnicity, age and sex. If the brief or reference
fixes any of those, check them one by one and say which ones match.

Then score. A candidate that reads wrong on DIRECTION or BUILT SPACE cannot
outscore one that reads right on them, however handsome it looks. If every
candidate fails an axis, say so plainly in `all_candidates_fail` — do not
pretend the least-bad one is good.

Answer in Korean for every prose field."""


def select_schema(labels: List[str]) -> Dict[str, Any]:
    return {
        "type": "object",
        "properties": {
            "readings": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "label": {"type": "string", "enum": labels},
                        "direction": {"type": "string"},
                        "built_space": {"type": "string"},
                        "entities": {"type": "string"},
                        "hard_violations": {
                            "type": "array", "items": {"type": "string"}},
                        "score": {"type": "integer"},
                    },
                    "required": ["label", "direction", "built_space",
                                 "entities", "hard_violations", "score"],
                    "additionalProperties": False,
                },
            },
            "winner": {"type": "string", "enum": labels},
            "why": {"type": "string"},
            "all_candidates_fail": {"type": "boolean"},
        },
        "required": ["readings", "winner", "why", "all_candidates_fail"],
        "additionalProperties": False,
    }


# ── ② 결함 + ③ 수정 지휘 ──────────────────────────────────────────────
CRITIQUE_SYS = """\
You inspect one finished film still against its brief, then decide whether it
is worth editing at all.

Editing is not free and not local. The image model redraws the whole frame
from your instruction: asking it to remove one stray foot has, in practice,
changed the clothing of corpses elsewhere, invented a skull in a tree, added
people who were never in the brief, and erased a character's eyes. So the
default is to leave the picture alone.

Rate each defect you find:
  critical — the still fails the brief or breaks physical sense in a way a
             viewer cannot miss: wrong person entirely (wrong sex, wrong
             apparent ethnicity when the brief fixes it), a duplicated or
             floating body part, an object hovering with no hand or support,
             a control surface duplicated (two steering wheels), a person in
             the wrong seat, a reflection that cannot physically occur, a
             named object that is the wrong object.
  major    — a real miss a careful viewer would notice, but the still still
             reads correctly.
  minor    — detail, texture, small continuity.

Then set `should_repair`. Say true ONLY if at least one `critical` defect is
present AND a local edit could plausibly fix it without redrawing the subject.
Camera position, seat geometry, and who-is-where are NOT locally fixable —
mark those `regenerate_needed` and set `should_repair` false.

If you set `should_repair` true, write `fix_prompt_en` yourself. It must:
  - name only the critical defects, one imperative each;
  - end with an explicit preservation clause listing, by name, the things in
    THIS picture that must not change (the people present, their positions
    and clothing, the set, the light, the framing);
  - never ask for a different camera angle or a different moment.
If `should_repair` is false, `fix_prompt_en` must be an empty string.

Then set `should_regenerate` — true when at least one `critical` defect is
marked `regenerate_needed`, i.e. the frame has to be shot again rather than
retouched. Seat geometry, camera position, who occupies which seat, a
duplicated control surface, and an impossible reflection all belong here.

If you set `should_regenerate` true, write `regen_clause_en` yourself: one or
two imperative sentences to be appended to the ORIGINAL brief before it is
photographed again. Say what must be true this time, in positive terms — where
each person sits, how many of each control surface exist, what the camera sees.
Do not describe the rejected image; the new attempt will not be shown it.
If false, `regen_clause_en` must be an empty string.

`should_repair` and `should_regenerate` are mutually exclusive — if the frame
must be reshot, do not also ask for a retouch.

Answer in Korean for `issue_ko` and `decision_reason`; English for the two
prompt fields."""

CRITIQUE_SCHEMA = {
    "type": "object",
    "properties": {
        "issues": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "issue_ko": {"type": "string"},
                    "severity": {
                        "type": "string",
                        "enum": ["critical", "major", "minor"]},
                    "regenerate_needed": {"type": "boolean"},
                },
                "required": ["issue_ko", "severity", "regenerate_needed"],
                "additionalProperties": False,
            },
        },
        "should_repair": {"type": "boolean"},
        "should_regenerate": {"type": "boolean"},
        "decision_reason": {"type": "string"},
        "fix_prompt_en": {"type": "string"},
        "regen_clause_en": {"type": "string"},
    },
    "required": ["issues", "should_repair", "should_regenerate",
                 "decision_reason", "fix_prompt_en", "regen_clause_en"],
    "additionalProperties": False,
}

# ── ⑤ 재판정 ──────────────────────────────────────────────────────────
REJUDGE_SYS = """\
Two photographs of the same moment, made from the same brief. One of them was
edited after the fact; you are not told which, and it does not matter. Judge
only which better realizes the brief.

Read both on the same three axes you would use for any still: where things are
aimed, whether the built space is coherent (control surfaces counted, seats
and reflections possible), and whether every named object and person is the
thing the brief names.

An edit that fixed the flaw it targeted but introduced a new body, a new
object, or changed someone's clothing has made the picture worse. Say so.

Answer in Korean for prose fields."""

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


def build_size_index() -> Dict[int, List[Path]]:
    idx: Dict[int, List[Path]] = {}
    for p in (ROOT / f"projects/{PROJ}").rglob("*.png"):
        idx.setdefault(p.stat().st_size, []).append(p)
    return idx


def resolve_refs(entries, size_idx) -> Tuple[List[Tuple[str, Path]], List]:
    """기록의 refs → [(label, Path)]. `<bytes:N>` 는 바이트 길이로 복원."""
    out, missing = [], []
    for e in entries or []:
        label, path = e.get("label", ""), str(e.get("path", ""))
        if path.startswith("<bytes:"):
            cands = size_idx.get(int(path[len("<bytes:"):-1]), [])
            (out.append((label, cands[0])) if cands
             else missing.append(label))
            continue
        p = Path(path)
        out.append((label, p)) if p.exists() else missing.append(label)
    return out, missing


def _ask(tag: str, sys_prompt: str, parts: List[Dict[str, Any]],
         schema: Dict[str, Any], model: str = SELECT_JUDGE_MODEL,
         ) -> Dict[str, Any]:
    return call_structured(
        tag, sys_prompt, parts, schema,
        project_config={tag: {"model": model}}, schema_name=tag,
        enable_fallback=False,
    )


# ── 이중 선정 판정 (2026-08-06 사용자 확정) ────────────────────────────
# 4모델 대조 실측이 근거다. 축마다 보는 모델이 다르다:
#   · 복잡 구조물 기하(핸들 개수·좌석) → **GPT-5.6 Sol 단독**으로 잡았다.
#     Opus·Fable 은 좁게 물어도 "핸들 1개"라 답했고, Sol 만 문제 이미지 2,2 /
#     대조군 1,1 로 일관됐다. 열린 판정에서도 Sol 만 "운전대가 두 개"를 스스로
#     짚고 후보 전부 실패를 선언했다.
#   · 방향·엔티티·서사 정합 → Opus 가 샘플에서 실증(총구 방향 A6:B14 를 역전).
#   · Gemini 는 **대조군을 두 번 틀렸다** — 핸들 1개짜리에 2개, 손이 쥔 폰에
#     "닿은 것 없음". 판정에서 뺀다.
#
# 합치는 규칙
#   ① 하드 위반은 **개수 페널티**로 센다(합집합). 처음엔 이진 탈락으로 짰다가
#      7샷 전부에서 모든 후보가 탈락해 필터가 아무것도 못 걸렀다 — 두 심판이
#      각자 위반을 넉넉히 적기 때문이다. 개수로 바꾸니 승자가 모든 샷에서
#      위반 최소 후보와 일치했다. 그리고 λ 를 0~0.40 으로 흔들어도 7샷 승자가
#      전부 그대로였다(유료 콜 0, 저장된 판정으로 재계산) — 선택이 견고하다.
#   ② 점수는 **모델별로 정규화**해서 더한다. 척도가 다르다(Opus·Sol 0~100,
#      Fable 0~10) — 원점수를 더하면 큰 척도 모델이 지배한다. 실측된 함정이다.
#   ③ `all_candidates_fail` 은 **심판 과반이 자체로 선언**했을 때만 세운다.
#      위반 유무로 판정하면 항상 참이 되어 신호가 사라진다.
PENALTY_PER_VIOLATION = 0.25
JUDGES_DEFAULT = ["claude-opus", "gpt"]
JUDGE_LABEL = {"claude-opus": "Opus 5", "gpt": "GPT-5.6 Sol",
               "claude-fable": "Fable 5", "gemini-pro": "Gemini 3.1 Pro"}


def combine_select(per_model: Dict[str, Dict[str, Any]],
                   labels: List[str]) -> Dict[str, Any]:
    """모델별 선정 결과 → 합의. 하드 위반 합집합 탈락 + 정규화 점수 합."""
    norm: Dict[str, float] = {lab: 0.0 for lab in labels}
    vio: Dict[str, List[str]] = {}
    n_fail = 0
    for model, res in per_model.items():
        readings = res.get("readings") or []
        if not readings:
            continue
        if res.get("all_candidates_fail"):
            n_fail += 1
        top = max((r.get("score") or 0) for r in readings) or 1
        for r in readings:
            lab = r["label"]
            if lab not in norm:
                continue
            norm[lab] += (r.get("score") or 0) / top
            for v in r.get("hard_violations") or []:
                vio.setdefault(lab, []).append(
                    f"[{JUDGE_LABEL.get(model, model)}] {v}")
    adj = {lab: norm[lab] - PENALTY_PER_VIOLATION * len(vio.get(lab, []))
           for lab in labels}
    winner = max(labels, key=lambda lab: adj[lab])
    n_judge = sum(1 for r in per_model.values() if r.get("readings"))
    return {
        "winner": winner,
        "normalized": {k: round(v, 3) for k, v in norm.items()},
        "adjusted": {k: round(v, 3) for k, v in adj.items()},
        "violations": vio,
        "violation_counts": {lab: len(vio.get(lab, [])) for lab in labels},
        # 심판 과반이 스스로 "후보 전부 실패"라 했을 때만 참.
        "all_candidates_fail": n_judge > 0 and n_fail * 2 >= n_judge,
        "judges_declaring_fail": f"{n_fail}/{n_judge}",
        "agreed": len({r.get("winner") for r in per_model.values()
                       if r.get("readings")}) == 1,
        "per_model_winner": {m: r.get("winner") for m, r in per_model.items()},
    }


def run_one(stem: str, rec: Dict[str, Any], size_idx, outdir: Path,
            gen_fn, judges: Optional[List[str]] = None) -> Dict[str, Any]:
    judges = judges or JUDGES_DEFAULT
    labels = sorted(
        p.stem.split("_")[-1].upper()
        for p in RECIPE.glob(f"{stem}_[abc].png"))
    if not labels:
        return {"stem": stem, "skipped": "후보 롤 없음"}

    roll_prompts = rec.get("roll_prompts") or {}
    base_prompt = rec.get("prompt", "")
    roll_refs = rec.get("roll_refs") or {}

    # ① 선정 — 후보 전부 제시
    parts: List[Dict[str, Any]] = [{
        "type": "text",
        "text": ("THE BRIEF (every candidate was made from this):\n"
                 + _compose_critique_prompt(
                     base_prompt, roll_prompts, labels[0], shared_prompt=None)),
    }]
    ref0, _ = resolve_refs(roll_refs.get(labels[0]) or rec.get("refs"),
                           size_idx)
    parts += ref_parts(ref0)
    for lab in labels:
        parts.append({"type": "text", "text": f"Candidate {lab}:"})
        parts.append(png_part(RECIPE / f"{stem}_{lab.lower()}.png"))
    schema = select_schema(labels)
    per_model: Dict[str, Any] = {}
    with ThreadPoolExecutor(max_workers=len(judges)) as jx:
        futs = {jx.submit(_ask, f"sel_{stem}_{m.replace('-', '')}",
                          SELECT_SYS, parts, schema, m): m for m in judges}
        for f in as_completed(futs):
            m = futs[f]
            try:
                per_model[m] = f.result()
            except Exception as exc:  # noqa: BLE001
                per_model[m] = {"error": repr(exc)}
    ok = {m: r for m, r in per_model.items() if "readings" in r}
    if not ok:
        return {"stem": stem, "error": "선정 판정 전건 실패",
                "per_model": per_model}
    sel = combine_select(ok, labels)
    sel["per_model"] = per_model
    winner = sel["winner"]
    orig = RECIPE / f"{stem}_{winner.lower()}.png"

    # ② 결함 + ③ 지휘
    crit_prompt = _compose_critique_prompt(
        base_prompt, roll_prompts, winner, shared_prompt=None)
    crefs, _ = resolve_refs(roll_refs.get(winner) or rec.get("refs"), size_idx)
    cparts: List[Dict[str, Any]] = [
        {"type": "text", "text": "THE BRIEF:\n" + crit_prompt}]
    cparts += ref_parts(crefs)
    cparts.append({"type": "text", "text": "The finished still:"})
    cparts.append(png_part(orig))
    crit = _ask(f"opus_critique_{stem}", CRITIQUE_SYS, cparts, CRITIQUE_SCHEMA)

    result = {
        "stem": stem, "select": sel, "critique": crit,
        "selected_roll": winner,
        "orig": str(orig),
        "prior_selected_roll": rec.get("selected"),
        "prior_had_fix": "fix_prompt" in rec,
    }

    final = orig
    fixed = None
    if crit.get("should_repair") and crit.get("fix_prompt_en", "").strip():
        # ④a i2i 국소 수정 — 원본을 참조로 준다.
        result["mode"] = "edit"
        fixed = outdir / f"{stem}_opusfix.png"
        try:
            gen_fn(f"opus_{stem}_fix", crit["fix_prompt_en"],
                   [("ORIGINAL PHOTOGRAPH", orig)], fixed)
        except Exception as exc:  # noqa: BLE001
            result["repair_error"] = repr(exc)
            fixed = None
    elif (crit.get("should_regenerate")
          and crit.get("regen_clause_en", "").strip()):
        # ④b 재생성 — 원 브리프 + Opus 가 쓴 교정절로 **다시 찍는다**.
        #  ★결함 이미지를 참조로 주지 않는다. 주면 모델이 그 기하를 그대로
        #   물려받아 같은 결함이 다시 나온다(구조물 재생성에서 실측된 것과
        #   같은 이유). 대신 원 브리프가 쓰던 참조(배경 플레이트·콘티·인물)는
        #   그대로 유지해야 장소와 인물이 유지된다.
        result["mode"] = "regenerate"
        regen_prompt = (roll_prompts.get(winner) or base_prompt).strip() \
            + "\n\n" + crit["regen_clause_en"].strip()
        result["regen_prompt"] = regen_prompt
        fixed = outdir / f"{stem}_opusregen.png"
        try:
            gen_fn(f"opus_{stem}_regen", regen_prompt, crefs, fixed)
        except Exception as exc:  # noqa: BLE001
            result["repair_error"] = repr(exc)
            fixed = None
    if fixed and fixed.exists():
        # ⑤ 재판정 — 정순 + 역순. 어느 쪽이 새것인지 판정자에게 알리지 않는다.
        def _pair(a: Path, b: Path, tag: str):
            pp: List[Dict[str, Any]] = [
                {"type": "text", "text": "THE BRIEF:\n" + crit_prompt}]
            pp += ref_parts(crefs)
            pp.append({"type": "text", "text": "Candidate A:"})
            pp.append(png_part(a))
            pp.append({"type": "text", "text": "Candidate B:"})
            pp.append(png_part(b))
            return _ask(tag, REJUDGE_SYS, pp, REJUDGE_SCHEMA)

        fwd = _pair(orig, fixed, f"opus_rejudge_{stem}")
        rev = _pair(fixed, orig, f"opus_rejudge_rev_{stem}")
        # canonical A=원본 B=새것. 역순은 라벨을 뒤집어 읽는다.
        # 2표 중 1표 이상이면 새것 채택 — 동점은 새것(production 관례와 동일).
        new_votes = int(fwd["winner"] == "B") + int(rev["winner"] == "A")
        new_won = new_votes >= 1
        result["rejudge"] = {
            "forward": fwd, "reverse": rev,
            "fix_votes": new_votes, "fix_won": bool(new_won),
        }
        result["fixed"] = str(fixed)
        final = fixed if new_won else orig

    final_out = outdir / f"{stem}_final.png"
    shutil.copy(final, final_out)
    result["final"] = str(final_out)
    result["final_is_repaired"] = (final != orig)
    return result


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("out")
    ap.add_argument("--stems", default="")
    ap.add_argument("--all", action="store_true")
    ap.add_argument("--workers", type=int,
                    default=int(os.environ.get("OPUS_WORKERS", "4")))
    ap.add_argument("--judges", default=",".join(JUDGES_DEFAULT),
                    help="선정 판정 모델 alias, 쉼표 구분 (기본 이중)")
    args = ap.parse_args()
    judges = [j.strip() for j in args.judges.split(",") if j.strip()]

    records = json.loads((RECIPE / "records.json").read_text("utf-8"))
    if args.all:
        stems = sorted(
            (k for k, v in records.items()
             if isinstance(v, dict) and "selected" in v
             and re.match(r"S\d+sh\d+$", k)),
            key=lambda s: tuple(int(x) for x in re.findall(r"\d+", s)))
    else:
        stems = ([s.strip() for s in args.stems.split(",") if s.strip()]
                 or SAMPLE_STEMS)

    out = Path(args.out)
    outdir = out.parent / "images"
    outdir.mkdir(parents=True, exist_ok=True)
    size_idx = build_size_index()
    gen_fn = make_nb2_gen_fn(
        project_id=PROJ, episode_id=EPI, operation_type="opus_pilot_fix")

    print(f"대상 {len(stems)}샷 · 선정 판정 "
          f"{' + '.join(str(JUDGE_LABEL.get(j, j)) for j in judges)}"
          f" · 결함/지휘 {JUDGE_LABEL.get(SELECT_JUDGE_MODEL, SELECT_JUDGE_MODEL)}",
          flush=True)
    results: Dict[str, Any] = {}
    lock, done = threading.Lock(), [0]

    def work(stem: str):
        return stem, run_one(stem, records[stem], size_idx, outdir, gen_fn,
                             judges)

    with ThreadPoolExecutor(max_workers=args.workers) as ex:
        futs = {ex.submit(work, s): s for s in stems}
        for f in as_completed(futs):
            s = futs[f]
            try:
                k, v = f.result()
                results[k] = v
            except Exception as exc:  # noqa: BLE001
                results[s] = {"error": repr(exc),
                              "trace": traceback.format_exc()[-900:]}
            with lock:
                done[0] += 1
                rep = sum(1 for v in results.values()
                          if v.get("final_is_repaired"))
                print(f"  {done[0]}/{len(stems)}  수정 확정 {rep}", flush=True)
                out.write_text(json.dumps(results, ensure_ascii=False,
                                          indent=1), "utf-8")

    out.write_text(json.dumps(results, ensure_ascii=False, indent=1), "utf-8")
    ok = [v for v in results.values() if "select" in v]
    changed = [k for k, v in results.items()
               if v.get("select", {}).get("winner")
               and v["select"]["winner"] != v.get("prior_selected_roll")]
    repaired = [k for k, v in results.items() if v.get("final_is_repaired")]
    n_edit = sum(1 for v in results.values() if v.get("mode") == "edit")
    n_regen = sum(1 for v in results.values() if v.get("mode") == "regenerate")
    n_kept = sum(1 for v in results.values()
                 if v.get("rejudge") and not v.get("final_is_repaired"))
    allfail = [k for k, v in results.items()
               if v.get("select", {}).get("all_candidates_fail")]
    disagree = [k for k, v in results.items()
                if v.get("select", {}).get("agreed") is False]
    disq = [k for k, v in results.items()
            if v.get("select", {}).get("disqualified")]
    print(f"\n완료 — 성공 {len(ok)} / 오류 {len(results) - len(ok)}")
    print(f"  선정이 기존과 달라진 샷 : {len(changed)}  {changed}")
    print(f"  ★두 심판이 갈린 샷      : {len(disagree)}  {disagree}")
    print(f"  하드 위반으로 탈락 발생 : {len(disq)}")
    print(f"  i2i 국소 수정 시도      : {n_edit}")
    print(f"  ★재생성(다시 찍기) 시도 : {n_regen}")
    print(f"  새것 채택               : {len(repaired)}  {repaired}")
    print(f"  개악 판정 → 원본 유지   : {n_kept}")
    print(f"  후보 전부 실패 판정     : {len(allfail)}  {allfail}")


if __name__ == "__main__":
    main()
