"""signage_author — 장면이 부르는 실물 표기의 원어 문안 저작 (2026-08-14 #119②).

카나리아 S3 실측: 표기 정책 v20 은 "장면이 부르는 원어 표기 허용 + 발명
금지"를 실었는데 팻말이 **뭐라고 쓰여 있는지**는 아무도 공급하지 않아
모델이 규정 준수로 백지 팻말을 그렸다. 허용은 값이 아니다 — 값(문안)을
저작해 공급해야 그릴 재료가 생긴다.

계약 요점:
- 판별+저작 = 1콜(gemini-flash). "대부분의 샷은 빈 목록"이 팩 본문에
  명문 — 당연한 것은 저작하지 않는다.
- 문안은 무조건 원어(세계관 사실이 주는 장소·시대의 언어) — 팩이 소유.
  코드에는 표기 대상 어휘가 없다(통칭 하드코딩 금지).
- 산출은 호출측이 records 사이드카에 캐시·영속한다(지문 = 샷 텍스트+
  장소+세계관+정책+팩) — 기록 없는 유료 저작은 없다.
- 플래그 `signage_author_enabled` 기본 OFF = byte-identical.
"""
from __future__ import annotations

import logging
from typing import Any, Dict, List, Optional

from app.modules.prompt_loader import load_prompt, pack_dir_content_hash

logger = logging.getLogger(__name__)

_MODULE = "signage_author"

PACK_VERSION_MAP = {
    "1": "1.202608141700",
    # v2 (2026-08-25): **출처를 계약으로 요구한다.** 완주 판 7샷 실측에서
    # 대본이 글자를 한 번도 부르지 않았는데 6샷(86%)에 저작이 나갔다 —
    # 「이 샷이 읽을 면을 보여주는가」가 언제나 「예」인 물음이었기 때문이다.
    # 물음을 금지로 조이는 대신 **어디서 왔는지를 값으로 받고** 추론을
    # 코드가 버린다. 면(surface)도 안 받는다 — 면을 정하려니 통칭이
    # 필요했고, 샷마다 면이 갈려 같은 장소가 다른 곳처럼 보였다.
    "2": "2.202608251730",
    # v3 (2026-08-27): **자가신고를 계약으로 바꾼다.** v2 는 출처를 열거값
    # 으로 받는데, 그것만으로는 「그렇게 분류했다」까지만 알 수 있고 맞는지는
    # 아무도 안 본다 — v2 를 낸 판이 반례를 스스로 적어 두었다(읽는 행위만
    # 지목한 입력에서 없는 문안을 지어내고 `scene_text` 라 신고). v2 팩은
    # 이유 문장 **안에** 인용을 요구했는데 모델이 풀어 쓰기만 했다. 이제
    # `source_quote` 를 별도 칸으로 받고 **코드가 입력에 있는지 본다.**
    "3": "3.202608270320",
}
# ★2026-08-27: v1 → v3 로 **한 번에** 올린다. v2 승격을 하루 전에 올렸다가
#  되돌린 것은 팩 문자열이 스텝 지문에 들어가 **승격 한 번 + v3 에서 또**
#  재저작하기 때문이었다. v3 로 바로 가면 한 번만 친다.
#  근거: v1 이 완주 판 7샷에서 6샷(86%)에 글자를 지어냈다. v2 는 같은
#  입력에서 0% 였고(모델이 낸 7건을 전부 스스로 `inferred` 로 분류),
#  v3 는 거기에 **인용 대조**를 더해 거짓 신고까지 막는다.
SIGNAGE_PACK_VERSION = "3"
# 정책 문자열 — ON 시 지문 기여. 저작 계약이 바뀌면 버전을 올린다.
# v3 = 출처 열거값 + **인용을 코드가 대조**하고, 버린 항목을 records 에.
SIGNAGE_POLICY_VERSION = "signage_author_v3_flash"
# 한 샷의 문안 상한 — 프레임을 지배하는 것만 (넘치면 앞에서 자른다).
MAX_INSCRIPTIONS = 3

