"""장부(`ChunkJournal`)의 `uncertain`·`reserved` 줄을 **사람이** 매듭짓는 운영자 도구 — production 은 자동으로 다시 사지 않는다.

    python tools/grounding_audit/journal_settle.py list    --journal <path/journal.json>
    python tools/grounding_audit/journal_settle.py settle  --journal <path> --identity <id> --not-bought --why "Opik trace 없음" --by <이름>
    python tools/grounding_audit/journal_settle.py release --journal <path> --identity <id> --why "worker 죽음" --by <이름>   # reserved 만

★PR #82 리뷰 2026-09-03: `settle_uncertain`·`release` 를 production 어디서도 안 불러 timeout 한 번이면 그 스텝이 장부 JSON 을
손으로 고칠 때까지 안 돌았다. 이 도구가 그 손이다 — **Opik 에서 그 신원의 trace 를 확인한 뒤**(있으면 샀다 → 다시 사야 한다 ·
없으면 안 샀다 → `--not-bought`) 부른다.
★Codex 재리뷰(A·B): 저장된 `contract` 를 **그대로** 열고(안 그러면 첫 정정에서 {} 로 덮여 production 이 drift 로 선다),
release 는 원행을 안 건드리는 정정 사건(`settle_reserved`)이며, 끝나면 contract 와 calls 가 한 바이트도 안 바뀐 것을 확인한다.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional


def stored(path: str) -> Dict[str, Any]:
    p = Path(path)
    if not p.is_file():
        raise SystemExit(f"장부가 없다: {p}")
    return json.loads(p.read_text(encoding="utf-8"))


def load(path: str):
    """★저장된 contract 로 연다 — `contract=None` 으로 열면 `_flush` 가 {} 를 써서 production 이 contract drift 로 선다."""
    from app.modules.pipeline.grounding_chunk_journal import ChunkJournal
    d = stored(path)
    c = d.get("contract")
    if not isinstance(c, dict) or not c:
        raise SystemExit(f"장부에 contract 가 없다 — 이 도구로 열지 않는다: {path}")
    j = ChunkJournal(Path(path), contract=c)
    if j.contract_drifted():
        raise SystemExit("저장된 contract 와 열린 contract 가 다르다 — 도구 결함")
    return j


def pending(journal) -> List[Dict[str, Any]]:
    from app.modules.pipeline.grounding_chunk_journal import STATUS_RESERVED, STATUS_UNCERTAIN
    out = []
    for k, e in (journal.entries or {}).items():
        if e.get("status") in (STATUS_UNCERTAIN, STATUS_RESERVED):
            out.append({"identity": k, "status": e.get("status"), "slot": e.get("slot"), "epoch": e.get("epoch"),
                        "attempt": e.get("attempt"), "why": e.get("why"), "outbound": e.get("outbound")})
    return out


def _invariants(path: str, before: Dict[str, Any]) -> None:
    """★끝점: contract 는 같고 calls 는 한 바이트도 안 바뀌고 settlements 는 늘었다."""
    after = stored(path)
    if after.get("contract") != before.get("contract"):
        raise SystemExit(f"contract 가 바뀌었다 — {before.get('contract')} → {after.get('contract')}")
    if json.dumps(after.get("calls"), sort_keys=True, ensure_ascii=False) != json.dumps(before.get("calls"), sort_keys=True, ensure_ascii=False):
        raise SystemExit("calls(원행)가 바뀌었다 — 정정은 settlements 에만 덧붙여야 한다")
    if len(after.get("settlements") or []) != len(before.get("settlements") or []) + 1:
        raise SystemExit("settlements 가 정확히 하나 늘지 않았다")


def main(argv: Optional[List[str]] = None) -> int:
    ap = argparse.ArgumentParser()
    sub = ap.add_subparsers(dest="cmd", required=True)
    a = sub.add_parser("list"); a.add_argument("--journal", required=True)
    b = sub.add_parser("settle"); b.add_argument("--journal", required=True); b.add_argument("--identity", required=True)
    b.add_argument("--not-bought", action="store_true", help="Opik 에 trace 가 없다 — 안 샀다고 정한다(자리가 풀린다)")
    b.add_argument("--why", required=True); b.add_argument("--by", required=True)
    c = sub.add_parser("release"); c.add_argument("--journal", required=True); c.add_argument("--identity", required=True)
    c.add_argument("--why", required=True); c.add_argument("--by", required=True)
    a_ = ap.parse_args(argv)
    j = load(a_.journal)
    if a_.cmd == "list":
        rows = pending(j)
        print(json.dumps({"journal": a_.journal, "contract": j.contract, "epoch": j.epoch, "pending": rows}, ensure_ascii=False, indent=2))
        return 0 if not rows else 2
    before = stored(a_.journal)
    if a_.cmd == "settle":
        if not a_.not_bought:
            raise SystemExit("샀다고 정하는 것은 답이 없어 못 한다 — 다시 사야 한다. 안 샀으면 --not-bought")
        rec = j.settle_uncertain(a_.identity, bought=False, why=a_.why, decided_by=a_.by)
        _invariants(a_.journal, before)
        print(json.dumps(rec, ensure_ascii=False)); return 0
    if a_.cmd == "release":
        rec = j.settle_reserved(a_.identity, why=a_.why, decided_by=a_.by)
        _invariants(a_.journal, before)
        print(json.dumps(rec, ensure_ascii=False)); return 0
    return 1


if __name__ == "__main__":
    sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
    raise SystemExit(main())
