"""call_gpt_image_bytes — gpt-image-2 저수준 primitive wrapper (Task B0).

gpt-image-2 direct call site(background_render / background_chain_render /
floor_plan_render / location_floor_plan / space_set_bg_provider)들이 공유하는
"openai images.generate/edit 호출 + b64 decode + capture enqueue"만 캡슐화한다.
retry/sanitizer/ref validation/out_path write/empty·too-small 검증/budget reserve/
result dict 는 각 호출부에 그대로 남긴다 — byte-identical 보존 + budget label drift /
double-count 방지(Codex 합의).

분기(현 사이트 형태 그대로 재현):
  - mode='generate' → ``images.generate(prompt=..., **call_kwargs)`` (ref 없음)
  - mode='edit'     → ``images.edit(image=핸들, prompt=..., **call_kwargs)``
      · ref_paths 1개  → image=단일 핸들 (리스트 아님)
      · ref_paths 2개+ → image=[핸들...] (순서 보존)

``call_kwargs`` 는 각 사이트가 현재 넘기는 정확한 dict 그대로 pass-through 한다
(space_set 은 quality/n 미포함 → 고정 인자로 강제 주입하지 않음).

capture = wrapper 단일점 + capture-only min-size 가드: ``generation_context`` scope 가
열렸고(아니면 sink no-op) ``len(png) >= min_capture_bytes`` 일 때만
``capture_generated_image`` enqueue. 작으면 capture 만 skip 하고 bytes 는 그대로 반환 —
사이트의 too-small/empty 검증·error type 을 wrapper 가 바꾸지 않는다.

빈 응답(resp.data 없음 / b64 None) → ``b""`` 반환(사이트가 자기 error 로 raise).
"""

from __future__ import annotations

import base64
import contextlib
import logging
import time
from pathlib import Path
from typing import Any, Dict, List, Optional

# 기록(DB+Opik)은 image_tracer 의 공용 헬퍼 한 벌을 쓴다 — gemini i2i 경로와
# 같은 모양이어야 한 곳에서 비교가 된다.
from app.modules.llm.image_tracer import (
    ambient_call_meta,
    record_provider_call,
    resolve_step_name,
)
from app.services.image_capture.sink import capture_generated_image

logger = logging.getLogger(__name__)

_MIN_CAPTURE_BYTES = 1024


def call_gpt_image_bytes(
    openai_client: Any,
    *,
    mode: str,
    prompt: str,
    ref_paths: Optional[List[Any]] = None,
    call_kwargs: Dict[str, Any],
    capture_role: Optional[str] = None,
    capture_metadata: Optional[Dict[str, Any]] = None,
    capture_input_image_ids: Optional[List[str]] = None,
    edit_image_as_list: bool = False,
    min_capture_bytes: int = _MIN_CAPTURE_BYTES,
) -> bytes:
    """gpt-image-2 호출 + b64 decode + (scope 열림 시) capture. decode 된 bytes 반환.

    ``edit_image_as_list`` — ref 가 1개일 때 ``image=핸들``(단일, 기본) vs
    ``image=[핸들]``(리스트)를 고른다. background/chain/floor_plan_render 는 1개면
    단일 핸들(default False), location_floor_plan 은 항상 리스트(True) — 각 사이트의
    현 호출 형태를 byte-identical 로 보존하기 위함.
    """
    refs = list(ref_paths or [])
    if mode not in ("edit", "generate"):
        raise ValueError(f"call_gpt_image_bytes: unknown mode {mode!r}")
    if mode == "edit" and not refs:
        raise ValueError("call_gpt_image_bytes(mode='edit') requires at least one ref_path")

    # 기록 메타 = ambient(scope) + 호출자 capture_metadata. **ambient 가 우선** —
    # project/episode/scene/shot 같은 신원은 scope 가 진실이고, 호출자 메타는
    # group_id/producer_stage/budget_source 같은 문맥을 얹는다(Codex 리뷰).
    meta: Dict[str, Any] = {k: v for k, v in (capture_metadata or {}).items()
                            if v is not None}
    meta.update(ambient_call_meta())
    step = resolve_step_name(capture_role, meta)
    model = str(call_kwargs.get("model") or "gpt-image")
    t0 = time.monotonic()

    def _elapsed_ms() -> int:
        return int((time.monotonic() - t0) * 1000)

    def _fail(exc: Exception, status: str = "error") -> None:
        record_provider_call(step=step, model=model, prompt=prompt, status=status,
                          duration_ms=_elapsed_ms(), meta=meta,
                          ref_count=len(refs), operation=capture_role,
                          ref_image_ids=capture_input_image_ids,
                          error=str(exc)[:500])

    # ★try 범위는 **정규화 완료까지**다 (Codex 리뷰). provider 호출만 감싸면
    #  resp.data 접근·base64 decode·PNG 정규화가 터졌을 때 기록이 없어
    #  "성공도 실패도 남는다"가 깨진다.
    try:
        if mode == "generate":
            resp = openai_client.images.generate(prompt=prompt, **call_kwargs)
        else:
            with contextlib.ExitStack() as stack:
                if len(refs) == 1 and not edit_image_as_list:
                    image: Any = stack.enter_context(Path(refs[0]).open("rb"))
                else:
                    image = [stack.enter_context(Path(p).open("rb")) for p in refs]
                resp = openai_client.images.edit(
                    image=image, prompt=prompt, **call_kwargs)

        b64 = resp.data[0].b64_json if (resp is not None and resp.data) else None
        if not b64:
            record_provider_call(step=step, model=model, prompt=prompt,
                              status="empty_response",
                              duration_ms=_elapsed_ms(), meta=meta,
                              ref_count=len(refs), operation=capture_role,
                              ref_image_ids=capture_input_image_ids,
                              error="no b64_json in response")
            return b""

        # 생성 바이트 PNG 정규화 (2026-08-06) — gemini 경로와 같은 계약.
        # gpt-image-2 는 통상 PNG 라 대개 no-op(같은 객체 반환)이지만, 형식이
        # 바뀌어도 `.png` 이름과 내용이 어긋나지 않게 진입점에서 못 박는다.
        from app.modules.llm.image_format import ensure_png_bytes

        png = ensure_png_bytes(
            base64.b64decode(b64), context=f"gpt-image/{capture_role or '?'}")
    except Exception as exc:
        _fail(exc)
        raise

    call_id = record_provider_call(
        step=step, model=model, prompt=prompt, status="success",
        duration_ms=_elapsed_ms(), meta=meta, ref_count=len(refs),
        operation=capture_role, ref_image_ids=capture_input_image_ids,
        output_text="[image generated]")

    if capture_role and len(png) >= min_capture_bytes:
        capture_generated_image(
            png,
            role=capture_role,
            input_image_ids=capture_input_image_ids,
            generation_call_id=call_id,
            prompt=prompt,
            pipeline_metadata=capture_metadata,
        )
    return png
