"""저작 출처 계약 — 팩 v2 (2026-08-25).

## 왜 이 계약인가

완주 판 7샷 실측: 대본 900자에 읽을 글자를 부르는 대목이 **0개**인데
6샷(86%)에 글자가 저작됐다. 팩 본문은 "대부분은 빈 목록"이라 명시했는데
정반대다. 모델이 적은 이유가 그대로 말한다 — "정비소임을 명확히 보여주는".
장면이 부른 것이 아니라 **장소 유형에서 끌어낸 것**이다.

「이 샷이 읽을 면을 보여주는가」는 언제나 「예」가 나오는 물음이다. 그래서
물음을 바꾸는 대신 **출처를 계약으로 요구**한다 — 모델이 스스로 분류한
값을 코드가 거른다(글자 비교가 아니다).

## 잠그는 것

  ① 기본 selector 는 v1 — 켜기 전까지 프로덕션 byte-identical.
  ② v1 계약은 그대로 — 옛 팩이 옛 schema 로 계속 돈다.
  ③ v2 는 `source` 를 필수로 받고 면(`surface_native`)은 **안 받는다**.
  ④ 추론(`inferred`)은 코드가 버린다. 모르는 값·빈 값도 버린다.
  ⑤ ★상한은 **거른 뒤에** 적용한다 — 먼저 자르면 근거 있는 항목이
    추론 항목에 밀려 사라진다.
"""
from __future__ import annotations

from typing import Any, Dict, List

import pytest

import app.modules.pipeline.signage_author as sa


def _wire(monkeypatch, items: List[Dict[str, Any]]):
    """모델 응답을 고정하고 나간 schema 를 잡아 둔다."""
    import app.modules.llm.llm_client as llm_client

    seen: Dict[str, Any] = {}

    def fake(tag, sys_p, parts, schema, **kw):
        seen["schema"] = schema
        seen["sys"] = sys_p
        seen["parts"] = parts
        return {"inscriptions": items}

    monkeypatch.setattr(llm_client, "call_structured", fake)
    return seen


def _author_out(monkeypatch, items, **kw):
    """전체 반환 — `{"inscriptions": [...], "dropped": [...]}`."""
    _wire(monkeypatch, items)
    kw.setdefault("shot_text", "s")
    kw.setdefault("place_text", "p")
    kw.setdefault("world_facts_block", "w")
    return sa.author_inscriptions(step_tag="t", **kw)


def _author(monkeypatch, items, **kw):
    """남은 목록만 — 거르기를 재는 시험들이 쓴다."""
    return _author_out(monkeypatch, items, **kw)["inscriptions"]


# ── ① 기본은 v1 ─────────────────────────────────────────────────────

def test_default_selector_is_v3():
    """2026-08-27: v1 → **v3 로 한 번에** 올렸다.

    v2 승격을 하루 전에 올렸다가 되돌린 것은 팩 문자열이 스텝 지문에
    들어가 **승격 한 번 + v3 에서 또** 재저작하기 때문이었다. v3 로 바로
    가면 한 번만 친다.
    """
    assert sa.SIGNAGE_PACK_VERSION == "3"
    assert sa.resolve_signage_pack() == "3.202608270320"
    assert sa.pack_has_source_contract() is True
    assert sa.pack_has_quote_contract() is True


def test_old_packs_stay_reachable():
    """옛 팩을 지우지 않는다 — 지문이 옛 값인 완주본을 읽을 수 있어야 한다."""
    assert sa.resolve_signage_pack("1") == "1.202608141700"
    assert sa.pack_has_source_contract("1") is False
    assert sa.pack_has_quote_contract("1") is False
    assert sa.pack_has_source_contract("2") is True
    assert sa.pack_has_quote_contract("2") is False


def test_v2_pack_is_published_and_resolvable():
    assert sa.resolve_signage_pack("2") == "2.202608251730"


def test_unknown_selector_still_raises():
    with pytest.raises(ValueError):
        sa.resolve_signage_pack("99")


# ── ②③ schema 가 팩으로 갈린다 ──────────────────────────────────────

def test_v1_schema_keeps_the_old_contract():
    props = sa.build_author_schema("1")["properties"]["inscriptions"]["items"]
    assert set(props["required"]) == {
        "surface_native", "text_native", "reason_ko"}
    assert "source" not in props["properties"]


