"""스틸 조립 — 캐릭터가 보이는 샷은 분류가 「사람 없음」이어도 **배경 전용이
아니다** (2026-09-19 사용자 지시).

실제 `run_still_recipe_generation` 루프를 최소 체크포인트로 걷게 하고
(confined·A/B 하네스와 같은 방식 — 생성은 막는다), 프로덕션 조립 함수
`build_still_prompt`·`build_still_refs` 가 **실제로 받은** `bg_only` 를 잰다.
진짜 함수를 감싸 값만 적는다 — 조립은 그대로 돈다.
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

PID, EID = "SAMPLE_P", "SAMPLE_E"


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": 0, "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": "[]", "dependent_scene_id": None,
    }


def _fake_db(ve_ids):
    """모델마다 정해 둔 행을 돌려주는 DB 대역 — 행은 진짜 ORM 모델이다.
    그 밖의 조회는 빈 결과(기존 하네스와 같다)."""
    from app.models.project import EntityCanon, SceneStill

    rows = {
        EntityCanon: [EntityCanon(
            id="E1", project_id=PID, short_id="C01",
            entity_type="character", name="SAMPLE ROBOT")],
        SceneStill: [SceneStill(
            id="st_1", project_id=PID, episode_id=EID,
            scene_index=1, shot_index=1,
            visible_entities_json=json.dumps(
                [{"id": i, "short_id": "C01"} for i in ve_ids]))],
    }

    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),
    # 기계의 .env 를 물면 걷기가 실제 유료 호출을 낸다(confined 하네스 주석)
    ("signage_author_enabled", False),
    ("era_research_enabled", False),
]


def _run(tmp_path, ve_ids):
    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"]}}})
    # ★분류 LLM 은 「사람 없음」이라고 판정했다 — 로봇만 보이는 샷의 실측 모양
    _write_cp(tmp_path, "shot_ref_classify", {
        "shots": {"S1sh1": {"person_visible": False, "prev": None,
                            "bgonly_reason_ko": "로봇만 보임"}},
        "scenes": {}, "world_anchor_en": ""})
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": []})
    _write_cp(tmp_path, "shot_conti_light", {"contis": {}})

    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    scene_cp = MagicMock()

    def spy_run(**kw):
        raise RuntimeError("SAMPLE generation blocked")

    from app.modules.pipeline import still_recipe as _sr

    seen = {"prompt": [], "refs": []}
    real_prompt, real_refs = _sr.build_still_prompt, _sr.build_still_refs

    def spy_prompt(*a, **kw):
        seen["prompt"].append(kw.get("bg_only"))
        return real_prompt(*a, **kw)

    def spy_refs(*a, **kw):
        seen["refs"].append(kw.get("bg_only"))
        return real_refs(*a, **kw)

    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=spy_run),
        patch("app.modules.pipeline.still_recipe.build_still_prompt",
              side_effect=spy_prompt),
        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

    with ExitStack() as stack:
        for pch in patches:
            stack.enter_context(pch)
        run_still_recipe_generation(
            db=_fake_db(ve_ids), project_id=PID, episode_id=EID,
            stills=[_still("st_1", 1, 1)], stills_orm=[],
            entity_lookup={}, ref_image_map={},
            reference_svc=MagicMock(),
            scene_ref_image_map={}, scene_ref_asset_id_map={},
            staging_map={}, 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]
    # 생성 직전까지 갔다 — 막힌 것은 표지(sentinel) 하나뿐
    assert len(failed) == 1 and "SAMPLE generation blocked" in failed[0], failed
    return seen


def test_character_in_shot_is_not_background_only(tmp_path):
    seen = _run(tmp_path, ve_ids=("E1",))
    assert seen["prompt"] == [False]
    assert seen["refs"] == [False]


def test_no_character_stays_background_only(tmp_path):
    """대조 — 캐릭터가 VE 에 없으면 분류 판정이 그대로 산다."""
    seen = _run(tmp_path, ve_ids=())
    assert seen["prompt"] == [True]
    assert seen["refs"] == [True]
