"""W21B-W7 W-C2 (2026-06-12) — zoom continuity crop/i2i-fill 렌더 경로.

zoom_continuity_anchor(21.73) manifest 의 zoom 멤버 still 을 독립 T2I 대신
**source_wide still 의 실제 생성 이미지에서 crop + i2i fill** 로 만든다
(스파이크1 ③ 포팅, Codex 판정 ②: scene_image 정식 generation path 안의
분기 — post-pass 금지, 단건/배치 공용 helper).

절차 (스파이크 검증 그대로 — gpt-5.5 bbox + gemini i2i fill 유지, Codex ③):
  1. source_wide still 의 primary scene 이미지 로드 (부재 → fallback)
  2. VLM bbox (litellm vision, normalized 좌표) — focal 묘사는 anchor 의
     locked_elements 에서 조립 (generic — 시나리오 토큰 0)
  3. 16:9 확장 crop (중심 유지 + 클램프 + ×1.15 margin) → LANCZOS upscale
  4. gemini i2i fill — "모든 요소 위치 그대로, 선명도/디테일만 보강"
     (+ caller 가 전달한 prop refs 동반, moderation 시 PromptSanitizer 재시도)

Codex W_C1_REVIEW WC2 guards:
  - **artifacts 의무**: bbox JSON / crop_box(전·후) / crop_upscaled.png /
    viability.json / fallback reason — zoom_continuity_anchor checkpoint dir
    하위 ``wc2_artifacts/S{si}sh{shi}/`` 에 저장.
  - **source_wide_crop_viability 진단**: bbox found + 면적 비율(과대/과소) +
    zoom target 실재 — fail-fast gate 아님, canary acceptance 기준 (기록만).
  - fallback 은 비차단 (caller 가 기존 T2I 경로로 진행) 그러나 canary
    acceptance 에서는 실패로 간주 — "fallback green ≠ W-C 성공".

flag OFF / anchor 부재 시 이 모듈은 호출되지 않는다 (caller gate).
"""
from __future__ import annotations

import base64
import json
import logging
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.services.image_capture.context import generation_context
from app.services.image_capture.sink import capture_artifact

logger = logging.getLogger(__name__)

WC2_ARTIFACTS_DIRNAME = "wc2_artifacts"

# ★#92 (2026-08-27): Sol → gemini 3.1 pro. 사용자 지시
#  「gpt sol vlm 은 성능이 안좋아」. Opik 실측에서 이 자리
#  (`zoom_continuity_anchor`)가 **아직 Sol 로 도는 두 곳 중 하나**
#  였다(30일 5건).
#
# ★**물리 모델 이름을 직접 쓴다** — 이 자리는 Router 를 안 거치고
#  litellm 을 직접 친다(`vlm_locate_bbox`). 별칭(`gemini-pro`)을 넣으면
#  모델을 못 찾는다. 옛 값이 `openai/gpt-5.6-sol` 이었던 것도 같은
#  이유다. 접두 `gemini/` 는 Router 등록부가 쓰는 것과 같다
#  (`llm_client.py:538`) — 여기서 지어낸 것이 아니다.
#
# ★그리고 **모델만 바꾸면 안 된다** — `llm_completion` 은 OpenAI 가
#  아닌 모델에 키를 안 끼운다. `vlm_locate_bbox` 가 키를 함께 넘긴다.
#
# ★**이중화(두 모델)는 여기서 안 한다.** bbox 는 좌표 하나를 내는데
#  두 값이 어긋났을 때 무엇을 쓸지가 정해져 있지 않다. 도면 읽기는
#  실측 8장으로 계약을 정한 뒤에 넣었다 — 여기도 그 순서를 지킨다.
VISION_MODEL_DEFAULT = "gemini/gemini-3.1-pro-preview"
VISION_TIMEOUT_SECONDS = 240
BBOX_MAX_TOKENS = 600

# viability 경계 (acceptance 기준 — 차단 아님): bbox 가 source 의 대부분을
# 차지하면 zoom 의미가 약하고(이미 close 한 source), 너무 작으면 신뢰 낮음.
VIABILITY_MAX_AREA_RATIO = 0.65
VIABILITY_MIN_AREA_RATIO = 0.003

