"""lane 샷에 typed FRAMING SCALE 이 안 실리던 것 (B-2b, #102).

## 무엇이 결함이었나

`shot_staging` 은 샷마다 `framing_scale` 을 typed 로 정한다. 그런데 그 값이
문장이 되는 자리는 `build_camera_frame_clause` **안**뿐이고
(`still_recipe.py` 의 `- FRAMING SCALE:` 줄), 서비스는 lane 샷에서 그
함수를 아예 안 부른다:

    # still_recipe_service.py
    camera_frame_en = ""
    if camera_frame_on and not lane_used:      # ← lane 제외
        camera_frame_en = build_camera_frame_clause(...)

그래서 lane 샷에는 **샷 크기가 한 글자도 안 나갔고**, 마커 스케치가 구도를
독점했다. 스케치 라벨은 그 독점을 글로도 못박고 있었다 —
「take from it ONLY the **camera framing**, figure placement, …」.

## 이 판이 고친 법

- 배치(CAMERA 산문·FRAME LAYOUT·KEY BG)는 **그대로 스케치 몫**이다.
  둘을 같이 주면 권위가 이중화된다.
- 스케일 한 줄만 되돌린다(`build_framing_scale_clause`).
- **같은 판에서** 라벨도 바꾼다(`sketch_label_lane`). 절만 넣고 라벨을
  두면 같은 요청 안에서 스케치와 스케일 줄이 서로 권위라고 우긴다.

## 이 시험이 재는 것

순수 함수만 재지 않는다. **서비스가 그 둘을 실제로 부르고 넘기는지**를
AST 로 따로 본다 — 만들어 놓고 호출부에 안 붙이면 시험은 초록인데
프로덕션은 그대로다(이 저장소에서 여러 번 그랬다).
"""
from __future__ import annotations

import ast
import pathlib

import pytest

from app.modules.pipeline.still_recipe import (
    FRAMING_SCALE_PROMPT_VERSION,
    LANE_SKETCH_LABEL_PROMPT_VERSION,
    _FRAMING_SCALE_PHRASES,
    build_framing_scale_clause,
    build_still_prompt,
    build_still_refs,
    resolve_prompt_version,
)

BACKEND = pathlib.Path(__file__).resolve().parents[2]
PACKS = BACKEND.parent / "prompts" / "_base" / "still_recipe"
SCALE_KEY = "medium"


def _base_kwargs(**over):
    kw = dict(
        shot_desc="a man walks up the alley",
        place_text="a narrow alley",
        time_of_day_en="night",
        bg_only=False,
        prev_used=False,
        prompt_version="3",
    )
    kw.update(over)
    return kw


# ────────────────────────────────── 절 렌더

def test_the_clause_carries_the_typed_value_and_nothing_else():
    out = build_framing_scale_clause({"framing_scale": SCALE_KEY})
    assert f"- FRAMING SCALE: {_FRAMING_SCALE_PHRASES[SCALE_KEY]}" in out
    # 배치 계약은 안 들어간다 — 그건 스케치 몫이다
    for placement in ("- CAMERA:", "- FRAME LAYOUT:",
                      "- KEY BACKGROUND ELEMENTS:"):
        assert placement not in out


@pytest.mark.parametrize("staging", [None, {}, {"framing_scale": ""},
                                     {"framing_scale": "없는값"}])
def test_no_typed_value_means_no_clause(staging):
    """typed 값이 없으면 "" — 그래야 라벨도 옛 판 그대로 간다."""
    assert build_framing_scale_clause(staging) == ""


# ────────────────────────────────── 프롬프트 조립

def test_lane_shot_carries_the_scale_line():
    scale = build_framing_scale_clause({"framing_scale": SCALE_KEY})
    got = build_still_prompt(**_base_kwargs(
        lane_ref_mode="sketch", framing_scale_en=scale))
    assert "- FRAMING SCALE:" in got


def test_lane_shot_without_the_clause_is_byte_identical():
    """양성 확인 — 절이 비면 종전 조립과 **글자 하나도** 안 다르다."""
    kw = _base_kwargs(lane_ref_mode="sketch")
    assert build_still_prompt(**kw, framing_scale_en="") == build_still_prompt(**kw)


def test_the_scale_clause_is_exclusive_with_the_camera_frame_clause():
    scale = build_framing_scale_clause({"framing_scale": SCALE_KEY})
    with pytest.raises(ValueError, match="상호 배타"):
        build_still_prompt(**_base_kwargs(
            lane_ref_mode="sketch", framing_scale_en=scale,
            camera_frame_en="CAMERA & FRAME (...):\n- FRAMING SCALE: wide shot"))


def test_the_scale_clause_is_refused_outside_lane():
    """lane 밖에서는 `camera_frame_en` 이 이미 그 줄을 갖고 있다."""
    scale = build_framing_scale_clause({"framing_scale": SCALE_KEY})
    with pytest.raises(ValueError, match="lane 샷 전용"):
        build_still_prompt(**_base_kwargs(framing_scale_en=scale))


def test_the_insert_framing_sentence_steps_aside():
    """무인 샷 fallback 문장과 스케일 줄이 같이 나가면 안 된다.

    `handled_object_framing` 은 「프레이밍의 유일한 재료」일 때만 붙는다.
    스케일 줄이 있으면 그 전제가 깨진다.
    """
    scale = build_framing_scale_clause({"framing_scale": SCALE_KEY})
    kw = _base_kwargs(lane_ref_mode="sketch", bg_only=True, char_names=[],
                      handled_by="", prompt_version="3")
    with_scale = build_still_prompt(**kw, framing_scale_en=scale)
    assert "- FRAMING SCALE:" in with_scale