def test_v2_schema_requires_source_and_drops_the_surface():
    props = sa.build_author_schema("2")["properties"]["inscriptions"]["items"]
    assert set(props["required"]) == {"text_native", "source", "reason_ko"}
    # 면을 코드가 정하지 않는다 — 통칭이 필요해지는 자리가 여기였다.
    assert "surface_native" not in props["properties"]
    assert props["properties"]["source"]["enum"] == list(sa.SOURCE_VALUES)


def test_v2_call_sends_the_v2_schema(monkeypatch):
    seen = _wire(monkeypatch, [])
    sa.author_inscriptions(step_tag="t", shot_text="s", place_text="p",
                           world_facts_block="w", pack_selector="2")
    items = seen["schema"]["properties"]["inscriptions"]["items"]
    assert "source" in items["properties"]


# ── ④ 추론은 버린다 ─────────────────────────────────────────────────

def test_v1_does_not_filter_anything(monkeypatch):
    """옛 계약에는 출처 칸이 없다 — 거를 근거가 없으므로 그대로 둔다.

    ★selector 를 **명시**한다. 기본값이 v2 로 올라간 뒤에도 이 시험은
     옛 팩의 계약을 재는 것이라, 기본값을 따라가면 재는 대상이 바뀐다.
    """
    got = _author(monkeypatch, [
        {"surface_native": "면", "text_native": "가", "reason_ko": "ㄱ"}],
        pack_selector="1")
    assert len(got) == 1


@pytest.mark.parametrize("bad", [
    {"source": "inferred"},          # 추론 — 이번 판의 표적
    {"source": "guessed"},           # 모르는 값
    {"source": ""},                  # 빈 값
    {},                              # 칸 자체가 없다
])
def test_v2_drops_entries_that_are_not_grounded(monkeypatch, bad):
    got = _author(monkeypatch, [
        {"text_native": "가", "reason_ko": "ㄱ", **bad}],
        pack_selector="2")
    assert got == []


@pytest.mark.parametrize("src", ["scene_text", "world_facts"])
def test_v2_keeps_grounded_entries(monkeypatch, src):
    got = _author(monkeypatch, [
        {"text_native": "가", "reason_ko": "ㄱ", "source": src}],
        pack_selector="2")
    assert len(got) == 1 and got[0]["source"] == src


# ── ⑤ 상한은 거른 뒤에 ──────────────────────────────────────────────

def test_cap_applies_after_filtering_so_grounded_entries_survive(monkeypatch):
    """★먼저 자르면 근거 있는 항목이 추론 항목에 밀려 사라진다.

    상한이 3 인데 모델이 추론 3개를 앞세우고 근거 있는 것을 뒤에 두면,
    `items[:3]` 을 먼저 하는 순서에서는 남는 것이 **0건**이 된다 —
    걸러야 할 것만 남기고 살려야 할 것을 버리는 정확히 반대 결과다.
    """
    items = [{"text_native": f"버릴{i}", "reason_ko": "ㄱ",
              "source": "inferred"} for i in range(sa.MAX_INSCRIPTIONS)]
    items.append({"text_native": "살릴것", "reason_ko": "ㄱ",
                  "source": "scene_text"})
    got = _author(monkeypatch, items, pack_selector="2")
    assert [x["text_native"] for x in got] == ["살릴것"]


def test_cap_still_holds_when_everything_is_grounded(monkeypatch):
    items = [{"text_native": f"가{i}", "reason_ko": "ㄱ",
              "source": "scene_text"} for i in range(sa.MAX_INSCRIPTIONS + 2)]
    got = _author(monkeypatch, items, pack_selector="2")
    assert len(got) == sa.MAX_INSCRIPTIONS


# ── 팩 신원 ─────────────────────────────────────────────────────────

def test_pack_content_hash_differs_between_versions():
    """팩 해시가 캐시 신원에 들어간다 — 갈리지 않으면 v2 가 안 실린다."""
    assert (sa.signage_pack_content_hash("1")
            != sa.signage_pack_content_hash("2"))


