"""P8 Inc3-B — immobilized pose sketch (visual anchor) focused units.

deterministic 배선만 단위로 커버한다 (Codex 합의 + 메모리 규칙: TDD 는 결정론
영역만). gpt-image-2 스케치 품질/자세 연속성은 단위 대상 아님 — acceptance live
regen + VLM/육안 gate.

커버:
  1. provider.build_immobilized_pose_sketch_prompt — contract/locked_elements →
     마네킹 포즈 프롬프트 (pose/contact/support 포함, 이름/엔티티ID 미주입, 빈 fallback).
  2. prompt_service role 'immobilized_pose_guide' 렌더 + Inc3-A lock 정렬
     (pose guide 동봉 시 가이드 SOT, 미동봉 시 'image wins' 유지).
  3. step loader 가 pose_sketch_path 노출(flag ON+파일 존재) + attach helper 가
     멤버 샷에 role/label/meta append, 비멤버 no-op.
"""
from __future__ import annotations

import json as _json
from typing import Any, Dict, List, Optional, Tuple

from app.modules.pipeline.visual_continuity_anchor_provider import (
    build_immobilized_pose_sketch_prompt,
)
from app.modules.pipeline.visual_continuity_anchor_plan import (
    ANCHOR_TYPE_IMMOBILIZED,
)
from app.services.prompt_service import (
    LabeledRefPayload,
    make_labeled_ref_payload,
    resolve_ref_roles,
)


# ───────────────────── 1. provider builder (pure) ─────────────────────


def test_pose_sketch_prompt_includes_pose_contact_support_and_contract():
    anchor = {
        "shared_state_contract": "The immobilized subject sits slumped, head fallen forward.",
        "locked_elements": [
            {"kind": "subject_pose",
             "description": "the body is seated, upper body slumped forward",
             "evidence_quote": "q", "confidence": "high"},
            {"kind": "held_prop",
             "description": "the right hand is clenched around a small flat object",
             "evidence_quote": "q", "confidence": "high"},
        ],
    }
    p = build_immobilized_pose_sketch_prompt(anchor)
    # 계약 + locked element description 가 주입된다.
    assert "head fallen forward" in p
    assert "the right hand is clenched around a small flat object" in p
    # 마네킹/Loomis + 손-사물 contact + support surface 지시.
    assert "MANNEQUIN" in p
    assert "LOOMIS" in p
    assert "contact point" in p
    assert "support" in p.lower()
    # 환경/배경 금지 명시 (pose/contact/support 전용).
    assert "no room" in p and "no background scenery" in p


def test_pose_sketch_prompt_no_name_or_entity_id_injection():
    """builder 는 계약 텍스트 + 마네킹 generic 기법만 emit — 이름/엔티티ID 를
    스스로 주입하지 않는다 (계약 자체가 Inc1 단계서 이름 금지). evidence_quote 도
    스케치엔 미주입(description 만)."""
    anchor = {
        "shared_state_contract": "The body lies on a low surface.",
        "locked_elements": [
            {"kind": "surface_state",
             "description": "the body rests on a low flat surface",
             "evidence_quote": "VERBATIM_SCENE_QUOTE_SHOULD_NOT_LEAK",
             "confidence": "high"},
        ],
    }
    p = build_immobilized_pose_sketch_prompt(anchor)
    assert "VERBATIM_SCENE_QUOTE_SHOULD_NOT_LEAK" not in p
    # generic — 복합/단순 엔티티ID 토큰을 builder 가 만들지 않는다.
    assert "C0" not in p and "Reference image" not in p


def test_pose_sketch_prompt_empty_anchor_fallback():
    p = build_immobilized_pose_sketch_prompt({})
    assert "an immobilized figure at rest" in p
    assert "MANNEQUIN" in p  # 기법은 항상 포함


# ───────────────────── 2. prompt_service role + Inc3-A 정렬 ─────────────────────


