"""측정 도구용 참조 복원 — **프로덕션과 같은 순서·같은 바이트로** (2026-08-29).

## 왜 만들었나 (Codex PR #49 BLOCK-1)

프로덕션 `judge_fn` 은 `ref_parts(labeled_refs)` 로 샷마다 참조를 **2~3장**
붙인다(location_plate / bgfirst_group_bg / prev_still + character_ref).
그런데 `judge_model_bakeoff` 는 생성 프롬프트 + 후보 A/B 만 보냈다. 그
결과를 옛 프로덕션 판정과 나란히 놓고 **「판정자만 갈랐다」**고 읽으면
거짓이다 — **판정자와 참조 유무가 함께 바뀐 대조**다.

## ★그리고 「붙이기만」 해서도 안 된다 (같은 BLOCK 의 둘째 갈래)

참조 중 **`prev_still` 은 recipe 폴더 안의 다른 샷 `_sel.png`** 다.
그 파일은 JIT 주행이 **다시 굽는다.** 그래서 백업 후보로 재판정하면서
참조는 live 를 열면, 후보는 pre-JIT 인데 참조만 post-JIT 가 된다 —
판정자 밖 변수가 또 섞인다. 실측:

    live  S2sh4_sel  c8b6eaa464d18a22
    백업  S2sh4_sel  57bb543499b66a82      ← 다르다

그래서 `--recipe-dir` 로 사본을 줬으면 **recipe 안쪽 참조도 그 사본에서**
푼다. 사본에 없으면 멈춘다.

★하나라도 못 풀면 **`SystemExit`**. 조용히 적게 보내면 재는 대상이
달라진다 — 이 저장소에서 `ref_labels` 를 `[:44]` 로 잘라 프레이밍 절을
지운 적이 있다.
"""
from __future__ import annotations

import pathlib
import re
import sys
from typing import Any, List, Optional, Sequence, Tuple

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import _db  # noqa: E402

ROOT = pathlib.Path(__file__).resolve().parents[3]
_BYTES_RE = re.compile(r"^<bytes:(\d+)>$")


def ref_mode(rows: Sequence[Any]) -> Tuple[bool, str]:
    """결과 행들의 `refs_attached` → `(참조 없는 판인가, 사람이 읽을 표시)`.

    ★**0 과 양수가 섞이면 멈춘다.** 참조 붙은 행과 안 붙은 행을 한 표로
    읽으면 「판정자만 갈랐다」가 거짓이 된다.

    ★**양수끼리 다른 것은 정상이다.** 샷마다 인물 수가 달라 참조가
    2~3장이 된다 — 이 저장소 baseline 실측: `S3sh2` 만 3장
    (location_plate + character_ref ×2), 나머지 5샷 2장. 종전에는
    `len(set) > 1` 로 막아서 **정상 데이터셋이 통째로 안 읽혔다**
    (2026-08-29 Codex 재리뷰). 지적의 범위를 넘겨 고치면 이렇게 된다.

    키가 아예 없는 옛 결과는 0 으로 본다 — 그때는 참조를 안 붙였다.
    """
    counts = {int(r.get("refs_attached") or 0) for r in (rows or [])}
    pos = sorted(c for c in counts if c > 0)
    if pos and (len(pos) != len(counts)):
        raise SystemExit(
            f"★한 결과 폴더에 참조 **있는 행과 없는 행**이 섞여 있다: "
            f"{sorted(counts)}\n"
            "  ref 판과 no-ref 판을 한 표로 읽지 않는다 — 폴더를 갈라라")
    if not pos:
        return True, "0"
    return False, (str(pos[0]) if len(pos) == 1 else f"{pos[0]}~{pos[-1]}")


def _asset_path(asset_id: str) -> pathlib.Path:
    got = _db.one_col(
        "SELECT file_path FROM image_asset WHERE id = :aid", {"aid": asset_id})
    if not got:
        raise SystemExit(
            f"★asset {asset_id} 의 file_path 를 못 찾았다 — 참조를 빼고 "
            "보내지 않는다(재는 대상이 달라진다)")
    p = pathlib.Path(str(got[0]))
    return p if p.is_absolute() else (ROOT / p)


