"""#77-B — 완료(already_done) 샷의 방문 시점 JIT 지문 검증 게이트.

하네스는 test_still_recipe_target_scope.py 와 같은 방식: 실제 ordered loop
를 최소 CP fixture 로 진입시키고, 생성은 sentinel(도달 관찰) 또는 성공
가짜(run_branch_select 대체)로 통제한다. 잠그는 계약(2026-08-09 Codex
BLOCK 3건 반영):
- record 없는 완료 샷 = 예전 그대로 skip(검증 불가 — 재지출 번짐 차단)
- record 있는 완료 샷 = 몸통 도달, 실패하면 **스텝을 완료로 닫지 않는다**
  (옛 primary 가 실패를 가리는 봉인 차단 — BLOCK 1)
- 지출 판정 = bytes 가 아니라 record 갱신 흔적 — 산출이 같아도 record 가
  움직였으면 상한 계수에 들어간다 (BLOCK 2)
- 상한 = 경계(정확히 limit 로 걷기 종료)에서도 raise + 래치 파일 영속 —
  자동 재시도가 지출 없이 즉시 멈춘다 (BLOCK 3)
- 스위치 off = 예전 완전 skip 그대로
시나리오 의존 0.
"""
from __future__ import annotations

import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest

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_mock(first_result=None):
    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 = first_result
    q.count.return_value = 0
    db = MagicMock()
    db.query.return_value = q
    return db


_FLAG_PATCHES = [
    ("still_bgfirst_enabled", False),
    ("still_bgfirst_full_enabled", False),
    ("still_variants_enabled", False),
    ("still_plate_select_enabled", False),
    ("still_conti_ab_enabled", False),
    ("multiroll_fix_rejudge_enabled", False),
    ("multiroll_gpt_composition_enabled", False),
    ("still_recipe_camera_frame_enabled", False),
    ("still_recipe_lighting_enabled", False),
    ("still_recipe_conduct_enabled", False),
    ("outdoor_lane_pipe_enabled", False),
    ("outdoor_lane_plan_enabled", False),
    ("background_share_plan_enabled", False),
    ("still_lane_prev_bgfirst_enabled", False),
    # 2026-08-20: 아래 둘이 없으면 이 시험이 **기계의 .env 값**을 문다
    # (opik pytest 플러그인이 본체 .env 를 환경에 싣는다). 지금 기계는
    # 변환 켜짐·참조 선별 켜짐이라, 변환 스테이지가 시험 의도와 무관하게
    # 돌아 StillCineTransformIncomplete 로 죽었다. 시험 기준선은 환경이
    # 아니라 시험이 정한다.
    ("still_cine_transform_enabled", False),
    ("still_fix_ref_gate_enabled", False),
    # 2026-08-20: 이 둘이 없으면 하네스가 기계의 .env 를 물어(둘 다 켜짐)
    # 걷기가 **실제 유료 LLM 호출**을 내보낸다 — 시험을 돌릴 때마다 돈이
    # 나간다(실측: 전체 시험 한 바퀴에 Gemini 168건). 이 파일의 시험들은
    # 간판·시대를 겨누지 않으므로 기준선에서 끈다.
    # 감시: .venv/bin/python -m pytest ... -p tests.netprobe
    ("signage_author_enabled", False),
    ("era_research_enabled", False),
]


def _recipe_dir(tmp_path):
    return tmp_path / "scene" / "recipe"


def _prior_asset(path, asset_id="PRIOR_ID", **kw):
    """옛 대표 자산 대역 — **진짜 모델로** 만든다.

    ★`SimpleNamespace` 흉내는 칸 이름이 틀려도 안 막는다. 실제로
     `is_manual_upload` 가 `prompt_used` 를 읽게 되자 이 파일의 시험
     넷이 `AttributeError` 로 죽었다 — 코드가 아니라 **대역이** 틀린
     것이었다. 모델을 import 해서 만들면 칸이 없으면 만들 때 터진다.
    """
    from app.models.project import ImageAsset

    return ImageAsset(id=asset_id, file_path=str(path), **kw)