UPSCALE_SIZE = (1536, 864)  # 16:9
CROP_MARGIN = 1.15

_BBOX_SYSTEM = (
    "You locate a region in an image. Return JSON "
    '{"bbox": [x0, y0, x1, y1], "found": true|false, "note": "..."} '
    "with normalized coordinates (0.0-1.0, x0<x1, y0<y1) of the TIGHT region "
    "containing: %s. Include a small margin so the whole subject is inside. "
    "Return ONLY the JSON object."
)

_FILL_PROMPT = (
    "Photorealistic cinematic close-up. Refine the FIRST reference image into a sharp, "
    "detailed close-up. It is a zoomed-in crop of a film frame: keep EVERY element exactly "
    "where it is — same poses, same object positions and angles, same surfaces, same "
    "lighting. Do NOT move, add, remove or restage anything. Only enhance sharpness, "
    "texture and detail lost in the enlargement.\n"
    "Locked visual facts (must remain exactly as in the crop): %s"
)

# S12sh12 인쇄 내용 발명 fix (2026-06-12): crop 시점의 인쇄/표시면은 수 픽셀이라
# 내용이 없는데, 위 "Do NOT add" 계약만으로는 모델이 확대하며 내용을 발명한다
# (실측: bbox area_ratio 0.004 의 사진 → 검은 원형 무늬 발명). object reference
# 가 동반되면 그 인쇄/표시 내용만 ref 를 SOT 로 선명화하도록 명시적 예외를 연다
# — 위치/방향/스케일/상태는 여전히 crop 이 SOT (generic — 사진/화면/간판 공통).
_FILL_OBJECT_REF_CLAUSE = (
    "\nObject references: the additional labeled reference images show the true "
    "appearance of specific objects present in the crop. For each such object, keep its "
    "position, orientation, scale and physical condition exactly as in the crop, but its "
    "printed or displayed content (picture, text, screen) must match the object reference "
    "image — the enlargement made that content illegible, and the object reference is the "
    "source of truth for it. Do not invent different printed or displayed content."
)


class ZoomContinuityRenderError(Exception):
    """fallback 신호 — caller 는 기존 T2I 경로로 진행 + diagnostic 기록."""


def build_fill_prompt(
    zoom_target: Dict[str, Any], *, has_object_content_sot_ref: bool,
) -> str:
    """i2i fill 프롬프트 조립 (순수) — locked facts + object ref SOT 절(조건부).

    SOT 절은 printed_prop anchor 계약이 적용된 object ref 가 있을 때만 붙는다
    (Codex narrow: 일반 prop ref 만 있는 still 의 프롬프트는 byte-identical).
    """
    locked_text = "; ".join(
        le.get("description", "") for le in zoom_target.get("locked_elements") or []
        if le.get("description")
    ) or "the composition of the crop"
    prompt = _FILL_PROMPT % locked_text
    if has_object_content_sot_ref:
        prompt += _FILL_OBJECT_REF_CLAUSE
    return prompt


def build_focal_description(zoom_target: Dict[str, Any]) -> str:
    """anchor locked_elements → VLM bbox 의 focal 묘사 (generic 조립)."""
    parts = [
        le.get("description", "").strip()
        for le in zoom_target.get("locked_elements") or []
        if le.get("kind") in ("held_prop", "subject_pose", "prop_state")
        and le.get("description")
    ]
    if not parts:
        parts = [
            le.get("description", "").strip()
            for le in zoom_target.get("locked_elements") or []
            if le.get("description")
        ]
    return "; ".join(p for p in parts if p) or "the focal subject of the zoom shot"


