#!/usr/bin/env python3
"""이미 어긋난 신원을 **찾아 보여 주기만** 한다 — 고치지 않는다.

## 왜 고치지 않나

2026-09-04 이전 판은 화마다 `short_id` 를 `01` 부터 다시 매기고, 아웃룩
stale cleanup 이 **프로젝트 전체**를 지웠다. 그래서 이미 돌아간 프로젝트에는
이런 것들이 남아 있다 —

    · 참조 이미지는 1화가 만든 것인데 canon 이름은 3화 것
    · 같은 사람이 두 행 (C02 기사 최씨 / C06 최씨)
    · 어느 화에도 안 붙은 고아 canon
    · 체크포인트에는 있는데 DB 에는 없는 신원 (지워진 것)

**어느 쪽이 맞는지는 코드가 정할 수 없다.** 자동으로 합치면 앞 화의 그림이
남의 것이 되고, 자동으로 지우면 되돌릴 수 없다. 그래서 이 도구는 **읽기만**
한다 — 무엇이 어긋났는지 사람이 보고 정한다.

    ★쓰기 없음. `SELECT` 만 한다. 확인: 이 파일에 UPDATE/DELETE/INSERT 없음.

## 쓰는 법

    python -m tools.multi_episode_audit                 # 여러 화 프로젝트 전부
    python -m tools.multi_episode_audit <project_id>    # 하나만
    python -m tools.multi_episode_audit --json          # 기계용
"""
from __future__ import annotations

import argparse
import json
import pathlib
import sys
from typing import Any, Dict, List

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))

from sqlalchemy import text as sql_text  # noqa: E402

#: 체크포인트에서 신원을 주울 때 볼 **키 이름들** — 본문 글자 훑기가 아니다.
_ID_KEYS = ("short_id", "outlook_id")

#: 어느 단계의 산출을 「그 화가 무엇을 뜻했는가」의 근거로 볼 것인가.
_EVIDENCE_STEPS = ("entity_t2i", "outlook_phase3", "entity_filter")


def _walk(node: Any, out: Dict[str, str], name_key: str = "name") -> None:
    """`short_id → 이름` 을 모은다. 키 기준으로만 본다."""
    if isinstance(node, dict):
        sid = None
        for k in _ID_KEYS:
            v = node.get(k)
            if isinstance(v, str) and v:
                sid = v
                break
        if sid and isinstance(node.get(name_key), str):
            out.setdefault(sid, node[name_key])
        for v in node.values():
            _walk(v, out, name_key)
    elif isinstance(node, list):
        for v in node:
            _walk(v, out, name_key)


def checkpoint_identities(projects_dir: str, project_id: str,
                          episode_id: str) -> Dict[str, str]:
    """그 화의 체크포인트가 말하는 `short_id → 이름`."""
    root = (pathlib.Path(projects_dir) / project_id / "checkpoints"
            / "episodes" / episode_id)
    found: Dict[str, str] = {}
    for step in _EVIDENCE_STEPS:
        mf = root / step / "manifest.json"
        if not mf.is_file():
            continue
        try:
            data = json.loads(mf.read_text(encoding="utf-8"))
        except Exception:  # noqa: BLE001
            continue
        _walk(data.get("data"), found)
    return found


