"""실내 shared-model pose 가이드 서비스 (Wave5).

bg plate(underlay) 위에 마네킹(들)을 등록한 pose/placement 가이드를 gpt-image-2 edit
으로 생성하고, 생성 후 guide QC 로 2차 게이트한다. registered_pose_guide_service 패턴
차용·indoor 전용 분리(single subject 전제 아님 — multi-figure ≤2 detailed).

★★ 절대 제약:
- 가이드는 **반드시 env_bg_bytes(실제 bg plate)를 underlay 로** 깔고 그 위에 등록한다.
  흰배경/blank canvas 스케치 경로는 존재하지 않는다(배경-구도 모순→이상한 샷 방지).
  env_bg_bytes 없으면 생성 skip → no-guide degrade(white-bg fallback 0).
- 프롬프트에 작품 고유명사·이름·엔티티 ID·소품명·FSC label 0. build_pose_brief 출력
  (slot=spatial descriptor, gesture=generic)만 사용.
- 모든 실패/거부 = (None, diagnostic). 호출측은 None 이면 ref 미부착.
"""
import hashlib
import json
import os
import threading
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

GUIDE_PROMPT_VERSION = "1"
_EDIT_SIZE = "1536x1024"

_GEN_LOCKS: Dict[str, threading.Lock] = {}
_GEN_LOCKS_GUARD = threading.Lock()


def _lock_for(key: str) -> threading.Lock:
    with _GEN_LOCKS_GUARD:
        lk = _GEN_LOCKS.get(key)
        if lk is None:
            lk = threading.Lock()
            _GEN_LOCKS[key] = lk
        return lk


def _capture_guide(
    png: bytes, *, disposition: str, input_image_ids: Optional[List[str]],
    prompt: Optional[str], pipeline_metadata: Optional[Dict[str, Any]],
) -> None:
    """guide PNG capture (role=indoor_pose_guide, disposition 명시) — 비차단.

    QC 결과로 accepted|rejected 를 박는다. scope 미개방이면 no-op. 캡처 실패는
    생성 흐름을 막지 않는다(거부본/채택본 둘 다 동일 role, disposition 으로만 구분).
    """
    try:
        from app.services.image_capture.sink import capture_generated_image

        capture_generated_image(
            png, role="indoor_pose_guide", disposition=disposition,
            input_image_ids=input_image_ids, prompt=prompt,
            pipeline_metadata=pipeline_metadata)
    except Exception:  # noqa: BLE001 — capture 는 비차단
        pass


def _atomic_write(path: Path, data: bytes) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
    tmp.write_bytes(data)
    tmp.replace(path)


def guide_cache_key(
    *, scene_index: int, bg_id: str, member_keys: List[Tuple[int, int]],
    pose_brief: Dict[str, Any], prompt_version: str, model: str, bg_asset_hash: str,
) -> str:
    """group base 캐시 key — 입력이 바뀌면 mismatch 되어 재생성(silent reuse 금지).

    scene_index + bg_id + member shot keys + pose_brief + prompt version + model
    + bg asset hash. context(T8)에서 group 단위 캐싱에 사용.
    """
    payload = {
        "scene_index": scene_index, "bg_id": bg_id,
        "member_keys": sorted([list(k) for k in member_keys]),
        "pose_brief": pose_brief, "prompt_version": prompt_version,
        "model": model, "bg_asset_hash": bg_asset_hash,
    }
    return hashlib.sha256(
        json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
    ).hexdigest()[:24]


def _brief_hash(group_id: str, bg_key: str, pose_brief: Dict[str, Any], model: str) -> str:
    payload = {"group_id": group_id, "bg_key": bg_key, "pose_brief": pose_brief,
               "model": model, "prompt_version": GUIDE_PROMPT_VERSION}
    return hashlib.sha256(
        json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
    ).hexdigest()[:16]


