"""grok 백엔드에서만 컴팩트 스템으로 가른다 — nb2 는 바이트 동일.

## 왜 이 테스트가 있나 (2026-08-18 실측)

xAI 이미지 모델의 텍스트 상한은 8,000바이트다(모델이 스스로 선언하고,
초과 요청은 실제로 400 으로 거부된다 — `"Prompt length exceeds the maximum
allowed length of 8000"`, provider xAI). 상한을 넘으면 그 롤이 죽고,
롤 하나가 죽으면 **샷 전체가 죽는다**(multiroll_select 가 예외를 그대로
올린다).

같은 대본 직전 판의 실제 base roll 457건을 재 보니 중앙값 9,787바이트라
대부분이 상한을 넘는다. grok 조립에는 이미 컴팩트 지도 절(v17)이 붙어
−1,752바이트를 줄이지만, 그 뒤 추가된 절 다섯이 **컴팩트 selector 를 안
따르고 원본 크기 그대로 실려** 있어 여전히 넘는다.

그래서 그 다섯 절의 컴팩트판을 새 팩(v22)에 저작해 **grok 조립에서만**
쓴다. 지키는 계약은 둘이다.

1. **nb2(기본)는 바이트 동일** — 기존 스템을 그대로 읽는다. 이 판이
   깨지면 완성된 에피소드의 지문이 흔들려 전량 재생성이 걸린다.
2. grok 조립은 컴팩트판을 읽고, 조립 결과가 상한 아래로 내려간다.

갈림은 조건 하나뿐이다 — `guidance_version` 이 비었는가. 그 값은
still_recipe_service 가 `still_image_backend == "grok2"` 일 때만 채운다.
"""

import pytest

from app.modules.llm.image_send_state import ImageSubmissionUnknown
from app.modules.pipeline import still_recipe as sr

# b 변주 절의 두 판 — **프로덕션이 고르는 그 함수**로 받는다 (2026-08-28).
# 이름·팩을 시험이 따로 적으면 조립이 옮겨 가도 시험은 옛 파일을 계속
# 재고 초록으로 남는다.
_BROLL_FULL = sr.broll_variation_stem("")
_BROLL_CMP = sr.broll_variation_stem(sr.STILL_COMPACT_PROMPT_VERSION)


# 조립 인자 — 두 갈래에 똑같이 넣는다. 작품 고유명사는 쓰지 않는다.
COMMON = dict(
    shot_desc=(
        "A middle-aged man in a dark jacket extends his forearm holding an "
        "open notebook diagonally toward a younger man in a shirt across a "
        "narrow shared desk edge. " * 3
    ),
    place_text="detective office workspace, interior",
    time_of_day_en="night",
    world_anchor=" — contemporary, 2016",
    bg_only=False,
    prev_used=True,
    prev_usage_en="continue from the previous frame",
    pose_clauses=["C01 seated, forearm extended", "C02 leaning in from the right"],
    movement_en="C01 holds the notebook steady",
    figures_en="two figures, upper-torso framing",
    carried_en="open notebook",
    handled_by="C01",
    char_names=["C01 (middle-aged man, dark jacket)", "C02 (young man, shirt)"],
    camera_frame_en="Insert framing from upper-torso height, three-quarter angle",
    lighting_mood_en="subdued neutral-to-cool nighttime ambient",
    identity_role_en="",
    signage_en="",
    prompt_version="1",
)


# 카메라 절이 실제로 렌더되는 staging 모양 (구조화 필드 → 결정론 렌더).
STAGING = {
    "camera_direction": "eye level, three-quarter",
    "framing_scale": "medium",
    "frame_spatial_contract": {"subject_zone": "center"},
    "key_bg_elements": ["desk"],
}


def _b(text: str) -> int:
    return len(text.encode("utf-8"))


# ── 계약 1: nb2 는 바이트 동일 ──