def _payload(
    labeled_refs: List[Tuple[str, bytes]],
    ref_roles: List[str],
    ref_role_metadata: Optional[List[Dict[str, Any]]] = None,
) -> LabeledRefPayload:
    n = len(labeled_refs)
    if ref_role_metadata is None:
        ref_role_metadata = [{} for _ in range(n)]
    return make_labeled_ref_payload(
        labeled_refs=labeled_refs,
        ref_roles=ref_roles,
        ref_role_metadata=ref_role_metadata,
        attached_meta=[("immobilized_pose_guide", "12:8") for _ in range(n)],
    )


def test_immobilized_pose_guide_role_render():
    payload = _payload(
        labeled_refs=[("pose guide", b"\x89")],
        ref_roles=["immobilized_pose_guide"],
        ref_role_metadata=[{"visible_focus": "the hand and the object"}],
    )
    res = resolve_ref_roles(payload)
    assert any("POSE GUIDE for the immobilized figure" in r for r in res.ref_roles)
    joined = "\n".join(res.ref_instructions)
    # 자세/접촉/지지면 SOT + 'do not copy poses' 예외 + 본문 wording 보다 우선.
    assert "one exception to the 'do not copy poses' rule" in joined
    assert "image 1 wins" in joined
    # 정체성/외형은 마네킹 아님.
    assert "NEVER from the mannequin" in joined
    # insert framing — visible_focus 주입.
    assert "the hand and the object" in joined
    assert "do NOT force the whole body" in joined
    # 마네킹/construction line 미렌더.
    assert "construction lines" in joined


def test_inc3a_lock_aligns_when_pose_guide_present():
    """zoomed-frame(immobilized) + pose guide 동봉 → zoomed lock 이 '가이드에서
    자세 가져오기'로 정렬 (3중 신호 충돌 방지)."""
    payload = _payload(
        labeled_refs=[("prev zoom", b"\x89"), ("pose guide", b"\x89")],
        ref_roles=["previous_shot_same_frame_zoomed", "immobilized_pose_guide"],
        ref_role_metadata=[
            {"immobilized_subjects": [{"character": "X", "state": "dead"}]},
            {"visible_focus": "hand"},
        ],
    )
    res = resolve_ref_roles(payload)
    joined = "\n".join(res.ref_instructions)
    # 정렬 wording — 가이드가 자세/접촉 SOT.
    assert "from the attached pose guide" in joined
    # 원래 Inc3-A 의 'image 1 wins' 단정은 zoomed 절에서 안 쓴다(가이드 정렬).
    # (pose guide 절 자체엔 'image 2 wins' 가 있으나 zoomed 절엔 image1 wins 없음)
    assert "image 1 wins" not in joined


def test_inc3a_lock_unchanged_when_no_pose_guide():
    """pose guide 미동봉 → 기존 Inc3-A 동작('image i wins') 보존."""
    payload = _payload(
        labeled_refs=[("prev zoom", b"\x89")],
        ref_roles=["previous_shot_same_frame_zoomed"],
        ref_role_metadata=[
            {"immobilized_subjects": [{"character": "X", "state": "dead"}]},
        ],
    )
    res = resolve_ref_roles(payload)
    joined = "\n".join(res.ref_instructions)
    assert "image 1 wins" in joined
    assert "from the attached pose guide" not in joined


# ───────────────────── 3. step loader + attach ─────────────────────


def _write_imm_manifest(tmp_path, *, pose_sketch_path: Optional[str], write_png: bool):
    cp_dir = tmp_path / "p1" / "checkpoints" / "episodes" / "e1" / "visual_continuity_anchor"
    cp_dir.mkdir(parents=True, exist_ok=True)
    group: Dict[str, Any] = {
        "group_id": "vca-s12-c04-immobilized",
        "anchor_type": ANCHOR_TYPE_IMMOBILIZED,
        "members": [
            {"scene_index": 12, "shot_index": 8, "role": "environment"},
            {"scene_index": 12, "shot_index": 13, "role": "close_insert"},
        ],
        "locked_elements": [],
        "subject_anchor": {
            "character_short_id": "C04", "subject_state": "dead",
            "shared_state_contract": "The body sits slumped.",
            "per_shot_visible_focus": {"8": "full body", "13": "hand"},
        },
    }
    if pose_sketch_path is not None:
        group["pose_sketch_path"] = pose_sketch_path
    if write_png and pose_sketch_path:
        png_abs = cp_dir / pose_sketch_path
        png_abs.parent.mkdir(parents=True, exist_ok=True)
        png_abs.write_bytes(b"\x89PNG_fake_bytes")
    manifest = {
        "status": "completed", "schema_version": 1, "config_hash": "h",
        "data": {"groups": [group]},
    }
    cp_dir.joinpath("manifest.json").write_text(_json.dumps(manifest))
    return cp_dir