def build_indoor_pose_guide(
    *,
    group_id: str,
    pose_brief: Dict[str, Any],
    env_bg_bytes: Optional[bytes],
    bg_key: str,
    cache_dir: Path,
    qc_fn: Optional[Callable[..., Tuple[bool, Optional[str]]]] = None,
    openai_client: Any = None,
    model: str = "gpt-image-2.5-sunburst",
    bg_asset_id: Optional[str] = None,
    force: bool = False,
) -> Tuple[Optional[bytes], Dict[str, Any]]:
    """gate → cache → underlay → gpt-image-2 edit → guide QC → atomic. (bytes|None, diag).

    None 반환 = no-guide degrade(white-bg fallback 없음). qc_fn pass 한 경우만 cache
    write + bytes 반환. per-path lock 으로 group/hash 당 1회 생성(동시 멤버 같은 bytes).
    """
    diag: Dict[str, Any] = {"group_id": group_id, "bg_key": bg_key}

    # gate 1: 등록할 figure 존재.
    if not (pose_brief.get("figures") or []):
        diag.update(status="skipped", reason="no_figures")
        return None, diag
    # gate 2: ★ bg plate underlay 필수 (흰배경 fallback 없음).
    if not env_bg_bytes:
        diag.update(status="skipped", reason="no_environment_bg_plate")
        return None, diag

    h = _brief_hash(group_id, bg_key, pose_brief, model)
    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

    cached = _read_cache()
    if cached is not None:
        diag["status"] = "cache_hit"
        return cached, diag

    with _lock_for(str(out_path)):
        cached = _read_cache()
        if cached is not None:
            diag["status"] = "cache_hit_after_lock"
            return cached, diag

        try:
            png = _generate_guide_png(
                pose_brief=pose_brief, env_bg_bytes=env_bg_bytes, group_id=group_id,
                bg_key=bg_key, model=model, openai_client=openai_client, cache_dir=cache_dir,
                bg_asset_id=bg_asset_id)
        except Exception as exc:  # noqa: BLE001 — 비차단 no-guide degrade
            diag.update(status="failed", reason=f"generate_error: {str(exc)[:160]}")
            return None, diag

        # capture lineage/metadata (생성 시점 _generate_guide_png 와 동일 구성).
        guide_prompt = _build_guide_prompt(pose_brief)
        lineage_ids = [bg_asset_id] if bg_asset_id else None
        # goal#3 (Codex 설계 A): guide_hash 를 capture metadata 에 실어
        # context flush 후 구조키 (group_id, bg_key, guide_hash) 로 accepted row →
        # asset_id resolve(라벨파싱 금지·구조 UUID SOT). accepted/rejected 공유.
        cap_meta: Dict[str, Any] = {
            "group_id": group_id, "bg_key": bg_key, "guide_hash": h}
        if not bg_asset_id:
            cap_meta["unresolved_inputs"] = [f"background_render:{bg_key}"]

        if qc_fn is not None:
            # qc_fn(png, *, expected_figures) — 생성 후 2차 게이트(VLM 이미지리드 →
            # evaluate_guide_qc). expected_figures 는 이 샷 pose_brief 의 figure 수.
            ok, qc_reason = qc_fn(png, expected_figures=len(pose_brief.get("figures") or []))
            if not ok:
                # 거부본도 capture (disposition=rejected) — 캔버스에서 QC-fail guide 구분.
                _capture_guide(
                    png, disposition="rejected", input_image_ids=lineage_ids,
                    prompt=guide_prompt,
                    pipeline_metadata={**cap_meta, "qc_reason": qc_reason})
                diag.update(status="qc_failed", qc_reason=qc_reason)
                return None, diag

        # QC pass(또는 qc_fn 없음) → cache write 성공 후에만 채택본 capture.
        # ★순서(Codex NARROW): accepted = "QC pass + cache/attach 가능한 guide" 의미.
        # _atomic_write 가 raise 하면 accepted capture 를 하지 않는다(캔버스엔 보이는데
        # pipeline 은 못 쓰는 guide 가 남는 정합성 깨짐 방지). rejected 는 cache write
        # 자체가 없어 QC fail 직후 capture 가 맞다(위).
        _atomic_write(out_path, png)
        _capture_guide(
            png, disposition="accepted", input_image_ids=lineage_ids,
            prompt=guide_prompt, pipeline_metadata=dict(cap_meta))
        diag["status"] = "generated"
        return png, diag


def evaluate_guide_qc(verdict: Any, *, expected_figures: int) -> Tuple[bool, Optional[str]]:
    """생성 후 guide QC (judge 외 2차 게이트). VLM 응답 dict → pass/fail 결정론 판정.

    pass = underlay layout 보존 + photoreal 인물 없음 + clothing/face 없음 +
    text/marker leakage 없음 + 환경 redraw 없음 + mannequin_count ±1 tolerance.
    fail → 호출측 no-guide + diagnostic.
    """
    if not isinstance(verdict, dict) or not verdict:
        return False, "qc_failed"
    if not verdict.get("layout_preserved"):
        return False, "layout_not_preserved"
    if verdict.get("photoreal_person"):
        return False, "photoreal_person"
    if verdict.get("clothing_or_face"):
        return False, "clothing_or_face"
    if verdict.get("text_or_marker_leakage"):
        return False, "text_or_marker_leakage"
    if verdict.get("environment_redraw"):
        return False, "environment_redraw"
    cnt = verdict.get("mannequin_count")
    if not isinstance(cnt, int) or abs(cnt - expected_figures) > 1:
        return False, "mannequin_count_mismatch"
    return True, None


