"""시네마틱 재료가 **어느 단계 것인가** (2026-08-27, 감사 1-H).

사용자 지적: 「카메라 구도와 빛감 등 시네마틱하게 만드는 부분은 i2i 로
변환하는 경우와 하지 않는 경우의 **삽입 지점이 다르다**」. 표를 보고
「이거 보면 제대로 구현이 안되었는데?」

## 고치기 전 (실측)

    `- CAMERA:` 산문        cine ON 이면 roll 에서 빠지고 cine 이 받는다  ✅
    `Follow exactly: angle` cine ON 인데 roll 에 **그대로 남았다**       ✕
                            → 가리킬 값이 없는 각도 권위
    CINEMATIC FINISH        roll 과 cine **양쪽**에                      ✕
    LIGHTING & MOOD         양쪽에 (사실+연출이 한 칸이라 못 가른다)     △

## 이 판이 고치는 둘

    ① 각도 권위     `omit_camera_direction` 이면 angle 을 뺀 스템을 쓴다
    ② 마감 소유권   cine ON → cine 만, cine OFF → roll 만

## 이 판이 **안** 고치는 것 (PR B 로 뗀다)

`lighting_mood` 한 칸에 장면 사실(광원이 있다·켜져 있다)과 연출(대비·색조)이
섞여 있다 — 실측 6,095 칸 중 최소 32%. 통째로 옮기면 광원이 사라지므로
**저작이 값을 둘로 나눠 줘야** 한다(`lighting_facts_en` /
`lighting_treatment_en`). 그건 shot_staging 스키마를 건드리는 일이라
재구매 파장이 크다 — 별도 판에서 cutover 를 같이 정한다.

★**코드가 의미로 쪼개지 않는다.** 자유문장을 글자로 가르는 것은 이
 저장소가 금지한다.
"""

import pathlib
import re

from app.modules.pipeline.still_recipe import (
    CAMERA_FRAME_NO_DIR_PROMPT_VERSION,
    CAMERA_FRAME_NO_DIR_STEMS,
    CAMERA_FRAME_PROMPT_VERSION,
    camera_frame_stem,
    LIGHTING_MOOD_PROMPT_VERSION,
    STILL_GROK_STEM_VERSION,
    attach_cinematic_finish,
    build_camera_frame_clause,
    build_cinematic_finish_clause,
    build_lighting_mood_clause,
    resolve_prompt_version,
    should_attach_cinematic_finish,
)
from app.modules.prompt_loader import PROMPTS_BASE

_STAGING = {"camera_direction": "From just below her eye line",
            "framing_scale": "close"}


def _stem(module: str, name: str, version: str) -> str:
    p = pathlib.Path(PROMPTS_BASE) / module / version / f"{name}.md"
    return p.read_text(encoding="utf-8") if p.exists() else ""


# ── ① 각도 권위 ──────────────────────────────────────────────────────

def test_the_angle_authority_leaves_with_its_value():
    """★**값이 빠지면 권위도 빠져야 한다.**

    고치기 전에는 `- CAMERA:` 줄만 빠지고 꼬리의 「angle 을 그대로 따르라」가
    남았다 — roll 에 각도를 가리키는 값이 하나도 없는 채 각도 권위만 있는
    글이 나갔다.
    """
    on = build_camera_frame_clause(_STAGING, "", omit_camera_direction=True)
    off = build_camera_frame_clause(_STAGING, "", omit_camera_direction=False)

    assert "- CAMERA:" in off and "- CAMERA:" not in on
    assert re.search(r"\bangle\b", off), "전제 확인 — 안 뺄 때는 남는다"
    assert not re.search(r"\bangle\b.{0,40}(exactly|specified)", on), (
        "값 없는 각도 권위가 남았다")


def test_the_open_angle_is_said_out_loud():
    """★**금지만 남기면 그릴 재료가 없다.**

    「각도를 따르라」를 그냥 지우면 그 자리가 침묵이 된다. 「각도는 열어
    뒀으니 네가 고르라」고 말해 줘야 앞 단계가 지어내지 않는다.
    """
    on = build_camera_frame_clause(_STAGING, "", omit_camera_direction=True)
    assert "angle" in on, "각도를 아예 언급 안 하면 침묵이다"
    assert re.search(r"angle is (deliberately )?left open|left open",
                     on, re.I), "각도가 열려 있다는 말이 없다"


