"""게이트 **미해결**이면 변환·자산 승격·완료 표시를 하지 않는다 (2026-09-20).

★Codex BLOCK: 선정에서 종착만 기록하고 끝내면 **실격본이 변환까지 사고
 자산으로 승격되고 완료로 찍힌다**. 확정을 막는 문이 따로 있어야 하고,
 그 문은 **새 선정과 캐시 재사용 둘 다** 지나야 한다.

이 시험은 실제 서비스 루프를 태운다 — 유료 경계만 대역이다.
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest

from app.modules.pipeline.multiroll_select import (
    GATE_CLEAN,
    GATE_INCOMPLETE,
    GATE_NOT_APPLICABLE,
    GATE_UNRESOLVED,
)
from app.services.still_recipe_service import gate_unresolved

from tests.services.test_still_cine_stage import (
    EID, PID, _FLAG_PATCHES, _db_mock, _still, _write_cp,
)

TAG, STILL_ID = "S1sh1", "still-1"


@pytest.fixture(autouse=True)
def _gate_on(monkeypatch):
    """★이 시험들은 **게이트가 켜진 판**을 잰다.

    적용 경계(`gate_is_on`)가 한 자리로 모이면서, 안 켜면 모든 판정이
    False 가 된다 — 그것이 바로 OFF 계약이다(별도 시험이 본다).
    """
    from app.core import config

    monkeypatch.setattr(config.settings, "still_winner_gate_enabled", True,
                        raising=False)


# ── 판정 helper ────────────────────────────────────────────────────

@pytest.mark.parametrize("outcome,held", [
    (GATE_UNRESOLVED, True),
    # ★★`incomplete` **도 붙잡는다** (2026-09-20 Codex BLOCK).
    #  내 첫 시험은 「incomplete 면 저장 1」을 **정답으로 잠갔다** — 그래서
    #  같은 샷이 **변환은 사고 앵커로는 못 쓰이는** 어긋남이 났다.
    #  「품질 결함이 확인되지 않았다」는 「사용해도 된다」가 아니다.
    (GATE_INCOMPLETE, True),
    ("blocked_dependency", True),
    (GATE_CLEAN, False),
    (GATE_NOT_APPLICABLE, False),
])
def test_the_holding_outcomes(outcome, held):
    assert gate_unresolved({"gate": {"outcome": outcome}}) is held


def test_a_record_without_a_gate_does_not_block():
    """게이트가 안 돈 판과 옛 기록은 **막지 않는다**."""
    for rec in ({}, {"gate": None}, {"gate": "?"}, None, "?"):
        assert gate_unresolved(rec) is False


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

def _run(tmp_path, *, gate_outcome, persistence, scene_cp):
    from app.services.still_recipe_service import run_still_recipe_generation

    _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 = tmp_path / "scene" / "recipe"
    recipe.mkdir(parents=True, exist_ok=True)
    sel = recipe / f"{TAG}_sel.png"
    sel.write_bytes(b"SEL")

    def _gen(**kwargs):
        rec = dict(kwargs["records"].data.get(kwargs["rec_key"]) or {})
        rec["gate"] = {"outcome": gate_outcome, "policy": "p"}
        return sel, rec

    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),
    ] + [patch(f"app.core.config.settings.{n}", v, create=True)
         for n, v in _FLAG_PATCHES] + [
        # ★★변환을 **끈다** — 안 끄면 이 시험이 실제 provider 를 부른다.
        #  `_FLAG_PATCHES` 는 `still_cine_transform_enabled=True` 라
        #  그대로 쓰면 clean 갈래가 **진짜 그림을 산다**(실측: 3회 나갔다).
        #  이 시험이 재는 것은 **게이트 문**이지 변환이 아니다.
        patch("app.core.config.settings.still_cine_transform_enabled",
              False, create=True),
    ]
    from contextlib import ExitStack

    with ExitStack() as st:
        for p in patches:
            st.enter_context(p)
        return run_still_recipe_generation(
            db=_db_mock(), project_id=PID, episode_id=EID,
            stills=[_still(STILL_ID, 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=persistence, progress=MagicMock(),
            project_config=None, scene_dir=tmp_path / "scene",
            already_done_stills=set(), target_scenes=None)


def _persistence():
    p = MagicMock()
    p.safety_ladder_call_provenance.return_value = None
    p.save_single_scene_asset.side_effect = lambda *a, **kw: MagicMock(
        id="G", is_primary=1, file_path="/x/g.png")
    return p


def test_an_unresolved_shot_is_not_saved_or_marked_complete(tmp_path):
    """★미해결이면 **저장 0 · 완료 표시 0**."""
    per, cp = _persistence(), MagicMock()
    _run(tmp_path, gate_outcome=GATE_UNRESOLVED, persistence=per, scene_cp=cp)

    assert per.save_single_scene_asset.call_count == 0, "실격본을 저장했다"
    assert cp.mark_completed.call_count == 0, "실격본을 완료로 찍었다"


def test_a_clean_shot_still_goes_all_the_way(tmp_path):
    """★정상 샷은 **종전 그대로** — 문이 멀쩡한 주행을 세우지 않는다."""
    per, cp = _persistence(), MagicMock()
    _run(tmp_path, gate_outcome=GATE_CLEAN, persistence=per, scene_cp=cp)

    assert per.save_single_scene_asset.call_count == 1
    assert cp.mark_completed.call_count == 1


def test_incomplete_also_stops_the_purchase(tmp_path):
    """★판정을 못 읽은 샷도 **변환을 사지 않는다** (Codex BLOCK).

    복구는 **판정 복구**이지 새 이미지 재구매가 아니다 — 그러니 사기
    전에 멈춘다.
    """
    per, cp = _persistence(), MagicMock()
    _run(tmp_path, gate_outcome=GATE_INCOMPLETE, persistence=per, scene_cp=cp)
    assert per.save_single_scene_asset.call_count == 0
    assert cp.mark_completed.call_count == 0


def test_not_applicable_still_goes_through(tmp_path):
    """비대상 갈래는 **종전 그대로** — 게이트가 안 도는 샷이다."""
    per, cp = _persistence(), MagicMock()
    _run(tmp_path, gate_outcome=GATE_NOT_APPLICABLE, persistence=per,
         scene_cp=cp)
    assert per.save_single_scene_asset.call_count == 1


def test_every_branch_passes_the_door():
    """문을 지나는 자리 **넷** — 새 선정 · 재사용 둘 · **prev 의존 대기**."""
    import inspect
    import pathlib

    from app.services import still_recipe_service as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    assert src.count("gate_unresolved_tags.append(tag)") == 4, (
        "네 자리(새 선정·재사용 둘·prev 의존 대기)가 다 문을 지나지 않는다")
    # 문은 **cine 를 사기 전**이어야 한다
    i = src.find("if gate_unresolved(record):")
    j = src.find("final_src = Path(sel_path)")
    assert 0 < i < j, "문이 변환 시작보다 뒤에 있다"


def test_the_run_summary_reports_unresolved_shots():
    """조용히 넘어가면 사람이 못 본다 — 끝 요약에 남는다."""
    import inspect
    import pathlib

    from app.services import still_recipe_service as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    assert "if gate_unresolved_tags:" in src
    i = src.find("if gate_unresolved_tags:")
    assert "미해결" in src[i:i + 400]


# ── 의존 대기가 **영속**되는가 (Codex BLOCK 2) ─────────────────────

def test_a_blocked_shot_records_its_dependency():
    """★`continue` 만 하면 **한 샷 뒤에서 다시 뚫린다**.

    A→B→C 에서 B 를 건너뛰기만 하면, B 의 기록에 아무것도 안 남아
    **C 가 B 의 옛 `clean` 기록을 읽고** 옛 그림을 앵커로 쓴다.
    그래서 B 에 `blocked_dependency` 를 **남긴다**.

    ★B 의 **품질 판정을 덮어쓰지 않는다** — 「지금 적용에서 A 때문에
     대기」로 따로 적고 옛 판정은 `prior` 에 보존한다. 부모가 풀리면
     다시 평가된다.
    """
    import inspect
    import pathlib

    from app.services import still_recipe_service as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    i = src.find('"outcome": GATE_DEPENDENCY,')
    assert i > 0, "의존 대기를 기록에 안 남긴다"
    seg = src[max(0, i - 600):i + 500]
    assert "records.save()" in seg, "기록을 저장하지 않는다 — 다음 방문이 못 본다"
    assert '"blocked_by": prev_tag' in seg, "무엇 때문에 대기인지 안 적는다"
    assert '"prior"' in seg, "옛 품질 판정을 덮어쓴다"


def test_the_dependency_state_is_held_by_the_same_judge():
    """의존 대기도 **같은 판정**이 붙잡는다 — 그래야 C 가 B 를 안 쓴다."""
    assert gate_unresolved({"gate": {"outcome": "blocked_dependency"}})


def test_only_shots_that_actually_use_prev_are_held():
    """★**독립 샷은 계속 간다** (Codex ㉠).

    `bg_only` 이고 공유 계획이 prev 를 지휘하지 않으면 원래 앞 샷을
    안 쓰므로 멈출 이유가 없다.
    """
    import inspect
    import pathlib

    from app.services import still_recipe_service as mod

    src = pathlib.Path(inspect.getfile(mod)).read_text(encoding="utf-8")
    i = src.find("_uses_prev = bool(prev_tag)")
    assert i > 0, "실제 prev 사용 여부를 안 가린다"
    assert "not bg_only or share_prev_directed" in src[i:i + 200]
    j = src.find("if _uses_prev and gate_blocks_prev_anchor(")
    assert j > i, "그 판정을 문에서 안 쓴다"


def test_all_four_consumers_read_one_judgement():
    """★cine 문 · prev 앵커 · verify 가 **같은 판정**을 본다.

    각자 다른 enum 을 읽으면 같은 샷이 「변환은 사고 앵커로는 못 쓰이는」
    어긋남이 난다 — 실제로 그랬다.
    """
    import inspect
    import pathlib

    from app.core.steps import image_steps
    from app.services import still_recipe_service as svc

    svc_src = pathlib.Path(inspect.getfile(svc)).read_text(encoding="utf-8")
    img_src = pathlib.Path(inspect.getfile(image_steps)).read_text(
        encoding="utf-8")
    assert "GATE_HOLDING_OUTCOMES = (" in svc_src, "공통 목록이 없다"
    assert "GATE_HOLDING_OUTCOMES" in img_src, "verify 가 공통 목록을 안 쓴다"
    # prev helper 도 같은 함수를 지난다
    i = svc_src.find("def gate_blocks_prev_anchor(")
    assert "gate_holds(rec)" in svc_src[i:i + 1600], (
        "prev 앵커가 공통 판정을 안 쓴다")