def vlm_locate_bbox(
    image_bytes: bytes,
    target_description: str,
    *,
    model: str = VISION_MODEL_DEFAULT,
) -> Dict[str, Any]:
    from app.core.openai_keys import (  # type: ignore
        llm_completion as _llm_completion,
    )

    # ★**모델만 바꾸면 안 된다 — 키가 같이 가야 한다** (#92, 2026-08-27).
    #
    #  `llm_completion` 은 OpenAI 가 **아닌** 모델에는 아무것도 안 끼운다
    #  (`openai_keys.py:463`). 그러면 litellm 이 `GEMINI_API_KEY` 환경변수를
    #  보는데, 이 저장소는 키를 `.env` 에서 **풀로 읽어 Router 에 실어**
    #  준다(`llm_client.py:533-541`) — 환경변수로 노출된다는 보장이 없다.
    #  모델 이름만 Sol 에서 gemini 로 바꿨으면 이 자리가 통째로 죽었다.
    extra = {}
    if model.startswith("gemini/"):
        from app.modules.llm.gemini_key_pool import get_next_key

        key = get_next_key()
        if key:
            extra["api_key"] = key

    b64 = base64.b64encode(image_bytes).decode()
    resp = _llm_completion(
        model=model,
        **extra,
        messages=[
            {"role": "system", "content": _BBOX_SYSTEM % target_description},
            {"role": "user", "content": [
                {"type": "image_url",
                 "image_url": {"url": f"data:image/png;base64,{b64}"}},
            ]},
        ],
        timeout=VISION_TIMEOUT_SECONDS,
        max_completion_tokens=BBOX_MAX_TOKENS,
        num_retries=1,
        response_format={"type": "json_object"},
    )
    content = (resp.choices[0].message.content if resp.choices else None) or ""
    return json.loads(content)


def expand_crop_16x9(
    image_size: Tuple[int, int],
    bbox_norm: List[float],
    *,
    margin: float = CROP_MARGIN,
) -> Tuple[int, int, int, int]:
    """normalized bbox → 16:9 확장 crop 박스(px, 중심 유지+프레임 클램프) — 스파이크1 포팅."""
    W, H = image_size
    x0, y0, x1, y1 = bbox_norm
    cx, cy = (x0 + x1) / 2 * W, (y0 + y1) / 2 * H
    bw, bh = (x1 - x0) * W, (y1 - y0) * H
    if bh <= 0 or bw <= 0:
        raise ZoomContinuityRenderError(f"invalid bbox {bbox_norm}")
    if bw / bh < 16 / 9:
        bw = bh * 16 / 9
    else:
        bh = bw * 9 / 16
    bw, bh = min(bw * margin, W), min(bh * margin, H)
    left = max(0, min(W - bw, cx - bw / 2))
    top = max(0, min(H - bh, cy - bh / 2))
    return (int(left), int(top), int(left + bw), int(top + bh))


def assess_viability(bbox: Dict[str, Any]) -> Dict[str, Any]:
    """source_wide_crop_viability (Codex WC2 guard 3) — 기록만, 차단 아님."""
    found = bool(bbox.get("found"))
    verdict = "ok"
    area_ratio: Optional[float] = None
    if not found:
        verdict = "bbox_not_found"
    else:
        try:
            x0, y0, x1, y1 = bbox["bbox"]
            area_ratio = max(0.0, (x1 - x0)) * max(0.0, (y1 - y0))
            if area_ratio > VIABILITY_MAX_AREA_RATIO:
                verdict = "bbox_too_large_weak_zoom"
            elif area_ratio < VIABILITY_MIN_AREA_RATIO:
                verdict = "bbox_too_small_low_confidence"
        except (KeyError, TypeError, ValueError):
            verdict = "bbox_malformed"
    return {
        "found": found,
        "area_ratio": area_ratio,
        "verdict": verdict,
        "note": bbox.get("note", ""),
    }


def _load_source_primary_bytes(
    db: Any, project_id: str, episode_id: str, source: Tuple[int, int],
) -> Tuple[bytes, str]:
    from app.core.config import settings
    from app.models.project import ImageAsset, SceneStill

    src_still = (
        db.query(SceneStill)
        .filter(
            SceneStill.project_id == project_id,
            SceneStill.episode_id == episode_id,
            SceneStill.scene_index == source[0],
            SceneStill.shot_index == source[1],
        )
        .first()
    )
    if not src_still:
        raise ZoomContinuityRenderError(
            f"source still S{source[0]}sh{source[1]} not found")
    asset = (
        db.query(ImageAsset)
        .filter(
            ImageAsset.project_id == project_id,
            ImageAsset.still_id == src_still.id,
            ImageAsset.asset_type == "scene",
            ImageAsset.is_primary == 1,
        )
        .order_by(ImageAsset.created_at.desc())
        .first()
    )
    if not asset:
        raise ZoomContinuityRenderError(
            f"source still S{source[0]}sh{source[1]} has no primary scene image")
    path = Path(asset.file_path)
    if not path.is_absolute():
        path = Path(settings.projects_dir).parent / asset.file_path
    if not path.exists():
        raise ZoomContinuityRenderError(f"source image file missing: {asset.file_path}")
    # asset.id = source primary scene asset 의 UUID — crop intermediate 의
    # input_image_ids lineage 로 사용(Phase C Decision #1: 구조 UUID resolve).
    return path.read_bytes(), asset.id


