"""EpisodeProjectionService — Episode status + T2I appearance count projection.

기존 `steps.py:_sync_checkpoints_to_db` line 1484-1495 + `_sync_t2i_appearance_counts` 702-773 이관.

problems.md #12 (2026-05-02): step_run 의 실제 완료 상태를 반영하지 않고 무조건
``ep.status='analyzed'`` 로 setting 하던 동작 제거. STEP_MANIFEST 의 active
analysis step 들이 모두 completed/not_applicable 일 때만 'analyzed' 로 advance.
``settings.episode_status_strict_projection=False`` 시 옛 동작 보존 (즉시 disable
경로).
"""
from __future__ import annotations

import json
import os
import re
from typing import Any, Dict, List, Tuple

from sqlalchemy import text as sql_text

from app.services.checkpoint_sync._base import BaseSyncService


# strict-projection 이 작성한 analysis_error 메시지 식별 prefix (review I1).
_STRICT_PROJECTION_PREFIX = "[strict-projection]"


_TRUTHY_VALUES = ("1", "true", "yes", "on")
_FALSY_VALUES = ("0", "false", "no", "off", "")


def _is_strict_projection_enabled() -> bool:
    """problems.md #12 toggle 평가.

    우선순위: ``EPISODE_STATUS_STRICT_PROJECTION`` ENV → ``settings.episode
    _status_strict_projection`` (Pydantic) → default True. ENV 직접 평가는
    운영자 instant-toggle 위해 보존.

    Review I2 — 인식 못 하는 ENV 값 (typo: ``"truee"``, ``"enabled"`` 등) 은
    silent False 가 아니라 Settings default 로 fallback. typo 가 default safety
    (strict=True) 를 우회하지 않도록.
    """
    import logging as _logging

    raw = os.environ.get("EPISODE_STATUS_STRICT_PROJECTION")
    if raw is not None:
        v = raw.strip().lower()
        if v in _TRUTHY_VALUES:
            return True
        if v in _FALSY_VALUES:
            return False
        _logging.getLogger(__name__).warning(
            "EPISODE_STATUS_STRICT_PROJECTION=%r unrecognized — falling back to "
            "Settings default. Use one of %s.",
            raw, _TRUTHY_VALUES + _FALSY_VALUES,
        )
    try:
        from app.core.config import settings
        return bool(getattr(settings, "episode_status_strict_projection", True))
    except Exception:
        return True


