"""confined(좁고 복잡한 조작 공간) 샷 — fp readback 부품 (2026-08-10).

설계 SOT: docs/superpowers/specs/2026-08-10-confined-interior-fp-readback-production-design.md
실험 SOT: backend/car_interior_ab_experiment.py + artifact/20260810_car_interior_ab/

자동차 전용이 아니다 — 전투로봇 콕핏·장갑차·버스·항공기·조타실 등
조작 장치가 많은 모든 밀폐 공간(사용자 확정 2026-08-10). 공간의 실체는
전부 브리프의 LOCATION/SHOT 텍스트가 정하고, 이 부품의 계약(팩)은
범용 어휘만 쓴다 — 특정 장치·차종·방향 하드코딩 금지.

흐름: ①정밀 fp 작화(gpt-image-2 — gemini 는 조작 장치 위치를 반복해서
틀린 실측, location_floor_plan 관례와 동일 부품) ②VLM readback(도면
읽기+브리프 대조 mismatches+카메라 시점 장면 설명 — 브리프 내부 모순
검출기 겸용) ③mismatch 시 gpt i2i 국소 수정 1회+재검증 ④생성에는
fp 도면+장면 설명을 함께 준다(잔류 금지 라벨).
"""
from __future__ import annotations

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

logger = logging.getLogger(__name__)

_MODULE = "confined_fp"

CONFINED_FP_PACK_VERSION_MAP = {
    "1": "1.202608110030",
    # v2 (08-11 육안 20샷 반영): "정밀"이 세밀 렌더링으로 새던 계약을
    # **간략 구도 도면**으로 교체(base_fp_head — 좌석 배열+조작 장치+
    # 조건부 미러만, 디테일·투시·실사·PIP 금지 명문) + base 도면을 같은
    # 공간 샷들이 공유하고 샷별 마커만 얹는 분리(shot_fp_mark_head).
    "2": "2.202608110130",
}
CONFINED_FP_PACK_VERSION = "2"
# 정책 버전 — 지문 기여(ON 시). 흐름이 바뀌면 올린다.
# v2: base 공유 도면+샷 마커 분리, 마커·readback 입력에 인물 배치 절
# (CARRIED STATE·PEOPLE) 동반, fp 적용 판별(LLM) 도입.
CONFINED_FP_POLICY_VERSION = "confined_fp_v2_shared_base"

# fp 적용 판별 — confined_structure=True 중에서도 fp 가 돕는 샷만
# (2026-08-11 사용자 확정: 창고류 단순 공간·공간 외부 샷 제외 — "사람들은
# 창고 안 물체 구분이 어렵고 바로 실사로 찍어도 일관성을 잘 캐치 못 한다").
# 판별은 LLM 구조화 판정만 — 글자·어휘 판단 금지.
APPLICABILITY_SYS = """\
You decide whether a floor-plan layout aid would help generate this shot.

Answer `applies: true` ONLY if BOTH hold:
1. The shot takes place INSIDE an enclosed, control-heavy space — an
   operator's or crew's interior where WHO SITS WHERE relative to the
   controls matters (a vehicle cabin, cockpit, wheelhouse, armored crew
   bay, bus, aircraft, a machine's operator station) — OR the SHOT TEXT
   aims a directional object whose pointing must read correctly.
2. Getting the seating/station arrangement or that aim wrong would be
   an obvious, story-breaking mistake.

Answer `applies: false` when the space is a simple storage or open
interior whose object arrangement ordinary viewers cannot tell apart
(a straight photograph reads fine there), or when the shot is not
actually inside the space. Write `reason_ko` in Korean."""
FP_SIZE = "1536x1024"
FP_REF_LABEL = ("FLOOR PLAN — layout authority, a diagram, never scenery")


def resolve_confined_fp_pack(selector: str = CONFINED_FP_PACK_VERSION) -> str:
    try:
        return CONFINED_FP_PACK_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown confined_fp pack selector {selector!r} "
            f"(known: {sorted(CONFINED_FP_PACK_VERSION_MAP)})"
        )


def confined_fp_pack_content_hash(
    selector: str = CONFINED_FP_PACK_VERSION,
) -> str:
    """팩 bytes 지문 (#77 관례 — 버전 문자열이 아니라 내용)."""
    from app.modules.prompt_loader import pack_dir_content_hash

    return pack_dir_content_hash(_MODULE, resolve_confined_fp_pack(selector))