# ── 출처 = 데이터 계약 (v2~) ─────────────────────────────────────────
# 모델이 **스스로 분류한** 값을 코드가 거른다. 글자를 뒤져 뜻을 재는 것이
# 아니라 열거값 하나를 보는 것이라 substring 판단이 아니다.
SOURCE_VALUES = ("scene_text", "world_facts", "inferred")
# 이 둘만 남긴다 — 「그럴듯하다」는 근거가 아니다.
GROUNDED_SOURCES = frozenset(("scene_text", "world_facts"))

# ── 읽을 글자와 「읽을 것이 있다」는 다르다 (v3~) ────────────────────
# 2026-08-27 Codex 판정. v2 실측이 이미 확정한 실패다 — 샷이 "reads the
# notice pinned to it" 이라고만 했는데 모델이 "공고" 를 내고 출처를
# `scene_text` 라 신고했다. 그 글자는 입력 어디에도 없다.
#
# ★**백지 물체와 임의 문안은 양자택일이 아니다.** 물체가 있다는 것과
#  거기 뭐라고 쓰였는지는 다른 값이고, 후자를 모르면 **안 읽히게 찍는다**.
#  (`no_text` 팩에 이미 그 기법이 있다 — 손이 가로지르거나 비스듬한
#   각도나 얕은 심도.)
SOURCE_VALUES_V3 = (
    "scene_text_quoted",    # 샷이 글자 자체를 적었다 — 문안이 인용 안에 있다
    "scene_text_implied",   # 읽을 것이 있다고만 했다 — 문안은 아무도 안 줬다
    "world_facts",          # 세계관이 이 장소·시대에 그 글자를 못박았다
    "inferred",             # 그럴듯함
)
# 읽을 문안으로 나갈 수 있는 것 — **인용 안에 그 글자가 있어야** 한다.
READABLE_SOURCES_V3 = frozenset(("scene_text_quoted", "world_facts"))
# 물체는 있으나 문안은 없다 — records 에 남기되 읽을 글자로는 안 나간다.
CUE_SOURCES_V3 = frozenset(("scene_text_implied",))

AUTHOR_MODEL = "gemini-flash"  # 보조 분업 — 판별·짧은 저작


def pack_has_source_contract(
        selector: str = SIGNAGE_PACK_VERSION) -> bool:
    """이 팩이 출처 칸을 내는가 — schema 와 거르기가 같이 갈린다.

    v1 에는 출처 칸이 아예 없다. 거기서 거르려 들면 전량이 사라진다
    (없는 칸은 언제나 「근거 없음」이므로).
    """
    return selector != "1"


def pack_has_quote_contract(
        selector: str = SIGNAGE_PACK_VERSION) -> bool:
    """이 팩이 **인용 칸**을 내는가 (v3~).

    v2 까지는 출처를 열거값으로만 받는다 — 자가신고다. v3 는 그 신고를
    뒷받침하는 원문 조각을 함께 받고, 코드가 **주어진 글에 실제로 있는지**
    본다. 옛 팩에서 이 검사를 걸면 인용 칸이 없어 전량이 사라진다.
    """
    return selector not in ("1", "2")


# ── 인용 대조 ────────────────────────────────────────────────────────
# ★이것은 **의미 판단이 아니다.** 글자가 무슨 뜻인지 재는 것이 아니라
#  「이 문자열이 저 문자열 안에 있나」만 본다 — 위 `source` 열거값 검사와
#  같은 부류다. 의미는 여전히 모델이 정한다.
#
# 정규화는 **최소만** 한다. 느슨하게 만들면 검사가 무력해지고 그때는 v2 와
# 같아진다.
_QUOTE_MARKS = "\"'“”‘’「」『』"


def _normalize_for_quote_match(text: str) -> str:
    """앞뒤 공백·연속 공백·인용부호만 고른다 — 그 이상은 안 한다."""
    stripped = "".join(
        " " if ch.isspace() else ch
        for ch in text if ch not in _QUOTE_MARKS)
    return " ".join(stripped.split())


def quote_is_grounded(quote: str, *, haystack: str) -> bool:
    """인용이 주어진 글에 실제로 있는가.

    빈 인용은 근거가 아니다 — `inferred` 가 아닌 항목이 빈 인용을 내면
    그것은 신고만 하고 근거를 못 댄 것이다.
    """
    needle = _normalize_for_quote_match(quote or "")
    if not needle:
        return False
    return needle in _normalize_for_quote_match(haystack or "")