def test_the_composition_authority_survives():
    """구도(거리·잘림·배치)는 남는다 — 과거 A/B 로 정해진 것이다."""
    on = build_camera_frame_clause(_STAGING, "", omit_camera_direction=True)
    assert "FRAMING SCALE" in on
    assert "subject scale" in on and "placement" in on


def test_both_backends_get_their_own_stem():
    """★**base 와 컴팩트는 본문이 다르다** — 한 판으로 합치면 한쪽이 바뀐다.

    컴팩트(v22)는 grok 8,000B 상한에 맞춰 짧다. base(v6)의 긴 문안을 grok
    갈래에 쓰면 상한에 그만큼 더 밀리고, 반대로 하면 nb2 문안이 줄어
    그 갈래의 그림이 바뀐다.
    """
    assert set(CAMERA_FRAME_NO_DIR_STEMS) == {
        CAMERA_FRAME_PROMPT_VERSION, STILL_GROK_STEM_VERSION}
    ver = resolve_prompt_version(CAMERA_FRAME_NO_DIR_PROMPT_VERSION)
    a = _stem("still_recipe", CAMERA_FRAME_NO_DIR_STEMS[
        CAMERA_FRAME_PROMPT_VERSION], ver)
    b = _stem("still_recipe", CAMERA_FRAME_NO_DIR_STEMS[
        STILL_GROK_STEM_VERSION], ver)
    assert a and b, "두 스템이 다 있어야 한다"
    assert a != b, "두 갈래가 같은 본문을 쓴다 — 한쪽이 바뀐 것이다"
    assert len(b) < len(a), "컴팩트 판이 더 짧아야 한다 (grok 상한)"


def test_each_no_dir_stem_keeps_its_own_headline():
    """★각 판은 **자기 원본에서** 나와야 한다 — 헤딩까지 그대로.

    1-C 에서 팩을 엉뚱한 원본에서 베껴 no-op 을 만든 적이 있다.
    """
    ver = resolve_prompt_version(CAMERA_FRAME_NO_DIR_PROMPT_VERSION)
    for sel, stem in CAMERA_FRAME_NO_DIR_STEMS.items():
        origin = _stem("still_recipe", "camera_frame_clause",
                       resolve_prompt_version(sel))
        made = _stem("still_recipe", stem, ver)
        assert origin and made
        assert origin.splitlines()[0] == made.splitlines()[0], (
            f"v{sel}: 다른 판의 헤딩을 가져왔다")


def test_an_unregistered_pack_dies_loudly():
    """★**조용히 옛 스템으로 돌아가면 각도 권위가 되살아난다.**

    새 camera_frame 팩을 올리고 짝을 안 만들면 여기서 죽어야 한다 —
    fallback 하면 그림에서만 보이고 시험은 초록이다.
    """
    import pytest

    from app.modules.pipeline import still_recipe as sr

    orig = dict(sr.CAMERA_FRAME_NO_DIR_STEMS)
    try:
        sr.CAMERA_FRAME_NO_DIR_STEMS.clear()
        with pytest.raises(KeyError):
            build_camera_frame_clause(
                _STAGING, "", omit_camera_direction=True)
    finally:
        sr.CAMERA_FRAME_NO_DIR_STEMS.update(orig)


def _folded_no_dir_stems(monkeypatch, backend: str) -> list:
    """그 백엔드에서 지문이 **실제로 접은** no-dir 스템 이름들."""
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep
    import app.modules.pipeline.still_recipe as sr

    step = SceneImagePipelineStep.__new__(SceneImagePipelineStep)
    step.project_config = {}
    for k, v in (("still_recipe_mode", "v1"),
                 ("still_recipe_camera_frame_enabled", True),
                 ("still_cine_transform_enabled", True),
                 ("still_cine_stage_direction_enabled", True),
                 ("still_image_backend", backend)):
        monkeypatch.setattr(settings, k, v)

    seen = []
    real = sr.recipe_stem_content_hash

    def _spy(selector, stem):
        if "no_dir" in stem:
            seen.append(stem)
        return real(selector, stem)

    monkeypatch.setattr(sr, "recipe_stem_content_hash", _spy)
    step._config_hash_base()
    return seen