def test_v2_pack_body_names_no_surface_types():
    """★통칭 이행 — 팩 본문이 글자를 지니는 면을 열거하지 않는다.

    v1 은 면을 목록으로 나열했고(그것이 통칭이 되돌아온 자리다),
    래칫은 낱말 둘만 봐서 한 번도 걸지 못했다. 여기서는 나간 문안
    자체를 본다.
    """
    from app.modules.prompt_loader import load_prompt

    body = load_prompt(
        "signage_author", "author_sys",
        version=sa.resolve_signage_pack("2")).lower()
    for word in ("signage", "signboard", "placard", "storefront",
                 "banner", "marquee"):
        assert word not in body, f"팩 본문에 면 통칭이 있다: {word}"


# ── v3 — 인용을 코드가 대조한다 ──────────────────────────────────────
#
# v2 는 출처를 열거값으로만 받는다. 그것만으로는 「그렇게 분류했다」까지만
# 알 수 있고 맞는지는 아무도 안 본다 — v2 를 낸 판이 반례를 스스로 적어
# 두었다(읽는 행위만 지목한 입력에서 없는 문안을 지어내고 `scene_text` 라
# 신고). v3 는 **원문 조각**을 함께 받아 주어진 글에 있는지 본다.

def test_v3_schema_requires_the_quote():
    props = sa.build_author_schema("3")["properties"]["inscriptions"]["items"]
    assert "source_quote" in props["properties"]
    assert "source_quote" in props["required"]
    # ★`minLength` 를 걸지 않는다 — `inferred` 는 빈 문자열이 정답이고,
    #  길이를 요구하면 모델이 채우려고 지어낸다.
    assert "minLength" not in props["properties"]["source_quote"]


def test_v2_schema_has_no_quote_field():
    props = sa.build_author_schema("2")["properties"]["inscriptions"]["items"]
    assert "source_quote" not in props["properties"]


def test_v3_keeps_an_entry_whose_words_are_inside_the_quote(monkeypatch):
    """★인용이 입력에 있는 것만으로는 모자라다 — **그 글자 자체**가
     인용 안에 있어야 읽을 글자로 나간다.
    """
    got = _author(
        monkeypatch,
        [{"text_native": "금일 휴업", "source": "scene_text_quoted",
          "source_quote": "문에 「금일 휴업」이라 적힌 종이",
          "reason_ko": "ㄱ"}],
        shot_text="남자가 문에 「금일 휴업」이라 적힌 종이를 읽는다.",
        pack_selector="3")
    assert len(got) == 1


def test_v3_drops_a_word_that_the_quote_does_not_contain(monkeypatch):
    """★이 판의 표적 — v2 가 "reads the notice" 에서 "공고" 를 냈다.

    인용은 입력에 있지만 저작 문안은 거기 없다. 옮기거나 풀어 쓴 것이다.
    """
    out = _author_out(
        monkeypatch,
        [{"text_native": "안내", "source": "scene_text_quoted",
          "source_quote": "notice", "reason_ko": "ㄱ"}],
        shot_text="A man reads the notice pinned to it.",
        pack_selector="3")
    assert out["inscriptions"] == []
    assert [d["why"] for d in out["dropped"]] == [sa.DROP_TEXT_NOT_IN_QUOTE]


def test_v3_keeps_implied_as_a_cue_not_as_readable_text(monkeypatch):
    """읽을 것이 있다는 신호는 **버리지도 읽히게 하지도** 않는다.

    읽을 글자로 내보내면 발명이고, 통째로 버리면 백지 물체가 된다.
    """
    out = _author_out(
        monkeypatch,
        [{"text_native": "안내", "source": "scene_text_implied",
          "source_quote": "reads the notice", "reason_ko": "ㄱ"}],
        shot_text="A man reads the notice pinned to it.",
        pack_selector="3")
    assert out["inscriptions"] == [], "신호가 읽을 글자로 나갔다"
    assert out["dropped"] == [], "신호를 버렸다 — 물체까지 사라진다"
    assert len(out["cues"]) == 1
    assert out["cues"][0]["source"] == "scene_text_implied"
    # ★**발명 문안을 신호에 안 남긴다** — schema 가 `text_native` 를
    #  필수로 받으므로 모델은 「모른다」면서도 무언가를 적는다. 그 글자를
    #  두면 다음 판이 승인 문안으로 오독한다.
    assert out["cues"][0]["text_native"] == "", (
        "신호에 발명 문안이 남았다")
    assert out["cues"][0]["source_quote"], "근거 인용은 남아야 한다"


