"""마커 맵 엔진 — 누가 그리는지 호출부가 몰라도 되게 (2026-08-25).

왜 있나 (사용자 지시 "코드 상수를 설정으로, 문제가 생기면 후보 모델로") —
마커를 그리는 두 자리가 `gpt-image-2` 를 **소스 상수로** 박고 있었다
(`outdoor_place_canon_step.py:37` · `shot_conti_light.py:77`). 설정으로 못
바꾸니 실패해도 다른 모델을 시켜 볼 길이 없었다.

08-25 실측 S2sh1 — 「카메라 시야각(FOV) 부채꼴이 피사체가 있는 방향(남동)이
아닌 반대 방향(북서)으로 열려 있다」로 검사 3회 소진, 스텝이 partial 로 멈췄다.
★세 번 다 **같은 방향으로** 틀렸다. 우연이 아니라 그 모델의 계통적 편향이고,
 같은 모델로 되풀이하는 것은 돈만 쓴다(3장 사서 3장 버렸다). 메모리에도 같은
 결함이 남아 있다 — "geometry 는 정확한데 모델이 쐐기를 반대로 그린다".
 그래서 되풀이가 아니라 **모델을 바꾼다**.

★두 경로는 API 모양이 다르다 — 모델 문자열만 갈아 끼우면 안 된다.

    gpt-image-2 계열   OpenAI `images.edit`     참조를 **파일**로 올린다
    그 밖(슬러그)      OpenRouter chat          참조를 **대화 속 이미지**로

 그래서 여기서 갈라 부르고, 호출부는 「이 모델로 그려라」만 안다.

★후보가 비면 후퇴하지 않는다 = 종전 동작 그대로. 설정에 슬러그를 넣는
 순간 켜진다.
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)


def marker_map_models() -> List[str]:
    """이 순서로 물러난다 — [기본, 후보1, 후보2, …].

    ★중복은 걷는다. 기본과 후보에 같은 슬러그가 들어 있으면 같은 모델을
     두 번 시키는 셈이라 값이 없다(그 되풀이를 없애려고 만든 장치다).
    """
    from app.core.config import settings

    base = str(getattr(settings, "marker_map_image_model", "") or "").strip()
    raw = str(getattr(settings, "marker_map_fallback_models", "") or "")
    out: List[str] = []
    for m in [base] + [x.strip() for x in raw.split(",")]:
        if m and m not in out:
            out.append(m)
    return out


def is_gemini_model(model: str) -> bool:
    """이 저장소가 Gemini 로 직접 부르는 이름인가.

    `nb2` 는 이 저장소가 쓰는 별명이고 물리 모델은 설정이 준다. Gemini 는
    이미 키 풀·재시도 기계를 갖고 있으므로 OpenRouter 로 우회하지 않는다 —
    같은 모델을 두 길로 부르면 과금도 기록도 갈린다.
    """
    m = model or ""
    return m == "nb2" or m.startswith("gemini")


def is_openrouter_model(model: str) -> bool:
    """OpenRouter 슬러그인가 — `provider/model` 꼴이면 그렇다.

    `gpt-image-2` 처럼 provider 접두가 없는 것은 OpenAI 직행이다. 이
    가름은 **문자열의 뜻을 읽는 것이 아니라 형식을 보는 것**이라
    substring 의미 판단이 아니다.
    """
    return "/" in (model or "") and not is_gemini_model(model)


def render_via_gemini(
    *,
    model: str,
    prompt: str,
    ref_paths: List[Any],
    capture_context: Optional[Dict[str, Any]] = None,
) -> bytes:
    """nb2(또는 다른 Gemini 이미지 모델)로 그린다 — PNG bytes.

    2026-08-25 사용자 판정: **공간·방향은 nb2 가 훨씬 낫다.** 그래서 카메라
    관련 도해는 이 갈래가 기본이 된다.
    """
    from app.core.config import settings
    from app.modules.llm.gemini_image_client import GeminiImageClient

    physical = (settings.gemini_image_model if model == "nb2" else model)
    client = GeminiImageClient(model=physical)
    if capture_context:
        client.set_context(**capture_context)
    labeled = [(f"REFERENCE {i}:", Path(str(p)).read_bytes())
               for i, p in enumerate(ref_paths, 1)]
    png, _elapsed = client.generate_image(prompt, labeled_references=labeled)
    return png


# ★OpenRouter 는 이미지 모델을 **chat/completions 로 안 받는다** — 전용
#  엔드포인트가 따로 있다. 2026-08-25 실측에서 그 사실이 오류 문구로 그대로
#  왔다:
#    "qwen/qwen-image-3-pro is an image generation model and cannot be used
#     with the chat/completions endpoint. Use the /api/v1/images endpoint
#     instead."
#  seedream 이 500 을 준 것도 같은 자리로 보인다(모델마다 코드만 다르다).
#  ★grok-imagine 은 chat 으로도 받아 준다 — 그래서 이 저장소가 그 방식만
#   알고 있었고, 다른 모델을 넣었을 때 「모델이 못 그린다」로 오독했다.
OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images"


def draw_marker_map_via_model(
    *,
    model: str,
    prompt: str,
    ref_paths: List[Any],
    role: str = "marker_map",
    capture_context: Optional[Dict[str, Any]] = None,
    openai_render=None,
    aspect_ratio: Optional[str] = None,
) -> bytes:
    """마커 맵 한 장 — **호출부는 모델 종류를 몰라도 된다.**

    ``aspect_ratio`` — OpenRouter 전용 이미지 엔드포인트 갈래에만 싣는다
    (2026-09-19). 안 실으면 seedream 이 기본 정사각형(2048×2048)을 낸다 —
    16:9 원본을 변환한 최종본이 정사각형으로 나왔다(S72sh43 실측). 주지
    않으면 요청은 예전과 byte 동일하다(마커 맵 호출부는 안 준다).

    세 갈래가 API 모양이 다르다:
        nb2·gemini    Gemini 직접 (키 풀·재시도를 이미 갖고 있다)
        provider/…    OpenRouter `/api/v1/images`
        그 밖         OpenAI `images.edit`

    ★OpenAI 갈래만 호출부가 넘긴다(`openai_render`) — 그쪽은 client·
     sanitizer·size 같은 자리별 사정이 붙어 있어 여기로 끌어오면 자리마다
     다른 것을 하나로 뭉개게 된다.
    """
    if is_gemini_model(model):
        return render_via_gemini(
            model=model, prompt=prompt, ref_paths=ref_paths,
            capture_context=capture_context)
    if is_openrouter_model(model):
        return render_via_openrouter(
            model=model, prompt=prompt, ref_paths=ref_paths, role=role,
            capture_context=capture_context, aspect_ratio=aspect_ratio)
    if openai_render is None:
        raise ValueError(
            f"{model!r} 은 OpenAI 갈래인데 그리는 함수를 못 받았다")
    return openai_render(model)


def render_via_openrouter(
    *,
    model: str,
    prompt: str,
    ref_paths: List[Any],
    role: str = "marker_map",
    capture_context: Optional[Dict[str, Any]] = None,
    aspect_ratio: Optional[str] = None,
) -> bytes:
    """OpenRouter 경유로 마커 맵을 그린다 — PNG bytes.

    grok-imagine 계열은 이 저장소가 이미 쓰던 chat 경로를 그대로 타고
    (`GrokImageClient` 재사용 — 같은 운반 코드를 두 벌 두지 않는다),
    그 밖의 이미지 모델은 **전용 `/api/v1/images`** 로 간다.
    """
    if not ref_paths:
        raise ValueError("마커 맵은 바탕 맵을 참조로 받아야 한다 — 참조 0장")
    if "grok-imagine" in model:
        from app.modules.llm.grok_image_client import GrokImageClient

        client = GrokImageClient(model=model)
        if capture_context:
            client.set_context(**capture_context)
        labeled = [(f"REFERENCE {i}:", Path(str(p)).read_bytes())
                   for i, p in enumerate(ref_paths, 1)]
        png, _elapsed = client.generate_image(
            prompt, labeled_references=labeled)
        return png
    return _render_via_images_api(
        model=model, prompt=prompt, ref_paths=ref_paths, role=role,
        capture_context=capture_context, aspect_ratio=aspect_ratio)


def _render_via_images_api(
    *,
    model: str,
    prompt: str,
    ref_paths: List[Any],
    role: str,
    capture_context: Optional[Dict[str, Any]],
    aspect_ratio: Optional[str] = None,
) -> bytes:
    """OpenRouter 전용 이미지 엔드포인트 — 참조는 `input_references`.

    ★예산은 **보내기 직전 한 번**만 예약한다. 재시도를 여기 두지 않는 것도
     같은 이유다 — 후퇴는 이미 「다른 모델」이 맡고, 같은 자리에 두 겹을
     쌓으면 한 지출이 두 번 세어진다.
    """
    import base64
    import json as _json
    import time
    import urllib.request

    from app.core.config import settings
    from app.core.image_call_budget import reserve_current_call
    from app.modules.llm.image_format import ensure_png_bytes
    from app.modules.llm.llm_logger import log_llm_call

    key = (getattr(settings, "openrouter_api_key", "") or "").strip()
    if not key:
        raise RuntimeError("OPENROUTER_API_KEY 가 설정에 없다")
    refs = []
    for p in ref_paths:
        data = base64.b64encode(Path(str(p)).read_bytes()).decode("ascii")
        refs.append({"type": "image_url",
                     "image_url": {"url": f"data:image/png;base64,{data}"}})
    body = {"model": model, "prompt": prompt, "input_references": refs}
    if aspect_ratio:
        # 모델이 받는 값인지는 이 자리에서 거르지 않는다 — 받는 값 목록은
        # 제공자 메타(`/api/v1/images/models/<id>/endpoints`)가 정본이고,
        # 틀리면 제공자가 400 으로 알려 준다(조용히 빼면 또 정사각형이 된다).
        body["aspect_ratio"] = aspect_ratio
    req = urllib.request.Request(
        OPENROUTER_IMAGES_URL, data=_json.dumps(body).encode("utf-8"),
        headers={"Authorization": f"Bearer {key}",
                 "Content-Type": "application/json"}, method="POST")
    ctx = dict(capture_context or {})
    t0 = time.monotonic()
    reserve_current_call(source="marker_map_engine.openrouter_images")
    try:
        with urllib.request.urlopen(
                req, timeout=settings.llm_timeout_image_gen) as resp:
            payload = _json.loads(resp.read().decode("utf-8"))
    except Exception as exc:  # noqa: BLE001
        log_llm_call(model_name=model, user_prompt=prompt, status="error",
                     error_message=f"{type(exc).__name__}: {exc}"[:500],
                     duration_ms=int((time.monotonic() - t0) * 1000),
                     project_id=ctx.get("project_id"),
                     episode_id=ctx.get("episode_id"),
                     operation_type=ctx.get("operation_type") or role)
        raise
    raw = _extract_image_bytes(payload)
    if raw is None:
        raise RuntimeError(
            f"OpenRouter images 응답에 그림이 없다 ({model}): "
            f"{_json.dumps(payload, ensure_ascii=False)[:300]}")
    elapsed = int((time.monotonic() - t0) * 1000)
    log_llm_call(model_name=model, user_prompt=prompt,
                 output_text="[image generated]", status="success",
                 duration_ms=elapsed,
                 project_id=ctx.get("project_id"),
                 episode_id=ctx.get("episode_id"),
                 operation_type=ctx.get("operation_type") or role)
    return ensure_png_bytes(raw, context=f"{model}/{role}")


def _extract_image_bytes(payload: Any):
    """응답에서 그림 bytes 를 꺼낸다 — data URL·b64·URL 어느 모양이든.

    ★모양을 하나로 단정하지 않는다. 제공자마다 다르고, 틀리면 「그림이
     없다」로 읽혀 산 것을 버리게 된다.
    """
    import base64
    import urllib.request

    def _from_str(s: str):
        if s.startswith("data:"):
            return base64.b64decode(s.split(",", 1)[1])
        if s.startswith("http"):
            with urllib.request.urlopen(s, timeout=120) as dl:
                return dl.read()
        try:
            return base64.b64decode(s)
        except Exception:  # noqa: BLE001
            return None

    stack = [payload]
    while stack:
        node = stack.pop()
        if isinstance(node, dict):
            for k in ("b64_json", "image_base64", "url", "image_url"):
                v = node.get(k)
                if isinstance(v, str) and len(v) > 32:
                    got = _from_str(v)
                    if got:
                        return got
                if isinstance(v, dict) and isinstance(v.get("url"), str):
                    got = _from_str(v["url"])
                    if got:
                        return got
            stack.extend(node.values())
        elif isinstance(node, list):
            stack.extend(node)
    return None