def _load(stem: str, selector: str = CONFINED_FP_PACK_VERSION) -> str:
    from app.modules.prompt_loader import load_prompt

    return load_prompt(
        _MODULE, stem, version=resolve_confined_fp_pack(selector))


def build_fp_readback_schema() -> Dict[str, Any]:
    return {
        "type": "object", "additionalProperties": False,
        "properties": {
            "reads": {
                "type": "object", "additionalProperties": False,
                "properties": {
                    "controls": {"type": "string"},
                    "mirrors": {"type": "string"},
                    "camera": {"type": "string"},
                    "occupants": {"type": "string"},
                },
                "required": ["controls", "mirrors", "camera", "occupants"],
            },
            "mismatches": {"type": "array", "items": {"type": "string"}},
            "scene_description_en": {"type": "string"},
        },
        "required": ["reads", "mismatches", "scene_description_en"],
    }


def strip_camera_block(prompt: str) -> str:
    """브리프에서 CAMERA & FRAME 블록만 뺀 전문 — confined 생성 전용.

    실측(2026-08-10): 화면 배치 문자("운전대는 오른쪽")가 조작 장치 소속
    좌석의 결정론 없이 실리면 모든 경로가 반대 배치로 수렴한다. 구도
    권위는 fp+장면 설명이 대신한다. 브리프 저작 자체는 불변 — 제거는
    confined 생성 호출에서만(설계 결정 2).
    """
    out: List[str] = []
    in_cam = False
    for ln in prompt.split("\n"):
        s = ln.strip()
        if s.startswith("CAMERA & FRAME"):
            in_cam = True
            continue
        if in_cam:
            if s.startswith("Compose the frame"):
                in_cam = False
            continue
        out.append(ln)
    return "\n".join(out)


_HEADER_RE = __import__("re").compile(r"^[A-Z][A-Z &/()'-]{4,}")


def extract_brief_sections(prompt: str) -> Dict[str, str]:
    """브리프 고정 헤더 절 발췌(팩 헤더 문구의 형식 소비) — fp 입력용.

    없는 절은 빈 문자열(브리프 구성은 샷마다 다르다 — 발췌는 입력을
    좁히는 용도이고 생성은 전문을 따로 받는다).

    carried/people (v2): 인물이 어느 자리에 있는지의 원천 절 — fp 샷
    마커·readback 입력에 없으면 뒷좌석 인물이 도면에서 빠진다(실측).
    두 절은 헤더 줄부터 다음 대문자 헤더 전까지의 블록.
    """
    out = {"shot_text": "", "location": "", "camera": "", "lighting": "",
           "carried": "", "people": ""}
    cam: List[str] = []
    in_cam = False
    block_key = ""
    block: List[str] = []

    def flush() -> None:
        nonlocal block_key, block
        if block_key:
            out[block_key] = "\n".join(block).strip()
        block_key, block = "", []

    for ln in prompt.split("\n"):
        s = ln.strip()
        if block_key and _HEADER_RE.match(s):
            flush()
        if s.startswith("SHOT TEXT"):
            out["shot_text"] = s
        elif s.startswith("LOCATION"):
            out["location"] = s
        elif s.startswith("CARRIED STATE"):
            flush()
            block_key, block = "carried", [s]
            continue
        elif s.startswith("PEOPLE:"):
            flush()
            block_key, block = "people", [s]
            continue
        elif s.startswith("CAMERA & FRAME"):
            in_cam = True
            cam.append(s)
            continue
        elif s.startswith("- LIGHTING & MOOD"):
            out["lighting"] = s
            in_cam = False
        elif in_cam:
            cam.append(s)
            if s.startswith("Compose the frame"):
                in_cam = False
        elif block_key:
            block.append(ln)
    flush()
    out["camera"] = "\n".join(cam)
    return out


def fp_context(sections: Dict[str, str]) -> str:
    # v2: 인물 배치 절(carried/people) 포함 — readback 대조에서 도면의
    # 인물 마커가 "텍스트에 없는 요소"로 오탐되지 않게.
    return "\n\n".join(x for x in (
        sections.get("shot_text"), sections.get("carried"),
        sections.get("people"), sections.get("location"),
        sections.get("camera"), sections.get("lighting")) if x)