def _run(tmp_path, *, stills, already_done, records=None, db=None,
         gen=None, settings_over=(), persistence=None, scene_cp=None):
    """gen=None → sentinel(생성 차단, 도달 관찰). gen=callable →
    run_branch_select 대체(성공 경로). raise 를 기대하는 시험은
    persistence/scene_cp mock 을 직접 만들어 넘기고 밖에서 단언한다."""
    _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": {}})
    recipe_dir = _recipe_dir(tmp_path)
    recipe_dir.mkdir(parents=True, exist_ok=True)
    if records is not None:
        (recipe_dir / "records.json").write_text(
            json.dumps(records, ensure_ascii=False), encoding="utf-8")

    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    progress = MagicMock()
    scene_cp = scene_cp if scene_cp is not None else MagicMock()
    persistence = persistence if persistence is not None else MagicMock()
    # safety 사다리 미발화 선언 — MagicMock 반환(truthy)이 provenance
    # 정정 분기를 오발화시켜 review_notes JSON 직렬화가 깨진다
    persistence.safety_ladder_call_provenance.return_value = None
    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.still_recipe.run_branch_select",
              side_effect=gen),
    ] if gen is not None else [
        patch("app.modules.pipeline.multiroll_select.run_multiroll_select",
              side_effect=RuntimeError("SAMPLE generation blocked")),
    ]) + [
        patch(f"app.core.config.settings.{name}", val, create=True)
        for name, val in _FLAG_PATCHES
    ] + [
        patch(f"app.core.config.settings.{name}", val, create=True)
        for name, val in settings_over
    ]
    from contextlib import ExitStack

    with ExitStack() as stack:
        for pch in patches:
            stack.enter_context(pch)
        generated = run_still_recipe_generation(
            db=db if db is not None else _db_mock(),
            project_id=PID, episode_id=EID,
            stills=stills, 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=persistence, progress=progress,
            project_config=None, scene_dir=tmp_path / "scene",
            already_done_stills=already_done,
            target_scenes=None,
        )
    failed_ids = [c.args[0] for c in scene_cp.mark_failed.call_args_list]
    return generated, failed_ids, persistence, scene_cp


def _gen_writing(path, data, counter=None):
    """항상 새 record 를 돌려주는 가짜 — 재생성(지출) 경로."""
    def _fake(**kwargs):
        if counter is not None:
            counter["n"] += 1
        path.write_bytes(data)
        return path, {"selected": "a", "totals": {}}
    return _fake


def _gen_echo(path, data, extra=None, counter=None):
    """저장된 record 를 그대로 돌려주는 가짜 — 진짜 전량 재사용(지출 0)
    경로 모사. extra 를 주면 '유료 소급이 record 를 갱신한' 경우 모사."""
    def _fake(**kwargs):
        if counter is not None:
            counter["n"] += 1
        path.write_bytes(data)
        rec = dict(kwargs["records"].data.get(kwargs["rec_key"]) or {})
        if extra:
            rec.update(extra)
        return path, rec
    return _fake


def test_done_without_record_skips(tmp_path):
    generated, failed_ids, persistence, _ = _run(
        tmp_path, stills=[_still("st_1", 1, 1)], already_done={"st_1"},
        records={})
    assert failed_ids == []  # 몸통 미도달 — 예전 완전 skip 그대로
    assert generated == 0
    persistence.save_single_scene_asset.assert_not_called()


def test_switch_off_keeps_full_skip(tmp_path):
    generated, failed_ids, persistence, _ = _run(
        tmp_path, stills=[_still("st_1", 1, 1)], already_done={"st_1"},
        records={"S1sh1": {"input_fingerprint": "old"}},
        settings_over=(("still_jit_verify_enabled", False),))
    assert failed_ids == []
    assert generated == 0
    persistence.save_single_scene_asset.assert_not_called()


def test_verify_failure_does_not_seal_step(tmp_path):
    # BLOCK 1: 검증 샷이 실패하면(sentinel) 옛 primary 가 남아 있어도
    # 걷기 끝에 raise — 스텝이 completed/새 hash 로 봉인되지 않는다.
    from app.services.still_recipe_service import StillJitVerifyIncomplete

    scene_cp = MagicMock()
    with pytest.raises(StillJitVerifyIncomplete) as ei:
        _run(tmp_path, stills=[_still("st_1", 1, 1)],
             already_done={"st_1"},
             records={"S1sh1": {"input_fingerprint": "old"}},
             scene_cp=scene_cp)
    assert "S1sh1" in str(ei.value)
    # 샷 단위 실패 격리(mark_failed)는 그대로 남는다
    assert [c.args[0] for c in scene_cp.mark_failed.call_args_list] == [
        "st_1"]