@pytest.mark.parametrize("quote,why", [
    ("", sa.DROP_EMPTY_QUOTE),
    ("입력에 없는 말", sa.DROP_UNGROUNDED_QUOTE),
])
def test_implied_still_has_to_show_its_grounding(monkeypatch, quote, why):
    """★신호도 근거를 대야 한다.

    종전에는 인용 검사 앞에서 빠져나가, 빈 인용이나 입력에 없는 인용도
    「근거 있는 신호」로 records 에 남았다 (2026-08-27 Codex BLOCK).
    """
    out = _author_out(
        monkeypatch,
        [{"text_native": "안내", "source": "scene_text_implied",
          "source_quote": quote, "reason_ko": "ㄱ"}],
        shot_text="A man reads the notice pinned to it.",
        pack_selector="3")
    assert out["cues"] == [], "근거 없는 신호가 남았다"
    assert [d["why"] for d in out["dropped"]] == [why]


@pytest.mark.parametrize("quote,why", [
    # ★이 판의 표적 — 신고는 했는데 그 말이 입력에 없다.
    ("금일 휴업", sa.DROP_UNGROUNDED_QUOTE),
    ("", sa.DROP_EMPTY_QUOTE),
    ("   ", sa.DROP_EMPTY_QUOTE),
])
def test_v3_drops_an_entry_whose_quote_is_not_in_the_given_text(
        monkeypatch, quote, why):
    out = _author_out(
        monkeypatch,
        [{"text_native": "가", "source": "scene_text_quoted",
          "source_quote": quote, "reason_ko": "ㄱ"}],
        shot_text="남자가 문에 붙은 종이를 읽는다.",
        pack_selector="3")
    assert out["inscriptions"] == []
    assert [d["why"] for d in out["dropped"]] == [why]


def test_v3_still_drops_inferred_before_looking_at_the_quote(monkeypatch):
    """추론은 인용이 맞아도 버린다 — 출처 검사가 먼저다."""
    out = _author_out(
        monkeypatch,
        [{"text_native": "가", "source": "inferred",
          "source_quote": "문에 붙은 종이", "reason_ko": "ㄱ"}],
        shot_text="남자가 문에 붙은 종이를 읽는다.",
        pack_selector="3")
    assert out["inscriptions"] == []
    assert out["dropped"][0]["why"] == sa.DROP_UNGROUNDED_SOURCE


def test_v3_looks_only_where_the_declared_source_points(monkeypatch):
    """★셋을 뭉쳐 찾으면 **신고한 출처와 다른 데서 인용해도 통과**한다.

    `world_facts` 라 해 놓고 샷 텍스트를 인용하는 식이다. 그러면 출처 칸이
    다시 자가신고로 돌아간다 (2026-08-27).

    ★**LOCATION 은 맥락이지 허용 출처가 아니다** (감사 보고서 v3 계약).
     장소 글은 「어떤 종류의 곳인가」를 말한다 — 거기서 인용해 저작하면
     「이런 곳이면 이런 글자가 있겠지」를 정당화하게 되고, 그것이 v2 가
     막으려던 바로 그 추론이다.
    """
    kw = {"shot_text": "샷에 있는 말", "place_text": "장소에 있는 말",
          "world_facts_block": "세계관에 있는 말"}

    def _one(source, quote):
        return _author(
            monkeypatch,
            [{"text_native": quote, "source": source,
              "source_quote": quote, "reason_ko": "ㄱ"}],
            pack_selector="3", **kw)

    # scene_text 는 **샷 글만** 근거로 인정한다.
    assert len(_one("scene_text_quoted", "샷에 있는 말")) == 1
    # ★장소 글은 맥락이지 출처가 아니다 — 「이런 곳이면 이런 글자가
    #  있겠지」를 정당화하는 통로가 된다.
    assert _one("scene_text_quoted", "장소에 있는 말") == []
    assert _one("scene_text_quoted", "세계관에 있는 말") == []

    # world_facts 는 세계관만.
    assert len(_one("world_facts", "세계관에 있는 말")) == 1
    assert _one("world_facts", "샷에 있는 말") == []
    assert _one("world_facts", "장소에 있는 말") == []


def test_haystack_for_source_is_a_pure_lookup():
    """어느 글을 볼지는 **출처 값 하나**가 정한다 — 글자 뜻을 안 본다."""
    kw = {"shot_text": "S", "place_text": "P", "world_facts_block": "W"}
    assert sa.haystack_for_source("world_facts", **kw) == "W"
    assert sa.haystack_for_source("scene_text_quoted", **kw) == "S"
    # 모르는 값·추론은 볼 글이 없다 — 어차피 출처 검사에서 먼저 걸린다.
    assert sa.haystack_for_source("inferred", **kw) == ""
    assert sa.haystack_for_source("guessed", **kw) == ""


