"""시네마틱 마감 절 — 붙는 조건과 문안 출처 (2026-08-27).

2026-08-13 사용자 지시("꼭 시네마틱하게")로 들어온 절인데 **시험이 하나도
없었다.** 그래서 붙는 조건이 `still_image_backend == "grok2"` 하나에 묶여
있는 것을, 백엔드를 nb2 로 되돌렸을 때 절이 조용히 사라진 것을, 아무도
못 봤다.

★`.env` 는 `STILL_IMAGE_BACKEND=nb2` + `STILL_CINE_TRANSFORM_ENABLED=true`
 라 **지금 실제 조합은 「i2i 에만」**이다. 이 판의 수정은 변환을 끄는
 순간 열리는 구멍을 미리 막는 것이고 그때까지 잠들어 있다.

가르는 것은 백엔드가 아니라 **최종 i2i 가 그 마감을 하느냐**다.

## 2026-08-27 재검토 — 「끝 단계 한 곳」

한때 「언제나 붙인다」로 갔다가 사용자가 표를 보고 뒤집었다. cine ON 인데
roll 도 마감을 받으면 **같은 미지정 선택을 두 단계가 각자 한다** — 「끝
단계가 정한다」는 계약이 서지 않는다.

포기·기각으로 원본이 최종본이 되는 갈래(611 중 2, 0.3%)는 **열어 둔 채
기록으로 드러낸다**(`cine_transform.finish_owner`). 사용자가 재생성을
물리셨다.
"""

import pytest

from app.modules.pipeline.still_recipe import (
    CINEMATIC_FINISH_PROMPT_VERSION,
    attach_cinematic_finish,
    build_cinematic_finish_clause,
    should_attach_cinematic_finish,
)


# ── 붙는 조건 ────────────────────────────────────────────────────────

@pytest.mark.parametrize("grok_backend", [True, False])
def test_the_roll_owns_the_finish_only_when_there_is_no_terminal_stage(
        grok_backend):
    """★**끝 단계 한 곳** — cine 가 있으면 cine, 없으면 roll."""
    assert should_attach_cinematic_finish(
        grok_backend=grok_backend, cine_on=False) is True
    assert should_attach_cinematic_finish(
        grok_backend=grok_backend, cine_on=True) is False


@pytest.mark.parametrize("grok_backend", [True, False])
def test_the_clause_actually_appears_and_disappears(grok_backend):
    """★조건이 아니라 **나가는 문안**을 잰다.

    조건만 재면 붙이는 줄이 통째로 빠져도 초록이다 (2026-08-27 Codex
    지적: helper-only 시험은 실제 조립을 안 태운 거짓 양성).
    """
    base = "BASE PROMPT"
    off = attach_cinematic_finish(
        base, grok_backend=grok_backend, cine_on=False)
    on = attach_cinematic_finish(
        base, grok_backend=grok_backend, cine_on=True)

    assert off.startswith(base), "앞 문안이 상했다"
    assert build_cinematic_finish_clause() in off
    assert build_cinematic_finish_clause() not in on
    assert on == base, "안 붙일 때 원문이 바뀌었다"


def test_attach_uses_its_own_selector_not_the_compact_pack(monkeypatch):
    """실제 조립이 컴팩트 팩 selector 를 안 탄다.

    두 축을 갈라 놓고도 호출부가 `_guidance` 를 넘기면 컴팩트 팩을 올리는
    순간 마감 문안이 딸려 간다 — 지금 두 값이 같아서(둘 다 17) 헬퍼만
    보면 안 드러난다.
    """
    import app.modules.pipeline.still_recipe as sr

    before = attach_cinematic_finish(
        "P", grok_backend=True, cine_on=True)
    monkeypatch.setattr(sr, "STILL_COMPACT_PROMPT_VERSION", "1")
    after = attach_cinematic_finish(
        "P", grok_backend=True, cine_on=True)
    assert before == after