def test_nb2_assembly_uses_original_stems():
    """guidance_version 이 비면 기존 스템 그대로 — 컴팩트 문안이 새면 안 된다."""
    from app.modules.prompt_loader import load_prompt

    nb2 = sr.build_still_prompt(guidance_version="", **COMMON)
    for stem, ver in (
        ("support_stillness_clause", sr.SUPPORT_STILLNESS_PROMPT_VERSION),
        # ★`COMMON` 에 `signage_en` 이 없다 — 승인 목록이 없는 샷이므로
        #  조립은 전면 금지 판을 쓴다(2026-08-27 읽을 글자 계약).
        ("no_text_none", sr.TEXT_POLICY_PROMPT_VERSION),
    ):
        original = load_prompt(
            "still_recipe", stem, version=sr.resolve_prompt_version(ver)
        ).strip()
        assert original in nb2, f"nb2 조립에 {stem} 원본이 없다"


def test_nb2_assembly_is_byte_identical_across_calls():
    """같은 인자면 같은 바이트 — 갈림이 조용히 새는지 잡는 기준선."""
    a = sr.build_still_prompt(guidance_version="", **COMMON)
    b = sr.build_still_prompt(guidance_version="", **COMMON)
    assert a == b


def test_camera_frame_clause_default_is_original():
    """콘티 등 다른 호출부는 인자를 안 준다 — 기본값이 기존 판이어야 한다."""
    from app.modules.prompt_loader import load_prompt

    default = sr.build_camera_frame_clause(STAGING)
    assert default, "이 staging 이면 카메라 절이 렌더돼야 한다"
    tmpl = load_prompt(
        "still_recipe", "camera_frame_clause",
        version=sr.resolve_prompt_version(sr.CAMERA_FRAME_PROMPT_VERSION),
        body="",
    ).strip()
    head = tmpl.split("\n")[0]
    assert head in default


# ── 계약 2: grok 조립은 컴팩트판을 읽고 더 작다 ──


def test_grok_assembly_is_smaller():
    nb2 = sr.build_still_prompt(guidance_version="", **COMMON)
    grok = sr.build_still_prompt(
        guidance_version=sr.STILL_COMPACT_PROMPT_VERSION, **COMMON)
    assert _b(grok) < _b(nb2)


def test_grok_assembly_uses_compact_stems():
    """grok 조립에는 컴팩트판이 들어가고 원본은 안 들어간다."""
    from app.modules.prompt_loader import load_prompt

    grok = sr.build_still_prompt(
        guidance_version=sr.STILL_COMPACT_PROMPT_VERSION, **COMMON)
    for stem, orig_ver, compact_ver in (
        ("support_stillness_clause", sr.SUPPORT_STILLNESS_PROMPT_VERSION,
         sr.STILL_GROK_STEM_VERSION),
        # ★`no_text` 는 제 selector 를 갖는다(다른 넷과 다르다) — grok 판도
        #  마찬가지다. 2026-08-27 에 읽을 글자 계약이 바뀌며 팩이 갈렸다.
        ("no_text_none", sr.TEXT_POLICY_PROMPT_VERSION,
         sr.TEXT_POLICY_GROK_PROMPT_VERSION),
    ):
        compact = load_prompt(
            "still_recipe", stem,
            version=sr.resolve_prompt_version(compact_ver),
        ).strip()
        original = load_prompt(
            "still_recipe", stem,
            version=sr.resolve_prompt_version(orig_ver),
        ).strip()
        assert compact in grok, f"grok 조립에 {stem} 컴팩트판이 없다"
        assert original not in grok, f"grok 조립에 {stem} 원본이 남아 있다"


