"""최종 샷 한 장에 무엇이 영향을 미쳤나 — 세 자료원을 합쳐 한 화면으로.

    ./backend/.venv/bin/python -m tools.shot_influence <still_id 또는 shot_run_uid>

자료원 셋:
  · records.json  — 문안·참조·판정·선정·수리·지문
  · image_asset   — 최종 파일과 계보
  · Opik          — 실제 호출들(프롬프트 전문·토큰)

합치기는 순수 함수(`merge_shot_influence`)로 두고 조회는 밖에서 한다 —
시험이 망을 안 타게 하기 위해서다.
"""
from __future__ import annotations

import json
from typing import Any, Dict, List, Optional


def _fix_applied(record: Dict[str, Any]) -> bool:
    """수리 산출이 최종이 됐는가 — **canonical 판정 하나**를 쓴다.

    `fix_stage_won`(`app.modules.pipeline.still_recipe`)이 자산 provenance 의
    단일 판정이다. 여기서 다시 구현하면 두 벌이 되어 갈린다.
    도구가 backend 없이 돌 때(요약 dict 만 넘겨받는 경우)는 False 로 둔다.
    """
    try:
        from app.modules.pipeline.still_recipe import fix_stage_won

        return fix_stage_won(record or {})
    except Exception:      # noqa: BLE001 — 도구는 조회가 막혀도 읽혀야 한다
        return False


def merge_shot_influence(
    *,
    record: Optional[Dict[str, Any]],
    asset_row: Optional[Dict[str, Any]],
    opik_trace_row: Optional[Dict[str, Any]],
    opik_spans: Optional[List[Dict[str, Any]]],
) -> Dict[str, Any]:
    """세 자료원을 uid 로 합친다. 없는 자료원은 조용히 빼고 표시한다."""
    record = record or {}
    uid = (record.get("shot_run_uid")
           or (asset_row or {}).get("shot_run_uid")
           or (opik_trace_row or {}).get("id"))

    sources: List[str] = []
    mismatch: List[str] = []
    if record:
        sources.append("records.json")
    if asset_row:
        sources.append("image_asset")
        if asset_row.get("shot_run_uid") and asset_row["shot_run_uid"] != uid:
            mismatch.append("image_asset")
    if opik_trace_row:
        sources.append("opik")
        if opik_trace_row.get("id") and opik_trace_row["id"] != uid:
            mismatch.append("opik")

    return {
        "shot_run_uid": uid,
        "sources": sources,
        "uid_mismatch": mismatch,
        "refs": list(record.get("refs") or []),
        "ref_mode": record.get("ref_mode"),
        "selected": record.get("selected"),
        "ranking": record.get("ranking"),
        "totals": record.get("totals"),
        "verdicts": list(record.get("verdicts") or []),
        "fix_skipped": bool(record.get("fix_skipped")),
        # ★수리 승패는 canonical 판정(fix_stage_won) 하나만 쓴다
        #   (2026-08-24 Codex BLOCK 2). `fix_skipped` 가 False 라는 것만으로
        #   「수리 적용」이라 하면 critique 를 아예 안 돌린 샷과 재판정에서
        #   **수정본이 진** 샷까지 적용됐다고 출력한다.
        #   ★순수 함수를 지역 import 한다 — 망을 안 탄다.
        "fix_applied": _fix_applied(record),
        "fix_skip_reason": record.get("fix_skip_reason"),
        "input_fingerprint": record.get("input_fingerprint"),
        "asset": asset_row or {},
        "thread_id": (opik_trace_row or {}).get("thread_id"),
        "calls": [
            {"name": s.get("name"),
             "tokens": (s.get("usage") or {}).get("total_tokens")}
            for s in (opik_spans or [])
        ],
    }


