"""카드의 공간 규칙을 **이 샷에 해당하는 것만** 싣는다 (2026-08-26).

## 무엇을 지키나

두 실패를 나란히 놓고 **둘 다** 막는다.

| | 막으려는 실패 |
|---|---|
| RO-9 (원래) | 입력이 **비어서** 규칙이 조용히 사라지는 것 (Trap #14) |
| 이번 수정 | 입력이 **확정적으로 「해당 없음」**인데 전문이 실리는 것 |

그래서 fail-safe 방향은 그대로다 — **확정됐을 때만** 걷는다.

★플래그 OFF 면 **한 글자도 안 바뀐다.** 그 갈래를 첫 시험이 못박는다.
★**진짜 끝점**(`build_render_prompt_card`)까지 태운다 — 거기서
 `assert_card_shape()` validator 가 돈다. 처음에는 중간까지만
 태워 놓고 「끝점」이라 이름 붙였다가, **플래그 ON 이면 카드가
 통째로 죽는 것을 놓쳤다**(2026-08-26 Codex BLOCK-1).
"""
from __future__ import annotations

import copy
import json
from typing import Any, Dict

import pytest

from app.core.config import settings
from app.core.steps.render_prompt_card import (  # noqa: I001
    FRAMING_CLOSE,
    FRAMING_INSERT,
    FRAMING_MEDIUM,
    FRAMING_WIDE,
    _build_spatial_consistency_dict,
    _build_render_strategy_constraints,
    _count_characters,
    _scope_constraints,
    _scope_spatial_rules,
    build_render_strategy,
)

# close_framing_rules 안에 있는 문장. framing 이 medium/wide 인 샷에
# 이것이 실리면 **그리라고 지정된 인물을 안 그려도 된다**는 뜻이 된다.
_위험한_문장 = "absent — not rendered at all even if listed in visible_entities"

# 팩 `scene_detail/*/system.md` 가 직접 부르는 이름들. 하나라도 사라지면
# 「카드가 준다는데 없다」가 된다.
_팩이_부르는_이름 = (
    "camera_frame_rule", "fg_bg_shared_anchor_rule", "primary_framing_rule")