def test_grok_stem_pack_is_smaller_than_originals():
    """새 팩의 다섯 스템 합이 원본 합보다 확실히 작아야 한다 — 이게 목적이다."""
    from app.modules.prompt_loader import load_prompt

    # (원본 스템, 원본 팩, 컴팩트 스템, 컴팩트 팩)
    # ★b 변주는 2026-08-28(감사 P0-B)부터 **같은 팩 안에서 스템 이름으로**
    #  갈린다 — `broll_variation_stem()` 이 단일 출처다. 다른 넷은 종전대로
    #  같은 스템 이름을 다른 팩에서 읽는다.
    pairs = (
        ("support_stillness_clause", sr.SUPPORT_STILLNESS_PROMPT_VERSION,
         "support_stillness_clause", sr.STILL_GROK_STEM_VERSION),
        ("no_text", sr.TEXT_POLICY_PROMPT_VERSION,
         "no_text", sr.TEXT_POLICY_GROK_PROMPT_VERSION),
        ("identity_ref_role_clause", sr.IDENTITY_ROLE_PROMPT_VERSION,
         "identity_ref_role_clause", sr.STILL_GROK_STEM_VERSION),
        (_BROLL_FULL[0], _BROLL_FULL[1], _BROLL_CMP[0], _BROLL_CMP[1]),
        ("camera_frame_clause", sr.CAMERA_FRAME_PROMPT_VERSION,
         "camera_frame_clause", sr.STILL_GROK_STEM_VERSION),
    )
    before = after = 0
    for stem, ver, compact_stem, compact_ver in pairs:
        kw = {"body": ""} if stem == "camera_frame_clause" else {}
        before += _b(load_prompt(
            "still_recipe", stem,
            version=sr.resolve_prompt_version(ver), **kw).strip())
        after += _b(load_prompt(
            "still_recipe", compact_stem,
            version=sr.resolve_prompt_version(compact_ver),
            **kw).strip())
    # 실측 목표: 약 1,800바이트 절감 (A롤·B롤 모두 상한 아래로).
    #
    # ★2026-08-27: **1500 → 1400 으로 낮췄다.** 읽을 글자 계약이 바뀌며
    #  `no_text` 계열 문안이 길어졌다(저작 목록 유무로 갈리고, 「물체는
    #  두되 안 읽히게」라는 재료를 담아야 한다). 컴팩트 판을 옛것보다
    #  짧게 줄여도 원본이 함께 길어져 절감 폭이 1,452B 로 내려왔다.
    #
    #  ★기준을 낮추는 것이 안전한 이유: 상한을 실제로 지키는 것은 이
    #   절감이 아니라 **`GROK_SHED_ORDER` 덜어내기**다(상한을 넘는 롤에서
    #   일반 규칙 절 여섯을 덜어 2,019B 를 확보한다). 이 시험은 컴팩트
    #   팩이 **원본보다 확실히 작다**는 방향을 지키는 것이고, 그 방향은
    #   그대로다.
    assert before - after >= 1400, f"절감 부족: {before} → {after}"


def test_bgfirst_bg_camera_clause_is_never_aliased_from_the_still_clause():
    """gpt-image-2 배경판 카메라 절은 스틸 조립본을 **빌려 쓰면 안 된다**.

    2026-08-18 실측: `_bg_cam = camera_frame_en` 한 줄 때문에 grok2 에서
    컴팩트 절이 배경판 프롬프트까지 흘러갔다. 배경판은 gpt-image-2 라
    8,000바이트 상한과 무관해서 줄여도 얻는 것이 없고, 그 프롬프트는
    지문(compute_input_fingerprint)에 들어가 배경판을 무효로 만든다.
    앞 시험은 호출에 selector 가 있는지만 보므로 **대입 별칭**은 못 잡는다.
    """
    import ast
    import pathlib

    src = pathlib.Path(
        "app/services/still_recipe_service.py").read_text(encoding="utf-8")
    tree = ast.parse(src)

    aliased = [
        node.lineno
        for node in ast.walk(tree)
        if isinstance(node, ast.Assign)
        and any(getattr(t, "id", "") == "_bg_cam" for t in node.targets)
        and isinstance(node.value, ast.Name)
    ]
    assert not aliased, (
        "_bg_cam 이 다른 이름을 그대로 받는다 — 스틸용 컴팩트 절이 "
        f"배경판으로 새어 나간다(line {aliased}). 항상 새로 만들 것."
    )