def _artifacts_dir(project_id: str, episode_id: str, scene_index: int, shot_index: int) -> Path:
    from app.core.config import settings
    d = (
        Path(settings.projects_dir) / project_id / "checkpoints" / "episodes"
        / episode_id / "zoom_continuity_anchor" / WC2_ARTIFACTS_DIRNAME
        / f"S{scene_index}sh{shot_index}"
    )
    d.mkdir(parents=True, exist_ok=True)
    return d


def generate_continuity_crop_png(
    *,
    db: Any = None,
    project_id: str,
    episode_id: str,
    scene_index: int,
    shot_index: int,
    zoom_target: Dict[str, Any],
    gemini_client: Any,
    prop_refs: Optional[List[Tuple[str, bytes]]] = None,
    prop_refs_have_anchor_contract: bool = False,
    trace_meta: Optional[Dict[str, Any]] = None,
    source_bytes: Optional[bytes] = None,
    still_id: Optional[str] = None,
    source_asset_id: Optional[str] = None,
) -> Tuple[bytes, str, Dict[str, Any]]:
    """zoom still 의 crop/i2i-fill PNG 생성 — (png_bytes, fill_prompt, diagnostics).

    실패는 ZoomContinuityRenderError — caller 가 기존 T2I 로 fallback 하고
    artifacts 의 fallback reason 으로 남긴다 (비차단, canary 에선 실패 간주).

    ★동시성 규칙 (2026-06-12 S23sh4 실측 — SQLAlchemy "concurrent operations
    are not permitted"): batch ThreadPool worker 에서는 db 세션을 절대 전달하지
    말 것 (코드베이스 규칙 "worker 안 self.db query 금지" 와 동일). batch 는
    ``source_bytes`` 를 직접 전달한다 — scene_paths_by_index_by_id (resume
    초기화 + 메인 스레드 batch 경계 갱신, worker 읽기 전용) 에서 읽은 bytes.
    db 조회 경로는 단건(메인 스레드) regen 전용.
    """
    from PIL import Image

    art_dir = _artifacts_dir(project_id, episode_id, scene_index, shot_index)
    diagnostics: Dict[str, Any] = {
        "group_id": zoom_target.get("group_id"),
        "source": list(zoom_target.get("source") or ()),
    }

    def _fail(reason: str) -> ZoomContinuityRenderError:
        diagnostics["fallback_reason"] = reason
        (art_dir / "fallback.json").write_text(
            json.dumps(diagnostics, ensure_ascii=False, indent=1), encoding="utf-8")
        return ZoomContinuityRenderError(reason)

    # 1. source primary 이미지 — batch=source_bytes 주입 / 단건=db 조회
    if source_bytes is None:
        if db is None:
            raise _fail("source_unavailable: no source_bytes and no db session")
        try:
            source_bytes, _resolved_source_asset_id = _load_source_primary_bytes(
                db, project_id, episode_id, tuple(zoom_target["source"]))
        except ZoomContinuityRenderError as exc:
            raise _fail(f"source_unavailable: {exc}") from exc
        # 단건(db) path: caller 가 source_asset_id 를 안 줬으면 방금 조회한 UUID 사용.
        if source_asset_id is None:
            source_asset_id = _resolved_source_asset_id

    # 2. VLM bbox
    focal = build_focal_description(zoom_target)
    diagnostics["focal_description"] = focal
    # ★어느 모델이 bbox 를 냈는지 **기록에 남긴다** (2026-08-27 Codex).
    #  안 남기면 Sol 판과 gemini 판을 나중에 못 가른다 —
    #  「계속 죽는데 dual 이 돈다」와 같은 오독의 자리다.
    diagnostics["bbox_model"] = VISION_MODEL_DEFAULT
    try:
        bbox = vlm_locate_bbox(source_bytes, focal)
    except Exception as exc:
        raise _fail(f"bbox_vlm_error: {type(exc).__name__}: {exc}") from exc
    (art_dir / "bbox.json").write_text(
        json.dumps({"target_description": focal,
                    "model": VISION_MODEL_DEFAULT, **bbox},
                   ensure_ascii=False),
        encoding="utf-8")

    viability = assess_viability(bbox)
    diagnostics["source_wide_crop_viability"] = viability
    (art_dir / "viability.json").write_text(
        json.dumps(viability, ensure_ascii=False, indent=1), encoding="utf-8")
    if not viability["found"]:
        raise _fail("bbox_not_found")

    # 3. crop + upscale
    im = Image.open(BytesIO(source_bytes))
    try:
        crop_box = expand_crop_16x9(im.size, bbox["bbox"])
    except ZoomContinuityRenderError as exc:
        raise _fail(str(exc)) from exc
    (art_dir / "crop_box.json").write_text(
        json.dumps({"bbox_norm": bbox["bbox"], "crop_box_px": list(crop_box),
                    "image_size": list(im.size)}),
        encoding="utf-8")
    crop_up = im.crop(crop_box).resize(UPSCALE_SIZE, Image.LANCZOS)
    buf = BytesIO()
    crop_up.save(buf, "PNG")
    crop_bytes = buf.getvalue()
    (art_dir / "crop_upscaled.png").write_bytes(crop_bytes)
    # Phase C: crop 중간물만 generation_context scope 안에서 영속화한다. ★fill(아래
    # i2i)은 scope **밖** — fill 의 gemini generate_image 가 만드는 건 최종 scene
    # asset 이므로(consumer 가 별도 등록), scope 로 감싸면 gemini_image_client 의
    # capture_generated_image 가 최종물을 intermediate 로 중복 저장한다(Decision #4).
    # scope 가 with-블록 종료 시 독립 세션으로 flush → batch worker thread 에서도
    # 안전(독립 SessionLocal, Codex #6). source_asset_id 는 구조 UUID lineage.
    with generation_context(
        project_id, episode_id, stage="zoom_continuity",
        still_id=still_id, scene_index=scene_index, shot_index=shot_index,
    ):
        capture_artifact(
            crop_bytes,
            role="zoom_continuity_crop",
            input_image_ids=([source_asset_id] if source_asset_id else None),
            pipeline_metadata={
                "bbox": bbox.get("bbox"),
                "group_id": zoom_target.get("group_id"),
                "source": list(zoom_target.get("source") or ()),
            },
        )

    # 4. i2i fill (+ prop refs, moderation 시 production sanitizer 재시도)
    fill_prompt = build_fill_prompt(
        zoom_target,
        has_object_content_sot_ref=bool(prop_refs) and prop_refs_have_anchor_contract,
    )
    labeled: List[Tuple[str, bytes]] = [
        ("Reference image 1: the zoomed crop to refine — keep composition and poses EXACTLY.",
         crop_bytes),
    ]
    for label, img in prop_refs or []:
        labeled.append((label, img))

    from app.modules.llm.gemini_image_client import ModerationError
    from app.modules.prompt_sanitizer import PromptSanitizer

    # P0c (2026-06-18): zoom-crop 경로도 정상 scene 경로(scene_image_pipeline)와
    # 동일하게 gemini_client 로깅 컨텍스트를 설정한다. 안 하면 generate_image 가
    # worker thread 의 stale/empty thread-local ctx 로 log_llm_call 을 호출해
    # llm_call_log 에 누락/오귀속된다 (S12sh15 scene_image_gen 행 결손 = 트레이스
    # 신뢰성 저하). set_context 는 thread-local (DB 미접근) 이라 worker 동시성
    # 규칙과 무관. operation_type 은 caller 가 trace_meta 로 override 가능
    # (단건 regen = single_scene_image_gen).
    _log_ctx: Dict[str, Any] = {
        "step": "scene_image_gen",
        "operation_type": "scene_image_gen",
        "project_id": project_id,
        "episode_id": episode_id,
        "scene_index": scene_index,
        "shot_index": shot_index,
    }
    if trace_meta:
        _log_ctx.update(trace_meta)
    gemini_client.set_context(**_log_ctx)

    prompt = fill_prompt
    last_exc: Optional[Exception] = None
    for attempt in (0, 1, 2):
        try:
            png, _ms = gemini_client.generate_image(prompt, labeled_references=labeled)
            diagnostics["fill_prompt"] = prompt
            (art_dir / "fill_prompt.txt").write_text(prompt, encoding="utf-8")
            return png, prompt, diagnostics
        except ModerationError as exc:
            last_exc = exc
            res = PromptSanitizer().sanitize(
                prompt, "SAFETY",
                list(getattr(exc, "block_categories", []) or []),
                attempt=attempt + 1,
            )
            prompt = res.get("sanitized_prompt", prompt)
        except Exception as exc:
            raise _fail(f"i2i_error: {type(exc).__name__}: {exc}") from exc
    raise _fail(f"i2i_moderation_blocked_after_retries: {last_exc}")


