"""i2i 시네마틱 변환 스테이지 (2026-08-13 #108) — sel 확정 후 grok 2.0 변환.

사용자 확정 구조(하이브리드): 최종 스틸 = nb2 생성·판정·수정(sel 확정)
→ grok 2.0 i2i "시네마틱 재구성" → 변환본이 scene ImageAsset primary 로
영속. 원본 `_sel.png` 은 그대로 보존되어 prev 체인 앵커(기하 SOT)로
남는다 — 변환은 최종 룩 패스일 뿐 체인 참조 재료가 아니다.

계약:
  · 문안 = still_recipe 팩 단일 스템 `cine_transform`
    (CINE_TRANSFORM_PROMPT_VERSION) — 짧은 범용 지시 하나, 원본 조립
    프롬프트·시나리오 고유명사 불사용. 화풍 교체 = 팩 버전 교체.
  · 지문 = 계약 버전 + 문안 스템 내용 해시 + 모델 + 원본 sel bytes —
    sel 재생성·문안 개정·모델 교체 어느 것이든 변환을 다시 이끈다.
  · 재사용 = 지문 일치 + applied + 산출 파일 실재 → 유료 호출 0,
    records 무변경 (#77-B `_jit_tag_snapshot` 지출 탐지가 거짓 지출을
    읽지 않는 전제 — 저장 record 를 그대로 돌려준다).
  · 실패 = 원본 fallback + 실패 기록(records) — 샷은 원본으로 영속돼
    체인(prev)을 막지 않는다. ★재시도는 JIT 가 아니라 **스텝 미봉인**이
    이끈다(Codex R1 BLOCK-1): 완료+config 일치 스텝은 whole-step SKIP
    이라 "다음 방문"이 오지 않는다 — 걷기 끝에 서비스가
    StillCineTransformIncomplete 를 raise 해 스텝을 완료로 닫지 않고,
    resume 재진입(전 샷 재사용 지출 0)이 실패 변환만 다시 산다.
    예외: ImageCallBudgetExceeded 는 삼키지 않는다 — 예산 브레이크를
    fallback 으로 눌러 버리면 "변환된 최종본" 오독의 조용한 미변환
    완주가 된다(전파 → 샷 실패 격리 → 스텝 실패 → 운영자 확인).
  · **검열 포기**(2026-08-19 사용자 결정) — 위 되풀이가 끝나지 않는
    경우가 실측으로 나왔다. 같은 원본이 xAI 검열에 나흘간 일곱 번 전부
    막혔고(S49sh10·S73sh6), 막는 것이 지시문이 아니라 **그림 내용**이라
    다시 보낼수록 같은 자리에서 막힌다. 게다가 **거부당해도 요금이
    나간다**(거부 응답에 `cost_in_usd_ticks` 가 붙는다). 그래서 같은
    지문에서 검열 거부가 `still_cine_moderation_give_up_after` 회 쌓이면
    그 변환을 포기로 기록하고(`declined`) 원본을 최종본으로 확정한다 —
    다음 방문은 유료 호출 없이 그 기록을 돌려주고, 서비스는 그 샷을
    미완으로 세지 않아 스텝이 닫힌다. 원본 sel 이 다시 만들어지면 지문이
    달라져 저절로 한 번 더 시도한다(포기는 이 입력에 대한 것이지 이
    샷에 대한 영구 선고가 아니다).
  · 쓰기 순서 = 산출 파일 durable(원자 쓰기) 후 records 기록 — 크래시
    창에서는 기록 없는 파일이 남고, 다음 방문이 재변환한다(재지출
    ~$0.03 1회 허용이 기록 없는 자산 신뢰보다 싸다).

경계(알려진 것):
  · prev 앵커 fallback(still_recipe_service — recipe `_sel.png` 부재 시
    DB primary 사용)은 변환 ON 완주본에서 변환본을 앵커로 집는다.
    변환은 장소·인물·순간 보존 계약이라 기하 권위는 유지되지만 룩이
    섞인다 — 정상 걷기(스토리 순서, sel 실재)에서는 닿지 않는 경로.
"""
from __future__ import annotations

import hashlib
import logging
from pathlib import Path
from typing import Any, Dict

from app.core.image_call_budget import ImageCallBudgetExceeded
from app.modules.llm.image_moderation import is_moderation_error

logger = logging.getLogger(__name__)

# 포기 기준 기본값 — 설정이 없거나 못 읽으면 이 값. 0 이하면 포기하지
# 않는다(종전 동작).
CINE_MODERATION_GIVE_UP_AFTER_DEFAULT = 2


def _give_up_after() -> int:
    from app.core.config import settings

    raw = getattr(
        settings, "still_cine_moderation_give_up_after",
        CINE_MODERATION_GIVE_UP_AFTER_DEFAULT)
    try:
        return int(raw)
    except (TypeError, ValueError):
        return CINE_MODERATION_GIVE_UP_AFTER_DEFAULT

# 문안 스템 이름 — still_recipe 팩 안 단일 스템 (팩 v19 도입).
CINE_TRANSFORM_STEM = "cine_transform"
# 참조 라벨 — 파일럿(grok_cine_batch) 실측 계약 그대로.
CINE_SOURCE_LABEL = "SOURCE STILL"
# 변환 계약 버전 — 참조 구성·산출 규약(1장 변환·_cine 파일)이 바뀌면 bump.
CINE_CONTRACT_VERSION = "cine_v1"