class EpisodeProjectionService(BaseSyncService):
    def sync_from_checkpoint(self) -> Dict[str, Any]:
        """T2I appearance count + Episode.status conditional advance.

        Claude Phase 2 M2: 다른 Service와 이름 통일 (project → sync_from_checkpoint).

        Behavior (problems.md #12):
          - strict=True (default): STEP_MANIFEST 의 active analysis step 의
            step_run.status 가 모두 completed/not_applicable 이면 status='analyzed'
            + analysis_error=None. 그 외에는 status 변경 안 함 + analysis_error 에
            incomplete step 목록 (sample 5개) 기록 + logger.warning emit.
          - strict=False: 옛 동작 — 무조건 status='analyzed'.

        Returns:
            {
              "appearance_updated": int,
              "analysis_complete": bool | None,   # strict=False 면 None
              "incomplete_steps": List[(sid, status)],  # 빈 list 면 모두 완료
            }
        """
        from app.models.project import Episode

        updated = self.sync_t2i_appearance_counts(commit=False)

        ep = self.db.query(Episode).filter(Episode.id == self.episode_id).first()
        result: Dict[str, Any] = {
            "appearance_updated": updated,
            "analysis_complete": None,
            "incomplete_steps": [],
        }
        if not ep:
            return result

        if not _is_strict_projection_enabled():
            # 옛 동작 보존 — 무조건 advance.
            ep.status = "analyzed"
            ep.updated_at = self.now
            return result

        completion = self._compute_analysis_completion()
        result["analysis_complete"] = completion["all_completed"]
        result["incomplete_steps"] = completion["incomplete_steps"]

        if completion["all_completed"]:
            ep.status = "analyzed"
            ep.analysis_error = None
        else:
            self._record_incomplete_analysis(ep, completion["incomplete_steps"])

        ep.updated_at = self.now
        return result

    def _compute_analysis_completion(self) -> Dict[str, Any]:
        """STEP_MANIFEST 의 active analysis step 들의 step_run.status 검사.

        Returns:
            {
              "all_completed": bool,
              "incomplete_steps": List[(step_id, status)],
              "active_count": int,  # 검사 대상 step 수
            }

        검사 대상 step 정의:
          - manifest.category == "analysis"
          - manifest.lifecycle in {"active"} (M4 명시 set)
          - applicability 정적 평가 결과 ``not_applicable`` 인 step 은 제외
            (Codex P1 fix): ``analysis_dispatch_service.select_steps_for_category``
            가 ``if_planning_doc`` 등 조건 false 시 dispatcher 단계에서 step 자체를
            skip → step_run row 생성 안 함. strict 검사가 'missing' 으로 분류
            하면 일반 프로젝트가 영원히 'analyzed' 도달 못 함.

        step_run row 자체가 없으면 'missing' 으로 분류 (incomplete 취급) — 분석이
        아예 시작 안 했거나, 누락된 step 으로 status advance 하지 않게.
        """
        from app.core.applicability import evaluate_step_applicability
        from app.core.step_manifest import STEP_MANIFEST

        _ACTIVE_LIFECYCLES = {"active"}

        # 1차 필터 — manifest schema (category + lifecycle).
        candidate_steps = [
            sid for sid, info in STEP_MANIFEST.items()
            if info.get("category") == "analysis"
            and info.get("lifecycle", "active") in _ACTIVE_LIFECYCLES
        ]

        # 2차 필터 — applicability 정적 평가 (Codex P1: not_applicable 제외).
        # dispatcher 가 step 자체를 skip 한 경우 step_run row 가 없으므로 strict
        # 가 'missing' 으로 잘못 분류하지 않도록 사전 제거.
        active_steps: List[str] = []
        for sid in candidate_steps:
            try:
                applicability = evaluate_step_applicability(
                    sid, self.project_id, self.episode_id,
                )
            except Exception as exc:
                self.logger.warning(
                    "evaluate_step_applicability(%s) raised %s — treating as applicable",
                    sid, exc,
                )
                applicability = "applicable"
            if applicability == "not_applicable":
                continue
            active_steps.append(sid)

        if not active_steps:
            return {"all_completed": True, "incomplete_steps": [], "active_count": 0}

        # PostgreSQL ANY() — execute query 한 번에 모든 step status 조회.
        rows = self.db.execute(
            sql_text(
                "SELECT step_id, status FROM step_run "
                "WHERE project_id = :pid AND episode_id = :eid "
                "AND step_id = ANY(:sids)"
            ),
            {"pid": self.project_id, "eid": self.episode_id, "sids": active_steps},
        ).fetchall()
        status_map = {r.step_id: r.status for r in rows}

        incomplete: List[Tuple[str, str]] = []
        for sid in active_steps:
            st = status_map.get(sid)
            if st in ("completed", "not_applicable"):
                continue
            incomplete.append((sid, st or "missing"))

        return {
            "all_completed": not incomplete,
            "incomplete_steps": incomplete,
            "active_count": len(active_steps),
        }

    def _record_incomplete_analysis(
        self, ep: Any, incomplete: List[Tuple[str, str]],
    ) -> None:
        """analysis_error 필드에 incomplete step 목록 기록 + logger.warning.

        Claude review I1 — 다른 caller (step_execution_service 의 partial-strict
        path 등) 가 이미 specific 에러 메시지를 set 한 경우 보존. strict-projection
        이 자기 메시지로 덮어쓰지 않도록 prefix tagging 으로 식별.
        """
        existing = getattr(ep, "analysis_error", None)
        if existing and not existing.startswith(_STRICT_PROJECTION_PREFIX):
            # 다른 caller 의 specific 에러 보존 (partial-strict, sync stale 등).
            self.logger.info(
                "Episode %s analysis_error already set by caller — strict projection preserves it",
                self.episode_id,
            )
            return

        sample = ", ".join(f"{sid}({st})" for sid, st in incomplete[:5])
        more = f" (+{len(incomplete) - 5} more)" if len(incomplete) > 5 else ""
        message = (
            f"{_STRICT_PROJECTION_PREFIX} Analysis incomplete: {len(incomplete)} "
            f"active step(s) not completed — {sample}{more}. Episode.status "
            "NOT advanced to 'analyzed' (problems.md #12 strict projection)."
        )
        ep.analysis_error = message[:2000]
        self.logger.warning(
            "Episode %s status NOT advanced — incomplete steps: %s",
            self.episode_id,
            [sid for sid, _ in incomplete],
        )

    def sync_t2i_appearance_counts(self, *, commit: bool = True) -> int:
        """T2I 프롬프트에서 short_id별 출현 횟수를 카운트하여 EntityEpisodeLink에 저장.

        카운트 단위: shot별 1회 (같은 shot의 variation N개에서 중복 → 1)
        소스: SceneStill.t2i_variations_json 우선, 없으면 t2i_prompt_cinematic fallback
        대상: selected / non-stale / still_index>=0 still 한정 (stale/비선택 still 제외)

        Returns: 업데이트된 link 개수.
        """
        from app.models.project import EntityEpisodeLink, EntityCanon, SceneStill

        stills = self.db.query(SceneStill).filter(
            SceneStill.project_id == self.project_id,
            SceneStill.episode_id == self.episode_id,
            SceneStill.is_selected == True,   # noqa: E712
            SceneStill.still_index >= 0,
            SceneStill.status != "stale",
        ).all()

        def _extract_sids(t2i_text: str, into: set):
            for m in re.finditer(r'(C\d{2,3})(O\d{2,3})', t2i_text):
                into.add(m.group(1))
                into.add(m.group(2))
            for m in re.finditer(r'(?<![CO\d])C\d{2,3}(?!O\d)', t2i_text):
                into.add(m.group(0))
            for m in re.finditer(r'(?<![A-Z])P\d{2,3}', t2i_text):
                into.add(m.group(0))
            for m in re.finditer(r'(?<![A-Z])L\d{2,3}', t2i_text):
                into.add(m.group(0))

        sid_counts: dict = {}
        for still in stills:
            shot_sids: set = set()
            try:
                variations = json.loads(still.t2i_variations_json or "[]")
            except (json.JSONDecodeError, TypeError):
                variations = []
            for var in variations:
                _extract_sids(var.get("t2i_prompt", ""), shot_sids)
            if not shot_sids:
                for field in (still.t2i_prompt_cinematic, still.t2i_prompt_closeup):
                    if field:
                        _extract_sids(field, shot_sids)
            for sid in shot_sids:
                sid_counts[sid] = sid_counts.get(sid, 0) + 1

        links = self.db.query(EntityEpisodeLink).filter(
            EntityEpisodeLink.project_id == self.project_id,
            EntityEpisodeLink.episode_id == self.episode_id,
        ).all()
        canon_ids = [lnk.canon_id for lnk in links]
        entities = self.db.query(EntityCanon).filter(
            EntityCanon.id.in_(canon_ids)
        ).all() if canon_ids else []

        updated = 0
        for lnk in links:
            canon = next((e for e in entities if e.id == lnk.canon_id), None)
            if not canon or not canon.short_id:
                continue
            count = sid_counts.get(canon.short_id, 0)
            if lnk.t2i_appearance_count != count:
                lnk.t2i_appearance_count = count
                updated += 1
        if commit:
            self.db.commit()
        self.logger.info(
            "T2I appearance counts: %d entities updated, %d total short_ids found",
            updated, len(sid_counts),
        )
        return updated