def test_each_backend_folds_only_the_stem_it_actually_sends(monkeypatch):
    """★★**안 쓰는 스템은 접으면 안 된다** (2026-08-27 Codex 재지적).

    내가 두 번 틀린 자리다.

        ① base 스템만 접었다 → grok 의 컴팩트 문안 수정이 **누락**
        ② 그래서 둘 다 접었다 → **반대편이 열렸다.** nb2 설정 그대로
           컴팩트 바이트만 고쳐도 nb2 완주 샷이 mismatch 로 다시 산다

    ★「`still_image_backend` 가 이미 지문에 있으니 지출이 안 는다」가 내
     근거였는데 틀렸다 — 그건 두 백엔드 지문이 서로 다르다는 뜻일 뿐이다.
    """
    assert _folded_no_dir_stems(monkeypatch, "nb2") == [
        CAMERA_FRAME_NO_DIR_STEMS[CAMERA_FRAME_PROMPT_VERSION]]
    assert _folded_no_dir_stems(monkeypatch, "grok2") == [
        CAMERA_FRAME_NO_DIR_STEMS[STILL_GROK_STEM_VERSION]]


def test_editing_the_other_backends_stem_leaves_this_hash_alone(monkeypatch):
    """★반대편 스템을 고쳐도 이쪽 지문은 **안 움직여야** 한다.

    「접은 스템 이름」만 세면 이 계약을 못 잰다 — 이름은 맞는데 다른
    경로로 반대편 바이트가 섞여 들어올 수 있다. 지문 값 자체를 본다.
    """
    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep
    import app.modules.pipeline.still_recipe as sr

    # ★**원본을 한 번만 잡는다.** 헬퍼 안에서 매번 잡으면 두 번째부터는
    #  「이미 씌운 대역」을 원본으로 잡아 사슬이 겹치고, 변조가 묻힌다
    #  (2026-08-27 실측 — 이 시험이 처음엔 그래서 거짓 초록에 가까웠다).
    real = sr.recipe_stem_content_hash

    def _hash(backend: str, doctored) -> str:
        step = SceneImagePipelineStep.__new__(SceneImagePipelineStep)
        step.project_config = {}
        for k, v in (("still_recipe_mode", "v1"),
                     ("still_recipe_camera_frame_enabled", True),
                     ("still_cine_transform_enabled", True),
                     ("still_cine_stage_direction_enabled", True),
                     ("still_image_backend", backend)):
            monkeypatch.setattr(settings, k, v)

        def _spy(selector, stem):
            # 「그 스템의 바이트가 바뀌었다」를 흉내 낸다.
            if doctored and stem == doctored:
                return "CHANGED"
            return real(selector, stem)

        monkeypatch.setattr(sr, "recipe_stem_content_hash", _spy)
        return step._config_hash_base()

    base_stem = CAMERA_FRAME_NO_DIR_STEMS[CAMERA_FRAME_PROMPT_VERSION]
    compact_stem = CAMERA_FRAME_NO_DIR_STEMS[STILL_GROK_STEM_VERSION]

    # nb2 는 컴팩트를 안 쓴다 — 그 바이트가 바뀌어도 지문이 그대로여야 한다
    assert _hash("nb2", None) == _hash("nb2", compact_stem), (
        "nb2 가 안 쓰는 컴팩트 스템을 접고 있다 — 헛돈이 나간다")
    # 반대도 같다
    assert _hash("grok2", None) == _hash("grok2", base_stem), (
        "grok 이 안 쓰는 base 스템을 접고 있다 — 헛돈이 나간다")

    # ★양성 대조 — **쓰는** 쪽을 고치면 반드시 움직여야 한다
    assert _hash("nb2", None) != _hash("nb2", base_stem), (
        "nb2 가 쓰는 스템을 고쳤는데 지문이 안 움직인다 — 옛 그림이 남는다")
    assert _hash("grok2", None) != _hash("grok2", compact_stem), (
        "grok 이 쓰는 스템을 고쳤는데 지문이 안 움직인다")