def test_fresh_output_writes_nothing(tmp_path):
    # 전량 재사용(echo record + 같은 bytes) = 영속·CP 무접촉, 지출 0 취급.
    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"SAME-BYTES")
    sel = tmp_path / "made_sel.png"
    # ★CP 대표가 **이미 DB 대표와 같다** — 그것이 무변경 방문의 모습이다.
    #  이 줄이 없으면 `_reconcile_cp_primary`(2026-09-20)가 MagicMock 의
    #  가짜 불일치를 보고 메타를 한 번 고쳐 「아무것도 안 쓴다」가 깨진다.
    #  코드가 아니라 **대역**이 프로덕션과 다른 것이다.
    _cp = MagicMock()
    _cp.get_completed.return_value = {
        "primary_id": "PRIOR_ID", "primary_path": str(prior),
        "recipe_tag": "S1sh1"}
    generated, failed_ids, persistence, scene_cp = _run(
        tmp_path, stills=[_still("st_1", 1, 1)], already_done={"st_1"},
        records={"S1sh1": {"input_fingerprint": "old"}},
        db=_db_mock(_prior_asset(prior)),
        gen=_gen_echo(sel, b"SAME-BYTES"), scene_cp=_cp)
    assert failed_ids == []
    assert generated == 0
    persistence.save_single_scene_asset.assert_not_called()
    scene_cp.mark_completed.assert_not_called()
    # 상한 래치도 안 생긴다
    from app.services.still_recipe_service import JIT_LATCH_FILENAME

    assert not (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists()


def test_spent_but_same_bytes_counts_toward_limit(tmp_path):
    # BLOCK 2: 산출 bytes 는 같은데 record 가 갱신됐다(유료 소급 모사) —
    # 영속은 생략하되 지출로 세어, limit=1 이면 걷기 끝에 raise+래치.
    from app.services.still_recipe_service import (
        JIT_LATCH_FILENAME,
        StillJitRegenLimitExceeded,
    )

    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"SAME-BYTES")
    sel = tmp_path / "made_sel.png"
    persistence = MagicMock()
    with pytest.raises(StillJitRegenLimitExceeded):
        _run(tmp_path, stills=[_still("st_1", 1, 1)],
             already_done={"st_1"},
             records={"S1sh1": {"input_fingerprint": "old"}},
             db=_db_mock(
                 _prior_asset(prior)),
             gen=_gen_echo(sel, b"SAME-BYTES",
                           extra={"critique": {"issues": []}}),
             settings_over=(("still_jit_regen_limit", 1),),
             persistence=persistence)
    persistence.save_single_scene_asset.assert_not_called()
    assert (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists()


def test_shared_groupbg_spend_counts_toward_limit(tmp_path):
    # Codex 재재리뷰 BLOCK: 공유 groupbg record 만 갱신되고(그룹 배경 유료
    # 재생성) 샷 자신의 record·산출 bytes 는 그대로인 경우 — tag 네임
    # 스페이스만 보면 지출이 안 보인다. 공유 키 변화도 지출로 세어 상한에
    # 들어가는 것을 잠근다.
    from app.services.still_recipe_service import (
        JIT_LATCH_FILENAME,
        StillJitRegenLimitExceeded,
    )

    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"SAME-BYTES")
    sel = tmp_path / "made_sel.png"

    def _gen(**kwargs):
        sel.write_bytes(b"SAME-BYTES")
        recs = kwargs["records"]
        recs.data["groupbg::SAMPLE_PLACE"] = {"input_fingerprint": "new"}
        return sel, dict(recs.data.get(kwargs["rec_key"]) or {})

    persistence = MagicMock()
    with pytest.raises(StillJitRegenLimitExceeded):
        _run(tmp_path, stills=[_still("st_1", 1, 1)],
             already_done={"st_1"},
             records={
                 "S1sh1": {"input_fingerprint": "old"},
                 "groupbg::SAMPLE_PLACE": {"input_fingerprint": "old"},
             },
             db=_db_mock(
                 _prior_asset(prior)),
             gen=_gen,
             settings_over=(("still_jit_regen_limit", 1),),
             persistence=persistence)
    persistence.save_single_scene_asset.assert_not_called()
    assert (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists()


def test_stale_output_persists_via_existing_path(tmp_path):
    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"OLD-BYTES")
    sel = tmp_path / "made_sel.png"
    generated, failed_ids, persistence, scene_cp = _run(
        tmp_path, stills=[_still("st_1", 1, 1)], already_done={"st_1"},
        records={"S1sh1": {"input_fingerprint": "old"}},
        db=_db_mock(_prior_asset(prior)),
        gen=_gen_writing(sel, b"NEW-BYTES"))
    assert failed_ids == []
    assert generated == 1
    assert persistence.save_single_scene_asset.call_count == 1
    scene_cp.mark_completed.assert_called_once()


