"""gpt-image-2.5 클라이언트 — nb2 gen_fn 슬롯·SAFETY 사다리 호환.

2026-09-20 사용자 결정: 최종 스틸 **조립**을 gpt-image-2.5 로 간다.
실측 근거 — nb2 가 두 판 이상 못 고치던 샷 8개를 같은 문안·같은 참조로
gpt-image-2.5 에 넘기니 **여덟 장 다 한 판**에 해결됐다
(`project-gpt25-direct-assembly-beats-nb2-rerolls`).

★왜 gen_fn 이 아니라 **클라이언트**인가. `gpt_image_gen.make_gpt_image_gen_fn`
 이 이미 있지만 그것은 「연화 1회」뿐이다. 사용자가 지시한 검열 사다리는
 **grok → seedream** 이고, 그 사다리(`multiroll_gemini.gen_fn_ladder`)는
 **클라이언트**를 단계마다 갈아 끼우는 구조다. 그래서 그 슬롯에 들어갈
 얼굴이 필요하다 — `GrokImageClient` 가 같은 이유로 `GeminiImageClient` 를
 상속한 것과 같은 자리다.
 ★사다리의 `_cross_client()` 는 「primary 가 Grok 이 아니면 Grok」이라,
  이 클라이언트를 primary 로 두면 **교차가 grok** 이고 그다음이
  seedream 이다 — 지시한 순서가 그대로 나온다.
 ★단계는 **여섯**이지 셋이 아니다 (Codex 정정): GPT → GPT 같은 모델
  재시도 → Grok → Seedream → GPT 연화 → Grok 연화. 그리고 이 사다리
  자체가 `still_safety_fallback_enabled=True` 일 때만 쓰인다.

상속 재사용: `set_context`/`_ctx`/`_trace_step`/`_log_ctx`/`_opik_meta`.
오버라이드: 운반층(`generate_image`)뿐이다.

★기록은 **`call_gpt_image_bytes` 가 한다**(`record_provider_call`).
 여기서 `image_tracer` 로 또 적지 않는다 — 같은 호출을 두 번 세게 된다.
★예산 문은 여기서 지난다(`reserve_current_call`) — 다른 gpt-image 자리와
 같은 관례다.
"""
from __future__ import annotations

import logging
import tempfile
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.core.image_call_budget import reserve_current_call
from app.modules.llm.gemini_image_client import GeminiImageClient

logger = logging.getLogger(__name__)

#: 비율 → gpt-image 가 받는 크기.
#:
#: ★★`16:9` 는 **1536x864** 다 (2026-09-20 사용자 지시). `gpt_image_gen`
#:  의 표는 `1536x1024` 인데 그것은 **3:2** 다 — 그 값으로 스틸을 그리면
#:  전 샷의 비율이 어긋난다. 수동 수리 8장이 `1536x864` 로 나갔고 비율이
#:  맞았다(`BGFIRST_BG_SIZE` 와 같은 값).
#: ★스틸 조립은 **언제나 16:9** 다(`make_nb2_gen_fn` 기본값, 호출부가
#:  다른 값을 안 준다). 나머지 둘은 같은 규칙으로 적어 두되 **이 경로에서
#:  실제로 쓰인 적은 없다** — 넓혀 쓰지 않는다.
SIZE_BY_ASPECT: Dict[str, str] = {
    "16:9": "1536x864",
    "9:16": "864x1536",
    "1:1": "1024x1024",
}
DEFAULT_SIZE = "1536x864"


def resolve_size(aspect_ratio: Optional[str]) -> str:
    """비율 문자열 → 크기. 모르는 값은 **기본(16:9)** 으로 두고 경고한다.

    ★크게 실패시키지 않는 이유: 이 경로는 16:9 만 받는다. 뜻밖의 값이
     오면 그림을 잃는 것보다 **16:9 로 그리고 로그에 남기는** 쪽이 낫다.
    """
    if not aspect_ratio:
        return DEFAULT_SIZE
    hit = SIZE_BY_ASPECT.get(str(aspect_ratio).strip())
    if hit:
        return hit
    logger.warning(
        "gpt_image_client: 모르는 비율 %r — 기본 %s 로 그린다",
        aspect_ratio, DEFAULT_SIZE)
    return DEFAULT_SIZE


