"""활성 경로 금지 어휘 ratchet (2026-07-31).

## 왜 이 테스트가 있는가

사용자 절대 제약: 작품 고유명사는 물론 **업종 이름도, 장소를 알리는
표시물의 통칭 자체도** 코드·프롬프트에 박지 않는다. 일반화된 표현은
"장소를 알리는 활자나 표시가 들어가는 면" 이고, 그런 면이 있는지·읽힐
크기인지·고칠 값어치가 있는지는 VLM 이 판정한다.

## ratchet 인 이유

어휘 이행은 후속 계획이다. 지금 있는 위반을 당장 없앨 수는 없지만,
**새 위반이 느는 것은 지금부터 막을 수 있다.** 그래서 현재 위반을 날짜와
함께 명시 부채로 등록하고 그 밖의 위반만 실패시킨다. 후속 계획이 부채
목록에서 항목을 지운다 — 목록이 비면 이 테스트가 완전한 금지가 된다.

## 발행된 구 팩은 대상이 아니다

프로젝트 규칙상 발행된 프롬프트 팩은 덮어쓰지 않는다. 활성 팩(로더가
현재 해석하는 버전)만 본다.
"""
from __future__ import annotations

import re
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parents[3]
APP = REPO / "backend" / "app"

# ── 두 패턴 (2026-08-27 Codex BLOCK) ────────────────────────────────
# 코드와 프롬프트는 **다른 잣대**를 쓴다.
#
#   코드   식별자·설정 키·로그. 사람이 읽는 이름이다. 통칭 그 자체만 막는다.
#   프롬프트  모델이 읽는 글. **열거도 막는다** — "signs, notices, labels,
#          documents, screens, markings" 같은 목록은 통칭을 여러 낱말로
#          펼친 것이고, 오히려 그것들을 그리라는 암시가 된다.
#
# 종전에는 두 낱말짜리 정규식 하나로 둘 다 봤다. 그래서 모듈 이름에 걸려
# 늘 빨갛고(코드), 정작 열거는 통과했다(프롬프트).
BANNED = re.compile(r"signage|signboard", re.IGNORECASE)

# 프롬프트 문안 전용 — 통칭 자체. **모든** 활성 팩에 건다.
PROSE_BANNED = re.compile(r"\bsignage\b|\bsignboards?\b", re.IGNORECASE)

# ── 글자를 이고 있는 물건의 이름 (2026-08-27 Codex BLOCK) ───────────
# ★통칭 두 낱말만 막으면 **"signs and notices"·"signs, notices and
#  labels"·단일 "notice"** 가 그대로 통과한다. 그것들이 곧 통칭을 여러
#  낱말로 펼친 것이고, 금지를 말하려다 **그것들을 그리라는 암시**가 된다.
#
# ★적용은 **읽을 글자를 다루는 자리에만** 건다. 전역으로 걸면 도면
#  라벨(`label`)과 참조 사진 종류(`poster`)가 걸려 래칫이 헐거워진다
#  (끄거나 부채로 덮게 된다). 오탐은 패턴을 좁힐 이유가 아니라 **범위를
#  나눌 이유**다.
#
# ★목록을 **줄이면서** 두 낱말을 흘렸다 (2026-08-27 Codex 재리뷰 BLOCK).
#  종전 열거 정규식이 막던 `documents`·`screens` 가 여기 안 옮겨져서,
#  "documents and screens" 가 읽을 글자 스템에 들어와도 초록이었다.
#  **목록을 다시 쓸 때는 옛 목록과 낱말 단위로 대 봐야 한다** — 아래
#  `must_catch` 가 그 대조를 시험으로 잠근다.
CARRIER_BANNED = re.compile(
    r"\bsignages?\b|\bsignboards?\b|\bsigns?\b|\bnotices?\b"
    r"|\blabels?\b|\bplacards?\b|\bbanners?\b|\bposters?\b"
    r"|\bplaques?\b|\bmarkings?\b|\bdocuments?\b|\bscreens?\b",
    re.IGNORECASE)

