"""W21B (2026-06-08) — dwelling_zone_map LLM / VLM IO boundary.

The EXPERIMENTAL half of the dwelling zone map (the deterministic join lives in
``dwelling_zone_map``). Three thin model calls, all fail-closed and all
generic (no scenario tokens — the dwelling is described only by the per-bg
text the caller assembles upstream):

  1. ``analyze_dwelling_space`` — a single gpt-5.5 text call that reads the
     dwelling's per-bg background descriptions and distils the BASE SET (a
     clean, lived-in initial state) into a use-based room list + openings,
     dropping per-shot events / props / proper nouns. (Port of the bird_warp
     ANALYZE_SYS, genericised.)
  2. ``build_clean_fp_prompt`` — a gpt-5.5 text call that turns that analysis
     into a SHORT English prompt for a clean black-and-white 2D architectural
     floor plan (rooms / walls / doors / windows + main furniture symbols
     only; room-USE labels allowed, no marker numbers, no mood/material).
  3. ``map_bgs_to_zones`` — a gpt-5.5 VISION call: it is shown the rendered
     clean FP image plus each bg's structural description and returns, per bg,
     the plan zone it belongs to + a 0..100 grid focus point + confidence
     (strict JSON list). This is the edge-judge bypass — the model reasons
     about camera angle off the actual plan image.

The gpt-image-2 render of the clean FP and the PIL number-overlay annotation
are done by the STEP (image client / file I/O), mirroring how
``floor_plan_light_sidecar`` keeps the render in the step and the text LLM in
the provider. Output quality is a VISUAL gate (user + canary), never a
deterministic assertion (LLM/VLM/T2I are not TDD-completable here).

Quality / completion is validated by ``dwelling_zone_map.validate_vlm_zone_output``
(strict shape) + a visual gate, never inside this module.
"""
from __future__ import annotations

import base64
import json
import logging
import os
import re
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

PROVIDER_VERSION: str = "dwelling_zone_map_v1"
PROMPT_VERSION: str = "1"
TEXT_MODEL_DEFAULT: str = "openai/gpt-5.6-sol"
VISION_MODEL_DEFAULT: str = "openai/gpt-5.6-sol"
ANALYZE_MAX_TOKENS: int = 2600
FP_PROMPT_MAX_TOKENS: int = 900
VLM_MAX_TOKENS: int = 2500
TEXT_TIMEOUT_SECONDS: int = 200
# Vision over a 6-bg dwelling sends 6 full plate descriptions (input is NEVER
# truncated — CLAUDE.md: pass scenario-derived text whole), so the call is the
# heaviest in this provider and its latency is variable. 240s flaked on the
# rooftop (6 bg) worst case → raised, with one bounded retry on the same input.
VISION_TIMEOUT_SECONDS: int = 480


class DwellingZoneMapProviderError(Exception):
    """Fail-closed signal for the dwelling-zone-map provider path."""


