"""shot_dependency_t2i canary — 옛(t2i 산문) vs lean(typed 프레이밍) 참조 edge 대조.

무엇을 지키나:
  · **이미지 0장** — 이 스텝은 이미지 경로를 안 탄다(import 에 없다).
  · **CP 안 건드림** — `_execute` 만 부른다. 저장은 `_execute_and_finalize`
    쪽이라 여기선 안 돈다.
  · **조립을 다시 만들지 않는다** — user_prompt 를 스크립트에서 재현하면
    프로덕션이 실제로 보내는 것과 어긋날 수 있다. `call_structured` 를
    **감싸서** 나가는 인자를 기록만 하고 원본을 그대로 부른다.
  · **모드는 프로세스로 가른다** — `STILL_RECIPE_MODE` env 를 세우고 나서
    설정을 읽는다. 한 프로세스 안에서 속성을 갈아끼우지 않는다.

쓰는 법:
    python scratchpad/canary_dep_edges.py --mode off --round 1 [--dry]
    python scratchpad/canary_dep_edges.py --compare OUT_A.json OUT_B.json
"""
import argparse
import json
import os
import sys
from pathlib import Path

ROOT = Path("/Users/manta/Documents/Projects/TheRoad-I1")
BACKEND = ROOT / "backend"
OUT_DIR = ROOT / "scratchpad" / "canary_dep_edges"

# 골목 끝 (3씬 6샷) — 설계 문서의 무료 실측과 같은 판.
PROJECT_ID = "da049582-2c6d-492c-979d-f468d61bab6e"
EPISODE_ID = "fb7a883f-baac-4145-9131-732ce628d474"


def _shot_key(si, shi) -> str:
    return f"S{si}sh{shi}"


def _edges(result: dict) -> dict:
    """스텝 반환에서 **참조 edge** 만 뽑는다 — 비교 대상 그 자체."""
    out = {}
    for d in result.get("data", {}).get("dependencies", []):
        key = _shot_key(d.get("scene_index"), d.get("shot_index"))
        refs = d.get("location_refs") or []
        if not refs:
            out[key] = None
            continue
        r = refs[0]
        out[key] = {
            "ref": _shot_key(r.get("scene_index"), r.get("shot_index")),
            "usage": r.get("ref_usage"),
            "keep": r.get("keep_elements"),
        }
    return dict(sorted(out.items()))


def run_one(mode: str, round_no: int, dry: bool, only_loc: str = "") -> Path:
    os.environ["STILL_RECIPE_MODE"] = mode
    sys.path.insert(0, str(BACKEND))
    os.chdir(BACKEND)  # config.py 의 env_file=".env" 는 상대 경로다

    from app.core.config import settings
    assert settings.still_recipe_mode == mode, (
        f"env 가 안 먹었다: still_recipe_mode={settings.still_recipe_mode!r}")

    from app.core.database import SessionLocal
    from app.core.steps import shot_dependency_t2i_step as mod
    from app.services.analysis_dispatch_service import get_step_runner
    from app.services.step_execution_service import _load_project_config

    calls = []
    real_call = mod.call_structured

    def _spy(**kw):
        usr = kw.get("user_prompt") or ""
        rec = {
            "loc_prompt": usr,
            "sys_len": len(kw.get("system_prompt") or ""),
            "usr_len": len(usr),
        }
        # `--only-loc` — 갈린 location 만 다시 태울 때. 장소 머리줄이
        # `장소: <이름> (<loc_id>)` 라 그것으로 가른다. 안 고른 장소는
        # **돈을 안 쓰고** 빈 결과로 둔다(그 샷들의 edge 는 비교에서 뺀다).
        if only_loc and f"({only_loc})" not in usr.split("\n")[0]:
            rec["result"] = f"<건너뜀 — only_loc={only_loc}>"
            rec["skipped"] = True
            calls.append(rec)
            return {"dependencies": []}
        if dry:
            rec["result"] = "<dry — 호출 안 함>"
            calls.append(rec)
            return {"dependencies": []}
        result = real_call(**kw)
        rec["result"] = result
        calls.append(rec)
        return result

    mod.call_structured = _spy

    db = SessionLocal()
    try:
        project_config = _load_project_config(db, PROJECT_ID)
        runner = get_step_runner(
            "shot_dependency_t2i", PROJECT_ID, EPISODE_ID, db, project_config,
            opik_context={
                "run_tag": f"canary_v1_lean_20260827_{mode}_r{round_no}",
                "project_name": "canary_v1_lean",
                "episode_title": "골목 끝",
            },
        )
        result = runner._execute(mode="resume")
    finally:
        mod.call_structured = real_call
        db.close()

    paid = [c for c in calls if not c.get("skipped")]
    payload = {
        "mode": mode,
        "round": round_no,
        "dry": dry,
        "only_loc": only_loc,
        "paid_calls": len(paid),
        "paid_usr_chars": sum(c["usr_len"] for c in paid),
        "config_hash": result.get("config_hash"),
        "completed_count": result.get("completed_count"),
        "failed_count": result.get("failed_count"),
        "llm_calls": len(calls),
        "usr_chars_total": sum(c["usr_len"] for c in calls),
        "edges": _edges(result),
        "calls": calls,
    }
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    suffix = "dry" if dry else "live"
    if only_loc:
        suffix = f"{only_loc}_{suffix}"
    out = OUT_DIR / f"{mode}_r{round_no}_{suffix}.json"
    out.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
                   encoding="utf-8")

    print(f"[{mode} r{round_no} {suffix}] paid_calls={len(paid)}/{len(calls)} "
          f"paid_usr_chars={payload['paid_usr_chars']} "
          f"failed={payload['failed_count']} cfg={payload['config_hash']}")
    for k, v in payload["edges"].items():
        print(f"  {k:8s} -> {v}")
    print(f"  saved: {out}")
    return out