def test_production_call_site_does_not_pass_the_compact_selector():
    """호출부가 selector 를 안 넘기는지 **소스로** 확인한다.

    위 두 시험은 함수를 직접 태우지만, 프로덕션이 그 함수를 어떻게 부르는지는
    못 본다. `_guidance` 를 다시 넘기는 판이 들어오면 여기서 걸린다.
    """
    import ast
    import pathlib

    # ★경로를 `__file__` 에서 만든다 — 상대 경로로 열면 저장소 루트에서
    #  pytest 를 부를 때 죽는다(이 저장소의 다른 AST 시험들과 같은 모양).
    src_path = (pathlib.Path(__file__).resolve().parents[2]
                / "app" / "services" / "still_recipe_service.py")
    src = src_path.read_text(encoding="utf-8")

    def _fn_name(node):
        """`f(...)` 와 `mod.f(...)` 를 **둘 다** 잡는다."""
        f = node.func
        if isinstance(f, ast.Name):
            return f.id
        if isinstance(f, ast.Attribute):
            return f.attr
        return ""

    calls = [n for n in ast.walk(ast.parse(src))
             if isinstance(n, ast.Call)
             and _fn_name(n) in ("attach_cinematic_finish",
                                 "build_cinematic_finish_clause")]
    assert calls, "호출부가 사라졌다 — 마감 절이 프로덕션에 안 닿는다"

    for c in calls:
        # ★인자 **하위 트리 전체**를 훑는다. 이름만 보면
        #  `_guidance or ""`(BoolOp)·`f(_guidance)`·`{"v": _guidance}` 가
        #  전부 빠져나간다.
        for node in [*c.args, *(k.value for k in c.keywords)]:
            names = {n.id for n in ast.walk(node)
                     if isinstance(n, ast.Name)}
            assert "_guidance" not in names, (
                "컴팩트 지도 팩 selector 를 마감 절에 넘긴다 — "
                f"두 축이 다시 묶인다 (line {c.lineno})")


def test_the_finish_has_exactly_one_owner_in_every_combination():
    """★이것이 이 판의 계약이다 — **어느 조합에서도 소유자가 하나**.

    한 줄씩 읽는 것과 달리 여기서는 계약을 통째로 건다. 나중에 백엔드가
    늘거나 플래그가 갈래를 더 가져도 이 시험은 그대로 성립해야 한다.

    ★소유자가 **없는** 갈래도 이 시험이 막는다 — cine 를 끄면 roll 이
     반드시 받아야 한다.
    """
    for grok in (True, False):
        for on in (True, False):
            roll_owns = should_attach_cinematic_finish(
                grok_backend=grok, cine_on=on)
            cine_owns = on
            assert roll_owns is not cine_owns, (
                f"grok={grok} cine_on={on}: roll={roll_owns} cine={cine_owns}"
                " — 둘 다 갖거나 둘 다 없다")


# ── 문안 출처 ────────────────────────────────────────────────────────

def test_clause_comes_from_the_pack_not_the_code():
    clause = build_cinematic_finish_clause()
    assert clause, "마감 절이 비었다"

    from app.modules.prompt_loader import load_prompt
    from app.modules.pipeline.still_recipe import (
        _MODULE, resolve_prompt_version,
    )
    on_disk = load_prompt(
        _MODULE, "cinematic_finish",
        version=resolve_prompt_version(CINEMATIC_FINISH_PROMPT_VERSION),
    ).strip()
    assert clause == on_disk, "코드가 팩 문안을 바꿔 내보낸다"


def test_empty_selector_resolves_instead_of_raising():
    """nb2 갈래는 `_guidance` 가 빈 문자열이다.

    종전 기본값은 컴팩트 지도 팩 selector 였고 호출부가 그 빈 값을 그대로
    넘겼다 — `resolve_prompt_version("")` 은 ValueError 를 낸다. 그래서
    이 절은 grok 백엔드에서만 불릴 수 있었다.
    """
    assert (build_cinematic_finish_clause("")
            == build_cinematic_finish_clause(
                CINEMATIC_FINISH_PROMPT_VERSION))


def test_finish_selector_is_not_the_compact_pack_selector(monkeypatch):
    """두 selector 는 지금 값이 같아도 **다른 축**이다.

    한쪽을 올릴 때 다른 쪽이 딸려 가면 안 된다 — grok 텍스트 상한 때문에
    지도 절을 새 팩으로 옮기는 것과, 마감 문안을 고쳐 쓰는 것은 서로
    상관이 없다. 컴팩트 selector 를 없는 팩으로 밀어도 마감 절은 그대로
    나와야 한다.
    """
    import app.modules.pipeline.still_recipe as sr

    before = build_cinematic_finish_clause()
    monkeypatch.setattr(sr, "STILL_COMPACT_PROMPT_VERSION", "1")
    assert build_cinematic_finish_clause() == before