# ────────────────────────────────── 스케치 라벨

def _lane_label(owned: bool, tmp_path) -> str:
    sketch = tmp_path / "sketch.png"
    sketch.write_bytes(b"\x89PNG\r\n\x1a\n")
    refs = build_still_refs(
        bg_only=False, plate=None, conti=None, prev_sel=None,
        char_refs=[], prop_refs=[], lane_sketch=sketch,
        prompt_version="3", framing_scale_owned=owned)
    return refs[0][0]


def test_the_old_label_claims_camera_framing(tmp_path):
    """양성 확인 — 이 문구가 있었기 때문에 라벨을 같이 바꿔야 했다."""
    assert "camera framing" in _lane_label(False, tmp_path)


def test_the_new_label_gives_the_scale_away(tmp_path):
    got = _lane_label(True, tmp_path)
    assert "ONLY the figure placement" in got
    assert "FRAMING SCALE" in got
    # 스케치가 스케일 권위라고 말하던 자리는 사라졌다
    assert "ONLY the camera framing" not in got


# ────────────────────────────────── 팩

def test_the_pack_holds_exactly_the_pair():
    """둘은 한 팩이다 — 따로 병합하면 중간 판이 자기모순이다."""
    d = PACKS / resolve_prompt_version(FRAMING_SCALE_PROMPT_VERSION)
    assert resolve_prompt_version(LANE_SKETCH_LABEL_PROMPT_VERSION) == d.name
    assert sorted(f.name for f in d.glob("*.md")) == [
        "framing_scale_clause.md", "sketch_label_lane.md"]


# ────────────────────────────────── 조립부가 실제로 부르나

def _service_tree() -> ast.AST:
    return ast.parse(
        (BACKEND / "app/services/still_recipe_service.py").read_text("utf-8"))


def test_the_service_builds_the_clause_for_lane_shots():
    """★함수만 만들고 호출부에 안 붙이면 위 시험은 전부 초록이다."""
    tree = _service_tree()
    called = {n.func.id for n in ast.walk(tree)
              if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
    assert "build_framing_scale_clause" in called, (
        "서비스가 스케일 절을 안 만든다 — lane 샷은 그대로 빈손이다")


def test_the_service_passes_both_halves():
    """프롬프트 절과 라벨 교체가 **둘 다** 넘어가야 한다."""
    tree = _service_tree()
    kwargs = {kw.arg for n in ast.walk(tree) if isinstance(n, ast.Call)
              for kw in n.keywords if kw.arg}
    assert "framing_scale_en" in kwargs, "절을 만들고 프롬프트에 안 넘긴다"
    assert "framing_scale_owned" in kwargs, (
        "절만 넘기고 라벨을 옛 판으로 둔다 — 같은 요청에서 권위가 둘이 된다")


# ────────────────────────────────── lane 판단은 한 곳에서 (2026-09-18 컨트리로드)
#
# 체인+배경 전용 lane 샷에서 스케일 줄만 남고 lane 모드가 꺼져
# 「framing_scale_en 은 lane 샷 전용」계약에 걸렸다 — scene_image_pipeline 이
# 242샷 중 121에서 통째로 죽었다.


def test_the_lane_sketch_decision_truth_table():
    from app.modules.pipeline.still_recipe import lane_sketch_mode
    assert lane_sketch_mode(lane_used=True, lane_chain=False, bg_only=False) is True
    assert lane_sketch_mode(lane_used=True, lane_chain=True, bg_only=False) is True
    assert lane_sketch_mode(lane_used=True, lane_chain=False, bg_only=True) is True
    # ★죽던 자리 — 체인이면서 배경 전용이면 스케치 샷이 아니다
    assert lane_sketch_mode(lane_used=True, lane_chain=True, bg_only=True) is False
    assert lane_sketch_mode(lane_used=False, lane_chain=True, bg_only=True) is False


def test_the_service_asks_the_same_question_in_all_three_places():
    """카메라 절·스케일 절·lane_ref_mode 가 **같은 이름** 하나를 본다."""
    src = (BACKEND / "app/services/still_recipe_service.py").read_text("utf-8")
    assert "lane_sketch_mode(" in src, "서비스가 공통 판단 함수를 안 쓴다"
    assert "if camera_frame_on and not lane_sketch_used:" in src
    assert "if camera_frame_on and lane_sketch_used:" in src
    assert 'lane_ref_mode=("sketch" if lane_sketch_used else "")' in src
    assert "lane_used and not (lane_chain and bg_only)" not in src, (
        "옛 판단이 서비스에 남아 있다 — 두 곳이면 한쪽만 고쳐진다")


def test_a_chain_bg_only_lane_shot_no_longer_breaks_the_contract():
    """그 조합으로 실제 조립을 불러 계약 위반이 안 나는지 본다(순수 함수 끝점)."""
    from app.modules.pipeline.still_recipe import (
        build_framing_scale_clause, lane_sketch_mode)
    staging = {"framing_scale": "medium"}
    used = lane_sketch_mode(lane_used=True, lane_chain=True, bg_only=True)
    scale = build_framing_scale_clause(staging) if used else ""
    assert scale == "", "스케치 샷이 아닌데 스케일 줄이 만들어졌다"