# 넓은 패턴을 거는 자리 — 저작 팩 전체 + 읽을 글자 스템.
CARRIER_SCOPE_MODULES = frozenset({"signage_author"})
CARRIER_SCOPE_STEMS = frozenset({
    "no_text", "no_text_none", "bg_reproject_tail", "groupbg_tail",
    "bg_fill_tail", "signage_section",
})


# ── 부채 없음 (2026-08-02 이행 완료) ─────────────────────────────────
# 2026-07-31 에 13건으로 등록했던 부채를 전부 지웠다. 이제 이 테스트는
# ratchet 이 아니라 **완전한 금지**다 — 활성 모듈·활성 팩 어디에도 장소를
# 알리는 표시물의 통칭이 있으면 실패한다.
#
# 이행 방식(사용자 지시 2026-08-02): 통칭을 다른 말로 바꾸는 데 그치지 않고
# **개념과 업종 판단을 걷어냈다.** "어떤 업종이면 간판이 필요한가"를 모델에게
# 판단시키던 절을 없애고, 활자가 있다면 어디서든 같은 내용이라는 한 줄로
# 줄였다(팩 v14, 저작 출력도 lettering_text 한 칸).
KNOWN_DEBT: set[str] = set()

# ── 코드 문자열의 부채 (2026-08-27) ─────────────────────────────────
# ★**모델이 읽지 않는 문자열**이다 — 설정 키·지문 payload 키·records 키·
#  스템 이름·로그 서식·독스트링. 식별자와 같은 부류이고, 프롬프트로
#  나가는 글은 팩에 있지 코드에 없다.
#
# 이 목록을 두는 이유는 종전 검사가 파일 전문을 훑어 **모듈 이름에 걸려
#  늘 빨갛고**, 그래서 정작 팩 문안은 한 번도 안 걸렸기 때문이다
#  (Codex BLOCK ④). 팩 쪽은 부채 없이 **전면 금지**로 남는다 — 그것이
#  진짜 관문이다.
#
# ★새 항목을 여기 넣기 전에 물을 것: **모델이 이 글을 읽는가?**
#  읽으면 부채가 아니라 고칠 것이다.
STRING_DEBT: set[str] = {
    "::signage",
    "signage_author",
    "signage_author_enabled",
    "signage_author_model",
    "signage_author_model_resolved",
    "signage_author_v3_flash",
    "signage_pack",
    "signage_pack_content",
    "signage_policy",
    "signage_section",
    "signage_section_stem",
}


def _is_debt_string(text: str, *, module_stem: str = "") -> bool:
    """이 문자열이 코드 부채인가.

    ★**여러 줄이라고 면제하지 않는다** (2026-08-27 Codex BLOCK). 프롬프트로
     나가는 문안도 여러 줄이다 — 그것까지 면제하면 코드에 박은 모델용 글이
     통과한다.

    면제는 셋뿐이다:
      · 명시 목록(설정 키·지문 키·records 키·스템 이름)
      · 로그 서식(`%s`·`%d`)
      · **그 모듈이 제 이름을 말하는 것** — 독스트링·오류 문구. 모듈
        이름은 식별자이고, 그것을 설명하는 글은 사람이 읽는다.
    """
    t = text.strip()
    if t in STRING_DEBT:
        return True
    if "%s" in text or "%d" in text:
        return True
    return bool(module_stem) and module_stem in text