# ── 마감 절이 지문에 접히는가 ────────────────────────────────────────
#
# 절을 붙이는 것만으로는 모자란다. 그 팩 문안을 고쳤을 때 **완주한 샷이
# 다시 돌아야** 새 문안이 그림에 닿는다. grok 갈래는 `still_compact_pack_
# content` 가 같은 팩을 이미 덮지만 nb2 는 그 키가 아예 없다 — 그대로
# 두면 문안을 고쳐도 지문이 안 움직여 낡은 그림이 남는다.

def _fresh_step(monkeypatch):
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep

    step = SceneImagePipelineStep.__new__(SceneImagePipelineStep)
    step.project_config = {}
    monkeypatch.setattr(settings, "still_recipe_mode", "v1")
    return step


def test_finish_pack_is_folded_into_the_hash_when_it_reaches_t2i(monkeypatch):
    """네 조합의 지문이 서로 달라야 한다.

    ★이 시험은 **마감 절과 무관하게도 성립한다** — `still_image_backend` 와
     `still_cine_transform_enabled` 가 이미 각각 지문에 들어가 있어서, 마감
     키를 통째로 빼도 네 값이 다르다(2026-08-27 자체 리뷰가 mutation 으로
     확인). 그래도 남긴다: 지문을 접는 **다른** 판이 네 조합 중 둘을
     뭉개면 여기서 걸린다. **마감 절 자체의 계약은 바로 아래 시험들이
     잰다** — 이 시험이 초록인 것을 마감 절이 접혔다는 근거로 읽지 말 것.
    """
    from app.core.config import settings

    step = _fresh_step(monkeypatch)
    seen = {}
    for be in ("nb2", "grok2"):
        for on in (True, False):
            monkeypatch.setattr(settings, "still_image_backend", be)
            monkeypatch.setattr(
                settings, "still_cine_transform_enabled", on)
            seen[(be, on)] = step._config_hash_base()

    assert len(set(seen.values())) == 4, (
        f"지문이 겹치는 조합이 있다 — 설정을 바꿔도 낡은 그림이 남는다: "
        f"{ {k: v[:8] for k, v in seen.items()} }")


def test_finish_stem_edit_moves_the_hash_on_nb2_with_transform_off(
        monkeypatch):
    """★이 판이 만든 구멍을 정확히 겨눈다.

    마감 문안을 고쳤을 때 nb2+변환OFF 조합의 지문이 움직이지 않으면,
    새 문안이 그림에 영영 안 닿는다.
    """
    from app.core.config import settings
    import app.modules.pipeline.still_recipe as sr

    step = _fresh_step(monkeypatch)
    monkeypatch.setattr(settings, "still_image_backend", "nb2")
    monkeypatch.setattr(settings, "still_cine_transform_enabled", False)

    before = step._config_hash_base()

    # 스템 내용이 바뀐 것처럼 꾸민다 — 팩 파일을 건드리지 않고 해시
    # 계산만 가로챈다.
    real = sr.recipe_stem_content_hash

    def _fake(selector, stem):
        if stem == "cinematic_finish":
            return "EDITED"
        return real(selector, stem)

    monkeypatch.setattr(sr, "recipe_stem_content_hash", _fake)
    after = step._config_hash_base()

    assert before != after, (
        "마감 문안을 고쳐도 지문이 안 움직인다 — 낡은 그림이 남는다")


def _watch_finish_stem(monkeypatch):
    """마감 스템 해시 계산을 가로채 **불렸는지 + 어느 selector 로** 남긴다.

    팩 18 에는 `cinematic_finish.md` 가 없다(실제로 올리려면 넣어야 한다).
    시험이 팩 파일을 만들지 않고도 갈림을 재려면 해시 계산만 가로채면 된다.
    """
    import app.modules.pipeline.still_recipe as sr

    seen = []
    real = sr.recipe_stem_content_hash

    def _fake(selector, stem):
        if stem == "cinematic_finish":
            seen.append(selector)
            return f"STEM@{selector}"
        return real(selector, stem)

    monkeypatch.setattr(sr, "recipe_stem_content_hash", _fake)
    return seen


