"""Generic raw-failure fixture capture (spec phase fixture extractor).

config JSON 으로 fixture 명세를 받고 그에 따라 PID/EID 의 step manifest +
DB row 를 immutable JSON 으로 추출. 스크립트 본문에는 시나리오 의존
(scene/shot index, short_id 등) 일체 없음.

사용 (G4.6 예):

    backend/.venv/bin/python scripts/g4_6_capture_fixtures.py \
      --pid 298d86d9-615b-4b87-8040-21144c0731c1 \
      --eid b6544514-babb-4781-95a2-6a0ff1da342f \
      --config scripts/configs/g4_6_fixtures.json \
      --out backend/tests/fixtures/g4_6/

config 형식 (JSON):

    {
      "fixtures": [
        {"name": "<file>.json", "type": "dependency_single", "scene": <int>, "shot": <int>},
        {"name": "<file>.json", "type": "dependency_pair", "scene": <int>, "shots": [<int>, <int>]},
        {"name": "<file>.json", "type": "image_asset", "scene": <int>, "shot": <int>},
        {"name": "<file>.json", "type": "scene_detail_shot", "scene": <int>, "shot": <int>},
        {"name": "<file>.json", "type": "shot_validator_shot", "scene": <int>, "shot": <int>},
        {"name": "<file>.json", "type": "entity_canon", "short_id": "<id>"}
      ],
      "low_freq_skip": {
        "pre_filename": "<file>.json",
        "post_filename": "<file>.json",
        "protected_short_ids": ["<id>", ...],
        "post_note": "<설명>"
      }
    }
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

REPO_ROOT = Path(__file__).resolve().parents[1]
BACKEND = REPO_ROOT / "backend"
sys.path.insert(0, str(BACKEND))

from app.core.database import SessionLocal  # noqa: E402
from app.models.project import EntityCanon, ImageAsset, SceneStill  # noqa: E402


def manifest_path(pid: str, eid: str, step: str) -> Path:
    return REPO_ROOT / "projects" / pid / "checkpoints" / "episodes" / eid / step / "manifest.json"


def load_step_manifest(pid: str, eid: str, step: str) -> dict:
    path = manifest_path(pid, eid, step)
    if not path.exists():
        raise FileNotFoundError(f"manifest missing: {path}")
    return json.loads(path.read_text(encoding="utf-8"))


def write_json(out_dir: Path, name: str, payload: object) -> None:
    target = out_dir / name
    target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"  wrote {target.relative_to(REPO_ROOT)} ({target.stat().st_size} bytes)")


def find_dependency(deps: list, scene: int, shot: int) -> dict | None:
    for d in deps:
        if d.get("scene_index") == scene and d.get("shot_index") == shot:
            return d
    return None


def find_scene_detail_shot(scenes: list, scene: int, shot: int) -> dict | None:
    for entry in scenes:
        if entry.get("scene_index") == scene and entry.get("_shot_index") == shot:
            return entry
    return None


def find_validator_shot(scenes: list, scene: int, shot: int) -> dict | None:
    for sc in scenes:
        if sc.get("scene_index") != scene:
            continue
        for sh in sc.get("shots", []):
            if sh.get("shot_index") == shot:
                return {"scene_index": scene, "scene_heading": sc.get("scene_heading"), "shot": sh}
    return None


def entity_canon_to_dict(row: EntityCanon) -> dict:
    return {
        "id": row.id,
        "short_id": row.short_id,
        "entity_type": row.entity_type,
        "name": row.name,
        "description": row.description,
        "stable_traits": row.stable_traits,
        "t2i_prompt": row.t2i_prompt,
        "status": row.status,
    }


def image_asset_to_dict(row: ImageAsset) -> dict:
    return {
        "id": row.id,
        "asset_type": row.asset_type,
        "still_id": row.still_id,
        "entity_id": row.entity_id,
        "variant_index": row.variant_index,
        "variant_label": row.variant_label,
        "shot_index": row.shot_index,
        "prompt_used": row.prompt_used,
        "reference_image_ids": row.reference_image_ids,
        "generation_model": row.generation_model,
        "code_version": row.code_version,
        "prompt_file_version": row.prompt_file_version,
        "status": row.status,
    }


def fetch_image_asset_for_shot(db, pid: str, eid: str, scene: int, shot: int) -> dict:
    still = (
        db.query(SceneStill)
        .filter(
            SceneStill.project_id == pid,
            SceneStill.episode_id == eid,
            SceneStill.scene_index == scene,
            SceneStill.shot_index == shot,
        )
        .first()
    )
    if still is None:
        raise RuntimeError(f"SceneStill missing for scene={scene} shot={shot} pid={pid} eid={eid}")
    assets = (
        db.query(ImageAsset)
        .filter(
            ImageAsset.project_id == pid,
            ImageAsset.episode_id == eid,
            ImageAsset.still_id == still.id,
        )
        .order_by(ImageAsset.variant_index)
        .all()
    )
    if not assets:
        raise RuntimeError(
            f"ImageAsset missing for scene={scene} shot={shot} pid={pid} eid={eid} "
            f"(still_id={still.id}) — fail-fast (silent fixture 차단)."
        )
    return {
        "scene_index": scene,
        "shot_index": shot,
        "still_id": still.id,
        "still_frame_prompt": still.still_frame_prompt,
        "image_assets": [image_asset_to_dict(a) for a in assets],
    }


def fetch_entity_canon(db, pid: str, short_id: str) -> dict:
    row = (
        db.query(EntityCanon)
        .filter(EntityCanon.project_id == pid, EntityCanon.short_id == short_id)
        .first()
    )
    if row is None:
        raise RuntimeError(f"EntityCanon short_id={short_id!r} missing for pid={pid}")
    return entity_canon_to_dict(row)


CAPTURE_HANDLERS: dict[str, str] = {
    "dependency_single": "shot_dependency_t2i",
    "dependency_pair": "shot_dependency_t2i",
    "scene_detail_shot": "scene_detail",
    "shot_validator_shot": "shot_validator",
}


def dispatch_fixture(
    spec: dict[str, Any],
    *,
    pid: str,
    eid: str,
    db,
    out_dir: Path,
    manifest_cache: dict[str, dict],
) -> None:
    name = spec["name"]
    ftype = spec["type"]

    if ftype in CAPTURE_HANDLERS:
        step = CAPTURE_HANDLERS[ftype]
        if step not in manifest_cache:
            manifest_cache[step] = load_step_manifest(pid, eid, step)
        manifest = manifest_cache[step]

    if ftype == "dependency_single":
        deps = manifest.get("data", {}).get("dependencies", [])
        entry = find_dependency(deps, spec["scene"], spec["shot"])
        if entry is None:
            raise RuntimeError(f"{name}: dependency missing scene={spec['scene']} shot={spec['shot']}")
        write_json(out_dir, name, {"source": "shot_dependency_t2i.manifest.json", "dependency": entry})

    elif ftype == "dependency_pair":
        deps = manifest.get("data", {}).get("dependencies", [])
        scene = spec["scene"]
        shots = spec["shots"]
        if len(shots) != 2:
            raise ValueError(f"{name}: dependency_pair requires exactly 2 shots, got {shots}")
        entries = {f"shot{s}": find_dependency(deps, scene, s) for s in shots}
        missing = [k for k, v in entries.items() if v is None]
        if missing:
            raise RuntimeError(f"{name}: missing entries {missing}")
        write_json(out_dir, name, {"source": "shot_dependency_t2i.manifest.json", **entries})

    elif ftype == "scene_detail_shot":
        scenes = manifest.get("data", {}).get("scenes", [])
        entry = find_scene_detail_shot(scenes, spec["scene"], spec["shot"])
        if entry is None:
            raise RuntimeError(f"{name}: scene_detail entry missing scene={spec['scene']} shot={spec['shot']}")
        write_json(out_dir, name, {"source": "scene_detail.manifest.json", "shot": entry})

    elif ftype == "shot_validator_shot":
        scenes = manifest.get("data", {}).get("scenes", [])
        entry = find_validator_shot(scenes, spec["scene"], spec["shot"])
        if entry is None:
            raise RuntimeError(f"{name}: shot_validator entry missing scene={spec['scene']} shot={spec['shot']}")
        write_json(out_dir, name, {"source": "shot_validator.manifest.json", **entry})

    elif ftype == "image_asset":
        write_json(out_dir, name, fetch_image_asset_for_shot(db, pid, eid, spec["scene"], spec["shot"]))

    elif ftype == "entity_canon":
        write_json(out_dir, name, fetch_entity_canon(db, pid, spec["short_id"]))

    else:
        raise ValueError(f"{name}: unknown fixture type {ftype!r}")


def write_low_freq_skip_pair(
    cfg: dict[str, Any],
    *,
    pid: str,
    eid: str,
    db,
    out_dir: Path,
) -> None:
    """ref_low_freq_skip pre + post 쌍 생성.

    pre: 현재 ref_low_freq_skip.json (UUID list) + short_id resolved.
    post: protected_short_ids 가 제거된 expected 결과.
    """
    skip_path = (
        REPO_ROOT / "projects" / pid / "checkpoints" / "images" / eid / "ref_low_freq_skip.json"
    )
    if not skip_path.exists():
        raise RuntimeError(f"ref_low_freq_skip.json missing at {skip_path}")
    skip_uuids = json.loads(skip_path.read_text(encoding="utf-8"))
    if not isinstance(skip_uuids, list):
        raise RuntimeError("ref_low_freq_skip.json is not a list")
    canon_rows = (
        db.query(EntityCanon.id, EntityCanon.short_id, EntityCanon.entity_type, EntityCanon.name)
        .filter(EntityCanon.project_id == pid, EntityCanon.id.in_(skip_uuids))
        .all()
    )
    resolved_index = {
        r.id: {"id": r.id, "short_id": r.short_id, "entity_type": r.entity_type, "name": r.name}
        for r in canon_rows
    }
    unresolved = [u for u in skip_uuids if u not in resolved_index]
    if unresolved:
        raise RuntimeError(
            f"ref_low_freq_skip 의 {len(unresolved)} UUID 가 EntityCanon 에 없음 "
            f"(pid={pid}): {unresolved!r} — fail-fast (silent fixture 차단)."
        )
    ordered_resolved = [resolved_index[u] for u in skip_uuids]

    write_json(out_dir, cfg["pre_filename"], {
        "source": str(skip_path.relative_to(REPO_ROOT)),
        "uuids": skip_uuids,
        "resolved": ordered_resolved,
    })

    protected = set(cfg.get("protected_short_ids", []))
    post_resolved = [r for r in ordered_resolved if r["short_id"] not in protected]
    post_uuids = [r["id"] for r in post_resolved]
    write_json(out_dir, cfg["post_filename"], {
        "note": cfg.get("post_note", ""),
        "protected_short_ids": sorted(protected),
        "uuids": post_uuids,
        "resolved": post_resolved,
    })


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--pid", required=True, help="project_id")
    parser.add_argument("--eid", required=True, help="episode_id")
    parser.add_argument("--config", required=True, help="fixture config JSON path")
    parser.add_argument("--out", required=True, help="output dir (relative to repo root)")
    args = parser.parse_args()

    config_path = (REPO_ROOT / args.config) if not Path(args.config).is_absolute() else Path(args.config)
    cfg = json.loads(config_path.read_text(encoding="utf-8"))

    fixtures = cfg.get("fixtures")
    if not isinstance(fixtures, list) or not fixtures:
        raise ValueError(f"config {config_path}: 'fixtures' must be a non-empty list")
    lfs = cfg.get("low_freq_skip")
    if not isinstance(lfs, dict):
        raise ValueError(f"config {config_path}: 'low_freq_skip' must be a dict")
    for required in ("pre_filename", "post_filename", "protected_short_ids"):
        if required not in lfs:
            raise ValueError(f"config {config_path}: low_freq_skip.{required} missing")

    out_dir = (REPO_ROOT / args.out) if not Path(args.out).is_absolute() else Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)
    print(f"capture: pid={args.pid}, eid={args.eid}, config={config_path.relative_to(REPO_ROOT)}, out={out_dir.relative_to(REPO_ROOT)}")

    manifest_cache: dict[str, dict] = {}
    db = SessionLocal()
    try:
        for spec in fixtures:
            dispatch_fixture(spec, pid=args.pid, eid=args.eid, db=db, out_dir=out_dir, manifest_cache=manifest_cache)
        write_low_freq_skip_pair(lfs, pid=args.pid, eid=args.eid, db=db, out_dir=out_dir)
    finally:
        db.close()

    expected = len(fixtures) + 2
    print(f"\nDONE — {expected} fixtures written to {out_dir.relative_to(REPO_ROOT)}")


if __name__ == "__main__":
    main()
