"""Phase 0 — reference-necessity audit (read-only, 동작 변경 0).

broad-union 안전망을 reference 생성에서 제거해도 안전한지 차집합 감사로
검증한다. fail-closed: 필수 입력이 누락/parse fail/non-completed 거나
selected_map 이 불완전하면 GATE 를 무조건 FAIL 처리한다 (false PASS 금지).
checkpoint 만 읽는 pure 함수 — DB step_run / SceneStill cross-check 는
CLI wrapper 책임.

GATE blocking predicate 는 materialization risk 기준이다 (2026-05-23 erratum,
Codex 검토): prompt-ID occurrence 자체는 diagnostic 일 뿐이고, blocking 은
required_refs 밖 recurring character(visible_shot_count>=2) 로 좁힌다.
location / outlook / prop / one-shot character 의 prompt-ID 등장은 warning
bucket 으로만 남긴다 (location·outlook 은 별도 background 경로라 scope 밖,
prop 은 required_refs(kind=prop) 가 단일 SOT, one-shot character 는 Phase 2
text_only 정정 대상).
"""
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

_ID_RE = re.compile(r"\b([CPLO]\d{2,3})(?:O\d{2,3})?\b")

# Phase 0 GATE 필수 입력 — 하나라도 비정상이면 무조건 FAIL.
REQUIRED_STEPS = (
    "shot_selection", "shot_director", "scene_director",
    "shot_validator", "scene_detail",
)


def _short_id_base(sid: str) -> str:
    return sid.split("O")[0] if sid and "O" in sid else (sid or "")