def test_the_assembly_and_the_hash_ask_the_same_function():
    """★두 자리가 **같은 함수**를 부르는지 소스로 본다.

    값이 지금 같은 것은 두 줄이 같은 뜻이어서가 아니라 우연일 수 있다 —
    마감 절이 그 함정을 겪었다(`image_steps.py:978-982` 주석).
    """
    import ast
    import pathlib

    root = pathlib.Path(__file__).resolve().parents[2]
    for rel in ("app/core/steps/image_steps.py",
                "app/modules/pipeline/still_recipe.py"):
        src = (root / rel).read_text(encoding="utf-8")
        names = {n.id for n in ast.walk(ast.parse(src))
                 if isinstance(n, ast.Name)}
        names |= {n.asname or n.name
                  for imp in ast.walk(ast.parse(src))
                  if isinstance(imp, ast.ImportFrom)
                  for n in imp.names}
        assert "camera_frame_stem" in names or "_cam_stem_of" in names, (
            f"{rel} 가 단일 출처를 안 부른다")


# ── ② 마감 소유권 ────────────────────────────────────────────────────

def test_the_finish_belongs_to_exactly_one_stage():
    assert should_attach_cinematic_finish(
        grok_backend=False, cine_on=False) is True, "cine 가 없으면 roll 이 한다"
    assert should_attach_cinematic_finish(
        grok_backend=False, cine_on=True) is False, "cine 가 있으면 cine 이 한다"


def test_the_backend_does_not_change_who_owns_it():
    """마감 소유권은 **끝 단계가 있느냐**로만 갈린다 — 백엔드와 무관."""
    for grok in (True, False):
        assert should_attach_cinematic_finish(
            grok_backend=grok, cine_on=True) is False
        assert should_attach_cinematic_finish(
            grok_backend=grok, cine_on=False) is True


def test_the_clause_actually_leaves_the_prompt():
    """★가르는 것만 재고 **나가는 문안**을 안 태우면 붙이는 줄이 통째로
    빠져도 초록이다 (2026-08-27 Codex 지적)."""
    fin = build_cinematic_finish_clause()
    assert fin, "전제 확인 — 마감 절이 있다"

    off = attach_cinematic_finish("BASE", grok_backend=False, cine_on=False)
    on = attach_cinematic_finish("BASE", grok_backend=False, cine_on=True)
    assert fin in off, "cine 가 없는데 roll 에 마감이 안 나갔다"
    assert fin not in on, "cine 가 있는데 roll 에도 마감이 나갔다"
    assert on == "BASE", "안 붙일 때 원문이 바뀌었다"


def test_the_terminal_stage_still_carries_it():
    """끝 단계가 실제로 마감을 말하는지 — 옮겨 놓고 받는 쪽이 없으면
    그 재료는 어디에도 없다."""
    cine = _stem("still_recipe", "cine_transform", "24.202608252115")
    assert cine, "전제 확인 — cine 스템이 있다"
    got = [w for w in ("depth", "grain", "film", "cinematic")
           if w in cine.lower()]
    assert got, "끝 단계 문안에 마감 재료가 하나도 없다"


# ── 아직 안 고친 것 (PR B) ───────────────────────────────────────────

def test_lighting_still_mixes_facts_with_treatment():
    """★**한 필드에 장면 사실과 연출이 섞여 있다** — 이 판은 안 건드린다.

    완주 CP 실측: "A sickly streetlamp casts dim gold over gray-black
    concrete" — 가로등이 **있다**(사실)와 `sickly`·`dim gold`(연출)가 한
    문장이다. 그래서 이 절을 roll 에서 통째로 빼면 **광원 자체가 사라진다.**

    저작이 값을 둘로 나눠 줘야 풀린다 — 그건 별도 판이다.
    이 시험은 그때 뒤집는다.
    """
    got = build_lighting_mood_clause(
        {"lighting_mood": "A sickly streetlamp casts dim gold"})
    assert "LIGHTING & MOOD" in got
    assert "MATERIAL REALISM" in got, (
        "물리 계약이 같은 절에 묶여 있다 — 통째로 옮기면 재질도 잃는다")


def test_material_realism_is_not_yet_its_own_stem():
    """물리 계약이 연출 절에 묶여 있어 따로 못 움직인다 (PR B 에서 가른다)."""
    d = (pathlib.Path(PROMPTS_BASE) / "still_recipe"
         / resolve_prompt_version(LIGHTING_MOOD_PROMPT_VERSION))
    names = {f.stem for f in d.glob("*.md")}
    assert "material_realism" not in names, (
        "별도 스템이 생겼다면 이 시험을 뒤집고 소유권 시험을 켤 것")
    assert "MATERIAL REALISM" in _stem(
        "still_recipe", "lighting_mood_clause", d.name)
