"""P8 I-1 — registered (bg-aware) immobilized pose guide. image-time 생성 service.

배경: `visual_continuity_anchor` step(order 21.66, analysis) 시점엔 background plate 가
아직 없다(`background_render`=24.72, image phase). 따라서 stash Inc3-B 의 white-bg
text-only 생성과 달리, **bg-aware 등록 pose guide 는 plate 가 존재하는 image phase
(scene_generation)에서 생성**해야 한다.

역할 분리 (Codex 합의): `scene_generation_coordinator` 는 orchestration 만 하고, 이
service 가 gate / cache / underlay / gpt-image-2 edit / diagnostic 을 담당한다 (재시도·
캐시·테스트 안전).

흐름:
  1. group 의 environment 멤버 background plate bytes 를 underlay 로 받는다(coordinator
     가 이 샷에 실제 쓰는 background_chain plate — location 재추론 금지).
  2. `make_registration_underlay`(PIL) 로 faint low-contrast 등록 레이어로 가공.
  3. `build_registered_pose_guide_prompt`(shared_state_contract + locked_elements
     description — Inc1 계약이 곧 SOT, 이름/엔티티 ID 0) 로 마네킹 등록 프롬프트 조립.
  4. `generate_floor_plan_image(ref_paths=[underlay])` = gpt-image-2 images.edit
     재사용(budget reserve_current_call + retry + ImageCallBudgetExceeded guard 내장).
  5. `<gid>_<hash>.png` hash cache (silent reuse 금지): hash mismatch → regenerate,
     force → overwrite, matching → reuse. temp→rename atomic write(concurrent 대비).

★ white-bg fallback 없음(Codex DP4): 생성 실패/gate 불충족 시 None 반환 = 시각 가이드
없이 text contract(Inc3-A) + dead state ref(Inc2-b)만으로 degrade(diagnostic 기록).
★ ROI crop 은 v1 미포함(v2) — whole registered master 부착. close/insert 의 "visible
portion 만" 은 prompt_service render branch 가 텍스트로 지시.
★ 하드코딩/시나리오 토큰 0 — 등록 자세/지지면 내용은 전부 anchor 계약이 결정.
"""
from __future__ import annotations

import hashlib
import json
import logging
import os
import threading
import uuid
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.services.image_capture.sink import capture_artifact

logger = logging.getLogger(__name__)

# 등록 가이드 자산 하위 디렉토리(episode images 아래) + 생성 프롬프트 버전.
GUIDE_SUBDIR = "immobilized_pose_guides"
# bump 시 기존 캐시 hash 가 mismatch 되어 자연 재생성.
# rpg-2 (2026-06-28, Codex BLOCKING2): pose 문구를 상태중립("limp and passive")으로
# 바꾸며 bump — 기존 'lifeless' 캐시를 자연 무효화한다.
GUIDE_PROMPT_VERSION = "rpg-2.202606281800"

# P8 Fix1 (Codex BLOCKING1 — 동시성): 같은 (group, hash) 를 배치 ThreadPool worker
# 가 동시에 cache-miss 로 보면 각자 다른 stochastic guide 를 생성해 같은 group 의
# 멤버 샷이 서로 다른 가이드를 붙는 문제 + temp 파일 충돌이 생긴다. cache file path
# 별 in-process lock 으로 "그룹당 1회 생성 + 나머지는 lock 해제 후 cache_hit" 를
# 보장한다(uvicorn 단일 프로세스 + ThreadPoolExecutor 모델). atomic write 는 보조.
_GEN_LOCKS_GUARD = threading.Lock()
_GEN_LOCKS: Dict[str, threading.Lock] = {}


def _lock_for(key: str) -> threading.Lock:
    """cache file path 별 per-key lock (get-or-create, guard 아래)."""
    with _GEN_LOCKS_GUARD:
        lk = _GEN_LOCKS.get(key)
        if lk is None:
            lk = threading.Lock()
            _GEN_LOCKS[key] = lk
        return lk
DEFAULT_UNDERLAY_VARIANT = "original"
_CANVAS = (1536, 1024)   # gpt-image-2 landscape (bg plate 1536x864 근사, 지원 size)
_EDIT_SIZE = "1536x1024"


