"""ShelfSyncService — 저빈도로 **보류한** 요소를 명부에 남긴다.

## 왜 이 서비스가 따로 있나

`entity_filter` 가 걸러낸 요소는 `entity_detail` → `entity_t2i` 를 **안 탄다**
(그것이 필터의 목적이다 — 그 유료 호출을 아끼려고 거른다). 그런데
`EntitySyncService` 는 `entity_t2i` 체크포인트만 읽으므로, 보류된 요소는
DB 에 **아무 흔적도 안 남았다.**

    실측 da049582 — 1화에서 공구상자·렌치·지팡이·담요가 걸러졌고, 체크포인트
    `decisions` 에 이름과 사유 한 줄만 남았다. 5화에서 담요가 중요해져도
    1화의 그 담요와 이을 근거가 없다.

그래서 **행은 만들되 이미지·상세는 안 만든다.**

    canon    이름·설명·번호를 잡아 둔다 (다음 화 명부에 실린다)
    link     `presence_status = shelved` — 「**이 화에서** 보류」

★「이 화에서 저빈도」는 **화마다 다른 상태**지 프로젝트 전역 상태가 아니다.
 1화에서 저빈도였다고 5화에서도 저빈도가 아니다. 다음 화에 다시 나오면
 그 canon 에 `active` 링크가 붙어 되살아난다 — **같은 번호로**.

★유료 호출은 **한 건도 안 는다.** `t2i_prompt` 는 비워 두고, 하류 게이트는
 링크의 `presence_status` 로 이 행들을 뺀다.
"""
from __future__ import annotations

import json
import logging
import uuid
from typing import Any, Dict, List

from app.core.errors import AppError
from app.services.checkpoint_sync._base import BaseSyncService

logger = logging.getLogger(__name__)

#: 체크포인트에서 보류분이 앉는 칸 — `entity_filter` 산출과 **한 벌**이다.
REMOVED_KEY = "removed_entities"

#: 갈래 이름 ← 체크포인트 복수 키. `entity_filter` 가 세 갈래만 다룬다.
_OWNER_OF = {"characters": "character", "locations": "location",
             "props": "prop"}