def _active_pack_targets() -> "list[tuple[Path, set[str] | None]]":
    """검사 대상 = **(module, selector, stem)**.

    ★2026-08-27 (Codex BLOCK ④): 종전에는 두 모듈만, 그것도 팩 디렉토리
     통째로 훑었다. 그래서 표기 저작 팩도 읽을 글자 정책 팩도 한 번도 안
     걸렸고, 반대로 **같은 팩에 있으나 코드가 안 읽는 스템**까지 잡았다
     (스템 하나만 새 판으로 옮긴 경우가 그렇다).

    스템 집합이 `None` 이면 그 팩 전체가 대상이다.
    """
    from app.core.steps.outdoor_structure_seed_step import (
        SEED_VARIANTS_PACK_VERSION,
    )
    from app.modules.pipeline import signage_author as _sg
    from app.modules.pipeline import still_recipe as _sr
    from app.modules.pipeline.search_grounded_ref import (
        REF_PACK_MODULE,
        resolve_ref_pack_version,
    )
    from app.modules.pipeline.seed_prompt_variants import (
        VARIANTS_PACK_MODULE,
        resolve_variants_pack_version,
    )

    base = REPO / "prompts" / "_base"
    spec = [
        (REF_PACK_MODULE, resolve_ref_pack_version(), None),
        (VARIANTS_PACK_MODULE,
         resolve_variants_pack_version(SEED_VARIANTS_PACK_VERSION), None),
        # 표기 저작 — 문안을 모델에게 보내는 그 팩
        ("signage_author", _sg.resolve_signage_pack(), None),
        # 읽을 글자 정책 — 이 팩에서 읽는 스템만
        ("still_recipe",
         _sr.resolve_prompt_version(_sr.TEXT_POLICY_PROMPT_VERSION),
         {"no_text", "no_text_none", "bg_reproject_tail",
          "groupbg_tail", "bg_fill_tail"}),
        ("still_recipe",
         _sr.resolve_prompt_version(_sr.TEXT_POLICY_GROK_PROMPT_VERSION),
         {"no_text", "no_text_none"}),
        # 저작 문안 머리말
        ("still_recipe",
         _sr.resolve_prompt_version(_sr.SIGNAGE_SECTION_PROMPT_VERSION),
         {"signage_section"}),
    ]
    out = []
    for module, resolved, stems in spec:
        d = base / module / resolved
        if d.is_dir():
            out.append((d, stems))
    return out


def _active_pack_dirs() -> list[Path]:
    return [d for d, _ in _active_pack_targets()]


def _rel(p: Path) -> str:
    return str(p.relative_to(REPO))


def _string_literals(path: Path) -> list[str]:
    """그 파일의 **문자열 리터럴만** — 식별자·주석은 뺀다.

    ★2026-08-27: 종전에는 파일 전문을 훑어 `signage_author` 같은 **모듈
     이름·심볼**에 걸렸다. 그래서 래칫이 늘 빨갛고, 정작 **모델에게 나가는
     문안**은 한 번도 안 걸렸다(Codex BLOCK ④). 식별자는 이름일 뿐 모델이
     읽지 않는다 — 걸러야 할 것은 나가는 글이다.
    """
    import ast

    try:
        tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
    except SyntaxError:
        return []
    out = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Constant) and isinstance(node.value, str):
            out.append(node.value)
    return out


def test_no_new_banned_vocabulary_in_strings_the_model_reads():
    """활성 모듈의 **문자열**에 금지 어휘가 들어오지 않는다.

    ★식별자(모듈·함수·변수 이름)는 대상이 아니다 — 모델이 안 읽는다.
     문자열은 프롬프트로 나가거나 산출 칸 이름이 되므로 대상이다.
    """
    offenders = set()
    for p in APP.rglob("*.py"):
        for lit in _string_literals(p):
            if BANNED.search(lit) and not _is_debt_string(
                    lit, module_stem=p.stem):
                offenders.add(f"{_rel(p)}: {lit[:60]!r}")
    new = sorted(offenders)
    assert not new, (
        "금지 어휘가 문자열로 들어왔다 — 장소를 알리는 표시물의 통칭은 "
        f"모델에게 나가는 글에 박지 않는다: {new}")


def test_known_debt_entries_still_exist():
    """부채 목록이 현실과 어긋나면 ratchet 이 헐거워진다.

    이행이 끝난 항목은 목록에서 지운다 — 남겨 두면 그 파일에 어휘가
    다시 들어와도 통과한다.
    """
    stale = [d for d in sorted(KNOWN_DEBT)
             if not (REPO / d).is_file()
             or not BANNED.search(
                 (REPO / d).read_text(encoding="utf-8", errors="ignore"))]
    assert not stale, (
        f"부채 목록에 있으나 이미 깨끗하다 — 목록에서 지울 것: {stale}")


def test_active_pack_discovery_is_not_empty():
    """★팩 목록이 비면 parametrize 가 조용히 0건이 되어 아무것도 검사하지
    않는다. 통과를 검사로 착각하지 않도록 별도로 잠근다."""
    dirs = _active_pack_dirs()
    assert dirs, "활성 팩 디렉토리를 하나도 못 찾았다 — 검사가 무력화된다"
    for d in dirs:
        assert list(d.rglob("*.md")), f"{d} 에 팩 파일이 없다"