def test_grok_folds_it_only_when_the_selectors_diverge(monkeypatch):
    """grok 갈래는 컴팩트 팩이 마감 스템을 덮는다 — **갈라질 때만** 접는다.

    같은 값인데 접으면 완주한 grok 판의 지문이 통째로 움직여 전량
    재실행된다. 갈라졌는데 안 접으면 나가는 문안은 바뀌는데 지문이 그대로라
    완주한 에피소드가 clean skip 한다 (2026-08-27 Codex BLOCK).

    ★지금은 갈라져 있다(마감 25 / 컴팩트 17) — 문안을 다시 쓰면서 팩을
     새로 냈기 때문이다. 그래서 **같은 값인 갈래는 monkeypatch 로 만들어**
     잰다.
    """
    from app.core.config import settings
    import app.modules.pipeline.still_recipe as sr

    def _run(fin_sel, compact_sel):
        step = _fresh_step(monkeypatch)
        monkeypatch.setattr(settings, "still_image_backend", "grok2")
        # ★cine 를 **끈 판**에서 잰다 — 켜면 roll 이 마감을 안 받으므로
        #  접을 것도 없어져 selector 비교 자체가 성립하지 않는다.
        monkeypatch.setattr(settings, "still_cine_transform_enabled", False)
        monkeypatch.setattr(sr, "CINEMATIC_FINISH_PROMPT_VERSION", fin_sel)
        monkeypatch.setattr(sr, "STILL_COMPACT_PROMPT_VERSION", compact_sel)
        seen = _watch_finish_stem(monkeypatch)
        return step._config_hash_base(), seen

    monkeypatch.setattr(settings, "still_cine_transform_enabled", False)
    same_hash, same_seen = _run("17", "17")
    assert same_seen == [], (
        f"두 selector 가 같은데 마감 키를 넣었다 — 완주한 grok 판이 "
        f"전량 재실행된다 (불린: {same_seen})")

    diff_hash, diff_seen = _run("25", "17")
    assert diff_seen == ["25"], (
        f"갈라졌는데 마감 스템을 안 접었다 (불린: {diff_seen})")
    assert same_hash != diff_hash, (
        "마감 selector 만 올렸는데 grok 지문이 안 움직인다 — "
        "완주한 에피소드가 새 문안을 못 받는다")


def test_nb2_folds_it_only_when_the_roll_owns_it(monkeypatch):
    """★**접는 조건이 붙는 조건과 같아야** 한다 (2026-08-27 재검토).

    cine 가 켜지면 roll 은 마감을 안 받는다 — 그런데 지문이 계속 그
    문안을 접고 있으면 마감 팩을 올릴 때 **나가지도 않는 문안** 때문에
    완주한 에피소드가 전량 재실행된다. 반대로 cine 를 껐는데 안 접으면
    새 문안이 나가는데 지문은 그대로라 clean skip 한다.
    """
    from app.core.config import settings

    for on, want in ((False, [CINEMATIC_FINISH_PROMPT_VERSION]), (True, [])):
        step = _fresh_step(monkeypatch)
        monkeypatch.setattr(settings, "still_image_backend", "nb2")
        monkeypatch.setattr(settings, "still_cine_transform_enabled", on)
        seen = _watch_finish_stem(monkeypatch)
        step._config_hash_base()
        assert seen == want, (
            f"nb2 + 변환{on}: 접은 것 {seen} · 기대 {want}")


def test_selector_and_pack_stay_in_step():
    """★상수를 올릴 때 팩에 스템을 안 넣으면 **스텝이 죽는다.**

    `_config_hash_base` 가 그 스템 해시를 부르고, 없으면 `ValueError` 다.
    `step_runner._cp_mismatch_reason` 은 `_config_hash` 예외에 fail-closed
    라 스텝 전체가 선다. 위의 갈림 시험은 해시 계산을 가로채므로 이
    위험을 못 본다 — 여기서 **실제 파일**을 본다.
    """
    import pathlib

    from app.modules.pipeline.still_recipe import (
        _MODULE, resolve_prompt_version,
    )
    from app.modules.prompt_loader import PROMPTS_BASE

    resolved = resolve_prompt_version(CINEMATIC_FINISH_PROMPT_VERSION)
    stem = (pathlib.Path(PROMPTS_BASE) / _MODULE / resolved
            / "cinematic_finish.md")
    assert stem.is_file(), (
        f"selector 는 {CINEMATIC_FINISH_PROMPT_VERSION!r} 인데 그 팩에 "
        f"마감 스템이 없다 — 올리려면 팩에 파일부터 넣어야 한다: {stem}")

    # 실제로 읽히는지도 본다 — 파일만 있고 비어 있으면 절이 사라진다.
    assert build_cinematic_finish_clause().strip()