def text_is_inside_quote(text_native: str, *, quote: str) -> bool:
    """저작 문안이 **인용 안에** 있는가 — 읽을 글자의 마지막 관문.

    ★인용이 입력에 있다는 것만으로는 모자란다. "notice" 를 인용하고
     "안내" 를 저작하면 인용은 맞지만 **글자는 발명**이다 — v2 실측이
     확정한 실패이고, 이 검사 없이 낸 v3 첫 판이 그것을 그대로 재현했다
     (2026-08-27 Codex 판정).

    옮기거나 풀어 쓴 것은 여기서 걸린다. 그런 항목은 「읽을 것이 있다」는
    신호(`scene_text_implied`)이지 읽을 글자가 아니다.
    """
    needle = _normalize_for_quote_match(text_native or "")
    if not needle:
        return False
    return needle in _normalize_for_quote_match(quote or "")


# 버린 이유 — 코드가 정하는 열거값. records 에 남아 나중에 세어진다.
DROP_UNGROUNDED_SOURCE = "not_grounded_source"
DROP_EMPTY_QUOTE = "empty_quote"
DROP_UNGROUNDED_QUOTE = "ungrounded_quote"
DROP_TEXT_NOT_IN_QUOTE = "text_not_in_quote"


def resolve_signage_pack(selector: str = SIGNAGE_PACK_VERSION) -> str:
    try:
        return PACK_VERSION_MAP[selector]
    except KeyError:
        raise ValueError(
            f"unknown signage_author pack selector {selector!r} "
            f"(known: {sorted(PACK_VERSION_MAP)})")


def signage_pack_content_hash(
        selector: str = SIGNAGE_PACK_VERSION) -> str:
    return pack_dir_content_hash(_MODULE, resolve_signage_pack(selector))


def build_author_schema(
        selector: str = SIGNAGE_PACK_VERSION) -> Dict[str, Any]:
    """팩이 schema 를 정한다 — 옛 팩은 옛 계약 그대로 돈다.

    v2 는 `source` 를 필수로 받고 면(`surface_native`)은 **안 받는다**.
    면을 코드가 정하려니 통칭이 필요해졌고, 샷마다 면이 갈려 같은 장소가
    다른 곳처럼 보였다(완주 판: 같은 글자가 유리문과 벽면에 따로).
    """
    if pack_has_source_contract(selector):
        item_props: Dict[str, Any] = {
            "text_native": {"type": "string", "minLength": 1},
            "source": {"type": "string", "enum": list(
                SOURCE_VALUES_V3 if pack_has_quote_contract(selector)
                else SOURCE_VALUES)},
            "reason_ko": {"type": "string", "minLength": 2},
        }
        required = ["text_native", "source", "reason_ko"]
        if pack_has_quote_contract(selector):
            # v3 — 신고를 뒷받침하는 원문 조각. `inferred` 면 빈 문자열이라
            # `minLength` 를 걸지 않는다(걸면 모델이 채우려고 지어낸다).
            item_props["source_quote"] = {"type": "string"}
            required.append("source_quote")
    else:
        item_props = {
            "surface_native": {"type": "string", "minLength": 2},
            "text_native": {"type": "string", "minLength": 1},
            "reason_ko": {"type": "string", "minLength": 2},
        }
        required = ["surface_native", "text_native", "reason_ko"]
    return {
        "type": "object",
        "properties": {
            "inscriptions": {
                "type": "array", "maxItems": 4,
                "items": {
                    "type": "object",
                    "properties": item_props,
                    "required": required,
                    "additionalProperties": False,
                },
            },
        },
        "required": ["inscriptions"],
        "additionalProperties": False,
    }