@pytest.mark.parametrize(
    "pack_dir,stems", _active_pack_targets(),
    ids=lambda x: x.name if isinstance(x, Path) else "")
def test_active_prompt_packs_are_scanned(pack_dir: Path, stems):
    """활성 팩에서 **실제로 읽히는 스템**의 위반을 잡는다."""
    offenders = []
    for f in sorted(pack_dir.rglob("*.md")):
        if stems is not None and f.stem not in stems:
            continue  # 이 팩에 있으나 코드가 여기서는 안 읽는 스템
        text = f.read_text(encoding="utf-8", errors="ignore")
        wide = (pack_dir.parent.name in CARRIER_SCOPE_MODULES
                or f.stem in CARRIER_SCOPE_STEMS)
        hit = (CARRIER_BANNED if wide else PROSE_BANNED).search(text)
        if hit:
            offenders.append(f"{_rel(f)}: {hit.group(0)!r}")
    new = [o for o in offenders if o not in KNOWN_DEBT]
    assert not new, f"활성 팩에 새 금지 어휘: {new}"


def test_the_scan_covers_every_module_that_sends_words_to_the_model():
    """★래칫이 **무엇을 훑는지**를 지킨다.

    2026-08-27 이전에는 두 모듈만 훑었다 — 표기 저작 팩도, 읽을 글자 정책
    팩도 한 번도 안 걸렸다. 목록에서 한 줄만 지워도 조용히 좁아지므로,
    **덮어야 할 모듈**을 여기 못박는다.
    """
    covered = {d.parent.name for d, _ in _active_pack_targets()}
    must = {"signage_author", "still_recipe"}
    missing = sorted(must - covered)
    assert not missing, (
        f"모델에게 글을 보내는 모듈이 검사에서 빠졌다: {missing}")


def test_the_scan_covers_the_stems_that_carry_readable_words():
    """읽을 글자를 다루는 스템이 하나라도 빠지면 안 된다."""
    seen = set()
    for d, stems in _active_pack_targets():
        if d.parent.name != "still_recipe":
            continue
        seen |= (stems or {f.stem for f in d.glob("*.md")})
    must = {"no_text", "no_text_none", "bg_reproject_tail",
            "groupbg_tail", "bg_fill_tail", "signage_section"}
    missing = sorted(must - seen)
    assert not missing, f"읽을 글자 스템이 검사에서 빠졌다: {missing}"


def test_readable_text_stems_name_no_carrier_at_all():
    """★읽을 글자를 다루는 자리에는 **물건 이름을 하나도** 안 쓴다.

    "signs and notices" · "signs, notices and labels" · 단일 "notice" —
    셋 다 통칭을 펼친 것이고, 금지를 말하려다 그것들을 그리라는 암시가
    된다. "읽을 수 있는 글자" 한 마디면 족하다 (2026-08-27 Codex BLOCK).
    """
    from app.modules.pipeline import signage_author as _sg
    from app.modules.pipeline import still_recipe as _sr

    targets = []
    for sel in (_sr.TEXT_POLICY_PROMPT_VERSION,
                _sr.TEXT_POLICY_GROK_PROMPT_VERSION,
                _sr.SIGNAGE_SECTION_PROMPT_VERSION):
        d = REPO / "prompts" / "_base" / "still_recipe" / \
            _sr.resolve_prompt_version(sel)
        targets += [f for f in sorted(d.glob("*.md"))
                    if f.stem in CARRIER_SCOPE_STEMS]
    d = REPO / "prompts" / "_base" / "signage_author" / \
        _sg.resolve_signage_pack()
    targets += sorted(d.glob("*.md"))

    assert targets, "검사 대상이 비었다 — 관문이 무력화된다"
    offenders = []
    for f in targets:
        hit = CARRIER_BANNED.search(
            f.read_text(encoding="utf-8", errors="ignore"))
        if hit:
            offenders.append(f"{_rel(f)}: {hit.group(0)!r}")
    assert not offenders, f"읽을 글자 자리에 물건 이름이 있다: {offenders}"