def render_text(merged: Dict[str, Any]) -> str:
    """사람이 읽는 한 화면."""
    L: List[str] = []
    L.append(f"샷 uid : {merged.get('shot_run_uid')}")
    L.append(f"자료원 : {', '.join(merged.get('sources') or []) or '없음'}")
    if merged.get("uid_mismatch"):
        L.append(f"★uid 어긋남: {', '.join(merged['uid_mismatch'])}")
    L.append(f"주행    : {merged.get('thread_id')}")
    L.append(f"지문    : {merged.get('input_fingerprint')}")
    L.append("")

    L.append(f"── 무엇이 들어갔나 (참조 {len(merged.get('refs') or [])}건, "
             f"방식={merged.get('ref_mode')}) ──")
    for r in (merged.get("refs") or []):
        aid = r.get("asset_id") or "자산 불명"
        L.append(f"  · {r.get('label')}")
        L.append(f"      asset_id={aid}  role={r.get('role')}")
    L.append("")

    L.append("── 무엇이 골랐나 ──")
    L.append(f"  선정: {merged.get('selected')}   순위: {merged.get('ranking')}"
             f"   점수: {merged.get('totals')}")
    for v in (merged.get("verdicts") or []):
        L.append(f"    {v.get('label')}: {v.get('score')}")
    L.append("")

    L.append("── 무엇을 고쳤나 ──")
    if merged.get("fix_applied"):
        L.append("  수리 적용 — 수리 산출이 최종")
    elif merged.get("fix_skipped"):
        L.append(f"  수리 건너뜀 — {merged.get('fix_skip_reason')}")
    else:
        # ★「적용 안 됨」의 갈래가 둘이다 — 수리를 아예 안 돌렸거나,
        #   돌렸는데 재판정에서 **수정본이 졌거나**. 둘을 「적용」으로
        #   뭉뚱그리면 읽는 사람이 최종본의 출처를 잘못 안다.
        L.append("  수리 미적용 — 안 돌렸거나 재판정에서 원본이 이겼다")
    L.append("")

    L.append(f"── 실제 호출 {len(merged.get('calls') or [])}건 ──")
    for c in (merged.get("calls") or []):
        tok = c.get("tokens")
        L.append(f"  · {c.get('name')}" + (f"   토큰 {tok}" if tok else ""))
    L.append("")
    L.append(f"최종 파일: {(merged.get('asset') or {}).get('file_path')}")
    return "\n".join(L)


#: 한 페이지에 받아 오는 건수. 조회는 「찾을 때까지」 페이지를 넘긴다.
PAGE_SIZE = 200


def _find_trace_by_uid(get, pid: str, uid: str) -> Optional[Dict[str, Any]]:
    """uid 로 trace 를 찾는다 — **페이지 끝까지** 넘긴다.

    ★첫 페이지만 보면 200건 밖의 uid 는 자산이 있어도 Opik 이 빠진 채
    조용히 성공한다(2026-08-24 Codex BLOCK 3). 계약은 「임의의
    still_id·shot_run_uid」이므로 오래된 것도 찾아야 한다.
    """
    page = 1
    while True:
        d = get(f"/v1/private/traces?project_id={pid}"
                f"&page={page}&size={PAGE_SIZE}")
        content = d.get("content") or []
        if not content:
            return None
        for t in content:
            if t.get("id") == uid:
                return t
        if page * PAGE_SIZE >= int(d.get("total") or 0):
            return None
        page += 1


def _fetch_spans(get, pid: str, uid: str) -> List[Dict[str, Any]]:
    """그 trace 의 span 전부 — 여기도 페이지를 끝까지 넘긴다."""
    out: List[Dict[str, Any]] = []
    page = 1
    while True:
        d = get(f"/v1/private/spans?project_id={pid}&trace_id={uid}"
                f"&page={page}&size={PAGE_SIZE}")
        content = d.get("content") or []
        if not content:
            return out
        out.extend(content)
        if page * PAGE_SIZE >= int(d.get("total") or 0):
            return out
        page += 1