def author_inscriptions(
    *,
    step_tag: str,
    shot_text: str,
    place_text: str,
    world_facts_block: str,
    project_config: Optional[Dict[str, Any]] = None,
    opik_metadata: Optional[Dict[str, Any]] = None,
    pack_selector: str = SIGNAGE_PACK_VERSION,
) -> Dict[str, Any]:
    """샷 하나의 표기 필요 판별 + 원어 문안 저작 — 1콜, 빈 목록이 기본.

    반환 `{"inscriptions": […], "cues": […], "dropped": […]}`.

    - `inscriptions` — **읽을 글자**. 인용 안에 그 글자가 있는 것만.
    - `cues` — 「읽을 것이 있다」는 신호. 물체는 있으나 문안은 아무도
      안 줬다. 읽을 글자로 안 나간다.
    - `dropped` — 버린 것과 그 이유.

    ★버린 것을 **값으로** 돌려준다(2026-08-27). 종전에는 리스트만 돌려주고
     버린 것은 로그로 흘러가, 나중에 「몇 건이 왜 버려졌나」를 셀 수 없었다.
     호출부가 records 에 함께 적는다.
    """
    from app.modules.llm.llm_client import call_structured

    author_sys = load_prompt(
        _MODULE, "author_sys",
        version=resolve_signage_pack(pack_selector)).strip()
    parts = [{
        "type": "text",
        "text": ("SHOT (authoritative):\n" + shot_text.strip()
                 + "\n\nLOCATION:\n" + (place_text or "").strip()
                 + "\n\nWORLD FACTS (place, era and language —"
                 " authoritative):\n" + (world_facts_block or "").strip()),
    }]
    pc = {**(project_config or {}), step_tag: {"model": AUTHOR_MODEL}}
    data = call_structured(
        step_tag, author_sys, parts, build_author_schema(pack_selector),
        project_config=pc, schema_name=step_tag,
        opik_metadata=opik_metadata,
    )
    items = [x for x in (data.get("inscriptions") or [])
             if isinstance(x, dict)]
    kept, cues, dropped = _filter_grounded(
        items, shot_text=shot_text, place_text=place_text,
        world_facts_block=world_facts_block, pack_selector=pack_selector)
    if dropped:
        logger.info(
            "signage_author[%s]: 근거 없는 저작 %d/%d 건을 버린다 — %s",
            step_tag, len(dropped), len(items),
            ", ".join(f"{d['text_native'][:20]}({d['why']})"
                      for d in dropped)[:300])
    if cues:
        logger.info(
            "signage_author[%s]: 읽을 것이 있다는 신호 %d건 — 문안은 "
            "안 나간다(물체는 두되 안 읽히게)", step_tag, len(cues))
    return {"inscriptions": kept[:MAX_INSCRIPTIONS],
            "cues": cues, "dropped": dropped}


def haystack_for_source(
    source: str, *, shot_text: str, place_text: str, world_facts_block: str,
) -> str:
    """그 출처가 **가리키는 글**만 돌려준다.

    ★셋을 뭉쳐 찾으면 신고한 출처와 다른 데서 인용해도 통과한다 —
     `world_facts` 라 해 놓고 샷 텍스트를 인용하는 식이다. 그러면 출처
     칸이 다시 자가신고로 돌아간다(2026-08-27).

    ★**LOCATION 은 맥락이지 허용 출처가 아니다** (감사 보고서 v3 계약).
     장소 글은 「어떤 종류의 곳인가」를 말한다 — 거기서 인용해 저작하면
     「이런 곳이면 이런 글자가 있겠지」를 정당화하게 되고, 그것이 v2 가
     막으려던 바로 그 추론이다. 처음 이 함수를 쓸 때 팩 문안의 "the shot
     **or location** text" 를 따라 장소를 넣었는데, 그 문안 자체가 v2 에서
     물려받은 것이라 계약과 어긋났다(2026-08-27 Codex BLOCK). 팩과 코드를
     함께 고쳤다.
    """
    if source == "world_facts":
        return world_facts_block or ""
    if source in ("scene_text", "scene_text_quoted", "scene_text_implied"):
        return shot_text or ""
    return ""


