"""A/B(콘티 유·무) 두 갈래의 판정기는 **그 갈래의 롤 수로** 지어진다 (2026-09-19).

S11sh1 실측: A/B 갈래는 갈래마다 1롤(`_run_branch(rc=1)`)인데 판정기는
전역 롤 수(2)로 지은 것이 그대로 나갔다. 후보는 A 한 장, 스키마는 A·B —
모델이 두 칸을 채우면 검사(A 한 칸 기대)에서 두 심판이 다 떨어져 샷이
죽었다. 네 판 중 세 판 실패, 한 판은 모델이 A 만 채워 통과했다.

실제 `run_still_recipe_generation` 루프를 최소 체크포인트로 걷게 하고,
`run_multiroll_select` 에 **실제로 넘어간** 판정기와 롤 수를 잰다
(confined 하네스와 같은 방식 — 생성은 막는다).
"""
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 _db():
    q = MagicMock()
    for m in ("filter", "filter_by", "join", "order_by", "options"):
        getattr(q, m).return_value = q
    q.all.return_value = []
    q.first.return_value = None
    q.count.return_value = 0
    db = MagicMock()
    db.query.return_value = q
    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),
    # ★이 시험의 대상 — 일반 콘티 샷 A/B
    ("still_conti_ab_enabled", True),
    ("still_recipe_roll_count", 2),
    ("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),
]


class _FakeJudge:
    """지어질 때 받은 스키마의 라벨을 들고 있는 판정기 대역."""

    def __init__(self, judge_schema):
        self.schema_labels = list(
            judge_schema["properties"]["winner"]["enum"])

    def __call__(self, *a, **k):  # pragma: no cover — 생성이 막혀 안 불린다
        raise AssertionError("judge must not be called in this harness")


def _run(tmp_path):
    conti_png = tmp_path / "conti_S1sh1.png"
    conti_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"CONTI")
    # A/B 는 LOCATION 권위(플레이트)가 있어야 갈래를 짓는다
    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": {}, "scenes": {}, "world_anchor_en": ""})
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": []})
    _write_cp(tmp_path, "shot_conti_light", {
        "contis": {"S1sh1": {"image_path": str(conti_png)}}})

    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    scene_cp = MagicMock()
    runs = []

    def spy_run(**kw):
        runs.append(kw)
        sel = tmp_path / f"{kw['tag']}_sel.png"
        sel.write_bytes(b"\x89PNG\r\n\x1a\n" + b"SEL")
        return str(sel), {"selected": "A"}

    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",
              side_effect=lambda **kw: _FakeJudge(kw["judge_schema"])),
        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.conti_ab.resolve_or_run_outer",
              side_effect=RuntimeError("SAMPLE outer blocked")),
    ] + [
        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=_db(), 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]
    return runs, failed


def test_each_ab_branch_gets_a_judge_built_for_its_own_roll_count(tmp_path):
    from app.modules.pipeline.multiroll_select import roll_labels

    runs, failed = _run(tmp_path)
    # 두 갈래(콘티 포함·미포함) 다 도달했고, 바깥 비교에서 막혔다
    assert [kw["tag"] for kw in runs] == [
        "still_S1sh1_ab_conti", "still_S1sh1_ab_noconti"]
    assert len(failed) == 1 and "SAMPLE outer blocked" in failed[0]
    for kw in runs:
        assert kw["roll_count"] == 1
        # ★전역(2) 판정기가 아니라 그 갈래의 롤 수(1)로 지은 판정기
        assert kw["judge_fn"].schema_labels == roll_labels(kw["roll_count"])