def _load_with_status(
    checkpoints_dir: Path, step_id: str,
) -> Tuple[Optional[dict], str]:
    """(manifest dict | None, status). status ∈ {ok, missing, parse_error,
    not_completed:<s>, empty}.

    fail-closed: top-level `status` 키가 아예 없는 manifest 도 FAIL 처리한다
    (`not_completed:missing`). StepRunner 는 checkpoint 저장 시 항상 top-level
    `status` 를 쓰므로 (step_runner.py), status 누락 = malformed checkpoint =
    GATE FAIL. 이전 `status is not None and` 가드는 malformed 를 false-pass
    시켰다 (range review IMPORTANT 2)."""
    p = checkpoints_dir / step_id / "manifest.json"
    if not p.exists():
        return None, "missing"
    try:
        cp = json.loads(p.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None, "parse_error"
    status = cp.get("status")
    if status != "completed":
        return cp, (
            "not_completed:missing" if status is None
            else f"not_completed:{status}"
        )
    if not (cp.get("data", {}).get("scenes")):
        return cp, "empty"
    return cp, "ok"


def compute_reference_usage_report(
    *,
    checkpoints_dir: Path,
    entity_catalog: Dict[str, Dict[str, Any]],
    generated_ref_counts: Dict[str, int],
) -> Dict[str, Any]:
    """usage matrix + safety_diff + fail-closed GATE 계산.

    checkpoints_dir = `.../checkpoints/episodes/{eid}` 디렉토리.
    entity_catalog  = {short_id: {name, entity_type}} — catalog SOT.
    generated_ref_counts = {short_id: 생성된 reference ImageAsset 수}.
    """
    cps: Dict[str, Optional[dict]] = {}
    input_status: Dict[str, str] = {}
    fail_reasons: List[str] = []
    for step in REQUIRED_STEPS:
        cp, st = _load_with_status(checkpoints_dir, step)
        cps[step] = cp
        input_status[step] = st
        if st != "ok":
            fail_reasons.append(f"{step}: {st}")

    # selected_map — shot_director 의 모든 scene 이 존재해야 함 (fallback 금지)
    selected: Dict[Any, set] = {}
    sel_cp = cps["shot_selection"]
    if sel_cp:
        for sc in sel_cp.get("data", {}).get("scenes", []) or []:
            selected[sc.get("scene_index")] = set(
                sc.get("selected_shot_indices", []) or []
            )
    sdir_cp = cps["shot_director"]
    if sdir_cp:
        for sc in sdir_cp.get("data", {}).get("scenes", []) or []:
            si = sc.get("scene_index")
            if si not in selected:
                fail_reasons.append(
                    f"shot_selection: scene {si} 누락 — selected_map "
                    f"불완전 (전체 shot fallback 금지)"
                )

    # broad union 4-source (entity_protection._collect_required_entity_ids 와 동일)
    broad: set = set()
    director_cp = cps["scene_director"]
    if director_cp:
        for sc in director_cp.get("data", {}).get("scenes", []) or []:
            for sid in sc.get("present_entity_ids", []) or []:
                broad.add(_short_id_base(sid))
    sv_cp = cps["shot_validator"]
    if sv_cp:
        for sc in sv_cp.get("data", {}).get("scenes", []) or []:
            for sh in sc.get("shots", []) or []:
                for sid in sh.get("character_ids", []) or []:
                    broad.add(_short_id_base(sid))
    if sdir_cp:
        for sc in sdir_cp.get("data", {}).get("scenes", []) or []:
            for sh in sc.get("shots", []) or []:
                for sid in sh.get("visible_entity_ids", []) or []:
                    broad.add(_short_id_base(sid))

    # visible_shot_count — selected shot 한정. selected 에 없는 scene 은
    # fallback 없이 skip (이미 fail_reasons 에 기록됨).
    # fail-closed: selected shot index 가 shot_director.shots 에 실재하지
    # 않으면 그 shot 의 visible 을 못 세 visible_shot_count 가 undercount 되어
    # recurring character 를 잘못 분류한다 → fail_reasons 에 기록 (range
    # review IMPORTANT 1).
    visible_shot_count: Dict[str, int] = {}
    if sdir_cp:
        for sc in sdir_cp.get("data", {}).get("scenes", []) or []:
            si = sc.get("scene_index")
            sel = selected.get(si)
            if sel is None:
                continue
            seen_selected: set = set()
            for sh in sc.get("shots", []) or []:
                shot_idx = sh.get("shot_index")
                if shot_idx not in sel:
                    continue
                seen_selected.add(shot_idx)
                for sid in sh.get("visible_entity_ids", []) or []:
                    b = _short_id_base(sid)
                    visible_shot_count[b] = visible_shot_count.get(b, 0) + 1
            missing_shots = sel - seen_selected
            if missing_shots:
                fail_reasons.append(
                    f"shot_director: scene {si} 의 selected shot "
                    f"{sorted(missing_shots)} 가 shot_director.shots 에 없음 "
                    f"— visible_shot_count undercount 위험 (fallback 금지)"
                )

    # scene_detail: required_refs + visible_entities + t2i prompt id 사용
    required_refs_only: set = set()
    required_ref_count: Dict[str, int] = {}
    prompt_id_count: Dict[str, int] = {}
    sd_cp = cps["scene_detail"]
    if sd_cp:
        for sh in sd_cp.get("data", {}).get("scenes", []) or []:
            for sid in sh.get("visible_entities", []) or []:
                broad.add(_short_id_base(sid))
            rpc = sh.get("render_prompt_card") or {}
            for ref in (rpc.get("asset_requirements") or {}).get(
                "required_refs", []
            ) or []:
                if not isinstance(ref, dict):
                    continue
                rid = ref.get("id")
                if rid:
                    b = _short_id_base(rid)
                    required_refs_only.add(b)
                    broad.add(b)
                    required_ref_count[b] = required_ref_count.get(b, 0) + 1
            for var in sh.get("t2i_variations", []) or []:
                prompt = var.get("t2i_prompt") or ""
                for m in _ID_RE.finditer(prompt):
                    b = _short_id_base(m.group(1))
                    prompt_id_count[b] = prompt_id_count.get(b, 0) + 1

    audit_set = sorted(broad - required_refs_only)
    audit_set_with_t2i = sorted(
        b for b in audit_set if prompt_id_count.get(b, 0) > 0
    )

    # GATE predicate erratum (2026-05-23, Codex 검토): prompt-ID occurrence 는
    # diagnostic 일 뿐이고, GATE blocking 은 materialization risk 로 좁힌다.
    #   - location(L##) / outlook(O##): reference 생성 skip 대상이 아님 (별도
    #     background 경로 — orchestrator 가 etype 으로 continue) → GATE 제외,
    #     out_of_scope warning.
    #   - prop(P##): required_refs(kind=prop) 가 단일 SOT. audit_set 의 prop 은
    #     정의상 required_refs 밖 = 의도된 text-only → prompt-ID 만으로 blocking
    #     불가, prompt_id_only_prop warning.
    #   - character/비인간(C##): recurring(visible_shot_count>=2) 인데
    #     required_refs 밖 + t2i 사용이면 진짜 SOT gap → blocking. one-shot
    #     (visible_shot_count<2) 은 Phase 2 text_only 정정 대상 → warning.
    # variant pole 보호(is_variant_self/is_base_for_variant)는 broad union 과
    # 독립이라 Phase 1 narrow 화 후에도 유지됨 — audit 가 variant 판정을 안 해도
    # GATE 안전성 gap 없음.
    out_of_scope_ids: List[str] = []
    prompt_id_only_prop_ids: List[str] = []
    expected_text_only_ids: List[str] = []
    gate_blocking_ids: List[str] = []
    for b in audit_set:
        if prompt_id_count.get(b, 0) <= 0:
            continue
        prefix = b[:1]
        if prefix in ("L", "O"):
            out_of_scope_ids.append(b)
        elif prefix == "P":
            prompt_id_only_prop_ids.append(b)
        elif prefix == "C":
            if visible_shot_count.get(b, 0) >= 2:
                gate_blocking_ids.append(b)
            else:
                expected_text_only_ids.append(b)
        else:
            # 미지 prefix — fail-closed 로 blocking 처리.
            gate_blocking_ids.append(b)
    gate_blocking_ids = sorted(gate_blocking_ids)

    entities: List[Dict[str, Any]] = []
    for sid in sorted(entity_catalog):
        meta = entity_catalog[sid]
        entities.append({
            "short_id": sid,
            "name": meta.get("name"),
            "entity_type": meta.get("entity_type"),
            "visible_shot_count": visible_shot_count.get(sid, 0),
            "prompt_id_count": prompt_id_count.get(sid, 0),
            "required_ref_count": required_ref_count.get(sid, 0),
            "generated_refs": generated_ref_counts.get(sid, 0),
        })

    if gate_blocking_ids:
        fail_reasons.append(
            f"required_refs SOT gap (materialization risk): {gate_blocking_ids} "
            f"가 recurring character(visible_shot_count>=2) 인데 required_refs "
            f"밖 + selected-shot t2i_prompt 사용 중"
        )
    gate_passed = not fail_reasons

    return {
        "entities": entities,
        "safety_diff": {
            "broad_required_union": sorted(broad),
            "required_refs_only": sorted(required_refs_only),
            "audit_set": audit_set,
            "audit_set_with_t2i_usage": audit_set_with_t2i,
            "gate_blocking_ids": gate_blocking_ids,
            "warnings": {
                "out_of_scope_prompt_id_ids": sorted(out_of_scope_ids),
                "prompt_id_only_prop_ids": sorted(prompt_id_only_prop_ids),
                "expected_text_only_prompt_id_ids": sorted(
                    expected_text_only_ids
                ),
            },
        },
        "gate": {
            "passed": gate_passed,
            "input_status": input_status,
            "fail_reasons": fail_reasons,
            "reason": (
                "필수 입력 정상 + GATE blocking 집합 비어있음 "
                "(materialization risk 0) — broad union 제거 안전"
                if gate_passed
                else "; ".join(fail_reasons)
            ),
        },
    }