class ShelfSyncService(BaseSyncService):
    """`entity_filter` 체크포인트 → 보류 canon + `shelved` 링크."""

    def sync_from_checkpoint(self) -> Dict[str, int]:
        """Returns: {"shelved": n, "revived": n, "skipped": n}"""
        from app.core.entity_identity import (PRESENCE_ACTIVE, PRESENCE_SHELVED)
        from app.models.project import EntityCanon, EntityEpisodeLink

        cp = self._load_cp("entity_filter")
        if not cp:
            return {"shelved": 0, "revived": 0, "skipped": 1}
        data = cp.get("data") or {}
        if REMOVED_KEY not in data:
            # ★옛 판의 체크포인트다. 「보류가 없었다」와 구별해서 넘어간다 —
            #  없는 칸을 빈 목록으로 읽으면 그 화의 보류가 통째로 사라진다.
            logger.info(
                "shelf sync: `%s` 칸이 없는 옛 entity_filter 산출 — 건너뛴다 "
                "(보류 0 이 아니라 **모른다**)", REMOVED_KEY)
            return {"shelved": 0, "revived": 0, "skipped": 1}

        removed: List[Dict[str, Any]] = list(data.get(REMOVED_KEY) or [])
        kept_sids = {
            str(e.get("short_id") or "")
            for key in _OWNER_OF
            for e in (data.get("filtered_entities") or {}).get(key, [])
            if e.get("short_id")
        }

        # 이 프로젝트가 아는 신원 — 보류분은 **이미 번호를 받은** 행이다.
        by_sid = {
            c.short_id: c for c in self.db.query(EntityCanon).filter(
                EntityCanon.project_id == self.project_id,
                EntityCanon.entity_type.in_(sorted(set(_OWNER_OF.values()))),
            ).all() if c.short_id
        }
        links = {
            lnk.canon_id: lnk for lnk in self.db.query(EntityEpisodeLink).filter(
                EntityEpisodeLink.project_id == self.project_id,
                EntityEpisodeLink.episode_id == self.episode_id,
            ).all()
        }

        # ★★★**전부 먼저 훑고**, 한 줄이라도 못 읽으면 쓰기 0 으로 선다
        #  (Codex BLOCK 2026-09-04). 종전에는 못 읽은 줄을 경고 후 버렸다 —
        #  「보존하려던 제거분」이 조용히 사라지는 것은 이 판이 고치려는
        #  결함 자체다.
        from app.core.entity_identity import parse_short_id

        bad: List[str] = []
        plan: List[tuple] = []
        for i, ent in enumerate(removed):
            sid = str(ent.get("short_id") or "").strip()
            # ★갈래는 `entity_filter` 가 행마다 찍어 준다 — 여기서 짐작하지 않는다.
            owner = str(ent.get("entity_type") or "").strip()
            if not sid or owner not in set(_OWNER_OF.values()):
                bad.append(f"#{i}: short_id={sid!r} entity_type={owner!r}")
                continue
            if parse_short_id(owner, sid) is None:
                bad.append(f"#{i}: `{sid}` 는 `{owner}` 의 신원이 아니다")
                continue
            canon = by_sid.get(sid)
            if canon is not None and canon.entity_type != owner:
                # ★같은 번호를 다른 갈래가 갖고 있다 — 접두 규칙이 깨진 것이니
                #  조용히 덮지 않는다.
                bad.append(
                    f"#{i}: `{sid}` 는 DB 에서 {canon.entity_type} 인데 "
                    f"체크포인트는 {owner} 라고 한다")
                continue
            plan.append((sid, owner, ent, canon))
        if bad:
            raise AppError(
                code="shelf_sync.bad_rows",
                message=("보류분 %d줄을 못 읽어 **한 줄도 쓰지 않는다** — "
                         "일부만 쓰면 나머지가 조용히 사라진다: %s"
                         % (len(bad), bad[:5])),
                status_code=422)

        shelved = 0
        for sid, owner, ent, canon in plan:
            if canon is None:
                canon = EntityCanon(
                    id=str(uuid.uuid4()), project_id=self.project_id,
                    short_id=sid, name=ent.get("name", ""),
                    entity_type=owner,
                    description=ent.get("description", ""),
                    # ★★T2I 프롬프트는 **비워 둔다.** 이 행은 그림을 만들지
                    #  않는다 — 채우면 하류가 만들 것으로 읽는다.
                    t2i_prompt="",
                    stable_traits=json.dumps(ent.get("visual_traits", []),
                                             ensure_ascii=False),
                    metadata_json="{}",
                    created_at=self.now, updated_at=self.now,
                )
                self.db.add(canon)
                self.db.flush()
                by_sid[sid] = canon

            notes = json.dumps({
                "name": ent.get("name", ""),
                "description": ent.get("description", ""),
                "shelved_reason": ent.get("shelved_reason", ""),
                "shot_count": ent.get("shot_count"),
            }, ensure_ascii=False)
            lnk = links.get(canon.id)
            if lnk is None:
                self.db.add(EntityEpisodeLink(
                    id=str(uuid.uuid4()), canon_id=canon.id,
                    project_id=self.project_id, episode_id=self.episode_id,
                    presence_status=PRESENCE_SHELVED,
                    episode_notes_json=notes,
                ))
            else:
                lnk.presence_status = PRESENCE_SHELVED
                lnk.episode_notes_json = notes
            shelved += 1

        # ★★되살리기 — 이번 화에서 **살아남은** 대상의 링크가 앞 판에서
        #  `shelved` 로 남아 있으면 `active` 로 돌린다. 안 돌리면 되살아난
        #  요소가 영영 보류로 읽혀 참조도 이미지도 안 만들어진다.
        revived = 0
        if kept_sids:
            for sid in kept_sids:
                canon = by_sid.get(sid)
                if canon is None:
                    continue
                lnk = links.get(canon.id)
                if lnk is not None and lnk.presence_status == PRESENCE_SHELVED:
                    lnk.presence_status = PRESENCE_ACTIVE
                    revived += 1

        self.db.flush()
        if shelved or revived:
            self.logger.info(
                "shelf sync: 이 화에서 보류 %d · 되살림 %d "
                "(canon 은 남고 유료 호출은 안 는다)", shelved, revived)
        return {"shelved": shelved, "revived": revived, "skipped": 0}