def test_every_still_camera_call_passes_the_selector():
    """스틸 서비스의 카메라 절 호출은 **전부** 컴팩트 갈림을 넘겨야 한다.

    2026-08-18 Codex BLOCK 실측: lane 샷은 첫 조립이 카메라 절을 건너뛰고
    (`not lane_used`) 체인 후보에서 다시 렌더하는데, 그 재조립 호출만
    selector 를 안 넘겨 lane+체인 샷의 카메라 절이 원본(+228B)으로 남았다.
    그 프롬프트는 grok 롤로 그대로 나가므로 경계 샷이 상한에 걸려 죽는다.

    호출부가 늘어날 때 같은 누락이 조용히 생기지 않게 소스를 훑어 잠근다.
    grok 롤로 안 가는 호출은 아래 허용 목록에 **이유와 함께** 적는다.
    """
    import ast
    import pathlib

    src = pathlib.Path(
        "app/services/still_recipe_service.py").read_text(encoding="utf-8")
    tree = ast.parse(src)

    # grok 최종 롤로 가지 않는 호출 — 넘기면 오히려 틀린다.
    #   _bg_cam: _run_bgfirst_bg → gpt-image-2 Step1 프롬프트(배경 재투영).
    ALLOWED_TARGETS = {"_bg_cam"}

    missing = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        fn = node.func
        name = getattr(fn, "id", None) or getattr(fn, "attr", None)
        if name not in ("build_camera_frame_clause", "_bcfc"):
            continue
        has_sel = any(kw.arg == "version_selector" for kw in node.keywords)
        if has_sel:
            continue
        # 대입 대상 이름으로 허용 여부를 가른다.
        target = ""
        for anc in ast.walk(tree):
            if isinstance(anc, ast.Assign) and anc.value is node:
                tgt = anc.targets[0]
                target = getattr(tgt, "id", "") or getattr(tgt, "attr", "")
        if target in ALLOWED_TARGETS:
            continue
        missing.append(f"line {node.lineno} (대상 {target or '?'})")

    assert not missing, (
        "카메라 절 호출에 version_selector 누락 — grok 롤 프롬프트가 원본 "
        f"절을 실어 상한에 걸린다: {missing}"
    )


def test_grok_stem_pack_keeps_camera_body_placeholder():
    """{body} 는 코드가 샷별 카메라 지시를 채우는 자리 — 없어지면 카메라
    지시가 통째로 사라진다."""
    from app.modules.prompt_loader import load_prompt

    rendered = load_prompt(
        "still_recipe", "camera_frame_clause",
        version=sr.resolve_prompt_version(sr.STILL_GROK_STEM_VERSION),
        body="-- SHOT CAMERA LINES --",
    )
    assert "-- SHOT CAMERA LINES --" in rendered


# ── 계약 3: 상한을 넘는 롤은 일반 규칙 절만 덜어 맞춘다 (2026-08-18) ──


def _rendered(stem: str) -> str:
    """덜어내기가 **실제로 찾는 그 문자열**을 만든다.

    ★2026-08-28: 종전엔 컴팩트 팩 하나로 고정해 렌더했다. 스템이 제 팩으로
     갈라져 나가면(`GROK_SHED_STEM_SELECTOR`) 이 시험만 옛 팩을 렌더해
     프로덕션이 지우는 문자열과 달라진다 — 그러면 시험이 재려는 계약을
     안 재고 엉뚱한 자리에서 빨강이 된다. 프로덕션과 **같은 표**를 본다.
    """
    from app.modules.prompt_loader import load_prompt

    return load_prompt(
        "still_recipe", stem,
        version=sr.resolve_prompt_version(
            sr.GROK_SHED_STEM_SELECTOR.get(
                stem, sr.STILL_COMPACT_PROMPT_VERSION)),
    ).strip()