def test_the_wide_pattern_actually_catches_what_the_header_promises():
    """★**선언과 검사가 어긋나면 초록이 거짓 안전을 준다.**

    파일 머리말이 「완전 금지」를 선언하는데 패턴이 두 낱말만 보면, 실제
    통칭이 팩에 있어도 초록이 뜬다 — 실제로 그랬다(2026-08-27 Codex
    BLOCK: 활성 저작 팩에 "reads a notice" 가 있는데 통과).

    선언이 막겠다고 한 모양들을 여기 적어 **패턴이 정말 잡는지** 잰다.
    """
    must_catch = [
        "signage",
        "signboard",
        "signs and notices",
        "signs, notices and labels",
        "reads a notice",
        "a placard on the wall",
        "banners overhead",
        "markings on the floor",
        "documents and screens",
        "a single document on the desk",
    ]
    missed = [t for t in must_catch if not CARRIER_BANNED.search(t)]
    assert not missed, f"선언한 것을 패턴이 못 잡는다: {missed}"

    # 좁은 패턴은 통칭만 — 범위를 나눈 이유가 여기 있다.
    assert PROSE_BANNED.search("signage")
    assert not PROSE_BANNED.search("a label on the floor plan")
    assert not PROSE_BANNED.search("a poster-style reference photo")


# 종전 열거 정규식(`_THING_WORDS`, 2026-08-27 오전)이 막던 낱말 **전부**.
# 넓은 패턴이 이것을 대신하므로 하나라도 빠지면 그만큼 구멍이 난다.
_SUPERSEDED_THING_WORDS = (
    "sign", "notice", "label", "document", "screen", "marking",
    "placard", "banner", "poster", "plaque",
)


def test_the_wide_pattern_inherits_every_word_the_old_one_had():
    """★**목록을 다시 쓰면 낱말이 새어 나간다** (2026-08-27 Codex 재리뷰).

    열거 정규식을 넓은 패턴으로 바꾸면서 `documents`·`screens` 두 낱말을
    빠뜨렸다. 팩에 그 낱말이 없어 초록이 떴고, 초록은 「검사했다」로
    읽힌다. 옛 목록을 여기 못박아 **낱말 단위로 대 본다**.
    """
    missed = [w for w in _SUPERSEDED_THING_WORDS
              if not (CARRIER_BANNED.fullmatch(w)
                      and CARRIER_BANNED.fullmatch(w + "s"))]
    assert not missed, (
        "옛 목록이 막던 낱말을 새 패턴이 못 잡는다 — 그만큼 구멍이다: "
        f"{missed}")


def test_place_parts_are_not_carriers():
    """★경계 판정 — 「가게 앞면」류는 **글자가 아니라 부위**다.

    Codex 가 `storefront` 의 포함 여부를 물었다. 판정은 **제외**이고
    근거는 둘이다.

    ① 이 패턴이 재는 축은 **글자를 이고 있는 물건의 이름**이다.
       `storefront`/`shopfront` 는 건물의 부위를 가리킨다 — 거기에 글자가
       있을 수도 없을 수도 있고, 그 말 자체는 글자를 그리라는 암시가
       아니다. 「캐노피·볼라드」와 같은 부류로 쓰인다.

    ② 실제 쓰임이 **글자 얘기가 아니다.** 활성 seed 팩에서
       "a shop without its shopfront" 는 「그 유형에 원래 있는 부속이
       빠졌다고 결함이라 하지 마라」는 규칙의 예시다. 여기서 걷어내면
       그 규칙이 설명할 재료를 잃는다.

    ★**이 래칫이 안 재는 축이 하나 있다** — 업종 이름(`shop`)이다. 두
     패턴은 둘 다 표시물 축만 본다. `shopfront` 가 업종 축에 걸리는지는
     별개 문제이고, 이 판에서 다루지 않는다. 초록을 「업종 축도
     검사했다」로 읽지 말 것.
    """
    for w in ("storefront", "storefronts", "shopfront", "shopfronts"):
        assert not CARRIER_BANNED.search(w), (
            f"{w!r} 가 걸린다 — 위 판정을 뒤집으려면 근거부터 고칠 것")
        assert not PROSE_BANNED.search(w)
