"""background_render — Phase 7 Step 6.

각 master plan background를 gpt-image-2로 PNG 생성. ref 우선순위는
``fp_path`` (1순위) → ``prior_bg_paths`` 의 concat. 두 개 이상이면
multi-image edit, 한 개면 single edit, 0개면 generate (text-only).

PromptSanitizer로 moderation 차단 시 retry. 실패 시 status='failed'
+ ``final_block_reason`` 채워서 dict 반환 (예외 미전파).
"""
from __future__ import annotations

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

from app.core.image_call_budget import (
    ImageCallBudgetExceeded,
    reserve_current_call,
)
from app.modules.llm.gpt_image_primitive import call_gpt_image_bytes
from app.modules.prompt_sanitizer import PromptSanitizer

logger = logging.getLogger(__name__)

_BACKGROUND_ONLY_REINFORCEMENT = (
    "BACKGROUND-ONLY architectural still — empty space, NO people, NO faces, "
    "NO body posture, NO action, NO weapons, NO blood. "
    "Photoreal scene without any human figures.\n\n"
)

# W-G (2026-07-03) — outdoor plate 에 same-building indoor floor plan 을 추가 ref 로
# 첨부할 때 프롬프트 끝에 덧붙이는 정합 지시. generic — 건물 규모/층수/개구부 정합만
# 요구, 반례·시나리오 토큰 0. fp 는 ref 리스트 **마지막**에 첨부된다(호출 계약).
BUILDING_FP_PLATE_GUIDANCE = (
    "\n\nThe LAST attached image is a TOP-DOWN INTERIOR FLOOR PLAN of the "
    "building at this location — a STRUCTURAL REFERENCE ONLY, never content to "
    "draw. It defines the building's real scale: its footprint, room count, and "
    "the placement of its doors and windows. Render the scene so the building's "
    "exterior size, number of storeys, and the scale and placement of its doors "
    "and windows stay consistent with that plan. Do NOT draw the floor plan "
    "itself, its lines, text or numbers anywhere in the image."
)

# W-I (2026-07-03) — 같은 building 그룹의 anchor 렌더(먼저 완성된 같은 건물 plate)를
# 추가 ref 로 첨부할 때의 시각 정체성 계약. 도면(fp)은 위상만 고정하고 외관 정체성
# (재질/색/문·창 스타일/설치물/노후도)은 렌더마다 재발명되는 결함의 근본 대응 —
# generic, 시나리오 토큰 0. 구도/앵글 복제는 금지(정체성만 상속).
BUILDING_ANCHOR_PLATE_GUIDANCE = (
    "\n\nOne attached image is a PHOTOREAL RENDER of this SAME building/site "
    "seen from a different viewpoint — treat it as the VISUAL IDENTITY "
    "reference. Keep this render consistent with it: the same wall materials "
    "and colors, the same door and window styles, the same permanently "
    "installed fixtures and objects, the same weathering and age. Do NOT copy "
    "its composition, framing or camera angle — frame this scene per the "
    "prompt; only the building's physical identity carries over."
)

# W-K (2026-07-03) — 같은 location 의 anchor 렌더(그 장소에서 먼저 완성된 plate)를
# 추가 ref 로 첨부할 때의 장소 정체성 계약. 같은 장소의 렌더들이 상호참조 0 으로
# 매번 다른 공간을 재발명하는 결함의 근본 대응 — generic, 시나리오 토큰 0.
# 앵글/시간대/조명/날씨는 이 프롬프트가 SOT — 구도 복제 금지, 장소 정체성만 상속.
SAME_PLACE_PLATE_GUIDANCE = (
    "\n\nOne attached image is a PHOTOREAL RENDER of this SAME place — the "
    "same physical location, possibly seen from a different angle or at a "
    "different moment. Treat it as the PLACE IDENTITY reference. Keep this "
    "render consistent with it: the same spatial layout and architecture, the "
    "same surface materials and colors, and the same permanently installed "
    "fixtures and furniture. Lettering visible in the reference is reproduced "
    "EXACTLY — same characters, same mark, same position; never invent "
    "different wording from render to render. Camera "
    "angle, framing, lighting, time of day and weather follow THIS prompt — "
    "do NOT copy the reference's composition or mood; only the place's "
    "physical identity carries over."
)