def test_regen_limit_brakes_before_next_verify(tmp_path):
    # 상한 도달 후 다음 검증 대상 앞에서 멈춘다 + 래치가 남는다.
    from app.services.still_recipe_service import (
        JIT_LATCH_FILENAME,
        StillJitRegenLimitExceeded,
    )

    sel = tmp_path / "made_sel.png"
    with pytest.raises(StillJitRegenLimitExceeded):
        _run(
            tmp_path,
            stills=[_still("st_1", 1, 1), _still("st_2", 1, 2)],
            already_done={"st_1", "st_2"},
            records={"S1sh1": {"input_fingerprint": "old"},
                     "S1sh2": {"input_fingerprint": "old"}},
            # prior primary 없음 → 산출 비교 불가 = 재생성 취급
            gen=_gen_writing(sel, b"NEW-BYTES"),
            settings_over=(("still_jit_regen_limit", 1),))
    assert (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists()


def test_boundary_exact_limit_raises_and_latches(tmp_path):
    # BLOCK 3 경계: 정확히 limit 개 재생성으로 걷기가 끝나도(다음 검증
    # 대상 없음) completed 로 봉인하지 않는다 — 걷기 끝 raise + 래치.
    from app.services.still_recipe_service import (
        JIT_LATCH_FILENAME,
        StillJitRegenLimitExceeded,
    )

    sel = tmp_path / "made_sel.png"
    persistence = MagicMock()
    with pytest.raises(StillJitRegenLimitExceeded):
        _run(tmp_path, stills=[_still("st_1", 1, 1)],
             already_done={"st_1"},
             records={"S1sh1": {"input_fingerprint": "old"}},
             gen=_gen_writing(sel, b"NEW-BYTES"),
             settings_over=(("still_jit_regen_limit", 1),),
             persistence=persistence)
    # 상한 도달 '전'의 재생성 산출은 이미 샷 단위로 영속됐다
    assert persistence.save_single_scene_asset.call_count == 1
    assert (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists()


def test_latch_blocks_without_spend(tmp_path):
    # BLOCK 3: 래치가 남아 있으면 자동 재시도는 지출 0 으로 즉시 멈춘다.
    from app.services.still_recipe_service import (
        JIT_LATCH_FILENAME,
        StillJitRegenLimitExceeded,
    )

    recipe_dir = _recipe_dir(tmp_path)
    recipe_dir.mkdir(parents=True, exist_ok=True)
    (recipe_dir / JIT_LATCH_FILENAME).write_text("{}", encoding="utf-8")
    sel = tmp_path / "made_sel.png"
    calls = {"n": 0}
    persistence = MagicMock()
    with pytest.raises(StillJitRegenLimitExceeded):
        _run(tmp_path, stills=[_still("st_1", 1, 1)],
             already_done={"st_1"},
             records={"S1sh1": {"input_fingerprint": "old"}},
             gen=_gen_writing(sel, b"NEW-BYTES", counter=calls),
             persistence=persistence)
    assert calls["n"] == 0  # 생성 가짜조차 호출되지 않았다
    persistence.save_single_scene_asset.assert_not_called()


def test_latch_ignored_when_switch_off(tmp_path):
    # 되돌림 레버 off 면 래치와 무관하게 예전 완전 skip 동작.
    from app.services.still_recipe_service import JIT_LATCH_FILENAME

    recipe_dir = _recipe_dir(tmp_path)
    recipe_dir.mkdir(parents=True, exist_ok=True)
    (recipe_dir / JIT_LATCH_FILENAME).write_text("{}", encoding="utf-8")
    generated, failed_ids, persistence, _ = _run(
        tmp_path, stills=[_still("st_1", 1, 1)], already_done={"st_1"},
        records={"S1sh1": {"input_fingerprint": "old"}},
        settings_over=(("still_jit_verify_enabled", False),))
    assert failed_ids == []
    assert generated == 0


# ── 게이트에 붙잡혀도 **지출은 센다** (2026-09-20 Codex) ───────────


def _gen_persisting(path, data, extra=None):
    """★프로덕션의 `run_branch_select._persist` 와 **같은 일**을 한다.

    선정이 끝나면 record 를 그 자리에서 records 에 쓰고 저장한다. 이걸
    빠뜨리면 「지출 흔적」 신호(`_jit_tag_snapshot` 비교)가 움직이지 않아,
    대역이 프로덕션과 **다른 것**을 재게 된다.
    """
    def _fake(**kwargs):
        path.write_bytes(data)
        key = kwargs["rec_key"]
        rec = dict(kwargs["records"].data.get(key) or {})
        if extra:
            rec.update(extra)
        kwargs["records"].data[key] = rec
        kwargs["records"].save()
        return path, rec
    return _fake


def test_a_gate_held_shot_still_counts_toward_the_limit(tmp_path):
    """★★붙잡힌 샷이 상한에 안 잡히면 **폭주 제동이 한 번도 안 센다**.

    게이트 미해결 문은 JIT 상한 계수보다 **앞**에 있다. 완료 샷이 지문
    불일치로 롤부터 다시 사고 여기서 붙잡히면, 종전에는 `jit_regen_count`
    가 한 번도 안 올라 상한(64)이 영영 안 걸렸다 — 게이트를 켜는 순간
    대상 전부를 그대로 다시 사게 된다.

    ★자산을 지우거나 완료로 찍자는 것이 아니다. **돈이 나간 사실을
     세자**는 것뿐이다.
    """
    from app.services.still_recipe_service import (
        JIT_LATCH_FILENAME,
        StillJitRegenLimitExceeded,
    )

    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"OLD")
    sel = tmp_path / "made_sel.png"
    persistence = MagicMock()
    with pytest.raises(StillJitRegenLimitExceeded):
        _run(tmp_path, stills=[_still("st_1", 1, 1)],
             already_done={"st_1"},
             records={"S1sh1": {"input_fingerprint": "old"}},
             db=_db_mock(
                 _prior_asset(prior)),
             gen=_gen_persisting(sel, b"NEW-BYTES",
                                 extra={"critique": {"issues": []},
                                        "gate": {"outcome": "unresolved",
                                                 "policy": "p"}}),
             settings_over=(("still_jit_regen_limit", 1),
                            ("still_winner_gate_enabled", True)),
             persistence=persistence)
    # 붙잡혔으니 자산은 안 올라간다 — 그래도 상한 래치는 남는다
    persistence.save_single_scene_asset.assert_not_called()
    assert (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists(), (
        "붙잡힌 샷의 지출을 안 세서 제동이 안 걸렸다")


def test_the_gate_off_run_counts_exactly_as_before(tmp_path):
    """★꺼진 판의 상한 계수는 **종전 그대로** — 레버 계약이다."""
    from app.services.still_recipe_service import (
        JIT_LATCH_FILENAME,
        StillJitRegenLimitExceeded,
    )

    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"OLD")
    sel = tmp_path / "made_sel.png"
    persistence = MagicMock()
    with pytest.raises(StillJitRegenLimitExceeded):
        _run(tmp_path, stills=[_still("st_1", 1, 1)],
             already_done={"st_1"},
             records={"S1sh1": {"input_fingerprint": "old"}},
             db=_db_mock(
                 _prior_asset(prior)),
             gen=_gen_persisting(sel, b"NEW-BYTES",
                                 extra={"critique": {"issues": []},
                                        "gate": {"outcome": "unresolved"}}),
             settings_over=(("still_jit_regen_limit", 1),
                            ("still_winner_gate_enabled", False)),
             persistence=persistence)
    # 꺼진 판은 문이 안 서므로 정상 영속 + 상한 계수도 종전대로
    persistence.save_single_scene_asset.assert_called()
    assert (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists()


def test_the_brake_stops_the_next_shot_after_a_held_paid_one(tmp_path):
    """★상한 1 — 첫 샷이 **돈을 쓰고 붙잡히면** 둘째는 유료 경계에 못 온다.

    Codex 가 요구한 최소 반례. 종전에는 붙잡힌 샷의 지출이 계수에 안 들어가
    둘째·셋째가 계속 돈을 썼다.
    """
    from app.services.still_recipe_service import (
        StillJitRegenLimitExceeded,
    )

    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"OLD")
    sel = tmp_path / "made_sel.png"
    hits = {"n": 0}

    def _gen(**kwargs):
        hits["n"] += 1
        sel.write_bytes(b"NEW-BYTES")
        key = kwargs["rec_key"]
        rec = dict(kwargs["records"].data.get(key) or {})
        rec.update({"critique": {"issues": []},
                    "shot_run_spend_attempt_count": 1,
                    "gate": {"outcome": "unresolved", "policy": "p"}})
        kwargs["records"].data[key] = rec
        kwargs["records"].save()
        return sel, rec

    with pytest.raises(StillJitRegenLimitExceeded):
        _run(tmp_path,
             stills=[_still("st_1", 1, 1), _still("st_2", 2, 1)],
             already_done={"st_1", "st_2"},
             records={"S1sh1": {"input_fingerprint": "old"},
                      "S2sh1": {"input_fingerprint": "old"}},
             db=_db_mock(_prior_asset(prior)),
             gen=_gen,
             settings_over=(("still_jit_regen_limit", 1),
                            ("still_winner_gate_enabled", True)),
             persistence=MagicMock())
    assert hits["n"] == 1, (
        f"붙잡힌 첫 샷의 지출을 안 세서 둘째까지 샀다 (호출 {hits['n']})")


def test_a_metadata_only_visit_spends_nothing(tmp_path):
    """★**대조** — 판정 이름표만 채운 방문은 지출 계수 0.

    무료 소급(옛 기록에 gate 만 채우기)이 「지출」로 읽히면 반대 방향으로
    틀린다 — 상한 1 에서도 멈추면 안 된다.
    """
    from app.services.still_recipe_service import JIT_LATCH_FILENAME

    prior = tmp_path / "prior_primary.png"
    prior.write_bytes(b"SAME-BYTES")
    sel = tmp_path / "made_sel.png"
    hits = {"n": 0}

    def _gen(**kwargs):
        hits["n"] += 1
        sel.write_bytes(b"SAME-BYTES")
        key = kwargs["rec_key"]
        rec = dict(kwargs["records"].data.get(key) or {})
        # 돈이 나간 흔적은 **없다** — 판정 이름표만 새로 붙었다.
        rec["gate"] = {"outcome": "clean", "policy": "p"}
        kwargs["records"].data[key] = rec
        kwargs["records"].save()
        return sel, rec

    _cp = MagicMock()
    _cp.get_completed.return_value = {
        "primary_id": "PRIOR_ID", "primary_path": str(prior),
        "recipe_tag": "S1sh1"}
    generated, failed_ids, persistence, _ = _run(
        tmp_path, stills=[_still("st_1", 1, 1)], already_done={"st_1"},
        records={"S1sh1": {"input_fingerprint": "old"}},
        db=_db_mock(_prior_asset(prior)),
        gen=_gen, scene_cp=_cp,
        settings_over=(("still_jit_regen_limit", 1),
                       ("still_winner_gate_enabled", True)))
    assert failed_ids == []
    persistence.save_single_scene_asset.assert_not_called()
    assert not (_recipe_dir(tmp_path) / JIT_LATCH_FILENAME).exists(), (
        "판정 이름표만 붙였는데 「지출」로 세어 제동이 걸렸다")