def _fetch_opik(uid: str) -> tuple:
    import urllib.request

    from app.core.config import settings
    base = settings.opik_url_override.rstrip("/")
    hdr = {"Comet-Workspace": settings.opik_workspace}

    def _get(path: str) -> Dict[str, Any]:
        req = urllib.request.Request(f"{base}{path}", headers=hdr)
        return json.load(urllib.request.urlopen(req, timeout=30))

    projs = _get("/v1/private/projects?page=1&size=100")
    pid = next((p["id"] for p in projs.get("content", [])
                if p["name"] == settings.opik_project_name), None)
    if not pid:
        return None, []
    row = _find_trace_by_uid(_get, pid, uid)
    if row is None:
        return None, []
    return row, _fetch_spans(_get, pid, uid)


def main(argv: Optional[List[str]] = None) -> int:
    import argparse

    ap = argparse.ArgumentParser(description="최종 샷의 영향 요인")
    ap.add_argument("key", help="still_id 또는 shot_run_uid")
    ns = ap.parse_args(argv)

    from app.core.database import SessionLocal
    from app.models.project import ImageAsset

    db = SessionLocal()
    try:
        # ★계약이 「still_id 또는 shot_run_uid」다 — 둘 다 찾아야 한다.
        #   still_id 로만 걸면 uid 를 준 경우 asset·records 를 못 찾고
        #   Opik 만 보여 「세 자료원 합치기」가 아니게 된다.
        q = db.query(ImageAsset).filter(ImageAsset.asset_type == "scene")
        row = q.filter(ImageAsset.still_id == ns.key).order_by(
            ImageAsset.created_at.desc()).first()
        if row is None:
            # uid 로 준 경우 — metadata 안을 본다.
            # ★최신 N 건만 훑으면 오래된 자산을 조용히 못 찾는다
            #   (2026-08-24 Codex BLOCK 3). 문자열 포함으로 DB 에서 먼저
            #   좁힌 뒤(인덱스는 없지만 후보가 몇 건으로 줄어든다) 정확히
            #   대조한다 — 상한을 걸지 않는다.
            like = f'%"shot_run_uid": "{ns.key}"%'
            for cand in q.filter(
                ImageAsset.pipeline_metadata_json.like(like)
            ).order_by(ImageAsset.created_at.desc()):
                try:
                    if json.loads(cand.pipeline_metadata_json or "{}").get(
                            "shot_run_uid") == ns.key:
                        row = cand
                        break
                except (TypeError, ValueError):
                    continue
            if row is None:
                # 직렬화 공백이 다를 수 있다 — 좁히지 못하면 전수로 간다.
                for cand in q.filter(
                    ImageAsset.pipeline_metadata_json.like(
                        f"%{ns.key}%")
                ).order_by(ImageAsset.created_at.desc()):
                    try:
                        if json.loads(
                                cand.pipeline_metadata_json or "{}").get(
                                "shot_run_uid") == ns.key:
                            row = cand
                            break
                    except (TypeError, ValueError):
                        continue

        asset_row = None
        record = None
        if row is not None:
            meta = json.loads(row.pipeline_metadata_json or "{}")
            asset_row = {"id": row.id, "file_path": row.file_path,
                         "generation_call_id": row.generation_call_id,
                         "still_id": row.still_id,
                         "shot_run_uid": meta.get("shot_run_uid")}
            from pathlib import Path
            rj = Path(row.file_path).parent / "recipe" / "records.json"
            if rj.exists():
                allrec = json.loads(rj.read_text(encoding="utf-8"))
                want = meta.get("shot_run_uid") or ns.key
                record = next(
                    (v for v in allrec.values()
                     if isinstance(v, dict) and v.get("shot_run_uid") == want),
                    None)

        uid = (record or {}).get("shot_run_uid") or (
            asset_row or {}).get("shot_run_uid") or ns.key
        trace_row, spans = _fetch_opik(uid)
        if row is None and trace_row is None:
            print(f"'{ns.key}' 로 아무것도 못 찾았다 — still_id 인지 "
                  f"shot_run_uid 인지 확인할 것")
            return 1
        print(render_text(merge_shot_influence(
            record=record, asset_row=asset_row,
            opik_trace_row=trace_row, opik_spans=spans)))
        return 0
    finally:
        db.close()


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