def _gpt_fp_image(prompt: str, out_path: Path,
                  ref: Optional[Path] = None,
                  capture_role: str = "confined_fp") -> None:
    """fp 작화·수정 — gpt-image-2 (기록은 부품에 내장)."""
    from app.core.config import settings
    from app.core.openai_keys import openai_client
    from app.modules.llm.gpt_image_primitive import call_gpt_image_bytes

    client = openai_client(timeout=float(settings.llm_timeout_image_gen))
    png = call_gpt_image_bytes(
        client,
        mode="edit" if ref else "generate",
        prompt=prompt,
        ref_paths=[ref] if ref else None,
        call_kwargs={"model": settings.openai_image_model,
                     "size": FP_SIZE, "quality": "high"},
        capture_role=capture_role)
    if not png:
        raise RuntimeError("confined_fp: gpt fp 생성 빈 응답")
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_bytes(png)


def _readback(step_tag: str, fp_path: Path, ctx: str,
              project_config: Optional[Dict[str, Any]],
              opik_metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    from app.modules.llm.llm_client import call_structured
    from app.modules.pipeline.multiroll_gemini import png_part

    return call_structured(
        step_tag, _load("fp_readback_sys"),
        [{"type": "text",
          "text": "LOCATION / CAMERA / SHOT TEXT:\n" + ctx},
         {"type": "text", "text": "THE FLOOR-PLAN DIAGRAM:"},
         png_part(fp_path)],
        build_fp_readback_schema(),
        project_config={**(project_config or {}),
                        step_tag: {"model": "gemini-pro"}},
        schema_name="confined_fp_readback",
        opik_metadata=opik_metadata,
        enable_fallback=False)


def produce_confined_fp(
    *,
    tag: str,
    brief: str,
    out_dir: Path,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    pack_version: str = "1",
) -> Tuple[Path, Dict[str, Any]]:
    """[v1 흐름 — 팩 v1 전용] 샷 단독 fp 산출 — (fp_path, readback dict).

    v2(base 공유+샷 마커: `produce_base_fp`+`produce_shot_fp`)가 현행이다.
    이 함수는 v1 팩 스템(fp_head) 전제라 selector "1" 고정 — 기록·재현용
    으로 남긴다(기존 모듈 삭제 금지 관례).

    readback dict = {reads, mismatches, scene_description_en, fixed}.
    mismatches 는 durable record 로 남길 것(브리프 내부 모순 보고 포함 —
    실측: LOCATION 문자 vs 미러 반사 요구의 모순을 정확히 보고했다).
    """
    sections = extract_brief_sections(brief)
    ctx = fp_context(sections)
    if not ctx.strip():
        raise ValueError(
            f"confined_fp[{tag}]: 브리프에서 발췌 절 0 — fp 를 그릴 입력이 "
            "없다 (fail-closed)")
    head = _load("fp_head", pack_version)
    base_fp = out_dir / f"{tag}_fp_draft.png"
    fp_path = out_dir / f"{tag}_fp.png"
    _gpt_fp_image(head + "\n\n" + ctx, base_fp)
    rb = _readback(f"confined_fp_readback_{tag}", base_fp, ctx,
                   project_config, opik_metadata)
    fixed = False
    if rb.get("mismatches"):
        logger.info(
            "confined_fp[%s]: fp 어긋남 %d건 — i2i 국소 수정",
            tag, len(rb["mismatches"]))
        _gpt_fp_image(
            _load("fp_fix_head", pack_version) + "\n- "
            + "\n- ".join(rb["mismatches"]) + "\n\n" + ctx,
            fp_path, ref=base_fp)
        rb = _readback(f"confined_fp_readback_{tag}_fix", fp_path, ctx,
                       project_config, opik_metadata)
        fixed = True
    else:
        import shutil

        shutil.copy(base_fp, fp_path)
    desc = str(rb.get("scene_description_en") or "").strip()
    if not desc:
        raise ValueError(
            f"confined_fp[{tag}]: 장면 설명이 비었다 — 배치 권위 없이 "
            "생성하지 않는다 (fail-closed)")
    return fp_path, {**rb, "fixed": fixed}


def build_confined_gen_prompt(
    scene_description_en: str,
    brief: str,
    pack_version: str = CONFINED_FP_PACK_VERSION,
) -> str:
    """confined 생성 프롬프트 — gen_head + 장면 설명 + 정리된 브리프.

    브리프에서 CAMERA 절과 prev 참조 전제 절을 뺀다(strip_confined_brief)
    — 실제 첨부([fp, 엔티티])와 지시가 일치해야 한다 (Codex BLOCK-4).
    """
    return (
        _load("gen_head", pack_version).strip()
        + "\n\nSCENE LAYOUT (what a careful reader saw in the attached "
        "floor plan):\n" + scene_description_en.strip()
        + "\n\n" + strip_confined_brief(brief)
    )


# 첨부를 전제하는 브리프 절 — confined 생성의 실제 첨부는 [fp 도면,
# 엔티티]뿐이라 이 절들이 남으면 "없는 첨부를 따르라"는 거짓 지시가 된다
# (Codex BLOCK-4 + 재리뷰 BLOCK-2). 헤더 문구는 브리프 팩·build_still_
# prompt 조립 실측(2026-08-11): prev 2종 + 복잡 구조물 STRUCTURE LOOK.
_NON_ATTACHED_AUTHORITY_HEADS = (
    "THIS SHOT CONTINUES THE PREVIOUS SHOT",
    "PREVIOUS STILL USAGE",
    "STRUCTURE LOOK AUTHORITY",
)
# LOCATION 줄의 조립 고정 접속구(still_recipe build_still_prompt) — 이
# 뒤에 첨부 사진 권위 꼬리(LOCATION PHOTOGRAPH / LOCATION STRUCTURE
# PHOTOGRAPH / chain 스템)가 붙는다. 장소 텍스트는 남기고 꼬리만 자른다.
_LOCATION_TAIL_MARKER = " The shot takes place here — "


def strip_confined_brief(prompt: str) -> str:
    """confined 생성용 브리프 정리 — 첨부 전제 절·꼬리 제거.

    제거: ①CAMERA 절 ②prev 참조 전제 절 2종 ③STRUCTURE LOOK 권위 절
    ④LOCATION 줄의 첨부 사진 권위 꼬리(장소 텍스트는 보존). 실제 첨부와
    지시가 일치해야 한다 — 세 실조립 모드(default/structure_seed/seed_bg)
    회귀로 잔존 0 을 잠근다.
    """
    out: List[str] = []
    in_drop = False
    for ln in strip_camera_block(prompt).split("\n"):
        s = ln.strip()
        if in_drop and _HEADER_RE.match(s):
            in_drop = False
        if any(s.startswith(h) for h in _NON_ATTACHED_AUTHORITY_HEADS):
            in_drop = True
            continue
        if in_drop:
            continue
        if s.startswith("LOCATION") and _LOCATION_TAIL_MARKER in ln:
            ln = (ln.split(_LOCATION_TAIL_MARKER, 1)[0]
                  + " The shot takes place here.")
        out.append(ln)
    return "\n".join(out)


def _sha16(*parts: Any) -> str:
    import hashlib

    h = hashlib.sha256()
    for p in parts:
        h.update(str(p).encode("utf-8"))
        h.update(b"\x00")
    return h.hexdigest()[:16]


def apt_fingerprint(brief: str,
                    pack_version: str = CONFINED_FP_PACK_VERSION) -> str:
    """적용 판별 캐시의 입력 지문 — 계약·스키마·물리 모델·브리프 전문.

    존재 캐시는 새 설정을 옛 판별로 봉인한다(Codex BLOCK-2 — #77 과
    같은 부류). 재료가 하나라도 바뀌면 재판별한다.
    """
    import json as _json

    from app.core.config import settings

    return _sha16(brief, APPLICABILITY_SYS,
                  _json.dumps(build_applicability_schema(), sort_keys=True),
                  settings.gemini_text_model, pack_version)


def base_fingerprint(place_text: str,
                     pack_version: str = CONFINED_FP_PACK_VERSION) -> str:
    """base 도면 캐시의 입력 지문 — 장소 절+작화 스템 bytes+gpt 모델·크기."""
    from app.core.config import settings

    return _sha16(place_text, _load("base_fp_head", pack_version),
                  settings.openai_image_model, FP_SIZE)


def shot_fingerprint(base_path: Path, sections: Dict[str, str],
                     pack_version: str = CONFINED_FP_PACK_VERSION) -> str:
    """샷 fp 캐시의 입력 지문 — base bytes+절 전문+스템 3종+물리 모델 2종."""
    import hashlib
    import json as _json

    from app.core.config import settings

    base_bytes = hashlib.sha256(Path(base_path).read_bytes()).hexdigest()
    return _sha16(base_bytes,
                  _json.dumps(sections, sort_keys=True, ensure_ascii=False),
                  _load("shot_fp_mark_head", pack_version),
                  _load("fp_readback_sys", pack_version),
                  _load("fp_fix_head", pack_version),
                  settings.openai_image_model, settings.gemini_text_model)


# ── v2 흐름 — base 공유 도면 + 샷 마커 + 적용 판별 ────────────────────

def build_applicability_schema() -> Dict[str, Any]:
    return {
        "type": "object", "additionalProperties": False,
        "properties": {
            "applies": {"type": "boolean"},
            "reason_ko": {"type": "string"},
        },
        "required": ["applies", "reason_ko"],
    }


def judge_fp_applicability(
    tag: str,
    brief: str,
    *,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """fp 적용 판별 — confined 샷 중 fp 가 돕는 샷만 (LLM 구조화 판정).

    창고류 단순 공간·공간 외부 샷은 false → 기존 경로 그대로. 호출측은
    결과를 record 에 캐시해 재개 시 재판별 지출을 막을 것.
    """
    from app.modules.llm.llm_client import call_structured

    step_tag = f"confined_fp_apt_{tag}"
    return call_structured(
        step_tag, APPLICABILITY_SYS,
        "SHOT BRIEF (full):\n" + brief,
        build_applicability_schema(),
        project_config={**(project_config or {}),
                        step_tag: {"model": "gemini-pro"}},
        schema_name="confined_fp_apt",
        opik_metadata=opik_metadata,
        enable_fallback=False)


def produce_base_fp(
    space_tag: str,
    location_text: str,
    out_path: Path,
    pack_version: str = CONFINED_FP_PACK_VERSION,
) -> Path:
    """공간 단위 base 도면 — 좌석 배열+조작 장치(+조건부 미러)만.

    같은 공간의 샷들이 이 한 장을 공유한다(샷마다 다른 공간을 그리던
    결함 제거). 캐시 키·재사용은 호출측 몫(장소 단위).
    """
    if not (location_text or "").strip():
        raise ValueError(
            f"confined_fp[{space_tag}]: LOCATION 절이 비어 base 도면을 "
            "그릴 수 없다 (fail-closed)")
    _gpt_fp_image(
        _load("base_fp_head", pack_version) + "\n\n" + location_text,
        out_path, capture_role="confined_fp_base")
    return out_path


def produce_shot_fp(
    tag: str,
    base_fp: Path,
    sections: Dict[str, str],
    out_path: Path,
    *,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    pack_version: str = CONFINED_FP_PACK_VERSION,
) -> Tuple[Path, Dict[str, Any]]:
    """샷 마커 도면 — base 에 카메라·인물·방향성 소품 마커만 i2i 로.

    마커 입력에 인물 배치 절(CARRIED STATE·PEOPLE)을 반드시 동반한다
    (v2 실측: 없으면 뒷좌석 인물이 도면에서 빠진다). readback 검증 후
    mismatch 가 있으면 지적을 지시로 i2i 국소 수정 1회+재검증.
    반환 = (fp_path, readback dict{reads,mismatches,scene_description_en,
    fixed}) — mismatch 는 record 로 남길 것(브리프 모순 보고 포함).
    """
    mark_ctx = "\n\n".join(x for x in (
        sections.get("shot_text"), sections.get("carried"),
        sections.get("people"), sections.get("camera")) if x)
    if not mark_ctx.strip():
        raise ValueError(
            f"confined_fp[{tag}]: 마커 입력 절이 비었다 (fail-closed)")
    ctx = fp_context(sections)
    _gpt_fp_image(
        _load("shot_fp_mark_head", pack_version) + "\n\n" + mark_ctx,
        out_path, ref=base_fp, capture_role="confined_fp_mark")
    rb = _readback(f"confined_fp_readback_{tag}", out_path, ctx,
                   project_config, opik_metadata)
    fixed = False
    if rb.get("mismatches"):
        logger.info(
            "confined_fp[%s]: 샷 도면 어긋남 %d건 — i2i 국소 수정",
            tag, len(rb["mismatches"]))
        _gpt_fp_image(
            _load("fp_fix_head", pack_version) + "\n- "
            + "\n- ".join(rb["mismatches"]) + "\n\n" + ctx,
            out_path, ref=out_path, capture_role="confined_fp_fix")
        rb = _readback(f"confined_fp_readback_{tag}_fix", out_path, ctx,
                       project_config, opik_metadata)
        fixed = True
    desc = str(rb.get("scene_description_en") or "").strip()
    if not desc:
        raise ValueError(
            f"confined_fp[{tag}]: 장면 설명이 비었다 — 배치 권위 없이 "
            "생성하지 않는다 (fail-closed)")
    return out_path, {**rb, "fixed": fixed}