def audit_project(db, projects_dir: str, project_id: str,
                  name: str) -> Dict[str, Any]:
    """한 프로젝트의 어긋남 — **읽기만** 한다."""
    episodes = db.execute(sql_text(
        "SELECT id, episode_number, title FROM episode "
        "WHERE project_id = :p ORDER BY episode_number"
    ), {"p": project_id}).fetchall()

    canons = db.execute(sql_text(
        "SELECT id, short_id, name, entity_type, status FROM entity_canon "
        "WHERE project_id = :p ORDER BY entity_type, short_id"
    ), {"p": project_id}).fetchall()
    by_sid = {c[1]: c for c in canons if c[1]}

    # ── ① 체크포인트가 말하는 이름과 DB 이름이 다른 신원 ──────────────
    name_drift: List[Dict[str, Any]] = []
    cp_by_ep: Dict[str, Dict[str, str]] = {}
    for eid, num, _title in episodes:
        cp_by_ep[eid] = checkpoint_identities(projects_dir, project_id, eid)
    for eid, num, _title in episodes:
        for sid, cp_name in cp_by_ep[eid].items():
            row = by_sid.get(sid)
            if row is None:
                continue
            if row[2] != cp_name:
                name_drift.append({
                    "short_id": sid, "episode": num,
                    "checkpoint_name": cp_name, "db_name": row[2],
                    "canon_id": row[0], "entity_type": row[3],
                })

    # ── ② 체크포인트에는 있는데 DB 에 없는 신원 (지워진 것) ────────────
    vanished: List[Dict[str, Any]] = []
    for eid, num, _title in episodes:
        for sid, cp_name in cp_by_ep[eid].items():
            if sid not in by_sid:
                vanished.append({"short_id": sid, "episode": num,
                                 "checkpoint_name": cp_name})

    # ── ③ 어느 화에도 안 붙은 canon ────────────────────────────────────
    orphans = [
        {"short_id": r[0], "name": r[1], "entity_type": r[2], "canon_id": r[3]}
        for r in db.execute(sql_text(
            "SELECT c.short_id, c.name, c.entity_type, c.id FROM entity_canon c "
            "WHERE c.project_id = :p AND NOT EXISTS ("
            "  SELECT 1 FROM entity_episode_link l WHERE l.canon_id = c.id)"
            " ORDER BY c.entity_type, c.short_id"
        ), {"p": project_id}).fetchall()
    ]

    # ── ④ 참조 이미지 생성 시각이 그 canon 이 붙은 화들과 어긋나는 것 ──
    #  ★그림은 만들어진 날의 화 것이다. 링크가 그 뒤 화에만 있으면 그림과
    #   이름이 갈렸을 가능성이 크다.
    image_drift = [
        {"short_id": r[0], "name": r[1], "image_created": r[2],
         "linked_episodes": r[3], "canon_id": r[4]}
        for r in db.execute(sql_text(
            "SELECT c.short_id, c.name, min(left(a.created_at, 10)), "
            "       string_agg(DISTINCT e.episode_number::text, ',' "
            "                  ORDER BY e.episode_number::text), c.id "
            "FROM entity_canon c "
            "JOIN image_asset a ON a.entity_id = c.id AND a.asset_type = 'reference' "
            "LEFT JOIN entity_episode_link l ON l.canon_id = c.id "
            "LEFT JOIN episode e ON e.id = l.episode_id "
            "WHERE c.project_id = :p "
            "GROUP BY c.id, c.short_id, c.name ORDER BY c.short_id"
        ), {"p": project_id}).fetchall()
    ]

    # ── ⑤ 같은 이름·같은 갈래인데 행이 둘 이상 (같은 것이 갈렸을 수 있다) ─
    split = [
        {"name": r[0], "entity_type": r[1], "short_ids": r[2]}
        for r in db.execute(sql_text(
            "SELECT name, entity_type, string_agg(short_id, ',' ORDER BY short_id) "
            "FROM entity_canon WHERE project_id = :p AND short_id IS NOT NULL "
            "GROUP BY name, entity_type HAVING count(*) > 1"
        ), {"p": project_id}).fetchall()
    ]

    return {
        "project_id": project_id, "project_name": name,
        "episodes": [{"id": e[0], "number": e[1], "title": e[2]}
                     for e in episodes],
        "name_drift": name_drift, "vanished": vanished, "orphans": orphans,
        "image_drift": image_drift, "same_name_split": split,
    }


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("project_id", nargs="?", help="없으면 여러 화 프로젝트 전부")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    from app.core.config import settings
    from app.core.database import SessionLocal

    db = SessionLocal()
    try:
        if args.project_id:
            rows = db.execute(sql_text(
                "SELECT id, name FROM project_registry WHERE id = :p"
            ), {"p": args.project_id}).fetchall()
        else:
            rows = db.execute(sql_text(
                "SELECT p.id, p.name FROM project_registry p "
                "JOIN episode e ON e.project_id = p.id "
                "GROUP BY p.id, p.name HAVING count(e.id) > 1 ORDER BY p.name"
            )).fetchall()
        reports = [audit_project(db, settings.projects_dir, r[0], r[1])
                   for r in rows]
    finally:
        db.close()

    if args.json:
        print(json.dumps(reports, ensure_ascii=False, indent=2))
        return 0

    if not reports:
        print("여러 화를 가진 프로젝트가 없습니다.")
        return 0
    for rep in reports:
        print(f"\n═══ {rep['project_name']}  ({rep['project_id']})")
        print(f"    에피소드 {len(rep['episodes'])}편: "
              + " · ".join(f"{e['number']}화 {e['title']}"
                           for e in rep["episodes"]))
        for key, label in (
            ("vanished", "체크포인트엔 있는데 DB 엔 없는 신원 (지워졌다)"),
            ("name_drift", "체크포인트 이름과 DB 이름이 다른 신원"),
            ("image_drift", "참조 이미지가 붙은 신원 — 그림 날짜와 링크된 화"),
            ("orphans", "어느 화에도 안 붙은 canon"),
            ("same_name_split", "같은 이름인데 행이 둘 이상"),
        ):
            items = rep[key]
            print(f"\n  ── {label}: {len(items)}건")
            for it in items[:20]:
                print("     " + json.dumps(it, ensure_ascii=False))
            if len(items) > 20:
                print(f"     … 그리고 {len(items) - 20}건 더")
    print("\n★이 도구는 **읽기만** 합니다. 고치려면 사람이 정해서 따로 해야 합니다.")
    return 0


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