"""ShotRefClassifyStep — s40/s41 레시피 샷 분류 (order 21.90).

선택(selected) 샷 전체를 한 번에 분류한다:
  data.shots[tag]  = {person_visible, bgonly_reason_ko, prev, prev_reason_ko,
                      usage_en, prev_violation,
                      place_en, environment, place_basis_ko (v3)}
  data.scenes[si]  = {time_of_day_en, place_en, basis_ko}

still_recipe_mode="v1" 일 때만 applicable(if_still_recipe) — OFF 면 run-all
정적 필터에서 제외되고 체크포인트도 만들지 않는다(기존 파이프 byte-identical).
분류 계약 정본: backend/app/modules/pipeline/shot_ref_classify.py docstring.
"""

import json
import logging
from pathlib import Path
from typing import Any, Dict, Optional

from app.core.step_runner import StepRunner

logger = logging.getLogger(__name__)

# v4 (2026-08-07 사용자 지적 — 손에 든 물건은 손까지 그려져야 한다):
# bgonly 번들에 `handled_by` 신설. 출력 shape 가 바뀌므로 schema 도 승격 —
# ★`step_manifest.py` 의 "shot_ref_classify".schema_version 과 **동기
# 의무**다(둘이 어긋나 completed CP resume 이 contract drift 로 BLOCK 되던
# 패턴이 세 번 재발했다).
# v3 (2026-07-19 fix2): time_of_day 장소 중립 + 샷별 place_en(서브공간
# 특정, environment 판정). v2: scenes.place_en + world_anchor_en.
SCHEMA_VERSION = 4
PROMPT_VERSION = "5"


class ShotRefClassifyStep(StepRunner):
    """샷 참조 분류 — bgonly·prev v3·time_of_day (레시피 이식)."""

    def _config_hash(self) -> str:
        import hashlib
        import json as _json

        from app.core.config import settings
        from app.modules.pipeline.shot_ref_classify import (
            resolve_prompt_version,
        )

        from app.core.step_runner import compute_config_hash

        payload = {
            "still_recipe_mode": settings.still_recipe_mode,
            "schema_version": SCHEMA_VERSION,
            "prompt_version": resolve_prompt_version(PROMPT_VERSION),
            "model": settings.openai_model,
            # HIGH-5: project_config step override(모델 라우팅) 변경 감지
            "project_config_hash": compute_config_hash(self.project_config),
        }
        return hashlib.sha256(
            _json.dumps(payload, sort_keys=True).encode("utf-8")
        ).hexdigest()[:16]

    def _load_prev_checkpoint(self, step_id: str) -> Optional[Dict[str, Any]]:
        from app.core.config import settings

        cp = (
            Path(settings.projects_dir)
            / self.project_id / "checkpoints" / "episodes"
            / self.episode_id / step_id / "manifest.json"
        )
        if cp.exists():
            try:
                return json.loads(cp.read_text(encoding="utf-8"))
            except Exception as exc:  # noqa: BLE001
                logger.warning(
                    "shot_ref_classify: %s 로드 실패: %s", step_id, exc
                )
        return None

    def _execute(self, mode: str = "resume") -> Dict[str, Any]:
        from app.modules.pipeline.outdoor_direct_common import (
            build_selected_keys,
            is_selected,
        )
        from app.modules.pipeline.shot_ref_classify import (
            derive_location_by_scene,
            run_shot_ref_classify,
        )

        # ── 입력 로드 (전부 상류 체크포인트) ─────────────────────────
        validator_cp = self._load_prev_checkpoint("shot_validator")
        scenes = (validator_cp or {}).get("data", {}).get("scenes", []) or []
        selected_keys = build_selected_keys(
            self._load_prev_checkpoint("shot_selection")
        )

        shots: list = []
        scene_headings: Dict[int, str] = {}
        for sc in scenes:
            si = sc.get("scene_index")
            if si is None:
                continue
            si = int(si)
            if sc.get("scene_heading"):
                scene_headings[si] = sc["scene_heading"]
            for sh in sc.get("shots", []) or []:
                shi = sh.get("shot_index")
                if shi is None:
                    continue
                if not is_selected((si, int(shi)), selected_keys):
                    continue
                shots.append(
                    {
                        "scene_index": si,
                        "shot_index": int(shi),
                        "description": sh.get("description") or "",
                    }
                )

        scene_save_cp = self._load_prev_checkpoint("scene_save")
        scene_texts: Dict[int, str] = {}
        for seg in (scene_save_cp or {}).get("data", {}).get("segments", []) or []:
            si = seg.get("scene_index")
            if isinstance(si, int):
                # 씬 원문 전문 — 절대 자르지 않는다
                scene_texts[si] = seg.get("text") or ""
                if seg.get("heading") and si not in scene_headings:
                    scene_headings[si] = seg["heading"]

        director_cp = self._load_prev_checkpoint("scene_director")
        scene_primary: Dict[int, str] = {}
        for sc in (director_cp or {}).get("data", {}).get("scenes", []) or []:
            si = sc.get("scene_index")
            primary = sc.get("primary_location", "") or ""
            if si is not None and primary:
                scene_primary[int(si)] = primary

        if not shots:
            logger.info("shot_ref_classify: 선택 샷 없음 — no-op")
            return {
                "applicable_count": 0,
                "completed_count": 0,
                "failed_count": 0,
                "schema_version": SCHEMA_VERSION,
                "config_hash": self._config_hash(),
                "data": {"shots": {}, "scenes": {}},
            }

        location_by_scene = derive_location_by_scene(
            self.db, self.project_id, scene_primary, scene_headings
        )

        data = run_shot_ref_classify(
            shots=shots,
            scene_texts=scene_texts,
            scene_headings=scene_headings,
            location_by_scene=location_by_scene,
            prompt_version=PROMPT_VERSION,
            project_config=self.project_config,
            opik_metadata=self.build_opik_metadata(),
        )
        return {
            "applicable_count": len(shots),
            "completed_count": len(data["shots"]),
            "failed_count": 0,
            "schema_version": SCHEMA_VERSION,
            "config_hash": self._config_hash(),
            "data": data,
        }
