"""참조 획득의 **단계별 사건 장부**. ★유료 판을 나중에 되짚기 위한 것.

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

참조 장부(`ChunkJournal`)는 **대상마다 최종 한 줄**이다. 그런데 한 대상은
라운드 ≤2 × (저작 + 검색 + 선택) = **최대 6번** 나간다. 그래서 장부만으로는
Opik trace 와 provider 로그를 **양방향으로 못 맞댄다** — 어느 호출이 어느
줄에 해당하는지 알 수 없다.

여기서는 **나가기 직전에** 한 줄씩 적는다 —

    run_nonce          이 주행 하나
    target_identity    이 대상의 획득 신원(장부 열쇠와 **같은 값**)
    stage · round_no   저작 / 검색 / 선택 · 몇 번째
    call_identity      이 호출 하나 (셋 + 나가는 것의 해시)
    outbound           **실제 나간 것** — 지문·질의·부류 이름

★append 전용이고 줄마다 flush+fsync 한다. 중간에 죽어도 **산 것까지는**
남는다. 앞서 기록 파일을 통째로 다시 써서 산 것을 잃은 적이 있다.

★고카디널리티 신원을 **tag 에 안 넣는다** — Opik tag whitelist 가 터진다.
신원은 **부모 trace metadata** 로 간다(`cc_runner` 가 검증한 방식).
"""
from __future__ import annotations

import hashlib
import json
import os
import threading
from pathlib import Path
from typing import Any, Dict, Optional

#: 단계 이름. ★`acquire_one` 이 부르는 차례 그대로.
STAGE_WRITE = "write_brief"
STAGE_SEARCH = "search"
STAGE_PICK = "pick"
STAGES = (STAGE_WRITE, STAGE_SEARCH, STAGE_PICK)


def call_identity(target_identity: str, *, stage: str, round_no: int,
                  outbound: Dict[str, Any]) -> str:
    """이 **호출 하나**의 신원. ★결정적 — 같은 것을 다시 보내면 같은 값.

    ★대상 신원만으로는 6개 호출을 못 가른다. 단계·라운드·**나간 것**까지
    접어야 장부 한 줄과 Opik trace 여럿이 이어진다.
    """
    if stage not in STAGES:
        raise ValueError(f"모르는 단계: {stage!r} (있는 것: {STAGES})")
    h = hashlib.sha256()
    for part in (str(target_identity), str(stage), str(int(round_no)),
                 json.dumps(outbound, sort_keys=True, ensure_ascii=False,
                            default=str)):
        h.update(part.encode("utf-8"))
        h.update(b"\x00")
    return h.hexdigest()[:20]


class RefEvents:
    """append 전용 사건 장부. ★줄마다 fsync — 죽어도 산 것까지는 남는다."""

    def __init__(self, path: Path, *, run_nonce: str) -> None:
        self.path = Path(path)
        self.run_nonce = str(run_nonce)
        self._lock = threading.RLock()
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def put(self, *, target_identity: str, stage: str, round_no: int,
            outbound: Dict[str, Any], trace_id: Optional[str] = None,
            **extra: Any) -> str:
        """**나가기 직전에** 부른다. 판정 뒤가 아니다."""
        cid = call_identity(target_identity, stage=stage, round_no=round_no,
                            outbound=outbound)
        row = {"run_nonce": self.run_nonce,
               "target_identity": str(target_identity),
               "stage": str(stage), "round_no": int(round_no),
               "call_identity": cid, "trace_id": trace_id,
               "outbound": outbound, **extra}
        line = json.dumps(row, ensure_ascii=False, default=str)
        with self._lock:
            with self.path.open("a", encoding="utf-8") as fh:
                fh.write(line + "\n")
                fh.flush()
                os.fsync(fh.fileno())
        return cid

    def rows(self):
        if not self.path.exists():
            return []
        out = []
        for ln in self.path.read_text(encoding="utf-8").splitlines():
            ln = ln.strip()
            if ln:
                out.append(json.loads(ln))
        return out

    def tally(self) -> Dict[str, Any]:
        """무엇이 몇 번 나갔나. ★Opik·provider 와 맞댈 때 여기부터."""
        rows = self.rows()
        by_stage: Dict[str, int] = {}
        by_target: Dict[str, int] = {}
        for r in rows:
            by_stage[r["stage"]] = by_stage.get(r["stage"], 0) + 1
            k = r["target_identity"]
            by_target[k] = by_target.get(k, 0) + 1
        return {"total": len(rows), "by_stage": by_stage,
                "by_target": by_target,
                "distinct_calls": len({r["call_identity"] for r in rows}),
                "traced": sum(1 for r in rows if r.get("trace_id"))}
