"""유료 뒤 **맞대어 보기** — 로컬 장부 · Opik trace · 산출. ★조회만 한다.

사용자 지시 (2026-08-31): 「Opik + log + DB + code + prompt 를 **동시에** 보면서
분석·수정할 것」. 어느 하나만 보고 원인을 확정하지 않는다.

## 왜 맞대야 하나

내 장부는 **내가 그렇게 짰다는 믿음**이지 보장이 아니다. 실제로 「무료 감사
도구」가 gemini 15콜을 사고도 「유료 0」이라고 보고한 적이 있다 — 그때
알아챌 수 있었던 유일한 자리가 Opik 이었다.

    장부에 있는데 Opik 에 없다   → 안 나갔거나 **로그를 못 남겼다**
    Opik 에 있는데 장부에 없다   → ★**장부 없이 샀다.** 제일 위험하다
    양쪽 다 있는데 신원이 다르다  → 열쇠가 안 맞는다. 재개가 헛돈다

    python tools/grounding_audit/cc_reconcile.py <journal.json> [시간창(h)] [tag]
"""
from __future__ import annotations

import json
import sys
import urllib.request
from pathlib import Path
from typing import Any, Dict, List

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))

OPIK_PROJECT = "theroad-scene-lab"


def opik_traces(hours: float, tag: str,
                rid: str = "") -> List[Dict[str, Any]]:
    """Opik 에서 이 실험 표식이 붙은 trace 를 가져온다. ★못 가져오면 **선다**.

    「조회 실패」를 「0건」으로 읽으면 「안 샀다」가 된다. 못 찾은 것을
    「없다」로 읽지 않는다.
    """
    import datetime as dt
    import os

    sys.path.insert(0, str(ROOT.parent / "scratchpad"))
    import _opik_env  # noqa: F401  — .env 를 환경에 올린다

    base = (os.environ.get("OPIK_URL_OVERRIDE")
            or "http://192.168.133.87:5173/api").rstrip("/")
    ws = os.environ.get("OPIK_WORKSPACE", "default")
    since = (dt.datetime.now(dt.timezone.utc)
             - dt.timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S")
    url = f"{base}/v1/private/traces?project_name={OPIK_PROJECT}&size=200"
    req = urllib.request.Request(url, headers={"Comet-Workspace": ws})
    d = json.load(urllib.request.urlopen(req, timeout=30))
    from tools.grounding_audit.cc_runner import ID_META_KEY, RUN_META_KEY

    out = []
    for t in d.get("content", []):
        if (t.get("start_time") or "") < since:
            continue
        # ★**tags 로 고르고, 신원·주행 표식은 부모 trace 의 metadata 에서**
        #  읽는다. tag 로는 못 싣는다 — 축 whitelist 가 고카디널리티를 막는다.
        tags = [str(x) for x in (t.get("tags") or [])]
        if tag not in tags:
            continue
        md = t.get("metadata") or {}
        got_rid = str(md.get(RUN_META_KEY) or "")
        # ★★**이번 주행 것만** 본다. 안 그러면 옛 판과 딴 도구 흔적까지
        #  같이 잡힌다 — 실제로 내 `probe` 하나 때문에 5↔5 가 맞는데도
        #  「안 맞는다」가 났다 (Codex 2026-08-31).
        if rid and got_rid != rid:
            continue
        out.append({"id": t.get("id"), "start": t.get("start_time"),
                    "identity": str(md.get(ID_META_KEY) or ""), "tags": tags,
                    "run_id": got_rid,
                    "model": str(md.get("model_alias") or md.get("model")
                                 or ""),
                    "usage": t.get("usage") or {}})
    return out


def reconcile(journal: Dict[str, Any],
              traces: List[Dict[str, Any]]) -> Dict[str, Any]:
    """세 벌을 맞댄다. ★한쪽만 보고 「샀다/안 샀다」를 말하지 않는다."""
    calls = {str(c.get("identity")): c for c in (journal.get("calls") or [])}
    by_trace: Dict[str, List[Dict[str, Any]]] = {}
    for t in traces:
        by_trace.setdefault(t["identity"], []).append(t)

    sent = {k for k, c in calls.items()
            if c.get("status") in ("ok", "uncertain")}
    logged = set(by_trace) - {""}

    return {
        "journal_sent": sorted(sent),
        "opik_logged": sorted(logged),
        "in_journal_not_opik": sorted(sent - logged),
        # ★★제일 위험한 칸 — **장부 없이 산 것**이다.
        "in_opik_not_journal": sorted(logged - sent),
        "unlabeled_traces": sum(1 for t in traces if not t["identity"]),
        "duplicate_buys": {k: len(v) for k, v in by_trace.items()
                           if len(v) > 1},
        "uncertain": sorted(k for k, c in calls.items()
                            if c.get("status") == "uncertain"),
        "lock": journal.get("lock"),
    }


def main() -> int:
    if len(sys.argv) < 2:
        print(__doc__)
        return 2
    from tools.grounding_audit.cc_runner import LIVE_TAG

    from tools.grounding_audit.cc_runner import run_id

    jp = Path(sys.argv[1])
    hours = float(sys.argv[2]) if len(sys.argv) > 2 else 6.0
    # ★태그는 **장부의 잠금**에서 온다 — 판마다 다르고, 손으로 적으면
    #  실제 에피소드를 합성 판 태그로 조회하게 된다.
    j0 = json.loads(jp.read_text(encoding="utf-8"))
    tag = (((j0.get("lock") or {}).get("trace") or {}).get("tag")
           or (sys.argv[3] if len(sys.argv) > 3 else None) or LIVE_TAG)
    journal = json.loads(jp.read_text(encoding="utf-8"))
    rid = ""
    if journal.get("lock") and journal.get("nonce"):
        rid = run_id(journal["lock"], journal["nonce"])
    if not rid:
        print("★이 장부에 주행 표식이 없다 — 옛 판과 섞여 세어진다. 선다")
        return 2
    try:
        traces = opik_traces(hours, tag, rid)
    except Exception as exc:                      # noqa: BLE001
        print(f"★Opik 조회 실패 — 「유료 0」이라 쓰지 않는다: {exc}")
        return 2

    r = reconcile(journal, traces)
    print(f"■ 맞대어 보기 — 장부 {jp.name} · 최근 {hours}시간 · "
          f"tag={tag} · run={rid}")
    print(f"  장부에 나간 것   {len(r['journal_sent'])}")
    print(f"  Opik 에 남은 것  {len(r['opik_logged'])} "
          f"(표식 없는 trace {r['unlabeled_traces']})")
    for k, label in (("in_journal_not_opik", "장부엔 있고 Opik 엔 없다"),
                     ("in_opik_not_journal", "★Opik 엔 있고 장부엔 없다"),
                     ("uncertain", "답을 못 받은 것")):
        v = r[k]
        print(f"  {label:26} {len(v)}건 {v[:4] if v else ''}")
    if r["duplicate_buys"]:
        print(f"  ★같은 신원을 두 번 샀다: {r['duplicate_buys']}")
    # ★★★**양쪽 차집합이 다 0** 이어야 맞는 것이다. 앞 판은 한쪽만 봐서,
    #  장부 5 · Opik 0 도 「맞는다」로 끝날 수 있었다 (Codex).
    ok = (not r["in_opik_not_journal"] and not r["in_journal_not_opik"]
          and not r["duplicate_buys"] and not r["unlabeled_traces"]
          and bool(r["journal_sent"]) == bool(r["opik_logged"]))
    print(f"  → 장부와 Opik 이 {'맞는다' if ok else '★안 맞는다'}")
    print("  ★provider 실패 재시도(키 슬롯 전환)는 성공 span 만 보는 Opik 에서")
    print("    안 보일 수 있다 — **물리 시도 수**는 provider/failover 로그까지")
    print("    합쳐야 한다. 여기 수는 **논리 dispatch** 다.")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