def _filter_grounded(
    items: List[Dict[str, Any]], *,
    shot_text: str, place_text: str, world_facts_block: str,
    pack_selector: str,
) -> "tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]":
    """세 갈래로 가른다 — **읽을 글자 / 신호 / 버림**.

    ★거르기가 **상한보다 먼저**다. 먼저 자르면 추론 항목이 앞자리를
     차지했을 때 근거 있는 항목이 밀려 사라진다 — 걸러야 할 것만 남기고
     살려야 할 것을 버리는 정확히 반대 결과가 난다.

    ★버린 것도 **값으로** 돌려준다. 종전에는 로그로만 흘러가 나중에
     「몇 건이 왜 버려졌나」를 셀 수 없었다.

    ★`scene_text_implied` 는 **버리지 않고 신호로 남긴다**(2026-08-27
     Codex 판정). 샷이 「읽을 것이 있다」고만 했으면 물체는 있는 것이고,
     뭐라고 쓰였는지만 아무도 모른다 — 읽을 글자로 내보내면 발명이고,
     통째로 버리면 백지 물체가 된다. 둘 다 피하려면 **물체는 두되 안
     읽히게** 찍어야 하고, 그 판단의 재료로 이 목록이 남는다.
    """
    if not pack_has_source_contract(pack_selector):
        # v1 에는 출처 칸이 없다 — 거를 근거가 없으므로 그대로 둔다.
        return list(items), [], []

    check_quote = pack_has_quote_contract(pack_selector)
    readable = READABLE_SOURCES_V3 if check_quote else GROUNDED_SOURCES
    kept: List[Dict[str, Any]] = []
    cues: List[Dict[str, Any]] = []
    dropped: List[Dict[str, Any]] = []

    def _row(item: Dict[str, Any], why: str = "") -> Dict[str, Any]:
        row = {
            "text_native": str(item.get("text_native") or ""),
            "source": str(item.get("source") or ""),
            "source_quote": str(item.get("source_quote") or ""),
        }
        if why:
            row["why"] = why
        return row

    for it in items:
        src = str(it.get("source") or "")

        if check_quote and src in CUE_SOURCES_V3:
            # 읽을 것이 있다는 신호 — 문안은 아무도 안 줬다.
            #
            # ★신호도 **근거를 대야 한다** (2026-08-27 Codex BLOCK).
            #  종전에는 인용 검사 앞에서 빠져나가, 빈 인용이나 입력에 없는
            #  인용도 「근거 있는 신호」로 records 에 남았다.
            quote = str(it.get("source_quote") or "")
            if not quote.strip():
                dropped.append(_row(it, DROP_EMPTY_QUOTE))
                continue
            if not quote_is_grounded(quote, haystack=haystack_for_source(
                    src, shot_text=shot_text, place_text=place_text,
                    world_facts_block=world_facts_block)):
                dropped.append(_row(it, DROP_UNGROUNDED_QUOTE))
                continue
            # ★**발명 문안을 신호에 남기지 않는다.** schema 가
            #  `text_native` 를 필수·최소 1자로 받으므로 모델은 「문안은
            #  모른다」면서도 무언가를 적는다. 그 글자를 그대로 두면 다음
            #  판이 그것을 승인 문안으로 오독한다 — 신호는 **읽을 것이
            #  있다는 사실**이지 그 글자가 아니다.
            cue = _row(it)
            cue["text_native"] = ""
            cues.append(cue)
            continue

        if src not in readable:
            dropped.append(_row(it, DROP_UNGROUNDED_SOURCE))
            continue

        if not check_quote:
            kept.append(it)
            continue

        quote = str(it.get("source_quote") or "")
        if not quote.strip():
            # 근거 있다고 신고했는데 댈 것이 없다.
            dropped.append(_row(it, DROP_EMPTY_QUOTE))
            continue
        # ★그 출처가 가리키는 글**에서만** 찾는다.
        if not quote_is_grounded(quote, haystack=haystack_for_source(
                src, shot_text=shot_text, place_text=place_text,
                world_facts_block=world_facts_block)):
            dropped.append(_row(it, DROP_UNGROUNDED_QUOTE))
            continue
        # ★인용이 입력에 있다는 것만으로는 모자란다 — **그 글자 자체**가
        #  인용 안에 있어야 한다. 옮기거나 풀어 쓴 것은 여기서 걸린다.
        if not text_is_inside_quote(
                str(it.get("text_native") or ""), quote=quote):
            dropped.append(_row(it, DROP_TEXT_NOT_IN_QUOTE))
            continue
        kept.append(it)
    return kept, cues, dropped