class GptImageClient(GeminiImageClient):
    """gpt-image-2.5 — `generate_image(prompt, labeled_references=...)` 계약."""

    def __init__(self, api_key: Optional[str] = None,
                 model: Optional[str] = None,
                 quality: str = "high") -> None:
        from app.core.config import settings as _s

        super().__init__(
            api_key=api_key,
            model=model or getattr(
                _s, "openai_image_model", "gpt-image-2.5-sunburst"),
        )
        self._quality = quality
        self._openai_client: Any = None

    def _client(self) -> Any:
        if self._openai_client is None:
            from app.core.steps.shot_conti_light_step import (
                _resolve_openai_client,
            )

            self._openai_client = _resolve_openai_client()
        return self._openai_client

    # ── 운반층 ────────────────────────────────────────────────────────
    def generate_image(
        self,
        prompt: str,
        reference_images: Optional[List[bytes]] = None,
        aspect_ratio: Optional[str] = None,
        labeled_references: Optional[List[Tuple[str, Any]]] = None,
    ) -> Tuple[bytes, int]:
        """(PNG bytes, ms) 반환. 참조가 있으면 `edit`, 없으면 `generate`.

        ★**라벨은 그림에 안 실린다.** gpt-image 의 `images.edit` 은 파일을
         **순서대로** 받을 뿐 라벨 칸이 없다 — 무엇이 무엇인지는 **문안이**
         말한다. 그래서 여기서 라벨을 버리되 **순서는 지킨다**. 수동 수리
         8장이 같은 방식으로 나갔고 다 맞았다.
        ★참조를 **조용히 빼지 않는다** — 하나가 사라지면 아예 다른 계약의
         그림이 되는데 기록은 원래 계약으로 남는다(`gpt_image_gen` 주석의
         2026-08-03 실측).
        """
        from app.modules.llm.gpt_image_primitive import call_gpt_image_bytes

        pairs: List[Tuple[str, Any]] = list(labeled_references or [])
        if not pairs and reference_images:
            pairs = [("", b) for b in reference_images]

        t0 = time.monotonic()
        with tempfile.TemporaryDirectory(prefix="gptimg_") as _tmp:
            ref_paths: List[str] = []
            for i, (label, val) in enumerate(pairs):
                if val is None:
                    raise FileNotFoundError(
                        f"gpt_image_client: 참조 {label!r} 이 비었다")
                if isinstance(val, (bytes, bytearray)):
                    # ★사다리가 bytes 로 준다. gpt 문은 **파일 경로**를
                    #  받으므로 여기서만 흘려 쓰고 끝나면 지운다.
                    p = Path(_tmp) / f"{i:02d}.png"
                    p.write_bytes(bytes(val))
                    ref_paths.append(str(p))
                    continue
                p = Path(str(val))
                if not p.exists():
                    raise FileNotFoundError(
                        f"gpt_image_client: 참조 {label!r} 파일이 없다 — {p}")
                ref_paths.append(str(p))

            # ★이미지 문 — run-wide cap 을 **모든** gpt-image 자리가 지난다
            reserve_current_call(source="gpt_image_client.generate_image")
            png = call_gpt_image_bytes(
                self._client(),
                mode="edit" if ref_paths else "generate",
                prompt=prompt,
                ref_paths=ref_paths or None,
                call_kwargs={
                    "model": self._model,
                    "size": resolve_size(aspect_ratio),
                    "quality": self._quality,
                    "n": 1,
                },
                capture_role=str(self._ctx.get("operation_type") or ""),
                capture_metadata={
                    k: v for k, v in self._ctx.items()
                    if k not in ("project_id", "episode_id", "operation_type")
                },
            )
        if not png:
            # ★빈 응답을 **성공으로 세지 않는다.**
            #  ★★그렇다고 **사다리가 다음 단계로 가지는 않는다** (Codex
            #   정정 — 내가 틀리게 적었다). 이 예외는 검열 분류
            #   (`_moderated`)에 안 들어가 그대로 전파되고 **샷이 실패**로
            #   끝난다. 그것이 지금의 보수적 계약이다 — 빈 응답을 검열인
            #   척 위장해 다음 **유료** 단계로 넘기지 않는다.
            raise RuntimeError("gpt-image: 빈 응답")
        return png, int((time.monotonic() - t0) * 1000)