def test_both_mirrors_compute_the_grok_axis_the_same_way():
    """조립 쪽과 지문 쪽이 **같은 식**으로 백엔드를 판정한다.

    한쪽이 `!= "nb2"`, 다른 쪽이 `== "grok2"` 이면 지금은 같은 값이 나온다
    — `config` validator 가 그 둘만 받기 때문이다. 백엔드가 하나 늘면
    조립은 절을 붙이고 지문은 안 접어 조용한 clean skip 이 난다
    (2026-08-27 자체 리뷰).
    """
    import ast
    import pathlib

    root = pathlib.Path(__file__).resolve().parents[2]

    def _exprs(rel, var):
        src = (root / rel).read_text(encoding="utf-8")
        out = []
        for n in ast.walk(ast.parse(src)):
            if (isinstance(n, ast.Assign) and len(n.targets) == 1
                    and isinstance(n.targets[0], ast.Name)
                    and n.targets[0].id == var):
                out.append(ast.dump(n.value))
        return out

    hash_side = _exprs("app/core/steps/image_steps.py", "_fin_grok")
    asm_side = _exprs("app/services/still_recipe_service.py", "_grok_backend")
    assert hash_side and asm_side, "두 자리 중 하나가 사라졌다"
    assert hash_side[0] == asm_side[0], (
        "지문 쪽과 조립 쪽이 백엔드를 다르게 판정한다 — "
        f"지문={hash_side[0][:80]} / 조립={asm_side[0][:80]}")


# ── 문안이 없는 것을 가리키지 않는가 (팩 v25) ────────────────────────

def test_clause_does_not_point_at_a_block_that_may_be_absent():
    """마감 절이 「위쪽 CAMERA 계약」을 가리키면 안 된다.

    옛 문안(v17)은 "Within the CAMERA contract above" 로 끝났는데, CAMERA
    & FRAME 블록이 없는 경로가 셋이다 — confined-fp(`strip_camera_block`
    이 걷는다) · lane 샷(`camera_frame_on and not lane_used`) · staging
    부재(`build_camera_frame_clause` 가 빈 문자열). 없는 것을 참조하라는
    지시가 유료 프롬프트에 그대로 실렸다.
    """
    clause = build_cinematic_finish_clause()
    low = clause.lower()
    assert "camera contract" not in low, (
        "마감 절이 CAMERA 계약을 이름으로 가리킨다 — 없을 수 있다")
    assert "above" not in low, (
        "마감 절이 **위치**를 가리킨다 — 그 자리가 비는 경로가 셋이다")


def test_clause_survives_the_confined_strip_on_its_own():
    """CAMERA 절을 걷어낸 뒤에도 절이 혼자 말이 되어야 한다.

    ★조건을 재는 것이 아니라 **실제 걷기 함수**를 실제 팩 문안에 태운다.
    """
    import pathlib

    from app.modules.pipeline.confined_fp import strip_camera_block
    from app.modules.prompt_loader import PROMPTS_BASE

    cam = (pathlib.Path(PROMPTS_BASE) / "still_recipe"
           / "6.202607192140" / "camera_frame_clause.md").read_text(
        encoding="utf-8").replace("{body}", "eye level, medium shot")

    body = f"SHOT: a man stands.\n\n{cam}\n\nNO TEXT: none."
    # ★`cine_on=False` — 이 시험이 재는 것은 「CAMERA 를 걷어도 마감 절이
    #  혼자 말이 되는가」다. 마감 소유권이 끝 단계 한 곳으로 바뀌면서
    #  cine 를 켜면 roll 에 마감이 안 붙는다 — 그러면 잴 대상이 없어진다.
    full = attach_cinematic_finish(body, grok_backend=False, cine_on=False)
    out = strip_camera_block(full)

    assert "CAMERA & FRAME" not in out, "걷기 경로가 바뀌었다 — 전제 확인"
    assert "CINEMATIC FINISH" in out, "마감이 함께 걷혔다"
    tail = out.lower().split("cinematic finish")[-1]
    assert "above" not in tail, (
        "CAMERA 가 걷힌 뒤에도 마감 절이 위쪽을 가리킨다")


def test_clause_still_carries_the_materials_to_draw_with():
    """금지만 남으면 그릴 재료가 없다 — 마감 재료가 살아 있는지 본다."""
    low = build_cinematic_finish_clause().lower()
    for material in ("light", "depth", "grain"):
        assert material in low, f"마감 재료 '{material}' 가 사라졌다"