def stale_zoom_targets(
    db: Any,
    project_id: str,
    still_id_by_key: Dict[Any, Any],
    *,
    current_model: str = "",
) -> List[Any]:
    """지금 남아 있는 결과가 **다른 모델로 만들어진** zoom 대상 키들.

    ## 왜 필요한가 — 지문만 움직여선 아무 일도 안 난다

    ★bbox 모델을 Sol → gemini 로 바꾸고 스텝 지문에 접어도, 재개는
     `scan_completed_scene_stills` 가 **primary 파일 존재만** 보고
     done 을 만들고(`scene_persistence_service.py:462-507`) legacy loop 가
     그 done 을 제외한 뒤에야 zoom 호출로 간다
     (`scene_image_service.py:702-704`). **완주한 Sol zoom 은 gemini
     bbox 를 한 번도 안 부르고 그대로 남는다** (2026-08-27 Codex BLOCK).

    ★그래서 **낡은 대상만** done 에서 뺀다. 전체 force 는 필요 없다 —
     그건 zoom 과 무관한 샷까지 다시 산다.

    ## 신원은 최종 primary 다 — 중간 파일이 아니다

    ★`bbox.json` 은 crop 을 뜨는 **도중에** 쓴다(:356). i2i fill 이
     거절당하거나 프로세스가 그 사이에 죽으면 파일만 새 모델 이름을
     달고 남고 primary 는 옛 Sol 그림 그대로다 — 그러면 이 함수가
     「gemini 로 만든 것」이라 읽어 **낡은 것을 안 뺀다**
     (2026-08-27 Codex BLOCK 3회차). 완료 신원은 done 을 만드는 것과
     같은 자리, 곧 **최종 primary ImageAsset** 에서 읽는다.

    판별:
      - primary 가 없다 → 낡지 않았다 (아직 안 만들었고 done 에도 없다)
      - primary 가 `zoom_continuity_crop` 이 아니다 → 낡지 않았다
        (zoom 이 fallback 으로 빠져 T2I 로 나온 것 — bbox 가 안 실렸다)
      - `zoom_continuity_crop` 인데 `review_notes.zoom_continuity.bbox_model`
        이 없거나 다르다 → **낡았다**
    """
    from app.models.project import ImageAsset

    want = current_model or VISION_MODEL_DEFAULT
    stale: List[Any] = []
    for key, still_id in (still_id_by_key or {}).items():
        if not still_id:
            continue
        primary = (
            db.query(ImageAsset)
            .filter(
                ImageAsset.project_id == project_id,
                ImageAsset.still_id == still_id,
                ImageAsset.asset_type == "scene",
                ImageAsset.is_primary == 1,
            )
            .first()
        )
        if primary is None:
            continue
        if getattr(primary, "variant_type", None) != "zoom_continuity_crop":
            continue
        try:
            notes = json.loads(getattr(primary, "review_notes", None) or "{}")
            got = (notes.get("zoom_continuity") or {}).get("bbox_model")
        except Exception:  # noqa: BLE001 — 못 읽으면 낡은 것으로 본다
            got = None
        if got != want:
            stale.append(key)
    return stale