# ── 정규화는 최소만 ──────────────────────────────────────────────────

@pytest.mark.parametrize("quote", [
    "  문에 붙은 종이  ",        # 앞뒤 공백
    "문에  붙은   종이",         # 연속 공백
    "「문에 붙은 종이」",         # 인용부호
    '"문에 붙은 종이"',
])
def test_quote_match_forgives_only_spacing_and_quote_marks(quote):
    assert sa.quote_is_grounded(
        quote, haystack="남자가 문에 붙은 종이를 읽는다.")


@pytest.mark.parametrize("quote", [
    "문에 붙은 공고",            # 한 낱말이 다르다
    "종이 붙은 문에",            # 순서가 다르다
    "문에붙은종이",              # 띄어쓰기를 없앤 것은 다른 문자열이다
])
def test_quote_match_does_not_stretch_further_than_that(quote):
    """느슨하게 만들면 검사가 무력해지고 그때는 v2 와 같아진다."""
    assert not sa.quote_is_grounded(
        quote, haystack="남자가 문에 붙은 종이를 읽는다.")


# ── 버린 것이 값으로 남는가 ─────────────────────────────────────────

def test_dropped_entries_carry_what_is_needed_to_count_them(monkeypatch):
    out = _author_out(
        monkeypatch,
        [{"text_native": "지어낸 문안", "source": "scene_text_quoted",
          "source_quote": "없는 말", "reason_ko": "ㄱ"},
         {"text_native": "추론", "source": "inferred",
          "source_quote": "", "reason_ko": "ㄴ"}],
        shot_text="남자가 문에 붙은 종이를 읽는다.",
        pack_selector="3")

    assert out["inscriptions"] == []
    assert len(out["dropped"]) == 2
    for d in out["dropped"]:
        assert set(d) == {"text_native", "source", "source_quote", "why"}
    assert {d["why"] for d in out["dropped"]} == {
        sa.DROP_UNGROUNDED_QUOTE, sa.DROP_UNGROUNDED_SOURCE}


def test_v2_path_reports_no_quote_reasons(monkeypatch):
    """옛 팩에서 인용 검사를 걸면 전량이 사라진다 — 안 건다."""
    out = _author_out(
        monkeypatch,
        [{"text_native": "가", "source": "scene_text", "reason_ko": "ㄱ"}],
        pack_selector="2")
    assert len(out["inscriptions"]) == 1
    assert out["dropped"] == []


def test_v3_schema_offers_the_four_source_values():
    """v3 는 「읽을 것이 있다」와 「글자가 적혀 있다」를 갈라 받는다."""
    props = sa.build_author_schema("3")["properties"]["inscriptions"]["items"]
    assert set(props["properties"]["source"]["enum"]) == {
        "scene_text_quoted", "scene_text_implied", "world_facts", "inferred"}


def test_v2_schema_keeps_the_three_old_values():
    props = sa.build_author_schema("2")["properties"]["inscriptions"]["items"]
    assert set(props["properties"]["source"]["enum"]) == {
        "scene_text", "world_facts", "inferred"}


def test_world_facts_also_needs_the_words_inside_the_quote(monkeypatch):
    """세계관 근거도 같은 규칙이다 — 인용에 그 글자가 있어야 한다."""
    ok = _author(
        monkeypatch,
        [{"text_native": "정비", "source": "world_facts",
          "source_quote": "간판에 정비 라고 쓴다", "reason_ko": "ㄱ"}],
        world_facts_block="이 시대 이 동네는 간판에 정비 라고 쓴다",
        pack_selector="3")
    assert len(ok) == 1

    out = _author_out(
        monkeypatch,
        [{"text_native": "수리", "source": "world_facts",
          "source_quote": "간판에 정비 라고 쓴다", "reason_ko": "ㄱ"}],
        world_facts_block="이 시대 이 동네는 간판에 정비 라고 쓴다",
        pack_selector="3")
    assert out["inscriptions"] == []
    assert out["dropped"][0]["why"] == sa.DROP_TEXT_NOT_IN_QUOTE