def labeled_refs(
    refs: Sequence[Any],
    snapshot_recipe: Optional[pathlib.Path] = None,
) -> List[Tuple[str, pathlib.Path]]:
    """`records.json` 의 `refs` → `(label, Path)` — **순서 그대로**.

    `snapshot_recipe` 를 주면 **recipe 폴더 안쪽 참조**(`prev_still` 등)를
    그 사본에서 푼다. 사본에 없으면 멈춘다 — live 로 조용히 떨어지면
    후보는 옛것인데 참조만 새것이 된다.

    `<bytes:N>` 은 `asset_id` 로 실파일을 찾고, **크기가 N 과 다르면**
    멈춘다(기록 당시와 다른 파일을 보내는 것이므로).
    """
    snap = pathlib.Path(snapshot_recipe).resolve() if snapshot_recipe else None
    out: List[Tuple[str, pathlib.Path]] = []
    for r in refs or []:
        r = r or {}
        raw = str(r.get("path") or "")
        role = r.get("role")
        m = _BYTES_RE.match(raw)
        if m:
            p = _asset_path(str(r.get("asset_id") or ""))
            want = int(m.group(1))
            if p.is_file() and p.stat().st_size != want:
                raise SystemExit(
                    f"★참조 크기가 기록과 다르다: role={role} {p.name} — "
                    f"기록 {want}B · 지금 {p.stat().st_size}B. 그때와 다른 "
                    "파일을 보내면 판정자 밖 변수가 섞인다")
        else:
            p = pathlib.Path(raw)
            if not p.is_absolute():
                p = ROOT / p
            # ★recipe 안쪽 참조는 사본에서 푼다
            if snap is not None and "/scene/recipe/" in str(p):
                cand = snap / p.name
                if not cand.is_file():
                    raise SystemExit(
                        f"★사본에 그 참조가 없다: role={role} {p.name} → "
                        f"{cand}\n  live 로 떨어지지 않는다 — 후보는 옛것인데 "
                        "참조만 새것이 되면 실험이 무너진다")
                p = cand
        if not p.is_file():
            raise SystemExit(
                f"★참조 파일이 없다: role={role} → {p}\n"
                "  조용히 빼고 보내지 않는다 — 그러면 프로덕션과 다른 것을 "
                "재게 되고, 그 결과를 프로덕션과 나란히 놓으면 거짓이 된다")
        out.append((str(r.get("label") or ""), p))
    return out


def claim_output_dir(base, expected_names, *, run_id=None):
    """굽기 **전에** 자리를 잡는다 — 기존 표본을 조용히 덮지 않는다.

    ## 왜 필요한가 (2026-08-29 Codex BLOCK)

    A/B 도구들이 고정 경로에 굽는다. `--run` 을 두 번 치면 옛 PNG 와
    `runs.json` 이 **확인 없이** 갈린다. 그런데 그 파일들은 눈가림 판정과
    HTML, probe 제외의 **근거**다. 같은 experiment id 아래에서 표본만 조용히
    바뀌면 앞서 적은 결론이 무엇을 보고 한 말인지 알 수 없게 된다 —
    돈이 아니라 **기록이 깨지는** 문제다.

    ★기본이 dry 인 것으로는 못 막는다. `--run` 재실행이 바로 그 자리다.

    Args:
        base: 실험의 기본 출력 디렉토리.
        expected_names: 이번에 쓸 파일 이름들(`runs.json` 은 자동 포함).
        run_id: 주면 `base/run_id` 를 쓴다. **새 id 여야 한다.**

    Returns:
        실제로 쓸 디렉토리 (`mkdir` 까지 마친 상태).

    Raises:
        SystemExit: 예상 출력이 하나라도 이미 있으면. 지우라고 하지 않고
            **새 run id 를 주라**고 안내한다 — 지우는 것은 사람이 판단한다.
    """
    import pathlib as _pl

    base = _pl.Path(base)
    out = base / run_id if run_id else base
    names = list(expected_names or []) + ["runs.json"]
    clashes = [n for n in names if (out / n).exists()]
    if clashes:
        head = ", ".join(sorted(clashes)[:4])
        more = f" 외 {len(clashes) - 4}개" if len(clashes) > 4 else ""
        raise SystemExit(
            f"\u2605이미 있는 산출을 덮을 뻔했다: {out}\n"
            f"  걸린 것: {head}{more}\n"
            "  이 파일들은 눈가림 판정\u00b7HTML\u00b7probe 제외의 **근거**다. "
            "같은 experiment id 아래에서 표본만 바뀌면 앞서 적은 결론이 "
            "무엇을 보고 한 말인지 알 수 없게 된다.\n"
            "  \u2192 새 run id 를 주고 다시 쳐라:  --run-id <새 이름>\n"
            "  옛것을 정말 버릴 거면 사람이 직접 지운다 \u2014 도구가 안 지운다"
        )
    out.mkdir(parents=True, exist_ok=True)
    return out


def run_id_from_argv(argv):
    """`--run-id <값>` 을 읽는다. 없으면 None."""
    if "--run-id" in argv:
        i = argv.index("--run-id")
        if i + 1 < len(argv) and not argv[i + 1].startswith("-"):
            return argv[i + 1]
        raise SystemExit("★`--run-id` 뒤에 이름이 없다")
    return None