def _enable_flags(monkeypatch, tmp_path, *, pose_flag: bool):
    from app.core.config import settings
    monkeypatch.setattr(settings, "projects_dir", str(tmp_path), raising=False)
    monkeypatch.setattr(settings, "visual_continuity_anchor_enabled", True, raising=False)
    monkeypatch.setattr(
        settings, "immobilized_subject_continuity_enabled", True, raising=False)
    monkeypatch.setattr(
        settings, "immobilized_pose_sketch_enabled", pose_flag, raising=False)


def test_loader_exposes_pose_sketch_path_when_flag_on(monkeypatch, tmp_path):
    from app.core.steps.visual_continuity_anchor_step import (
        load_immobilized_subject_anchor_context,
    )
    rel = "immobilized_pose_sketches/vca-s12-c04-immobilized.png"
    cp_dir = _write_imm_manifest(tmp_path, pose_sketch_path=rel, write_png=True)
    _enable_flags(monkeypatch, tmp_path, pose_flag=True)

    ctx = load_immobilized_subject_anchor_context("p1", "e1")
    sa = ctx["anchors_by_group"]["vca-s12-c04-immobilized"]
    assert sa["pose_sketch_path"] == str(cp_dir / rel)
    assert ctx["members_by_shot"][(12, 8)] == ["vca-s12-c04-immobilized"]
    assert ctx["members_by_shot"][(12, 13)] == ["vca-s12-c04-immobilized"]


def test_loader_hides_pose_sketch_path_when_flag_off(monkeypatch, tmp_path):
    from app.core.steps.visual_continuity_anchor_step import (
        load_immobilized_subject_anchor_context,
    )
    rel = "immobilized_pose_sketches/vca-s12-c04-immobilized.png"
    _write_imm_manifest(tmp_path, pose_sketch_path=rel, write_png=True)
    # pose flag OFF (Inc1 두 flag 는 ON — text 계약은 노출되나 sketch path 는 X).
    _enable_flags(monkeypatch, tmp_path, pose_flag=False)

    ctx = load_immobilized_subject_anchor_context("p1", "e1")
    sa = ctx["anchors_by_group"]["vca-s12-c04-immobilized"]
    assert "pose_sketch_path" not in sa


def test_loader_hides_pose_sketch_path_when_file_missing(monkeypatch, tmp_path):
    from app.core.steps.visual_continuity_anchor_step import (
        load_immobilized_subject_anchor_context,
    )
    rel = "immobilized_pose_sketches/vca-s12-c04-immobilized.png"
    # manifest 엔 path 있으나 PNG 파일 미생성.
    _write_imm_manifest(tmp_path, pose_sketch_path=rel, write_png=False)
    _enable_flags(monkeypatch, tmp_path, pose_flag=True)

    ctx = load_immobilized_subject_anchor_context("p1", "e1")
    sa = ctx["anchors_by_group"]["vca-s12-c04-immobilized"]
    assert "pose_sketch_path" not in sa