# W-M (2026-07-03) — 실내 plate 의 외부 렌더 첨부 제거(사용자 계약: 건물 외부
# 이미지를 건물 내부 렌더에 사용 금지) 후, 실내↔실외 연속성을 이미지 ref 없이
# **프롬프트 문구만**으로 유지하는 계약. mixed 그룹(실내·실외 공존)의 실내 bg
# 렌더에 append — 첨부 이미지를 언급하지 않는다(첨부가 없으므로). generic,
# 시나리오 토큰 0.
INTERIOR_SAME_BUILDING_TEXT_GUIDANCE = (
    "\n\nThis interior belongs to the same single real building/place as "
    "this location group's other spaces. Keep its construction era, age and "
    "state of repair, material quality and window/door frame styles "
    "consistent with ONE ordinary real building of the kind this prompt "
    "describes, and anything visible through its windows, doorways or "
    "passages must plausibly belong to that same building and its "
    "immediate surroundings."
)

# W-K (2026-07-03) — BUILDING_ANCHOR_PLATE_GUIDANCE 의 실내↔실외 정합 개정판.
# same_place_render_chain_enabled ON 일 때 그룹 anchor 첨부가 그룹 전 멤버
# (실내 포함)로 확장되므로, anchor(외부 렌더)와 대상 렌더가 실내/실외 어느
# 쪽이든 성립하는 정합 계약으로 교체한다: 시대감/노후도·팔레트·마감 품질·
# 창/문틀 스타일·개구부 너머로 보이는 것의 일관성. 조명/시간대는 프롬프트 SOT.
# flag OFF 면 기존 문구 그대로 사용(byte-identical).
BUILDING_INTERIOR_EXTERIOR_ANCHOR_GUIDANCE = (
    "\n\nOne attached image is a PHOTOREAL RENDER of the SAME building/site "
    "this scene belongs to — it may show the outside while this scene is "
    "inside, or another part of the same place. Treat it as the BUILDING "
    "IDENTITY reference. Keep this render consistent with it: the same era, "
    "age and state of repair, the same palette and material quality, the same "
    "window and door frame styles, and anything visible through openings "
    "(windows, doorways, passages) must belong to that same building and its "
    "surroundings. Lettering visible in the reference is reproduced EXACTLY "
    "— same characters, same mark, same position; never invent different "
    "wording from render to render. "
    "Lighting and time of day follow "
    "THIS prompt. Do NOT copy the reference's composition, framing or camera "
    "angle; only the building's physical identity carries over."
)