# ───────────────────────── prompts (generic — no scenario tokens) ─────────────────────────
# A dwelling interior's permanent base set, distilled from its bg descriptions.
ANALYZE_SYSTEM = (
    "너는 영화 미술감독을 돕는 세트 디자이너다. 입력은 한 실내 거주 공간이 등장하는 여러 배경 플레이트 묘사다. "
    "이것들을 모두 읽고, 그 장소의 초기 세트장(base set) — 사람이 사는 기본 상태의 공간 — 을 짓기 위한 영구 배경 요소를 추출하라.\n"
    "가장 중요: 너는 '초기 세트장'을 만든다 — 깨끗하고 사람이 사는 기본 상태. 플레이트마다 벌어지는 사건·변형(피·핏자국·시신·"
    "환각·어질러짐·뒤집힌 가재도구·반대로 깨끗이 치워짐 등)은 절대 배경에 넣지 마라. 그건 나중에 샷을 찍을 때 입힌다.\n"
    "반드시 지켜라:\n"
    "1) 사건·줄거리·변형 전부 배제 — 피/핏자국/시신/사진/액자/인형/가방/배낭/조리 중 냄비/휴대폰/어질러진 상태 등은 영구 배경이 아니다.\n"
    "2) 영구 배경만 — 방 구조·벽·문·창, 붙박이(주방 설비·욕실 변기/세면대/거울/샤워), 기본 가구(침대·식탁·의자·TV·조명).\n"
    "3) 직물·생활 살림 포함(생활감) — 묘사에 커튼이 보이면 커튼은 영구 배경이다. 사람이 사는 곳이므로 거주 살림(옷장/행거, "
    "이불·침구, 수납 선반 등)이 자연스럽게 있다. 단 특정 품목을 기계적으로 박지 말고 합리적으로.\n"
    "4) 거실·주방·식사공간이 하나의 열린(open) 공간으로 묘사되면 하나의 일체형 공간으로 다뤄라.\n"
    "5) 침실이 물리적으로 여러 개면 반드시 각각 별개의 bedroom 으로 분리하라 — 절대 하나로 합치지 마라. 단 이름은 용도로만 — "
    "침실이 둘이면 크기·위치로 구분('smaller bedroom'/'larger bedroom'), 인물·소유 명명 금지.\n"
    "6) 모든 방 이름·묘사는 '용도(기능)' 중심 — 인물·소유·고유명사 절대 금지. 오직 공간 용도로만: 거실-주방-식사 일체형="
    "'open living-dining-kitchen', 침실='bedroom', 욕실='bathroom', 현관='entry'.\n"
    "7) 목표는 실내 세트다. 건물 외관·외부 전경 같은 외부는 평면도 대상 아님(출입문 등 내/외부 경계만).\n"
    "8) 각 요소에 근거(evidence)를 달아라.\n"
    "9) 같은 '물리적 공간'은 이미지 하나만 — 어떤 방이 다른 방과 벽·문으로 나뉘지 않은 하나의 연속된 공간이면(대표 예: 현관 "
    "entry 는 거실-주방 일체형과 분리벽이 없는 같은 공간이다) same_space_as 에 그것이 속한 더 큰 공간의 방 이름을 적어라. "
    "벽·문으로 분명히 분리된 독립 방(각 침실·욕실)은 same_space_as=null.\n"
    'JSON 으로만 출력: {"space_type":"...","summary":"한 줄","rooms":[{"name":"영문 방이름",'
    '"same_space_as":"물리적으로 같은 하나의 연속 공간을 이루는 다른 방 이름, 독립 공간이면 null",'
    '"fixtures":["붙박이/기본가구"],"evidence":"근거"}],'
    '"openings":[{"name":"door/window/curtain","connects":"A - B","evidence":"..."}],"excluded_events":["배제한 사건·변형·소품"]}'
)

# Analysis → a clean B&W 2D architectural floor plan T2I prompt (structure only).
FP_PROMPT_SYSTEM = (
    "너는 gpt-image-2 에게 줄 T2I 프롬프트를 쓰는 건축 제도사다. 주어진 '공간 분석'으로 그 집의 표준적인 "
    "흑백 2D 건축 평면도(a clean black-and-white 2D architectural floor plan / top-down blueprint)를 그리는 영어 프롬프트를 작성하라.\n"
    "핵심: 평면도는 '순수 공간 구조 도면'이다 — 방의 배치·벽·문·창 + 주요 가구/설비의 위치를 평면 심볼로 보여줄 뿐이다. "
    "분위기·재질·조명·생활감·장소 성격(낡음·가난함 같은 형용사)을 절대 넣지 마라. 그런 톤은 나중 단계에서 입힌다.\n"
    "원칙:\n"
    "1) 흑백(monochrome) 순수 top-down 2D 도면 — 색·텍스처·그림자·원근·입체 없음. 벽=굵은 검은 선, 문=개구부(swing arc), "
    "창=벽의 빈 구간, 가구·설비=위에서 본 단순 평면 심볼.\n"
    "2) 가볍게 — 방·벽·문·창 + 주요 가구(침대·식탁·의자·TV·싱크대·쿡탑·변기·세면대·큰 옷장 정도)의 위치 윤곽만. 잔살림·생활 "
    "디테일은 넣지 마라.\n"
    "3) 각 방을 그 '용도(기능)'로만 다뤄라 — 인물·소유 기반 명명 절대 금지. 분석의 용도 기반 방 이름을 그대로 쓴다"
    "(open living-dining-kitchen / bedroom / bathroom / entry). 거실·주방·식사 일체형은 내부 벽/문 없이 하나의 열린 공간.\n"
    "4) 분석에 있는 방·가구·문·창만 — 없는 것 발명 금지.\n"
    "5) 글자·치수선·라벨 최소 — 방 용도 이름 정도만 허용하고, 가구·문에는 설명 텍스트를 붙이지 마라. 번호 마커도 넣지 마라. "
    "부정형(no blood 등) 나열도 금지.\n"
    "영어 3~5문장으로만 출력."
)