def test_attach_pose_sketch_ref_member_and_nonmember(monkeypatch, tmp_path):
    from app.core.steps.visual_continuity_anchor_step import (
        attach_immobilized_pose_sketch_ref,
        load_immobilized_subject_anchor_context,
    )
    rel = "immobilized_pose_sketches/vca-s12-c04-immobilized.png"
    _write_imm_manifest(tmp_path, pose_sketch_path=rel, write_png=True)
    _enable_flags(monkeypatch, tmp_path, pose_flag=True)
    ctx = load_immobilized_subject_anchor_context("p1", "e1")

    # 멤버 샷 (12,13=insert) → append.
    labeled_refs: List[Any] = []
    ref_roles: List[str] = []
    ref_role_metadata: List[Dict[str, Any]] = []
    attached_meta: List[Any] = []
    ok = attach_immobilized_pose_sketch_ref(
        labeled_refs, ref_roles, ref_role_metadata, attached_meta,
        scene_index=12, shot_index=13, anchor_ctx=ctx)
    assert ok is True
    assert ref_roles == ["immobilized_pose_guide"]
    assert labeled_refs[0][1] == b"\x89PNG_fake_bytes"
    assert "POSE GUIDE" in labeled_refs[0][0]
    assert ref_role_metadata[0]["group_id"] == "vca-s12-c04-immobilized"
    assert ref_role_metadata[0]["character_short_id"] == "C04"
    assert ref_role_metadata[0]["visible_focus"] == "hand"
    assert attached_meta[0] == ("immobilized_pose_guide", "12:13")

    # 비멤버 샷 (12,99) → no-op (lists 불변).
    lr2: List[Any] = []
    rr2: List[str] = []
    rrm2: List[Dict[str, Any]] = []
    am2: List[Any] = []
    ok2 = attach_immobilized_pose_sketch_ref(
        lr2, rr2, rrm2, am2, scene_index=12, shot_index=99, anchor_ctx=ctx)
    assert ok2 is False
    assert lr2 == [] and rr2 == [] and rrm2 == [] and am2 == []


def test_attach_pose_sketch_ref_noop_when_no_path(monkeypatch, tmp_path):
    """pose flag OFF → loader 가 path 미노출 → attach no-op (byte-identical)."""
    from app.core.steps.visual_continuity_anchor_step import (
        attach_immobilized_pose_sketch_ref,
        load_immobilized_subject_anchor_context,
    )
    rel = "immobilized_pose_sketches/vca-s12-c04-immobilized.png"
    _write_imm_manifest(tmp_path, pose_sketch_path=rel, write_png=True)
    _enable_flags(monkeypatch, tmp_path, pose_flag=False)
    ctx = load_immobilized_subject_anchor_context("p1", "e1")

    labeled_refs: List[Any] = []
    ref_roles: List[str] = []
    ref_role_metadata: List[Dict[str, Any]] = []
    attached_meta: List[Any] = []
    ok = attach_immobilized_pose_sketch_ref(
        labeled_refs, ref_roles, ref_role_metadata, attached_meta,
        scene_index=12, shot_index=13, anchor_ctx=ctx)
    assert ok is False
    assert ref_roles == []


# ───────────────────── 4. ref_contract 방어 (Codex M2) ─────────────────────


_REQ_C04 = [{"kind": "character", "id": "C04", "policy": "required"}]


def test_pose_guide_alone_does_not_satisfy_required_character():
    """immobilized_pose_guide attached_meta 만으로는 required character 미충족 —
    (char/prop/background 계약 불참). future drift 방지."""
    import pytest
    from app.core.ref_contract_validator import RefContractError, validate_attached_refs

    rpc = {"asset_requirements": {"required_refs": _REQ_C04}}
    labeled_refs = [("POSE GUIDE for the immobilized figure", b"\x89")]
    attached_meta = [("immobilized_pose_guide", "12:13")]
    with pytest.raises(RefContractError, match="required character 'C04' missing"):
        validate_attached_refs(
            rpc, labeled_refs, attached_meta,
            prompt="insert of dead hand", is_close_framing=True,
            reference_phrase_kinds=[],
        )


def test_pose_guide_coexists_with_required_character_ref():
    """immobilized_pose_guide 가 정상 required character ref 와 공존 — 정상 ref 가
    계약을 충족하고 pose guide 는 추가 채널로 통과 (RefContractError 없음)."""
    from app.core.ref_contract_validator import validate_attached_refs

    rpc = {"asset_requirements": {"required_refs": _REQ_C04}}
    labeled_refs = [
        ("character appearance reference: a person", b"\x89"),
        ("POSE GUIDE for the immobilized figure", b"\x89"),
    ]
    attached_meta = [("character", "C04"), ("immobilized_pose_guide", "12:13")]
    # RefContractError 없이 통과해야 한다.
    validate_attached_refs(
        rpc, labeled_refs, attached_meta,
        prompt="insert of dead hand", is_close_framing=True,
        reference_phrase_kinds=["character"],
    )