def test_fit_leaves_prompt_untouched_when_within_budget():
    """상한 안이면 한 글자도 안 바꾼다 — 대다수 롤이 이 길이다."""
    p = "\n\n".join([_rendered(s) for s in sr.GROK_SHED_ORDER])
    out, shed = sr.fit_grok_prompt(p, 0, 100_000)
    assert out == p and shed == []


def test_fit_sheds_in_declared_order_until_it_fits():
    """정해진 순서대로, **맞을 때까지만** 덜어낸다(더 덜지 않는다)."""
    stems = list(sr.GROK_SHED_ORDER)
    p = "HEAD\n\n" + "\n\n".join(_rendered(s) for s in stems)
    total = len(p.encode("utf-8"))
    first = len(_rendered(stems[0]).encode("utf-8"))
    # 첫 절 하나만 덜면 맞는 상한
    limit = total - first - 2
    out, shed = sr.fit_grok_prompt(p, 0, limit)
    assert shed == [stems[0]], f"첫 절만 덜어야 한다: {shed}"
    assert len(out.encode("utf-8")) <= limit
    assert _rendered(stems[0]) not in out
    for s in stems[1:]:
        assert _rendered(s) in out, f"{s} 는 덜면 안 된다"


def test_fit_counts_label_bytes_toward_the_budget():
    """참조 라벨도 상한에 함께 세어진다 — 그만큼 더 덜어야 맞는다."""
    stems = list(sr.GROK_SHED_ORDER)
    p = "HEAD\n\n" + "\n\n".join(_rendered(s) for s in stems)
    total = len(p.encode("utf-8"))
    label_bytes = 900
    out, shed = sr.fit_grok_prompt(p, label_bytes, total)
    assert shed, "라벨 바이트를 세지 않으면 안 덜어낸다"
    assert len(out.encode("utf-8")) + label_bytes <= total


def test_fit_never_sheds_shot_specific_material():
    """샷 고유 재료는 덜어내기 목록에 없다 — 그림 재료가 사라지면 안 된다."""
    forbidden = {
        "camera_frame_clause", "no_text", "people_clause",
        "people_clause_scene", "identity_ref_role_clause",
        "support_stillness_clause", "continues_clause", "still_head",
        "handled_object_clause", "structure_look_clause",
    }
    assert not (set(sr.GROK_SHED_ORDER) & forbidden)


def test_shed_stems_are_static_and_present_in_the_pack():
    """덜어내기는 정확한 문자열 제거다 — 변수가 있으면 못 지운다."""
    for stem in sr.GROK_SHED_ORDER:
        text = _rendered(stem)
        assert text, f"{stem} 렌더 실패"
        assert "{" not in text, f"{stem} 에 변수가 있어 정확한 제거가 안 된다"


def test_fit_returns_original_when_nothing_matches():
    """덜어낼 문안이 없으면 아무것도 안 지우고 그대로 돌려준다(그 뒤 크게 실패)."""
    p = "X" * 20_000
    out, shed = sr.fit_grok_prompt(p, 0, 7900)
    assert out == p and shed == []