def render_one_background(
    *,
    openai_client: Any,
    image_model: str,
    prompt: str,
    out_path: Path,
    fp_path: Optional[Path],
    prior_bg_paths: List[Path],
    bg_id: str = "",
    sanitizer: Optional[PromptSanitizer] = None,
    size: str = "1536x864",
    quality: str = "high",
    max_attempts: int = 4,
    building_fp_path: Optional[Path] = None,
    building_anchor_path: Optional[Path] = None,
    same_place_anchor_path: Optional[Path] = None,
    aerial_ref_path: Optional[Path] = None,
    capture_input_image_ids: Optional[List[str]] = None,
    plate_multiroll_ctx: Optional[Dict[str, Any]] = None,
    _unmanned_retry: bool = False,
) -> Dict[str, Any]:
    """단일 배경 PNG 생성.

    - ``aerial_ref_path`` (W-L: 야외 location 의 항공뷰 배치도 — top-down
      site plan, 위상 SOT)가 존재하면 ref **최우선(첫 번째)** 으로 추가 —
      야외 bg 는 호출자가 fp_path 를 None 으로 교체해 분열된 개별 평면도의
      위상 지배를 제거한다.
      프롬프트의 위상 계약(AERIAL_SITE_GUIDANCE)도 호출자가 덧붙인다.
      None(default) = byte-identical.
    - ``fp_path`` 가 존재하면 ref 1순위로 사용.
    - ``prior_bg_paths`` 의 존재하는 path들을 ref 2순위로 concat.
    - ``same_place_anchor_path`` (W-K: 같은 location 의 anchor 렌더, 장소
      정체성 참조)가 존재하면 prior 다음·building anchor **앞**에 추가. 중복
      경로 dedup 은 호출자 책임(계약) — 프롬프트의 장소 정체성 지시도
      호출자가 덧붙인다. None(default) = byte-identical.
    - ``building_anchor_path`` (W-I: 같은 building 그룹의 anchor 렌더, 시각
      정체성 참조)가 존재하면 그 다음·building fp **앞**에 추가. 중복 경로
      dedup 은 호출자 책임(계약) — 프롬프트의 정체성 지시도 호출자가 덧붙인다.
      None(default) = byte-identical.
    - ``building_fp_path`` (W-G: same-building indoor floor plan, 구조 참조)가
      존재하면 ref **마지막**에 추가 — 기존 ref 우선순위 불변, 프롬프트의
      정합 지시는 호출자가 덧붙인다. None(default) = byte-identical.
    - 합산 ref ≥2 → ``client.images.edit(image=[...])`` (multi-image edit).
    - 합산 ref == 1 → ``client.images.edit(image=f)`` (single edit).
    - 합산 ref == 0 → ``client.images.generate`` (text-only).

    ``capture_input_image_ids`` 는 capture(generation_context scope 열림 시)의
    intermediate ImageAsset lineage 로 threading 만 한다(None=기존과 동일).

    moderation 류 차단이 감지되면 PromptSanitizer로 retry. attempt가 한도에
    도달하면 status='failed' + ``final_block_reason`` 으로 종료.

    Returns dict: ``{status, attempts, strategies, ref_used,
    final_block_reason, building_fp_attached, building_anchor_attached,
    same_place_anchor_attached, aerial_ref_attached, png_path?}``.
    """
    ref_paths: List[Path] = []
    aerial_ref_attached = False
    if aerial_ref_path is not None and aerial_ref_path.exists():
        ref_paths.append(aerial_ref_path)
        aerial_ref_attached = True
    if fp_path is not None and fp_path.exists():
        ref_paths.append(fp_path)
    for p in prior_bg_paths:
        if p is not None and p.exists():
            ref_paths.append(p)
    same_place_anchor_attached = False
    if same_place_anchor_path is not None and same_place_anchor_path.exists():
        ref_paths.append(same_place_anchor_path)
        same_place_anchor_attached = True
    building_anchor_attached = False
    if building_anchor_path is not None and building_anchor_path.exists():
        ref_paths.append(building_anchor_path)
        building_anchor_attached = True
    building_fp_attached = False
    if building_fp_path is not None and building_fp_path.exists():
        ref_paths.append(building_fp_path)
        building_fp_attached = True

    if not ref_paths:
        ref_used = "text_only"
    elif len(ref_paths) == 1 and fp_path is not None and fp_path.exists():
        ref_used = "fp_only"
    else:
        ref_used = f"refs_{len(ref_paths)}"

    info: Dict[str, Any] = {
        "status": "failed",
        "attempts": 0,
        "strategies": [],
        "ref_used": ref_used,
        "final_block_reason": None,
        "building_fp_attached": building_fp_attached,
        "building_anchor_attached": building_anchor_attached,
        "same_place_anchor_attached": same_place_anchor_attached,
        "aerial_ref_attached": aerial_ref_attached,
    }

    # ── 무인 계약 (2026-07-20 이식 ①, opt-in): 배경 플레이트=무인 —
    # LLM 저작 프롬프트에 씬 인물 서술이 남아도(E2E9 L09B01 실측: 인물
    # 구워짐) 렌더 seam 에서 계약 절을 강제 부착한다. 단롤 gpt 경로와
    # plate_multiroll(nb2) 위임 경로가 같은 계약을 상속. OFF=byte-identical.
    from app.core.config import settings as _settings

    _orig_prompt = prompt
    _no_people_on = bool(
        getattr(_settings, "background_no_people_enabled", False))
    _gate_on = _no_people_on and bool(
        getattr(_settings, "background_no_people_gate_enabled", False))
    if _no_people_on:
        from app.modules.pipeline.background_unmanned import (
            build_no_people_clause,
            build_retry_clause,
        )

        prompt = prompt + "\n\n" + build_no_people_clause()
        if _unmanned_retry:
            prompt = prompt + "\n\n" + build_retry_clause()
        info["no_people_clause_attached"] = True

    def _gate(result: Dict[str, Any]) -> Dict[str, Any]:
        # 무인 VLM 게이트 — 위반 시 1회 강화 재렌더(재귀, 게이트 재진입
        # 금지=_unmanned_retry). 실패·판정 불가는 전부 fail-open+감사
        # (run_unmanned_gate 계약).
        if not _gate_on or _unmanned_retry:
            return result
        from app.modules.pipeline.background_unmanned import (
            run_unmanned_gate,
        )

        return run_unmanned_gate(
            info=result,
            out_path=out_path,
            project_config=(plate_multiroll_ctx or {}).get("project_config"),
            retry_fn=lambda: render_one_background(
                openai_client=openai_client,
                image_model=image_model,
                prompt=_orig_prompt,
                out_path=out_path,
                fp_path=fp_path,
                prior_bg_paths=prior_bg_paths,
                bg_id=bg_id,
                sanitizer=sanitizer,
                size=size,
                quality=quality,
                max_attempts=max_attempts,
                building_fp_path=building_fp_path,
                building_anchor_path=building_anchor_path,
                same_place_anchor_path=same_place_anchor_path,
                aerial_ref_path=aerial_ref_path,
                capture_input_image_ids=capture_input_image_ids,
                plate_multiroll_ctx=plate_multiroll_ctx,
                _unmanned_retry=True,
            ),
        )

    current_prompt = prompt
    if sanitizer is None:
        sanitizer = PromptSanitizer()

    # ── s40 레시피 (2026-07-13, opt-in): 플레이트 공통 multiroll 파이프 ──
    # ON 시 gpt-image-2 단롤 대신 nb2 N롤+Gemini 선정+결함 i2i 수정으로 위임.
    # OFF(default) = 아래 기존 경로 byte-identical.
    if bool(getattr(_settings, "plate_multiroll_enabled", False)):
        from app.modules.pipeline.plate_multiroll import (
            render_plate_multiroll,
        )

        # 컨텍스트(프로젝트 귀속·judge 모델 override·force)는 step 이 스레드
        # (Codex 1차 리뷰 MEDIUM-6). 미전달=빈 dict — 렌더는 성립.
        _ctx = plate_multiroll_ctx or {}
        return _gate(render_plate_multiroll(
            prompt=prompt,
            ref_paths=ref_paths,
            out_path=out_path,
            bg_id=bg_id,
            info=info,
            sanitizer=sanitizer,
            project_id=_ctx.get("project_id") or "",
            episode_id=_ctx.get("episode_id"),
            project_config=_ctx.get("project_config"),
            force=bool(_ctx.get("force")),
        ))

    for attempt in range(1, max_attempts + 1):
        info["attempts"] = attempt
        try:
            # gpt-image 호출 + b64 decode 는 primitive wrapper 로 위임(생성물 capture
            # 동시 수행, scope 미배선이면 no-op). reserve/검증/write/retry 는 여기 유지.
            call_kwargs = {
                "model": image_model,
                "size": size,
                "quality": quality,
                "n": 1,
            }
            if len(ref_paths) >= 2:
                reserve_current_call(source="background_render.edit_multi")
                png = call_gpt_image_bytes(
                    openai_client,
                    mode="edit",
                    prompt=current_prompt,
                    ref_paths=ref_paths,
                    call_kwargs=call_kwargs,
                    capture_role="background_render",
                    capture_metadata={
                        "budget_source": "background_render.edit_multi",
                        "bg_id": bg_id,
                        "ref_used": ref_used,
                        "ref_count": len(ref_paths),
                    },
                    capture_input_image_ids=capture_input_image_ids,
                )
            elif len(ref_paths) == 1:
                reserve_current_call(source="background_render.edit_single")
                png = call_gpt_image_bytes(
                    openai_client,
                    mode="edit",
                    prompt=current_prompt,
                    ref_paths=ref_paths,
                    call_kwargs=call_kwargs,
                    capture_role="background_render",
                    capture_metadata={
                        "budget_source": "background_render.edit_single",
                        "bg_id": bg_id,
                        "ref_used": ref_used,
                        "ref_count": 1,
                    },
                    capture_input_image_ids=capture_input_image_ids,
                )
            else:
                reserve_current_call(source="background_render.generate")
                png = call_gpt_image_bytes(
                    openai_client,
                    mode="generate",
                    prompt=current_prompt,
                    ref_paths=None,
                    call_kwargs=call_kwargs,
                    capture_role="background_render",
                    capture_metadata={
                        "budget_source": "background_render.generate",
                        "bg_id": bg_id,
                        "ref_used": ref_used,
                        "ref_count": 0,
                    },
                    capture_input_image_ids=capture_input_image_ids,
                )

            if not png:
                raise RuntimeError("empty b64 response")
            out_path.write_bytes(png)
            info["status"] = "ok"
            info["png_path"] = str(out_path)
            return _gate(info)
        except ImageCallBudgetExceeded:
            raise
        except Exception as exc:
            msg = str(exc).lower()
            is_moderation = any(
                k in msg
                for k in (
                    "moderation",
                    "safety",
                    "content_policy",
                    "prohibited",
                    "policy",
                    "blocked",
                    "violates",
                    "violation",
                )
            )
            if is_moderation and attempt < max_attempts:
                try:
                    s_attempt = min(attempt, 3)
                    sr = sanitizer.sanitize(
                        original_prompt=current_prompt,
                        block_reason="SAFETY",
                        block_categories=[],
                        attempt=s_attempt,
                    )
                    sanitized = sr.get("sanitized_prompt", "")
                    if not sanitized.lstrip().startswith("BACKGROUND-ONLY"):
                        sanitized = _BACKGROUND_ONLY_REINFORCEMENT + sanitized
                    current_prompt = sanitized
                    info["strategies"].append(sr.get("strategy"))
                    continue
                except Exception as se:
                    info["final_block_reason"] = f"sanitize_failed: {se}"[:200]
                    return info
            logger.error(
                "background_render %s attempt %d/%d: %s",
                bg_id,
                attempt,
                max_attempts,
                str(exc)[:200],
            )
            if attempt >= max_attempts:
                info["final_block_reason"] = str(exc)[:200]
                return info
    return info
