"""자세 고정(pose-lock)이어도 **몸이 곧 신원인 인물**의 참조는 싣는다 (2026-09-19).

실측(컨트리로드 2판 S84sh1): 누운 찰리(로봇)의 얼굴 근접 샷. 앞 샷과 이
샷을 정본 자세가 함께 덮어 「prev 스틸이 자세·외형 SOT — 캐릭터 참조 제외」
갈래를 탔고, 찰리 참조 없이 앞 샷 한 장만 받아 **사람 눈**이 그려졌다.
다음 샷(S85sh11)이 그 샷을 앞 샷으로 받아 같은 눈을 물려받았다.

잠그는 것 — 실제 `run_still_recipe_generation` 루프를 최소 체크포인트로
걷고 프로덕션 조립 함수 `build_still_refs` 가 **실제로 받은** 인물 참조를 잰다.
- 몸=신원 인물(outlook_phase1 `body_identity_chars`)은 자세 고정이어도 참조가 실린다.
- 대조: 사람 인물은 종전 그대로 빠진다(이중 참조 충돌 S12sh9 규칙 유지).
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

PID, EID = "SAMPLE_P", "SAMPLE_E"
REF = b"\x89PNG\r\n\x1a\nSAMPLE-CHAR-REF"


def _write_cp(tmp_path, step_id, data):
    d = tmp_path / PID / "checkpoints" / "episodes" / EID / step_id
    d.mkdir(parents=True, exist_ok=True)
    (d / "manifest.json").write_text(
        json.dumps({"status": "completed", "data": data},
                   ensure_ascii=False), encoding="utf-8")


def _still(sid, si, shi):
    return {
        "id": sid, "still_index": shi - 1, "scene_index": si,
        "shot_index": shi, "screenplay_scene_heading": f"S#{si}. SAMPLE",
        "beat_title": "", "still_frame_prompt": "SAMPLE still prompt",
        "camera_json": "{}", "lighting_json": "{}",
        "visible_entities_json": json.dumps(
            [{"id": "E1", "short_id": "C01"}]),
        "dependent_scene_id": None,
    }


def _fake_db():
    from app.models.project import EntityCanon, SceneStill

    rows = {
        EntityCanon: [EntityCanon(
            id="E1", project_id=PID, short_id="C01",
            entity_type="character", name="SAMPLE ONE")],
        SceneStill: [
            SceneStill(id=f"st_{i}", project_id=PID, episode_id=EID,
                       scene_index=1, shot_index=i, is_selected=True,
                       visible_entities_json=json.dumps(
                           [{"id": "E1", "short_id": "C01"}]))
            for i in (1, 2)],
    }

    def _query(model, *a, **k):
        q = MagicMock()
        for m in ("filter", "filter_by", "join", "order_by", "options"):
            getattr(q, m).return_value = q
        q.all.return_value = list(rows.get(model, []))
        q.first.return_value = None
        q.count.return_value = 0
        return q

    db = MagicMock()
    db.query.side_effect = _query
    return db


_FLAG_PATCHES = [
    ("still_confined_fp_enabled", False),
    ("still_plate_select_enabled", False),
    ("still_bgfirst_enabled", False),
    ("still_bgfirst_full_enabled", False),
    ("still_variants_enabled", False),
    ("still_conti_ab_enabled", False),
    ("still_cine_transform_enabled", False),
    ("still_recipe_critique_enabled", False),
    ("multiroll_fix_rejudge_enabled", False),
    ("multiroll_gpt_composition_enabled", False),
    ("still_recipe_camera_frame_enabled", False),
    ("still_recipe_lighting_enabled", False),
    ("outdoor_lane_pipe_enabled", False),
    ("outdoor_lane_plan_enabled", False),
    ("background_share_plan_enabled", False),
    ("still_lane_prev_bgfirst_enabled", False),
    ("signage_author_enabled", False),
    ("era_research_enabled", False),
    ("still_cast_wardrobe_lock_enabled", False),
]


def _run(tmp_path, *, body_identity):
    plate_png = tmp_path / "plate_BG1.png"
    plate_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"PLATE")
    _write_cp(tmp_path, "background_render", {"groups": {"BG1": {
        "status": "ok", "png_path": str(plate_png),
        "shot_ids": ["S1_Shot1", "S1_Shot2"]}}})
    _write_cp(tmp_path, "shot_ref_classify", {
        "shots": {"S1sh1": {"person_visible": True, "prev": None},
                  "S1sh2": {"person_visible": True, "prev": "S1sh1"}},
        "scenes": {}, "world_anchor_en": ""})
    # 정본 자세가 앞 샷과 이 샷을 **함께** 덮는다 — S84sh1 의 모양
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": [
        {"character_short_id": "C01", "shots": ["S1sh1", "S1sh2"]}]})
    _write_cp(tmp_path, "shot_conti_light", {"contis": {}})
    _write_cp(tmp_path, "outlook_phase1", {
        "body_identity_chars": ["C01"] if body_identity else []})
    scene_dir = tmp_path / "scene"
    (scene_dir / "recipe").mkdir(parents=True)
    # 앞 샷의 선정본이 이미 있다 — prev 앵커
    (scene_dir / "recipe" / "S1sh1_sel.png").write_bytes(
        b"\x89PNG\r\n\x1a\nPREV")

    from app.modules.pipeline import still_recipe as _sr
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    seen = []
    real_refs = _sr.build_still_refs

    def spy_refs(*a, **kw):
        seen.append({"prev": kw.get("prev_sel") is not None,
                     "chars": [n for n, _src in kw.get("char_refs") or []]})
        return real_refs(*a, **kw)

    ref_svc = MagicMock()
    ref_svc.detect_state_variant_sids.return_value = {}
    patches = [
        patch("app.core.config.settings.projects_dir", str(tmp_path)),
        patch("app.modules.pipeline.multiroll_gemini.make_nb2_gen_fn",
              return_value=MagicMock()),
        patch("app.modules.pipeline.multiroll_gemini.make_gemini_judge_fn",
              return_value=MagicMock()),
        patch(
            "app.modules.pipeline.multiroll_gemini.make_gemini_critique_fn",
            return_value=MagicMock()),
        patch("app.modules.pipeline.multiroll_select.run_multiroll_select",
              side_effect=RuntimeError("SAMPLE generation blocked")),
        patch("app.modules.pipeline.still_recipe.build_still_refs",
              side_effect=spy_refs),
    ] + [
        patch(f"app.core.config.settings.{name}", val, create=True)
        for name, val in _FLAG_PATCHES
    ]
    from contextlib import ExitStack

    scene_cp = MagicMock()
    with ExitStack() as stack:
        for pch in patches:
            stack.enter_context(pch)
        run_still_recipe_generation(
            db=_fake_db(), project_id=PID, episode_id=EID,
            stills=[_still("st_1", 1, 1), _still("st_2", 1, 2)],
            stills_orm=[],
            entity_lookup={"E1": {"id": "E1", "name": "SAMPLE ONE",
                                  "entity_type": "character",
                                  "short_id": "C01"}},
            ref_image_map={}, reference_svc=ref_svc,
            scene_ref_image_map={"E1": REF},
            scene_ref_asset_id_map={"E1": "asset-E1"},
            staging_map={}, scene_cp=scene_cp,
            persistence_svc=MagicMock(), progress=MagicMock(),
            project_config=None, scene_dir=scene_dir,
            already_done_stills=set(), target_scenes=None,
        )
    failed = [str(c.args[1]) if len(c.args) > 1 else ""
              for c in scene_cp.mark_failed.call_args_list]
    # 두 샷 다 생성 직전까지 갔다 — 막힌 것은 표지(sentinel)뿐
    assert len(failed) == 2 and all(
        "SAMPLE generation blocked" in f for f in failed), failed
    return seen


def test_body_identity_character_keeps_reference_under_pose_lock(tmp_path):
    seen = _run(tmp_path, body_identity=True)
    second = seen[-1]
    assert second["prev"] is True
    assert second["chars"] == ["SAMPLE ONE"]


def test_human_character_is_still_excluded_under_pose_lock(tmp_path):
    """대조 — 사람 인물은 종전 규칙 그대로(prev 가 자세·외형 SOT)."""
    seen = _run(tmp_path, body_identity=False)
    second = seen[-1]
    assert second["prev"] is True
    assert second["chars"] == []