def cine_output_path(recipe_dir: Path, tag: str) -> Path:
    """변환본 관례 경로 — `_sel.png` 과 나란한 `{tag}_cine.png`."""
    return recipe_dir / f"{tag}_cine.png"


def cine_fingerprint(
    *, stem_content_hash: str, model: str, sel_bytes: bytes,
) -> str:
    """변환 1건의 재사용 지문 — 문안(exact-stem)·모델·원본 bytes."""
    sel_sha = hashlib.sha256(sel_bytes).hexdigest()
    return hashlib.sha256(
        f"{CINE_CONTRACT_VERSION}|{stem_content_hash}|{model}|{sel_sha}"
        .encode("utf-8")
    ).hexdigest()


def resolve_or_run_cine_transform(
    *,
    tag: str,
    sel_path: Path,
    recipe_dir: Path,
    records: Any,
    client: Any,
    prompt: str,
    model: str,
    stem_content_hash: str,
    pack: str,
    context: Dict[str, Any],
) -> Dict[str, Any]:
    """재사용 우선 변환 — 반환 record 로 최종 영속 원본이 갈린다.

    반환: records 저장본과 동일 내용 + transient `reused`(스냅샷 제외
    키 — _jit_tag_snapshot 이 걸러낸다). applied=True 면 호출자가
    `file` 을 최종 영속 원본으로 쓰고, False 면 원본 sel 로 fallback.
    """
    sel_bytes = sel_path.read_bytes()
    fp = cine_fingerprint(
        stem_content_hash=stem_content_hash, model=model,
        sel_bytes=sel_bytes)
    source_sha = hashlib.sha256(sel_bytes).hexdigest()
    rec_key = f"{tag}::cine"
    out_path = cine_output_path(recipe_dir, tag)

    prior = records.data.get(rec_key)
    if (
        isinstance(prior, dict)
        and prior.get("fingerprint") == fp
        and prior.get("applied") is True
        and out_path.exists()
    ):
        # 재사용 — 저장 record 그대로(값 재계산·시간 갱신 금지: 재사용
        # 바퀴에 record 가 1비트라도 움직이면 #77-B 가 지출로 센다).
        return {**prior, "reused": True}
    if (
        isinstance(prior, dict)
        and prior.get("fingerprint") == fp
        and prior.get("declined") is True
    ):
        # 포기한 변환 — 같은 입력에 다시 돈을 쓰지 않는다. 재사용과 같은
        # 규칙으로 저장 record 를 그대로 돌려준다(거짓 지출 방지).
        logger.info(
            "cine_transform %s: 검열로 포기한 변환 — 원본 유지, 호출 없음",
            tag)
        return {**prior, "reused": True}

    # 같은 지문에서 검열 거부가 몇 번 쌓였는가 — 지문이 다르면 새 입력
    # 이므로 0 부터다.
    prior_refusals = 0
    if isinstance(prior, dict) and prior.get("fingerprint") == fp:
        _pr = prior.get("moderation_refusals")
        if isinstance(_pr, int) and not isinstance(_pr, bool) and _pr > 0:
            prior_refusals = _pr

    rec: Dict[str, Any] = {
        "applied": False,
        "fingerprint": fp,
        "model": model,
        "pack": pack,
        # 최종 자산 lineage 용 — grok 이 실제로 본 직접 입력(원본 sel)의
        # 신원. 자산 기록이 nb2 롤 참조를 "직접 첨부"로 오기하지 않는
        # 전제 재료(Codex R1 BLOCK-3).
        "source_file": sel_path.name,
        "source_sha256": source_sha,
    }
    try:
        client.set_context(**context)
        png, elapsed_ms = client.generate_image(
            prompt,
            labeled_references=[(CINE_SOURCE_LABEL, sel_bytes)],
        )
        from app.modules.pipeline.multiroll_gemini import atomic_write_bytes

        atomic_write_bytes(out_path, png)  # durable 먼저, 기록은 그 뒤
        rec["applied"] = True
        rec["file"] = out_path.name
        rec["latency_ms"] = int(elapsed_ms)
    except ImageCallBudgetExceeded:
        raise
    except Exception as exc:  # noqa: BLE001 — 원본 fallback + 기록
        rec["error"] = f"{type(exc).__name__}: {exc}"[:500]
        # ★검열 거부만 센다 — 서버가 그날만 까다로웠던 것(5xx·타임아웃)은
        #  다시 보내면 되는 일이라 포기 근거가 아니다.
        if is_moderation_error(exc):
            refusals = prior_refusals + 1
            rec["moderation_refusals"] = refusals
            limit = _give_up_after()
            if limit > 0 and refusals >= limit:
                rec["declined"] = True
                rec["declined_reason"] = "moderation"
                logger.warning(
                    "cine_transform %s: 검열 거부 %d회 — 이 원본의 변환을 "
                    "포기하고 원본을 최종본으로 확정한다 (거부에도 요금이 "
                    "나가므로 다시 보내지 않는다)",
                    tag, refusals,
                )
            else:
                logger.warning(
                    "cine_transform %s: 검열 거부 %d회 (포기 기준 %d) — "
                    "원본 sel fallback",
                    tag, refusals, limit,
                )
        else:
            if prior_refusals:
                # 검열 이력은 남긴다 — 셈은 검열 거부에만 늘어난다.
                rec["moderation_refusals"] = prior_refusals
            logger.warning(
                "cine_transform %s: 변환 실패 — 원본 sel fallback (%s)",
                tag, rec["error"],
            )
    records.data[rec_key] = {k: v for k, v in rec.items()}
    records.save()
    return {**rec, "reused": False}