def make_registration_underlay(
    bg_bytes: bytes, variant: str = DEFAULT_UNDERLAY_VARIANT, *, capture: bool = True,
) -> bytes:
    """background plate bytes → faint low-contrast registration underlay PNG bytes.

    capture=False: 차용 caller(예: indoor_shared_pose)가 자기 role 로 별도 capture
    할 때 registered_pose_underlay 중복 capture 를 끈다 (2026-07-02, E2E 0e07d5e3
    에서 indoor 경로가 registered+indoor 이중 underlay row 를 남긴 결함 수정).

    gpt-image-2 edit 가 사진을 보존/실사화하려는 습관 때문에 프롬프트만으로 faint 를
    맡기지 않고 PIL 로 미리 가공한다(variant). 목표는 배경을 '그리게' 하는 게 아니라
    floor/surface/opening 의 픽셀 좌표만 제공하는 것.

    variant: 'original'(전체 bg — support surface 전달 최강, default) /
             'blur_gray'(흐릿 회색 — 과복사/실사화 시 fallback) /
             'edge'(엣지 옅게).
    """
    from PIL import Image, ImageFilter, ImageOps

    im = ImageOps.fit(Image.open(BytesIO(bg_bytes)).convert("RGB"), _CANVAS,
                      method=Image.LANCZOS)
    if variant == "original":
        out = im
    elif variant == "blur_gray":
        white = Image.new("L", _CANVAS, 255)
        g = im.convert("L").filter(ImageFilter.GaussianBlur(7))
        out = Image.blend(white, g, 0.40).convert("RGB")
    elif variant == "edge":
        white = Image.new("L", _CANVAS, 255)
        g = im.convert("L").filter(ImageFilter.GaussianBlur(1))
        e = ImageOps.invert(g.filter(ImageFilter.FIND_EDGES))
        out = Image.blend(white, e, 0.50).convert("RGB")
    else:
        raise ValueError(f"unknown underlay variant: {variant!r}")
    buf = BytesIO()
    out.save(buf, format="PNG")
    png = buf.getvalue()
    # Phase B: 등록 underlay capture(비모델 PIL 아티팩트, scope 미배선이면 no-op).
    if capture:
        capture_artifact(
            png,
            role="registered_pose_underlay",
            pipeline_metadata={"variant": variant},
        )
    return png


def _pose_text_from_anchor(subject_anchor: Dict[str, Any]) -> str:
    """Inc1 계약(shared_state_contract + locked_elements[*].description) →
    deterministic 자세/접촉 설명 (추가 LLM 0 — 계약이 곧 SOT). evidence_quote 는
    디버그용이라 미주입. Inc1 단계가 이미 이름/엔티티 ID 금지를 강제하므로 토큰 0."""
    parts: List[str] = []
    contract = str(subject_anchor.get("shared_state_contract") or "").strip()
    if contract:
        parts.append(contract)
    for el in subject_anchor.get("locked_elements") or []:
        if not isinstance(el, dict):
            continue
        desc = str(el.get("description") or "").strip()
        if desc:
            parts.append("- " + desc)
    return "\n".join(parts) if parts else "an immobilized figure at rest"


def build_registered_pose_guide_prompt(subject_anchor: Dict[str, Any]) -> str:
    """underlay(공간) 위에 마네킹 pose 를 등록하는 gpt-image-2 edit 프롬프트.

    PoC registered_guide_exp.build_guide_prompt 의 generic 이식 — 단일 부동 피사체의
    자세/접촉/지지면을 underlay 의 실제 표면에 등록한다. 환경은 underlay 가 제공하므로
    다시 그리지 않고, gore/text/marks 는 금지. 정체성/의상은 그리지 않음(final 의 character
    state ref+본문이 담당)."""
    pose = _pose_text_from_anchor(subject_anchor)
    return (
        "The attached image is a FAINT, PALE, LOW-CONTRAST REGISTRATION UNDERLAY of a "
        "real interior space. Do NOT redraw, repaint, enhance, photo-realise, relight, "
        "sharpen, decorate or change the layout of this underlay in any way — keep it "
        "exactly as a faint pale washed-out background. Its ONLY job is to give you the "
        "pixel positions of the floor, walls, surfaces and openings of this room.\n\n"
        "On top of this underlay, draw ONE single featureless wooden artist's MANNEQUIN "
        "as a clean HIGH-CONTRAST black line-art outline: a smooth featureless "
        "articulated mannequin with visible ball joints at shoulders, elbows, hips and "
        "knees, and a LOOMIS-method head (a sphere with the side plane sliced flat, a "
        "vertical centerline and a horizontal brow line) so the head's facing direction "
        "is explicit; NO face, NO hair, NO clothing, NO skin, NO texture, NO colour. It "
        "must read as a flat 2D line drawing laid over the faint room, NOT a photograph "
        "and NOT a 3D render.\n\n"
        f"POSE (draw the mannequin exactly in this configuration):\n{pose}\n\n"
        "SUPPORT / CONTACT REGISTRATION: position and scale the mannequin so its "
        "weight-bearing contact (where the body actually rests) sits ON the matching "
        "real support surface visible in the underlay at the correct pixel location — "
        "the body must look supported by that surface, never floating in empty space. "
        "The body is limp and fully passive (an immobilized figure that cannot hold "
        "itself up): every limb, hand, wrist and any held object must "
        "come to REST on the nearest support surface beneath it, drawn touching it, with "
        "no gap of empty space under any hand or object. Draw any held or directly-"
        "adjacent object as a plain featureless placeholder shape only (no printed "
        "content, no markings).\n\n"
        "Keep the underlay's framing and the room's spatial layout unchanged; only ADD "
        "the single line-art mannequin registered into it. ABSOLUTELY NO blood, wounds, "
        "injuries, gore, bruises or body fluids of any kind. NO text, letters, numbers, "
        "labels, arrows, captions or signatures anywhere in the image."
    )