@pytest.fixture
def 켬(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(
        settings, "card_spatial_rules_scoped_enabled", True, raising=False)


@pytest.fixture
def 끔(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(
        settings, "card_spatial_rules_scoped_enabled", False, raising=False)


def _원본() -> Dict[str, Any]:
    return _build_spatial_consistency_dict()


def _걷혔나(rule: Any) -> bool:
    return isinstance(rule, dict) and rule.get("not_applicable") is True


# ── ① 꺼져 있으면 한 글자도 안 바뀐다 ────────────────────────────

@pytest.mark.parametrize("framing", [FRAMING_CLOSE, FRAMING_MEDIUM,
                                     FRAMING_WIDE, FRAMING_INSERT, None])
@pytest.mark.parametrize("인물수", [None, 0, 1, 2, 3])
def test_꺼져_있으면_입력_그대로(끔, framing, 인물수) -> None:
    """어떤 조건에서도 OFF 는 **받은 것을 그대로** 돌려준다."""
    원본 = _원본()
    기준 = copy.deepcopy(원본)
    나온것 = _scope_spatial_rules(
        원본, framing_scale=framing, character_count=인물수)
    assert _직렬화(나온것) == _직렬화(기준), (
        "플래그 OFF 인데 값이 바뀌었다 — 기존 주행과 대조가 안 된다")


def _직렬화(x: Any) -> str:
    return json.dumps(x, ensure_ascii=False, sort_keys=True)


# ── ② 켜면 확정값일 때만 걷는다 ──────────────────────────────────

def test_close_면_wide_medium_규칙이_걷힌다(켬) -> None:
    out = _scope_spatial_rules(
        _원본(), framing_scale=FRAMING_CLOSE, character_count=None)
    pfr = out["primary_framing_rule"]
    assert _걷혔나(pfr["wide_medium_rules"])
    # close 샷에는 close 규칙이 **남아야** 한다 — 재료를 없애는 게 아니다
    assert not _걷혔나(pfr["close_framing_rules"])
    assert _위험한_문장 in _직렬화(pfr["close_framing_rules"])


@pytest.mark.parametrize("framing", [FRAMING_MEDIUM, FRAMING_WIDE])
def test_medium_wide_면_close_규칙이_걷히고_위험한_문장이_사라진다(
        켬, framing) -> None:
    """이번 수정이 노리는 **바로 그 자리**."""
    원본 = _원본()
    assert _위험한_문장 in _직렬화(원본), "기준선 자체가 틀렸다"

    out = _scope_spatial_rules(
        원본, framing_scale=framing, character_count=None)
    pfr = out["primary_framing_rule"]
    assert _걷혔나(pfr["close_framing_rules"])
    assert not _걷혔나(pfr["wide_medium_rules"])
    assert _위험한_문장 not in _직렬화(out), (
        f"framing={framing} 인데 close 전용 지시가 남았다")


@pytest.mark.parametrize("인물수", [0, 1])
def test_인물이_한_명_이하면_두_인물_규칙이_걷힌다(켬, 인물수) -> None:
    """★`0` 도 걷는다 — `visible_entities=[]` 는 이 코드베이스에서
     **명시적 빈 목록**이고 「모름」은 `None` 이다 (2026-08-26 Codex 지적).
     0 을 빼 두면 인물 없는 샷에 1,381자가 계속 실린다."""
    out = _scope_spatial_rules(
        _원본(), framing_scale=None, character_count=인물수)
    assert _걷혔나(out["fg_bg_shared_anchor_rule"])


# ── ③ 모르면 걷지 않는다 (RO-9 가 막던 실패를 되살리지 않는다) ──

@pytest.mark.parametrize("framing", [None, FRAMING_INSERT, "알수없는값", ""])
def test_framing_이_확정이_아니면_안_걷는다(켬, framing) -> None:
    """`insert` 와 모르는 값은 어느 쪽이 해당하는지 단정할 수 없다."""
    out = _scope_spatial_rules(
        _원본(), framing_scale=framing, character_count=None)
    pfr = out["primary_framing_rule"]
    assert not _걷혔나(pfr["close_framing_rules"])
    assert not _걷혔나(pfr["wide_medium_rules"])


@pytest.mark.parametrize("인물수", [None, 2, 3])
def test_인물이_둘_이상이거나_모르면_안_걷는다(켬, 인물수) -> None:
    """2명 이상이어도 **해당한다고 단정 못 한다** — 상호작용·fg/bg 분리
    여부는 코드가 모른다. `None`(모름)도 그대로 둔다."""
    out = _scope_spatial_rules(
        _원본(), framing_scale=None, character_count=인물수)
    assert not _걷혔나(out["fg_bg_shared_anchor_rule"])


# ── ④ 팩이 부르는 이름은 언제나 남는다 ──────────────────────────

@pytest.mark.parametrize("framing", [FRAMING_CLOSE, FRAMING_MEDIUM,
                                     FRAMING_WIDE, FRAMING_INSERT, None])
@pytest.mark.parametrize("인물수", [None, 1, 2])
def test_세_이름은_언제나_남는다(켬, framing, 인물수) -> None:
    out = _scope_spatial_rules(
        _원본(), framing_scale=framing, character_count=인물수)
    for 이름 in _팩이_부르는_이름:
        assert 이름 in out, f"{이름} 이 사라졌다 — system.md 가 이것을 부른다"
    pfr = out["primary_framing_rule"]
    for 이름 in ("close_framing_rules", "wide_medium_rules"):
        assert 이름 in pfr, f"{이름} 이 사라졌다"


def test_걷어도_왜_해당없는지가_남는다(켬) -> None:
    out = _scope_spatial_rules(
        _원본(), framing_scale=FRAMING_MEDIUM, character_count=1)
    fg = out["fg_bg_shared_anchor_rule"]
    assert fg.get("not_applicable_reason")
    # 원래 규칙에 applies_when 이 있으면 그것도 남긴다
    assert fg.get("applies_when")


# ── ⑤ 인물 세기는 축 계약으로 한다 ──────────────────────────────

@pytest.mark.parametrize("ve,기대", [
    (None, None),
    ([], 0),
    (["C02", "L01", "P02"], 1),
    (["C01", "C02", "L01"], 2),
    (["L02"], 0),
    (["C01", "C03", "L02", "P02"], 2),
])
def test_인물_수는_C축으로_센다(ve, 기대) -> None:
    assert _count_characters(ve) == 기대


# ── ⑥ 중간 — build_render_strategy 가 내놓는 것 ─────────────────

def _staging(framing: str) -> Dict[str, Any]:
    return {
        "framing_scale": framing,
        "camera_direction": "eye level, facing the subject",
        "lighting_mood": "overcast daylight",
    }


def _shot() -> Dict[str, Any]:
    return {"shot_index": 2, "camera_direction": "", "primary_subject": "C02"}


def test_전략_medium_샷의_close_지시가_사라진다(켬) -> None:
    """★이것은 **끝점이 아니다.** `build_render_strategy` 는 중간이고,
     진짜 끝점은 `build_render_prompt_card` 다(거기서 validator 가 돈다).
     처음에 이 시험에 「끝점」이라 이름 붙였다가 **플래그 ON 이면 카드가
     통째로 죽는 것을 놓쳤다** (2026-08-26 Codex BLOCK-1)."""
    rs = build_render_strategy(
        seg={}, shot_info=_shot(), staging=_staging(FRAMING_MEDIUM),
        perception_mode=None, visible_entities=["C02", "L01", "P02"])
    나온것 = _직렬화(rs["spatial_consistency"])
    assert _위험한_문장 not in 나온것
    # 인물이 한 명이므로 두 인물 규칙도 걷힌다
    assert _걷혔나(rs["spatial_consistency"]["fg_bg_shared_anchor_rule"])


def test_전략_꺼져_있으면_그대로다(끔) -> None:
    rs = build_render_strategy(
        seg={}, shot_info=_shot(), staging=_staging(FRAMING_MEDIUM),
        perception_mode=None, visible_entities=["C02", "L01", "P02"])
    assert _직렬화(rs["spatial_consistency"]) == _직렬화(_원본())


def test_전략_실제로_얼마나_줄었나(켬) -> None:
    """줄어든 양을 **수로** 남긴다 — 「줄었다」만으로는 확인이 안 된다."""
    전 = len(_직렬화(_원본()))
    rs = build_render_strategy(
        seg={}, shot_info=_shot(), staging=_staging(FRAMING_MEDIUM),
        perception_mode=None, visible_entities=["C02", "L01", "P02"])
    후 = len(_직렬화(rs["spatial_consistency"]))
    assert 후 < 전
    # 인물 1명 + medium = fg_bg(1,381) + close_framing(742) 가 대상.
    # 사유 문구가 들어가므로 순감은 그보다 조금 적다.
    assert 전 - 후 > 1_500, f"기대보다 적게 줄었다 ({전:,} → {후:,})"


# ── ⑦ 진짜 끝점 — build_render_prompt_card + validator ──────────
#
# ★2026-08-26 Codex BLOCK-1. 카드는 반환 직전 `assert_card_shape()` 를
#  거치고, 거기서 `close_framing_rules` 4키·`wide_medium_rules` 4키·
#  `fg_bg_shared_anchor_rule` 6키를 **요구**한다. 걷어낸 자리가 그 검사에
#  걸려 **플래그 ON 이면 카드 생산이 통째로 죽었다.** 중간까지만 태운
#  시험은 그것을 못 봤다.

def _풀카드(**kw):
    """기존 통합 fixture 를 그대로 쓴다 — 카드 인자를 여기서 다시 지어내면
    프로덕션과 다른 것을 재게 된다."""
    from tests.unit.test_g4_5a_spatial_lift import _make_full_card
    return _make_full_card(**kw)


@pytest.mark.parametrize("켤까", [False, True])
def test_끝점_카드가_validator_를_통과한다(monkeypatch, 켤까) -> None:
    """ON/OFF **양쪽 다** 카드가 만들어져야 한다."""
    monkeypatch.setattr(
        settings, "card_spatial_rules_scoped_enabled", 켤까, raising=False)
    card = _풀카드()          # AppError 면 여기서 터진다
    assert "spatial_consistency" in card["render_strategy"]


def _framing_바꾼_staging(framing: str):
    import copy as _copy

    from tests.unit.test_g4_5a_spatial_lift import _DEFAULT_STAGING
    st = _copy.deepcopy(_DEFAULT_STAGING)
    st["framing_scale"] = framing
    return st


@pytest.mark.parametrize("framing,걷혀야", [
    (FRAMING_CLOSE, {"fg_bg_shared_anchor_rule", "wide_medium_rules"}),
    (FRAMING_WIDE, {"fg_bg_shared_anchor_rule", "close_framing_rules"}),
    (FRAMING_MEDIUM, {"fg_bg_shared_anchor_rule", "close_framing_rules"}),
    (FRAMING_INSERT, {"fg_bg_shared_anchor_rule"}),
])
def test_끝점_네_framing_갈래가_모두_카드까지_간다(켬, framing, 걷혀야) -> None:
    """★fixture 가 `medium` 하나뿐이면 close/wide 갈래는 **한 번도 안 돈다.**
     이번 판에서 세 번 당한 것이 전부 「덜 태워서」였다 (2026-08-26).
    """
    card = _풀카드(staging=_framing_바꾼_staging(framing),
                   visible_entities=["C01"])
    sc = card["render_strategy"]["spatial_consistency"]
    pfr = sc["primary_framing_rule"]
    걷힌것 = {이름 for 이름, 값 in (
        ("fg_bg_shared_anchor_rule", sc["fg_bg_shared_anchor_rule"]),
        ("close_framing_rules", pfr["close_framing_rules"]),
        ("wide_medium_rules", pfr["wide_medium_rules"]))
        if _걷혔나(값)}
    assert 걷힌것 == 걷혀야, f"framing={framing} 에서 걷힌 것이 다르다"


def test_끝점_켜면_실제로_걷히고_카드는_산다(켬) -> None:
    card = _풀카드()
    sc = card["render_strategy"]["spatial_consistency"]
    pfr = sc["primary_framing_rule"]
    걷힌것 = [이름 for 이름, 값 in (
        ("fg_bg_shared_anchor_rule", sc["fg_bg_shared_anchor_rule"]),
        ("close_framing_rules", pfr["close_framing_rules"]),
        ("wide_medium_rules", pfr["wide_medium_rules"]))
        if _걷혔나(값)]
    assert 걷힌것, "켰는데 아무것도 안 걷혔다 — fixture 조건을 확인할 것"
    assert len(_직렬화(sc)) < len(_직렬화(_원본()))


def test_끝점_꺼져_있으면_카드가_한_글자도_안_다르다(끔) -> None:
    card = _풀카드()
    assert _직렬화(card["render_strategy"]["spatial_consistency"]) == \
        _직렬화(_원본())


# ── ⑧ 걷힌 자리도 모양을 검사한다 ───────────────────────────────

@pytest.mark.parametrize("나쁜값,왜", [
    ({"not_applicable": True, "not_applicable_reason": "   "}, "사유가 비었다"),
    ({"not_applicable": True}, "사유 칸이 없다"),
    ({"not_applicable": True, "not_applicable_reason": "x", "몰래": 1},
     "예상 밖 키"),
])
def test_걷힌_자리가_망가지면_validator_가_잡는다(나쁜값, 왜) -> None:
    """아무 dict 나 통과시키면 validator 가 있으나 마나다."""
    from app.core.errors import AppError
    from app.core.steps.render_prompt_card import _assert_scoped_out_shape
    with pytest.raises(AppError):
        _assert_scoped_out_shape(나쁜값, "경로", "어디")


# ── ⑨ 플래그가 checkpoint 지문에 접힌다 ─────────────────────────
#
# ★2026-08-26 Codex BLOCK-2. 이 플래그는 카드를 바꾸는데 `_config_hash()`
#  에 안 접히면 ON/OFF 를 바꿔도 **옛 checkpoint 가 current 로 읽혀**
#  A/B 두 판이 사실은 같은 것을 본다.

def test_지문_플래그가_켜지면_config_hash_가_갈린다(monkeypatch) -> None:
    from app.core.steps.detail_steps import SceneDetailStep

    class _가짜(SceneDetailStep):
        def __init__(self):            # 부모 __init__ 을 타지 않는다
            pass

        def _vca_printed_prop_context(self):
            return None

        def _vca_immobilized_subject_context(self):
            return None

    s = _가짜()
    monkeypatch.setattr(
        settings, "card_spatial_rules_scoped_enabled", False, raising=False)
    off = s._config_hash()
    monkeypatch.setattr(
        settings, "card_spatial_rules_scoped_enabled", True, raising=False)
    on = s._config_hash()
    assert off != on, (
        "플래그를 켜도 지문이 같다 — 옛 checkpoint 를 그대로 재사용해 "
        "A/B 가 같은 판을 본다")


def test_지문_꺼져_있으면_옛_지문이_보존된다(monkeypatch) -> None:
    """OFF 는 payload 가 불변이라 **기존 주행의 지문과 같아야** 한다."""
    import hashlib
    import json as _json

    from app.core.steps.detail_steps import (
        SCENE_DETAIL_PROMPT_VERSION, SCENE_DETAIL_SCHEMA_VERSION, SceneDetailStep,
    )

    class _가짜(SceneDetailStep):
        def __init__(self):
            pass

        def _vca_printed_prop_context(self):
            return None

        def _vca_immobilized_subject_context(self):
            return None

    monkeypatch.setattr(
        settings, "card_spatial_rules_scoped_enabled", False, raising=False)
    옛payload = {
        "schema_version": SCENE_DETAIL_SCHEMA_VERSION,
        "prompt_version": SCENE_DETAIL_PROMPT_VERSION,
        "chain_bg_guide_enabled": settings.chain_bg_guide_enabled,
        "chain_bg_camera_meta_enabled": settings.chain_bg_camera_meta_enabled,
        "shot_essence_enabled": settings.shot_essence_enabled,
    }
    옛 = hashlib.sha256(
        _json.dumps(옛payload, sort_keys=True).encode("utf-8")).hexdigest()[:16]
    assert _가짜()._config_hash() == 옛, (
        "플래그를 껐는데 지문이 달라졌다 — 기존 checkpoint 가 통째로 stale 이 "
        "되어 재실행 비용이 발생한다")


# ── ⑩ 걷어낸 규칙을 **가리키는 지시**도 같이 뺀다 ───────────────
#
# ★`_scope_spatial_rules` 만으로는 걷다 만다. `constraints[3]` 은
#     two characters in fg/bg separation **must** share a named
#     surface/space anchor — see …fg_bg_shared_anchor_rule
#  라고 **명령형**으로 말한다. 규칙 본문만 걷고 이 줄을 두면 모델은
#  여전히 「두 인물이 …해야 한다」는 지시를 받는다.

_FG_BG_참조 = "spatial_consistency.fg_bg_shared_anchor_rule"


@pytest.mark.parametrize("인물수", [None, 0, 1, 2, 3])
def test_지시_꺼져_있으면_그대로다(끔, 인물수) -> None:
    원본 = _build_render_strategy_constraints()
    assert _scope_constraints(원본, character_count=인물수) == 원본


@pytest.mark.parametrize("인물수", [0, 1])
def test_지시_인물_한_명_이하면_두_인물_지시가_빠진다(켬, 인물수) -> None:
    원본 = _build_render_strategy_constraints()
    assert any(_FG_BG_참조 in c for c in 원본), "기준선 자체가 틀렸다"
    나온것 = _scope_constraints(원본, character_count=인물수)
    assert not any(_FG_BG_참조 in c for c in 나온것)
    assert len(나온것) == len(원본) - 1


@pytest.mark.parametrize("인물수", [None, 2, 3])
def test_지시_둘_이상이거나_모르면_그대로다(켬, 인물수) -> None:
    원본 = _build_render_strategy_constraints()
    assert _scope_constraints(원본, character_count=인물수) == 원본


def test_지시_끝점_카드에서도_같이_빠진다(켬) -> None:
    """규칙과 지시가 **같이** 빠져야 모순이 안 남는다."""
    card = _풀카드(visible_entities=["C01", "L01"])
    rs = card["render_strategy"]
    assert _걷혔나(rs["spatial_consistency"]["fg_bg_shared_anchor_rule"])
    assert not any(_FG_BG_참조 in c for c in rs["constraints"]), (
        "규칙은 걷혔는데 그것을 가리키는 명령형 지시가 남았다")


def test_지시_끝점_인물이_둘이면_규칙도_지시도_남는다(켬) -> None:
    card = _풀카드(visible_entities=["C01", "C02", "L01"])
    rs = card["render_strategy"]
    assert not _걷혔나(rs["spatial_consistency"]["fg_bg_shared_anchor_rule"])
    assert any(_FG_BG_참조 in c for c in rs["constraints"])