def test_grok_client_sheds_and_sends_instead_of_killing_the_shot(monkeypatch):
    """클라이언트 발송 경로 실동 — 상한을 넘어도 덜어내고 **보낸다**.

    이 시험이 없으면 fit_grok_prompt 가 맞아도 클라이언트가 안 부르는 결함을
    못 잡는다(롤 하나가 죽으면 샷이 죽는다).
    """
    import json as _json

    from app.modules.llm import grok_image_client as gic
    from app.modules.llm import image_tracer as _it

    stems = [_rendered(s) for s in sr.GROK_SHED_ORDER]
    prompt = "HEAD\n\n" + "\n\n".join(stems) + "\n\n" + "X" * 7000
    assert len(prompt.encode("utf-8")) > gic.GROK_TEXT_BYTE_LIMIT

    class _Noop:
        def log(self, *a, **kw):
            return None

    monkeypatch.setattr(_it, "get_image_tracer", lambda *a, **kw: _Noop())

    class _Sent(RuntimeError):
        pass

    captured = {}

    def _fake_urlopen(req, timeout=None):
        captured["body"] = req.data
        raise _Sent("보냄")

    monkeypatch.setattr(gic.urllib.request, "urlopen", _fake_urlopen)

    client = gic.GrokImageClient(api_key="test-key")
    # ★기대 예외가 `_Sent` 에서 `ImageSubmissionUnknown` 으로 바뀌었다
    #  (2026-08-26 감사 0-A). 이 시험은 「보냈는가」를 예외로 표시하는데,
    #  새 계약에서 **정체를 모르는 예외는 전부 unknown** 이라 클라이언트가
    #  그것을 감싸 올린다. 이 시험이 보는 것은 `captured["body"]` 이므로
    #  계약은 그대로다.
    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image(prompt, labeled_references=[("LBL", b"\x89PNG")])

    body = _json.loads(captured["body"])
    text = body["messages"][0]["content"][0]["text"]
    assert len(text.encode("utf-8")) <= gic.GROK_TEXT_BYTE_LIMIT
    # 맞을 때까지만 덜었는지 — 뒤쪽 절은 남아 있어야 한다.
    assert _rendered(sr.GROK_SHED_ORDER[0]) not in text
    assert _rendered(sr.GROK_SHED_ORDER[-1]) in text


def test_hard_limit_is_separate_from_the_shed_target(monkeypatch):
    """더 못 덜어도 **모델 상한 안이면 보낸다** (2026-08-18 Codex BLOCK 수용).

    여유분 100B 를 거절선으로 쓰면 7,900~8,000 사이 롤이 보내 보지도 못하고
    죽는다 — 직전 판 실측에 그런 롤이 1건 있었다. 모델이 실제로 거절하는
    값은 8,000B 이고 그 거절은 400 이라 돈이 들지 않는다.
    """
    import json as _json

    from app.modules.llm import grok_image_client as gic
    from app.modules.llm import image_tracer as _it

    assert gic.GROK_TEXT_BYTE_HARD_LIMIT > gic.GROK_TEXT_BYTE_LIMIT

    class _Noop:
        def log(self, *a, **kw):
            return None

    monkeypatch.setattr(_it, "get_image_tracer", lambda *a, **kw: _Noop())

    class _Sent(RuntimeError):
        pass

    captured = {}

    def _fake_urlopen(req, timeout=None):
        captured["body"] = req.data
        raise _Sent("보냄")

    monkeypatch.setattr(gic.urllib.request, "urlopen", _fake_urlopen)
    client = gic.GrokImageClient(api_key="test-key")

    # ① 목표는 넘지만 모델 상한 안 — 덜 게 없어도 그대로 보낸다.
    between = "Y" * (gic.GROK_TEXT_BYTE_LIMIT + 38)
    assert len(between.encode("utf-8")) <= gic.GROK_TEXT_BYTE_HARD_LIMIT
    # ★기대 예외 변경 사유는 위 시험의 주석 참조 (감사 0-A).
    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image(between)
    body = _json.loads(captured["body"])
    assert body["messages"][0]["content"][0]["text"] == between

    # ② 모델 상한 초과 — 기존대로 크게 실패한다.
    captured.clear()
    with pytest.raises(gic.GrokPromptOverBudget):
        client.generate_image("Z" * (gic.GROK_TEXT_BYTE_HARD_LIMIT + 1))
    assert not captured, "상한 초과인데 발송됐다"
