"""촬영 계획이 화면 안에 세운 **등록 인물**은 VE 에 없어도 재료를 받는다 (2026-09-19).

실측(컨트리로드 2판): shot_director VE 에는 없고 shot_staging
`character_angles` 에는 있는 등록 인물이 선택 샷 239개 중 35쌍. 카메라 지시는
「찰리의 등을 왼쪽 앞에」라고 세우는데 찰리 참조가 안 실려, 로봇 찰리가 **사람
뒷모습**으로 그려졌다(S60sh4·S64sh7).

잠그는 것:
- 이름 조인 helper — POV 인물은 화면 밖, 정본에 없는 이름은 건너뜀,
  같은 이름이 둘이면 어느 쪽인지 모르니 뺀다.
- 실제 `run_still_recipe_generation` 루프에서 그 인물이 **참조**(build_still_refs)와
  **인물 목록**(build_still_prompt char_names)과 **이어지는 상태 문장**(carried)에
  모두 실린다 — 세 자리가 같은 값을 쓴다.
- 대조: 배경 전용 샷은 건드리지 않는다.
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

from app.modules.pipeline.shot_visibility import (
    staged_in_frame_character_ids,
    unique_character_ids_by_name,
)

PID, EID = "SAMPLE_P", "SAMPLE_E"


# ── 이름 조인 ──────────────────────────────────────────────────────


def test_pov_and_unknown_names_are_skipped():
    ids = {"SAMPLE A": "E1", "SAMPLE B": "E2"}
    got = staged_in_frame_character_ids(
        [{"character": "SAMPLE B"}, {"character": "SAMPLE CROWD"},
         {"character": "SAMPLE A"}, {"character": " SAMPLE B "}],
        ids, pov_character="SAMPLE A")
    assert got == ["E2"]


def test_duplicate_names_are_dropped():
    got = unique_character_ids_by_name(
        [("E1", "SAMPLE A"), ("E2", "SAMPLE A"), ("E3", "SAMPLE C")])
    assert got == {"SAMPLE C": "E3"}


# ── 실제 루프 ──────────────────────────────────────────────────────


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 _fake_db(ve):
    from app.models.project import EntityCanon, SceneStill

    rows = {
        EntityCanon: [
            EntityCanon(id="E1", project_id=PID, short_id="C01",
                        entity_type="character", name="SAMPLE ROBOT"),
            EntityCanon(id="E2", project_id=PID, short_id="C02",
                        entity_type="character", name="SAMPLE OTHER")],
        SceneStill: [SceneStill(
            id="st_1", project_id=PID, episode_id=EID, scene_index=1,
            shot_index=1, is_selected=True,
            visible_entities_json=json.dumps(ve))],
    }

    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, *, person_visible, ve):
    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"]}}})
    _write_cp(tmp_path, "shot_ref_classify", {
        "shots": {"S1sh1": {"person_visible": person_visible,
                            "prev": None}},
        "scenes": {}, "world_anchor_en": ""})
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": [], "carried": {
        "S1sh1": {"carried_contract": "scoped_v1", "objects_en": "",
                  "people": [
                      {"character_ko": "SAMPLE ROBOT",
                       "character_short_id": "C01",
                       "state_en": "SAMPLE ROBOT STATE",
                       "basis_ko": "SAMPLE"},
                      {"character_ko": "SAMPLE OTHER",
                       "character_short_id": "C02",
                       "state_en": "SAMPLE OTHER STATE",
                       "basis_ko": "SAMPLE"}],
                  "offscreen_effects_en": "", "basis_ko": ""}}})
    _write_cp(tmp_path, "shot_conti_light", {"contis": {}})

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

    seen = {"chars": [], "names": [], "carried": []}
    real_refs, real_prompt = _sr.build_still_refs, _sr.build_still_prompt

    def spy_refs(*a, **kw):
        seen["chars"].append([n for n, _src in kw.get("char_refs") or []])
        return real_refs(*a, **kw)

    def spy_prompt(*a, **kw):
        seen["names"].append(list(kw.get("char_names") or []))
        seen["carried"].append(kw.get("carried_en") or "")
        return real_prompt(*a, **kw)

    ref_svc = MagicMock()
    seen["state_rows"] = []

    def _state(rows, *a, **k):
        seen["state_rows"].append([r.get("id") for r in rows])
        return {}

    ref_svc.detect_state_variant_sids.side_effect = _state
    ref_svc.get_visible_entities.return_value = [
        {**v, "name": "SAMPLE OTHER", "entity_type": "character"}
        for v in ve]
    staging = {"1_1": {
        "character_angles": [
            {"character": "SAMPLE ROBOT", "angle": "over_shoulder"},
            {"character": "SAMPLE OTHER", "angle": "three_quarter_left"}],
        "pov_character": ""}}
    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("app.modules.pipeline.still_recipe.build_still_prompt",
              side_effect=spy_prompt),
    ] + [
        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(ve), project_id=PID, episode_id=EID,
            stills=[{
                "id": "st_1", "still_index": 0, "scene_index": 1,
                "shot_index": 1, "screenplay_scene_heading": "S#1. SAMPLE",
                "beat_title": "", "still_frame_prompt": "SAMPLE",
                "camera_json": "{}", "lighting_json": "{}",
                "visible_entities_json": json.dumps(ve),
                "dependent_scene_id": None}],
            stills_orm=[],
            entity_lookup={
                "E1": {"id": "E1", "name": "SAMPLE ROBOT",
                       "entity_type": "character", "short_id": "C01"},
                "E2": {"id": "E2", "name": "SAMPLE OTHER",
                       "entity_type": "character", "short_id": "C02"}},
            ref_image_map={}, reference_svc=ref_svc,
            scene_ref_image_map={"E1": b"REF-E1", "E2": b"REF-E2"},
            scene_ref_asset_id_map={"E1": "asset-E1", "E2": "asset-E2"},
            staging_map=staging, scene_cp=scene_cp,
            persistence_svc=MagicMock(), progress=MagicMock(),
            project_config=None, scene_dir=tmp_path / "scene",
            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]
    assert len(failed) == 1 and "SAMPLE generation blocked" in failed[0], failed
    return seen


def test_staged_character_gets_reference_name_and_state(tmp_path):
    seen = _run(tmp_path, person_visible=True,
                ve=[{"id": "E2", "short_id": "C02"}])
    assert seen["chars"][-1] == ["SAMPLE OTHER", "SAMPLE ROBOT"]
    assert any("SAMPLE ROBOT" in n for n in seen["names"][-1])
    assert "SAMPLE ROBOT STATE" in seen["carried"][-1]
    assert "SAMPLE OTHER STATE" in seen["carried"][-1]
    # 상태 변형 판정(쓰러짐·의식 없음 등)도 더한 인물을 본다
    assert seen["state_rows"][-1] == ["E2", "E1"]


def test_background_only_shot_is_left_alone(tmp_path):
    """대조 — 분류가 사람을 빼기로 한 샷은 더하지 않는다.

    ★VE 에 캐릭터가 **없어야** 배경 전용이 선다 — 캐릭터가 하나라도 있으면
     「캐릭터가 보이는 샷은 사람 샷」 규칙(94f763b7)이 먼저 사람 샷으로 바꾼다.
    """
    seen = _run(tmp_path, person_visible=False, ve=[])
    assert all("SAMPLE ROBOT" not in " ".join(n) for n in seen["names"])
    assert all("SAMPLE ROBOT STATE" not in c for c in seen["carried"])


# ── 앞 샷 인물 명단 (Codex 2026-09-19) ─────────────────────────────


def test_prev_roster_counts_the_person_the_prev_shot_staged(tmp_path):
    """앞 샷에서 촬영 계획으로 더해 그린 인물은 **앞 샷 명단**에도 있다.

    명단이 앞 샷 VE 만 보면 그 인물은 「앞 사진에만 있는 사람」이 되어,
    이번 샷 문안이 「그 사진의 나머지 사람은 이 샷에 없다」로 빼 버린다.
    """
    _write_cp(tmp_path, "shot_ref_classify", {
        "shots": {"S1sh1": {"person_visible": True, "prev": None},
                  "S1sh2": {"person_visible": True, "prev": "S1sh1"}},
        "scenes": {}, "world_anchor_en": ""})
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": []})
    _write_cp(tmp_path, "shot_conti_light", {"contis": {}})
    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"]}}})
    scene_dir = tmp_path / "scene"
    (scene_dir / "recipe").mkdir(parents=True)
    (scene_dir / "recipe" / "S1sh1_sel.png").write_bytes(
        b"\x89PNG\r\n\x1a\nPREV")

    from app.models.project import EntityCanon, SceneStill
    from app.modules.pipeline import still_recipe as _sr
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    ve1 = [{"id": "E2", "short_id": "C02"}]                 # 앞 샷: E1 은 촬영 계획으로만
    ve2 = [{"id": "E1", "short_id": "C01"}, {"id": "E2", "short_id": "C02"}]
    rows = {
        EntityCanon: [
            EntityCanon(id="E1", project_id=PID, short_id="C01",
                        entity_type="character", name="SAMPLE ROBOT"),
            EntityCanon(id="E2", project_id=PID, short_id="C02",
                        entity_type="character", name="SAMPLE OTHER")],
        SceneStill: [
            SceneStill(id="st_1", project_id=PID, episode_id=EID,
                       scene_index=1, shot_index=1, is_selected=True,
                       visible_entities_json=json.dumps(ve1)),
            SceneStill(id="st_2", project_id=PID, episode_id=EID,
                       scene_index=1, shot_index=2, is_selected=True,
                       visible_entities_json=json.dumps(ve2))],
    }

    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
    names = {"E1": "SAMPLE ROBOT", "E2": "SAMPLE OTHER"}
    ref_svc = MagicMock()
    ref_svc.detect_state_variant_sids.return_value = {}
    ref_svc.get_visible_entities.side_effect = lambda raw: [
        {**v, "name": names[v["id"]], "entity_type": "character"}
        for v in json.loads(raw or "[]")]
    staging = {
        "1_1": {"character_angles": [
            {"character": "SAMPLE ROBOT", "angle": "over_shoulder"},
            {"character": "SAMPLE OTHER", "angle": "three_quarter_left"}],
            "pov_character": ""},
        "1_2": {"character_angles": [
            {"character": "SAMPLE ROBOT", "angle": "three_quarter_left"},
            {"character": "SAMPLE OTHER", "angle": "profile_left"}],
            "pov_character": ""}}
    seen = []
    real_rule = _sr.build_prev_people_rule

    def spy_rule(shared, *a, **kw):
        seen.append(list(shared))
        return real_rule(shared, *a, **kw)

    flags = [(n, v) for n, v in _FLAG_PATCHES
             if n != "still_cast_wardrobe_lock_enabled"]
    flags.append(("still_cast_wardrobe_lock_enabled", True))
    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_prev_people_rule",
              side_effect=spy_rule),
    ] + [patch(f"app.core.config.settings.{n}", v, create=True)
         for n, v in flags]
    from contextlib import ExitStack

    def _st(sid, shi, ve):
        return {"id": sid, "still_index": shi - 1, "scene_index": 1,
                "shot_index": shi, "screenplay_scene_heading": "S#1. SAMPLE",
                "beat_title": "", "still_frame_prompt": "SAMPLE",
                "camera_json": "{}", "lighting_json": "{}",
                "visible_entities_json": json.dumps(ve),
                "dependent_scene_id": None}

    with ExitStack() as stack:
        for pch in patches:
            stack.enter_context(pch)
        run_still_recipe_generation(
            db=db, project_id=PID, episode_id=EID,
            stills=[_st("st_1", 1, ve1), _st("st_2", 2, ve2)],
            stills_orm=[],
            entity_lookup={k: {"id": k, "name": n, "entity_type": "character",
                               "short_id": "C0" + k[-1]}
                           for k, n in names.items()},
            ref_image_map={}, reference_svc=ref_svc,
            scene_ref_image_map={"E1": b"REF-E1", "E2": b"REF-E2"},
            scene_ref_asset_id_map={"E1": "asset-E1", "E2": "asset-E2"},
            staging_map=staging, scene_cp=MagicMock(),
            persistence_svc=MagicMock(), progress=MagicMock(),
            project_config=None, scene_dir=scene_dir,
            already_done_stills=set(), target_scenes=None,
        )
    assert seen, "앞 샷 명단 규칙이 한 번도 안 불렸다"
    assert sorted(seen[-1]) == ["SAMPLE OTHER", "SAMPLE ROBOT"]


# ── 상태 변형 — **실물 선택기**를 태운다 (Codex 2026-09-19) ──────────


def test_staged_immobilized_character_gets_state_variant(tmp_path):
    """촬영 계획으로 더한 인물이 쓰러진 상태면 **상태 변형 참조**가 붙는다.

    ★대역이 아니라 프로덕션 `SceneReferenceService.detect_state_variant_sids`
     를 그대로 부른다. VE 행만 넘기면 그 인물의 이름을 못 찾아 건너뛰고,
     상태 변형이 있는데도 기본형이 붙는다(C 가 지킨 state > 합성 > 기본
     우선순위를 D 가 우회).
    """
    from app.services.scene_reference_service import SceneReferenceService

    _write_cp(tmp_path, "background_render", {"groups": {}})
    _write_cp(tmp_path, "shot_ref_classify", {
        "shots": {"S1sh1": {"person_visible": True, "prev": None}},
        "scenes": {}, "world_anchor_en": ""})
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": []})
    _write_cp(tmp_path, "shot_conti_light", {"contis": {}})

    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(dict(kw.get("char_refs") or []))
        return real_refs(*a, **kw)

    ve = [{"id": "E2", "short_id": "C02"}]
    ref_svc = MagicMock()
    ref_svc.detect_state_variant_sids.side_effect = (
        lambda *a, **k: SceneReferenceService.detect_state_variant_sids(
            None, *a, **k))
    ref_svc.get_visible_entities.return_value = [
        {"id": "E2", "short_id": "C02", "name": "SAMPLE OTHER",
         "entity_type": "character"}]
    staging = {"1_1": {"character_angles": [
        {"character": "SAMPLE ROBOT", "angle": "three_quarter_left",
         "subject_state": "unconscious"},
        {"character": "SAMPLE OTHER", "angle": "profile_left",
         "subject_state": "alive"}], "pov_character": ""}}
    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.{n}", v, create=True)
         for n, v in _FLAG_PATCHES]
    from contextlib import ExitStack

    with ExitStack() as stack:
        for pch in patches:
            stack.enter_context(pch)
        run_still_recipe_generation(
            db=_fake_db(ve), project_id=PID, episode_id=EID,
            stills=[{
                "id": "st_1", "still_index": 0, "scene_index": 1,
                "shot_index": 1, "screenplay_scene_heading": "S#1. SAMPLE",
                "beat_title": "", "still_frame_prompt": "SAMPLE",
                "camera_json": "{}", "lighting_json": "{}",
                "visible_entities_json": json.dumps(ve),
                "dependent_scene_id": None}],
            stills_orm=[],
            entity_lookup={
                "E1": {"id": "E1", "name": "SAMPLE ROBOT",
                       "entity_type": "character", "short_id": "C01"},
                "E2": {"id": "E2", "name": "SAMPLE OTHER",
                       "entity_type": "character", "short_id": "C02"}},
            ref_image_map={}, reference_svc=ref_svc,
            scene_ref_image_map={
                "E1": b"REF-E1-BASE", "E2": b"REF-E2",
                "state_variant:E1:unconscious": b"REF-E1-UNCONSCIOUS"},
            scene_ref_asset_id_map={
                "E1": "asset-E1", "E2": "asset-E2",
                "state_variant:E1:unconscious": "asset-E1-sv"},
            staging_map=staging, scene_cp=MagicMock(),
            persistence_svc=MagicMock(), progress=MagicMock(),
            project_config=None, scene_dir=tmp_path / "scene",
            already_done_stills=set(), target_scenes=None,
        )
    assert seen, "build_still_refs 가 안 불렸다"
    assert seen[-1]["SAMPLE ROBOT"] == b"REF-E1-UNCONSCIOUS"
    assert seen[-1]["SAMPLE OTHER"] == b"REF-E2"