# Vision: map each bg plate to its plan zone + grid focus (strict JSON list).
VLM_SYSTEM = (
    "너는 실내 공간 매핑 전문가다. 첨부된 흑백 2D 평면도(공간 용도 라벨이 표기됨)를 보고, 각 배경 플레이트(BG) 묘사가 "
    "평면도의 어느 공간에 해당하는지 판정한다. 배경 묘사의 영구 구조물/가구 단서와 평면도 위치를 함께 본다. "
    "사건·변형·소품·인물·고유명사는 무시하고 오직 영구 공간 구조로만 판단하라. "
    "같은 물리 공간(같은 방)에 속한 BG 들은 zone_group 문자열을 동일하게 묶는다. 반드시 JSON 배열만 출력."
)


def _strip_fence(text: str) -> str:
    t = (text or "").strip()
    t = re.sub(r"^```(?:json)?", "", t).strip()
    t = re.sub(r"```$", "", t).strip()
    return t


def _require_api_key() -> None:
    """호출 전 키 존재 확인 — 슬롯 브로커 기준.

    [2026-08-01] 이전에는 환경변수가 비면 ``settings.openai_api_key``(=**1차
    키**)를 환경변수에 써 넣었다. 그러면 보조 슬롯으로 전환된 뒤에도 죽은 1차
    키가 환경에 박혀 전환이 통째로 무효가 된다. `space_set_bg_provider` 가
    2026-07-30 에 세운 패턴(활성 슬롯 우선)을 따른다.
    """
    from app.core.openai_keys import has_openai_key

    if has_openai_key():
        return
    raise DwellingZoneMapProviderError(
        "OPENAI_API_KEY missing or empty; refusing to call the model"
    )