def compare(paths: list) -> int:
    docs = [json.loads(Path(p).read_text(encoding="utf-8")) for p in paths]
    labels = [f"{d['mode']}/r{d['round']}" for d in docs]

    # ★`--only-loc` 판은 **안 태운 장소의 샷**이 빈 값으로 남는다. 그것을
    #  「달라졌다」로 세면 재는 도구가 없는 차이를 만든다. 그래서 어느 한
    #  판이라도 안 태운 장소의 샷은 비교에서 뺀다 — 그리고 **몇 개를 뺐는지**
    #  찍는다(조용히 줄이면 「전부 봤다」로 읽힌다).
    covered = []
    for d in docs:
        loc = d.get("only_loc") or ""
        if not loc:
            covered.append(None)  # 전 장소
            continue
        shots = set()
        for c in d["calls"]:
            if c.get("skipped"):
                continue
            for line in c["loc_prompt"].split("\n"):
                if line.startswith("[S") and line.rstrip().endswith("]"):
                    tag = line.strip()[1:-1]           # S01_Shot3
                    si, shi = tag.split("_Shot")
                    shots.add(_shot_key(int(si[1:]), int(shi)))
        covered.append(shots)

    all_keys = sorted({k for d in docs for k in d["edges"]})
    keys = [k for k in all_keys
            if all(c is None or k in c for c in covered)]
    dropped = [k for k in all_keys if k not in keys]
    if dropped:
        print(f"[비교 제외] {len(dropped)}샷 — 어느 판에서 안 태웠다: "
              f"{', '.join(dropped)}")

    print("\n=== edge 대조 ===")
    header = f"{'shot':8s} " + " ".join(f"{lb:22s}" for lb in labels)
    print(header)
    diff_shots = []
    for k in keys:
        cells = []
        sigs = []
        for d in docs:
            e = d["edges"].get(k)
            sig = None if e is None else f"{e['ref']}/{e['usage']}"
            sigs.append(sig)
            cells.append(f"{str(sig):22s}")
        same = len(set(map(str, sigs))) == 1
        print(f"{k:8s} " + " ".join(cells) + ("" if same else "   <-- 다름"))
        if not same:
            diff_shots.append(k)

    print("\n=== 발송량 (실제 태운 것만) ===")
    for d in docs:
        # 옛 판에는 paid_* 칸이 없다 — 그때는 전부 태웠으니 총계가 곧 유료다.
        print(f"  {d['mode']}/r{d['round']}"
              f"{('/' + d['only_loc']) if d.get('only_loc') else ''}: "
              f"calls={d.get('paid_calls', d['llm_calls'])} "
              f"usr_chars={d.get('paid_usr_chars', d['usr_chars_total'])}")

    print(f"\n다른 샷: {diff_shots if diff_shots else '없음 — edge 동일'}")
    return len(diff_shots)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--mode", choices=["off", "v1"])
    ap.add_argument("--round", type=int, default=1)
    ap.add_argument("--dry", action="store_true")
    ap.add_argument("--only-loc", default="",
                    help="이 loc_id 만 유료로 태운다 (예: L01)")
    ap.add_argument("--compare", nargs="+")
    a = ap.parse_args()

    if a.compare:
        return compare(a.compare)
    if not a.mode:
        ap.error("--mode 또는 --compare 중 하나가 필요하다")
    run_one(a.mode, a.round, a.dry, a.only_loc)
    return 0


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