def _build_guide_prompt(pose_brief: Dict[str, Any]) -> str:
    """generic 마네킹 등록 프롬프트 (★canary 육안 튜닝 대상 — T2I 는 TDD 불가).

    ★시나리오 의존성 0: pose_brief 의 spatial descriptor/generic gesture 만 사용,
    이름/ID/소품명 0. underlay 보존 + 마네킹만 ADD + identity/clothing/face 0.
    """
    figures = pose_brief.get("figures") or []
    lines: List[str] = []
    for f in figures:
        parts = [str(f.get("slot") or "figure")]
        z = str(f.get("screen_zone") or "").strip()
        if z:
            parts.append(f"at screen {z}")
        d = str(f.get("depth_plane") or "").strip()
        if d:
            parts.append(f"in the {d}")
        if f.get("pose"):
            parts.append(str(f["pose"]))
        if f.get("facing"):
            parts.append(str(f["facing"]))
        if f.get("gesture"):
            parts.append(str(f["gesture"]))
        lines.append("- " + ", ".join(parts))
    figure_block = "\n".join(lines) if lines else "- a single figure"
    contact_note = ("" if pose_brief.get("contact_locked", True)
                    else " Keep the figures separate; do NOT lock any inter-figure contact "
                         "or hand-over of objects — approximate placement only.")
    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 the figures below as clean HIGH-CONTRAST black line-art "
        "artist's MANNEQUINS (smooth featureless articulated mannequins with visible ball joints "
        "at shoulders, elbows, hips and knees, and a LOOMIS-method head so facing direction is "
        "explicit). NO face, NO hair, NO clothing, NO skin, NO texture, NO colour. They must read "
        "as flat 2D line drawings laid over the faint room, NOT photographs and NOT 3D renders. "
        "Draw at most two detailed mannequins; if more figures are listed, render the extras as "
        "simple placement silhouettes only.\n\n"
        f"FIGURES (spatial placement, draw each at the described screen position and depth):\n"
        f"{figure_block}\n\n"
        "SUPPORT / CONTACT REGISTRATION: position and scale each mannequin so its weight-bearing "
        "contact sits ON the matching real support surface visible in the underlay at the correct "
        f"pixel location. {pose_brief.get('support_clause', '')}.{contact_note}\n\n"
        "Keep the underlay's framing and the room's spatial layout unchanged; only ADD the "
        "line-art mannequins registered into it. ABSOLUTELY NO blood, wounds or gore. NO text, "
        "letters, numbers, labels, arrows, captions or signatures anywhere in the image."
    )


def _generate_guide_png(
    *, pose_brief: Dict[str, Any], env_bg_bytes: bytes, group_id: str, bg_key: str,
    model: str, openai_client: Any, cache_dir: Path, bg_asset_id: Optional[str] = None,
) -> bytes:
    """underlay(PIL) → temp file → gpt-image-2 edit. (실호출 — 결정론 테스트는 monkeypatch.)

    ★ env_bg_bytes 를 underlay 로 사용(흰배경 경로 없음). registered_pose_guide_service
    의 make_registration_underlay + generate_floor_plan_image 차용.

    capture(scope 열려 있을 때만, Wave2a 패턴):
      - underlay → role=indoor_pose_underlay, disposition=diagnostic (PIL 비모델 아티팩트)
      - guide(gpt-edit) → role=indoor_pose_guide (모델 산출, generate_floor_plan_image 경유)
      두 capture 모두 input_image_ids=[bg_asset_id](있으면) — bg plate lineage. 없으면
      None + pipeline_metadata.unresolved_inputs=[background_render:<bg_id>] (★bg_id 문자열을
      input_image_ids 에 넣지 않음, Codex 정렬).
    """
    from app.modules.pipeline.location_floor_plan import generate_floor_plan_image
    from app.services.image_capture.sink import capture_artifact
    from app.services.registered_pose_guide_service import (
        _resolve_openai_client,
        make_registration_underlay,
    )

    lineage_ids = [bg_asset_id] if bg_asset_id else None
    base_meta: Dict[str, Any] = {"group_id": group_id, "bg_key": bg_key}
    if not bg_asset_id:
        base_meta["unresolved_inputs"] = [f"background_render:{bg_key}"]

    # capture=False — 바로 아래에서 indoor_pose_underlay role 로 직접 capture 한다
    # (registered_pose_underlay 이중 row 방지, 2026-07-02).
    underlay_png = make_registration_underlay(env_bg_bytes, capture=False)
    # underlay 중간물 capture (scope 미개방이면 no-op).
    try:
        capture_artifact(
            underlay_png, role="indoor_pose_underlay", disposition="diagnostic",
            input_image_ids=lineage_ids, pipeline_metadata=dict(base_meta))
    except Exception:  # noqa: BLE001 — capture 는 비차단(생성 흐름 보호)
        pass

    cache_dir.mkdir(parents=True, exist_ok=True)
    underlay_tmp = cache_dir / f".{group_id}.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)
        # guide capture 는 여기(생성 시점, QC 前)서 하지 않는다 — capture_role=None 으로
        # 끄고, build_indoor_pose_guide 가 QC 결과로 disposition(accepted|rejected) 을
        # 명시해 capture 한다(거부본도 캔버스에서 구분). underlay capture(위, diagnostic)
        # 는 QC 무관이라 여기 유지.
        return generate_floor_plan_image(
            prompt=_build_guide_prompt(pose_brief),
            openai_client=client,
            ref_paths=[underlay_tmp],
            model=model,
            size=_EDIT_SIZE,
            capture_role=None,
        )
    finally:
        try:
            underlay_tmp.unlink(missing_ok=True)
        except OSError:
            pass