def _guide_hash(
    *,
    group_id: str,
    bg_key: str,
    subject_anchor: Dict[str, Any],
    model: str,
    variant: str,
) -> str:
    """캐시 무결성 hash — 입력이 바뀌면 mismatch 되어 재생성(silent reuse 금지).

    구성: group_id + bg asset key + subject_anchor(contract+locked+visible_focus) +
    prompt version + model + underlay variant.
    """
    anchor_repr = {
        "shared_state_contract": subject_anchor.get("shared_state_contract"),
        "locked_elements": subject_anchor.get("locked_elements"),
        "per_shot_visible_focus": subject_anchor.get("per_shot_visible_focus"),
        "subject_state": subject_anchor.get("subject_state"),
    }
    payload = json.dumps(
        {
            "group_id": group_id,
            "bg_key": bg_key,
            "anchor": anchor_repr,
            "prompt_version": GUIDE_PROMPT_VERSION,
            "model": model,
            "variant": variant,
        },
        sort_keys=True, ensure_ascii=False,
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


def _resolve_openai_client() -> Any:
    """gpt-image-2 edit 용 raw OpenAI 클라이언트 (lazy, cache-miss 시에만 생성).
    테스트는 build_registered_pose_guide 에 fake client 를 주입하므로 이 경로 미진입."""
    from app.core.openai_keys import openai_client
    from app.core.config import settings
    return openai_client(
        timeout=float(getattr(settings, "llm_timeout_image_gen", 300)),
    )


def _atomic_write(path: Path, data: bytes) -> None:
    """temp→rename atomic write. temp 이름은 pid+uuid 로 격리해 같은 프로세스의
    여러 thread 가 같은 path 를 동시에 쓰더라도 temp 파일이 충돌하지 않게 한다
    (Codex BLOCKING1). os.replace 는 atomic 이라 reader 가 torn 파일을 보지 않는다."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.parent / f".{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}"
    tmp.write_bytes(data)
    os.replace(tmp, path)


def build_registered_pose_guide(
    *,
    group_id: str,
    subject_anchor: Dict[str, Any],
    env_bg_bytes: Optional[bytes],
    bg_key: str,
    cache_dir: Path,
    openai_client: Any = None,
    model: str = "gpt-image-2",
    underlay_variant: str = DEFAULT_UNDERLAY_VARIANT,
    force: bool = False,
) -> Tuple[Optional[bytes], Dict[str, Any]]:
    """gate → cache → underlay → gpt-image-2 edit → atomic cache. (bytes|None, diagnostic).

    None 반환 = 시각 가이드 없이 degrade (white-bg fallback 없음). 호출측은 None 이면
    pose guide ref 를 부착하지 않는다. 모든 분기는 diagnostic dict 로 사유를 남긴다.

    ★ ``force`` 계약 (Codex narrow re-review future note): force=False(기본)는 멱등 —
    per-path lock + cache 재확인으로 그룹/hash 당 생성 1회, 동시 멤버 샷은 같은 bytes
    를 공유한다. force=True 는 **명시적 non-idempotent** = lock 안에서 unconditional
    재생성·overwrite(operator 가 단발 재생성할 때만 사용). 현재 production caller
    (coordinator attach)는 force 를 넘기지 않는다 — force 를 batch 멤버 attach 에 연결할
    경우, 멤버마다 stochastic overwrite 가 되어 같은 group 일관성이 깨지므로 그때
    'hash 당 단일 재생성' 정책으로 별도 설계/테스트가 필요하다."""
    diag: Dict[str, Any] = {"group_id": group_id, "bg_key": bg_key}

    # gate: 계약 존재 + environment plate bytes 존재.
    contract = str(subject_anchor.get("shared_state_contract") or "").strip()
    if not contract:
        diag["status"] = "skipped"
        diag["reason"] = "empty_shared_state_contract"
        return None, diag
    if not env_bg_bytes:
        diag["status"] = "skipped"
        diag["reason"] = "no_environment_bg_plate"
        return None, diag

    h = _guide_hash(group_id=group_id, bg_key=bg_key, subject_anchor=subject_anchor,
                    model=model, variant=underlay_variant)
    out_path = cache_dir / f"{group_id}_{h}.png"
    diag["hash"] = h
    diag["path"] = str(out_path)

    def _read_cache() -> Optional[bytes]:
        if out_path.exists() and not force:
            try:
                if out_path.stat().st_size >= 1024:
                    return out_path.read_bytes()
            except OSError:
                pass
        return None

    # 1차(lock-free) cache 확인 — 흔한 cache_hit 는 lock 없이 통과.
    cached = _read_cache()
    if cached is not None:
        diag["status"] = "cache_hit"
        return cached, diag

    # P8 Fix1 (Codex BLOCKING1): per-path lock 아래에서 생성. 같은 (group, hash) 를
    # 동시에 cache-miss 로 본 다른 worker 들이 각자 stochastic guide 를 만들지 않도록
    # 단 1회만 생성하고, lock 을 기다린 쪽은 재확인 후 cache_hit 로 같은 bytes 를 받는다.
    with _lock_for(str(out_path)):
        cached = _read_cache()
        if cached is not None:
            diag["status"] = "cache_hit_after_lock"
            return cached, diag

        # 생성: underlay(PIL) → temp file → gpt-image-2 edit(generate_floor_plan_image 재사용).
        try:
            underlay_png = make_registration_underlay(env_bg_bytes, underlay_variant)
        except Exception as exc:
            diag["status"] = "failed"
            diag["reason"] = f"underlay_error: {str(exc)[:160]}"
            logger.warning("registered_pose_guide: underlay 실패 (group=%s): %s", group_id, exc)
            return None, diag

        from app.modules.pipeline.location_floor_plan import generate_floor_plan_image

        cache_dir.mkdir(parents=True, exist_ok=True)
        # temp 이름 pid+uuid 격리 (Codex BLOCKING1) — 동시 thread 가 같은 hash 라도 충돌 X.
        underlay_tmp = cache_dir / f".{group_id}_{h}.underlay.{os.getpid()}.{uuid.uuid4().hex}.png"
        client = openai_client if openai_client is not None else _resolve_openai_client()
        try:
            underlay_tmp.write_bytes(underlay_png)
            png = generate_floor_plan_image(
                prompt=build_registered_pose_guide_prompt(subject_anchor),
                openai_client=client,
                ref_paths=[underlay_tmp],
                model=model,
                size=_EDIT_SIZE,
                # Phase C: 이 gpt-edit 수렴점은 floor_plan 과 공유되므로 default role
                # 'floor_plan_image' 로 오라벨된다 → registered_pose_guide 로 교정.
                # bg plate UUID 는 worker DB 금지로 v1 미해결 → input_image_ids defer,
                # group/bg lineage 는 metadata 로(Decision #1/#5).
                capture_role="registered_pose_guide",
                capture_extra_metadata={
                    "group_id": group_id,
                    "bg_key": bg_key,
                    "variant": underlay_variant,
                    # P0 (2026-07-01, Codex 합의): guide_hash 로 exact match 강화
                    # (persistence post-hoc resolve: group_id+bg_key+guide_hash).
                    "guide_hash": h,
                },
            )
        except Exception as exc:
            diag["status"] = "failed"
            diag["reason"] = f"edit_error: {str(exc)[:160]}"
            logger.warning(
                "registered_pose_guide: gpt-image-2 edit 실패 (group=%s, 비차단 no-guide): %s",
                group_id, exc)
            return None, diag
        finally:
            try:
                underlay_tmp.unlink(missing_ok=True)
            except OSError:
                pass

        _atomic_write(out_path, png)
        diag["status"] = "generated"
        return png, diag