def _text_completion(
    *, system: str, user: str, model: str, max_tokens: int, timeout: int
) -> str:
    _require_api_key()
    try:
        from app.core.openai_keys import (  # type: ignore
            llm_completion as _llm_completion,
        )
    except Exception as exc:  # pragma: no cover — env-dependent
        raise DwellingZoneMapProviderError(
            f"litellm import failed: {type(exc).__name__}: {exc}"
        ) from exc
    try:
        resp = _llm_completion(
            model=model,
            messages=[
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
            timeout=timeout,
            max_completion_tokens=max_tokens,
            num_retries=0,
            reasoning_effort="low",
        )
    except Exception as exc:
        raise DwellingZoneMapProviderError(
            f"text completion raised: {type(exc).__name__}: {exc}"
        ) from exc
    content = (resp.choices[0].message.content if resp.choices else None) or ""
    if not content.strip():
        raise DwellingZoneMapProviderError("text completion returned empty content")
    return content.strip()


def _format_bg_blocks(bg_blocks: List[Dict[str, Any]]) -> str:
    parts: List[str] = []
    for b in bg_blocks or []:
        bid = b.get("bg_id", "")
        desc = b.get("description", "")
        block = f"[{bid}]\n{desc}"
        struct = b.get("structure_facts")
        if struct:
            # dossier structural marker context (numeric only) — enrichment.
            block += "\n(구조 마커 facts): " + json.dumps(struct, ensure_ascii=False)
        parts.append(block)
    return "\n\n".join(parts)


def analyze_dwelling_space(
    *,
    bg_blocks: List[Dict[str, Any]],
    model: str = TEXT_MODEL_DEFAULT,
) -> Dict[str, Any]:
    """Distil the dwelling's permanent base-set rooms/openings from its per-bg
    descriptions (gpt-5.5 text). Returns the parsed analysis dict (rooms /
    openings / same_space_as). Raises on empty / unparseable output."""
    if not bg_blocks:
        raise DwellingZoneMapProviderError("bg_blocks empty — nothing to analyze")
    user = (
        "다음은 한 실내 거주 공간이 등장하는 여러 배경 플레이트 묘사다. 이 장소의 초기 세트장 영구 배경"
        "(구조·붙박이·기본가구·거주 살림)만 분석해 JSON 으로 출력하라. 플레이트마다의 사건·변형은 절대 배경에 넣지 마라:\n\n"
        + _format_bg_blocks(bg_blocks)
    )
    raw = _text_completion(
        system=ANALYZE_SYSTEM, user=user, model=model,
        max_tokens=ANALYZE_MAX_TOKENS, timeout=TEXT_TIMEOUT_SECONDS,
    )
    try:
        analysis = json.loads(_strip_fence(raw))
    except (TypeError, ValueError) as exc:
        raise DwellingZoneMapProviderError(
            f"space analysis not valid JSON: {type(exc).__name__}: {exc}"
        ) from exc
    if not isinstance(analysis, dict) or not isinstance(analysis.get("rooms"), list):
        raise DwellingZoneMapProviderError("space analysis missing rooms[]")
    return analysis


def build_clean_fp_prompt(
    *,
    space_model: Optional[Dict[str, Any]] = None,
    space_analysis: Optional[Dict[str, Any]] = None,
    model: str = TEXT_MODEL_DEFAULT,
    timeout: int = TEXT_TIMEOUT_SECONDS,
) -> str:
    """Write the clean B&W 2D floor-plan T2I prompt (gpt-5.5 text).

    PRIMARY input = the upstream ``floor_plan_prompt.space_model`` (the
    CANONICAL dwelling structure — EVERY zone of the dwelling). The FP must
    show the WHOLE dwelling (all of its rooms), not just the spaces that happen
    to have a bg plate — so we feed the full zone list with an explicit "draw
    every zone" instruction.
    ``space_analysis`` (the bg-derived fallback) is used only when no
    space_model is available. Only spatial structure — no mood, no marker
    numbers."""
    if isinstance(space_model, dict) and isinstance(space_model.get("zones"), list) \
            and space_model.get("zones"):
        zones = [
            {
                "zone_id": z.get("zone_id"),
                "zone_type": z.get("zone_type"),
                "essential_elements": z.get("essential_elements") or [],
                "openings": z.get("openings") or [],
                "adjacency": z.get("adjacency") or [],
            }
            for z in space_model["zones"] if isinstance(z, dict)
        ]
        user = (
            "[이 거주 공간의 정식 공간 구조(space_model JSON) — 이 집의 모든 zone 과 그 연결]\n"
            + json.dumps({"zones": zones}, ensure_ascii=False, indent=1)
            + "\n\n위 구조로 순수 구조용 가벼운 흑백 2D 건축 평면도를 그릴 T2I 프롬프트를 영어로 작성하라. "
            "★위 JSON 의 zone 을 하나도 빠뜨리지 말고 전부 평면도에 포함하라(같은 zone_type 이 여러 개면 모두 별개의 방으로). "
            "★연결 규칙(각 zone 의 openings 로 판단): 두 zone 사이 opening 이 문이 없는 열린 통로면 두 zone 을 내부 벽 없이 "
            "하나의 연속된 열린 공간으로 그리고, 문으로 연결되면 벽과 문으로 분리된 독립 방으로 그려라. "
            "각 zone 안에는 그 zone 의 zone_type 에 해당하는 짧은 영어 용도 단어 하나만 표기하라(개별 가구 라벨·번호 마커 금지). "
            "각 zone 의 essential_elements 를 위에서 본 단순 평면 가구/설비 심볼로 배치하고, 벽/문 swing arc/창 더블라인을 그려라. "
            "분위기·재질·색·생활감·번호 마커·이벤트 흔적은 절대 넣지 마라."
        )
    else:
        sa = space_analysis or {}
        light = {
            "space_type": sa.get("space_type"),
            "summary": sa.get("summary"),
            "rooms": [
                {
                    "name": r.get("name"),
                    "same_space_as": r.get("same_space_as"),
                    "fixtures": r.get("fixtures") or [],
                }
                for r in (sa.get("rooms") or []) if isinstance(r, dict)
            ],
            "openings": sa.get("openings") or [],
        }
        user = (
            "[초기 세트장 공간 분석(JSON) — 용도 기반 방 + 주요 가구]\n"
            + json.dumps(light, ensure_ascii=False, indent=2)
            + "\n\n위 분석의 모든 방을 빠짐없이 포함해 순수 구조용 가벼운 흑백 2D 건축 평면도를 그릴 T2I 프롬프트를 영어로 작성하라. "
            "방은 용도(기능)로만 명명하고(open living-dining-kitchen / bedroom / bathroom / entry), 거실·주방·식사 일체형은 "
            "하나의 열린 공간. 방 배치 + 벽/문/창 + 주요 가구 심볼만 — 분위기·재질·생활감·번호 마커는 넣지 마라."
        )
    return _text_completion(
        system=FP_PROMPT_SYSTEM, user=user, model=model,
        max_tokens=FP_PROMPT_MAX_TOKENS, timeout=timeout,
    )


def map_bgs_to_zones(
    *,
    fp_image_path: str,
    bg_blocks: List[Dict[str, Any]],
    room_labels: Optional[List[str]] = None,
    model: str = VISION_MODEL_DEFAULT,
) -> List[Dict[str, Any]]:
    """Vision: show the clean FP image + each bg's structural description,
    return per-bg ``{bg_id, room, grid{x,y}, zone_group, confidence, evidence}``
    (strict JSON list). The deterministic ``validate_vlm_zone_output`` gates the
    shape; this only issues the call and parses."""
    if not bg_blocks:
        raise DwellingZoneMapProviderError("bg_blocks empty — nothing to map")
    if not fp_image_path or not os.path.exists(fp_image_path):
        raise DwellingZoneMapProviderError(
            f"fp_image_path missing: {fp_image_path!r}"
        )
    _require_api_key()
    try:
        from app.core.openai_keys import (  # type: ignore
            llm_completion as _llm_completion,
        )
    except Exception as exc:  # pragma: no cover — env-dependent
        raise DwellingZoneMapProviderError(
            f"litellm import failed: {type(exc).__name__}: {exc}"
        ) from exc

    b64 = base64.b64encode(open(fp_image_path, "rb").read()).decode()
    labels_hint = ""
    if room_labels:
        labels_hint = "\n평면도 공간 용도 라벨: " + ", ".join(room_labels)
    user_text = (
        "=각 배경 플레이트 묘사=\n" + _format_bg_blocks(bg_blocks) + labels_hint
        + "\n\n각 BG 가 평면도 어느 공간인지 + 같은 공간 묶기. JSON 배열만:\n"
        '[{"bg_id":"<원래 bg_id>","room":"평면도 라벨 또는 위치 설명",'
        '"grid":{"x":0~100,"y":0~100},"zone_group":"같은 공간끼리 동일 문자열",'
        '"confidence":0.0~1.0,"evidence":"영구 구조 근거"}]'
    )
    try:
        resp = _llm_completion(
            model=model,
            messages=[
                {"role": "system", "content": VLM_SYSTEM},
                {"role": "user", "content": [
                    {"type": "text", "text": user_text},
                    {"type": "image_url",
                     "image_url": {"url": f"data:image/png;base64,{b64}"}},
                ]},
            ],
            timeout=VISION_TIMEOUT_SECONDS,
            max_completion_tokens=VLM_MAX_TOKENS,
            num_retries=1,  # bounded retry on the SAME input (no truncation)
            reasoning_effort="low",
        )
    except Exception as exc:
        raise DwellingZoneMapProviderError(
            f"vision completion raised: {type(exc).__name__}: {exc}"
        ) from exc
    content = (resp.choices[0].message.content if resp.choices else None) or ""
    if not content.strip():
        raise DwellingZoneMapProviderError("vision completion returned empty content")
    try:
        parsed = json.loads(_strip_fence(content))
    except (TypeError, ValueError) as exc:
        raise DwellingZoneMapProviderError(
            f"vision output not valid JSON: {type(exc).__name__}: {exc}"
        ) from exc
    if not isinstance(parsed, list):
        raise DwellingZoneMapProviderError("vision output is not a JSON list")
    return parsed
