"""이름은 **본문에서 온다** — 근거 구절로 결속한다 (2026-09-18).

## 무엇이 결함이었나

이름 없는 인물의 역할명을 지으면서 모델이 **본문에 없는 인종**을 붙였다.
대본에 「백인」은 한 곳(앰버)뿐인데 인물 셋이 백인이 됐고, 그 이름이 하류
전부로 퍼졌다 — 본문은 「집사로봇」인데 「백인 여성형 집사 로봇」이 됐다.

**금지문을 넣고 다시 돌렸더니 같은 판에서 11개가 나왔다.** 글로는 못 막는다.

## 어떻게 바꿨나 — 뜻이 아니라 출처

    ① `name_source_quote` 가 본문에 **글자 그대로** 있는가
    ② `name` 이 그 구절 안에 **글자 그대로** 있는가

지어낸 말은 근거 구절 안에 있을 수 없다. 이것은 인용 확인이지 의미 판단이
아니다(이 저장소의 「글자로 뜻 판단 금지」와 어긋나지 않는다).
"""
from __future__ import annotations

from app.modules.pipeline.name_provenance import (
    check_name_provenance, correction_block)

# 실제 대본에서 가져온 대목 (2026-09-18 컨트리로드)
TEXT = (
    "셰퍼드가 여인의 머리를 물어뜯자, 가죽이 벗겨지면서 내부의 금속 재질이 "
    "드러난다. 동시에 울리는 사이렌 소리~! (집사로봇이 위험에 처하면 바로 "
    "경찰 지구대로 연결되는 시스템) 황급히 집 안으로 들어가 2층 계단을 "
    "뛰어오르는 현우.  자고 있던 노인 부부, 현우를 보고는 겁먹고 얼어붙는다."
)


def test_the_invented_name_is_caught():
    """★이번 실측 그대로 — 본문은 「집사로봇」인데 이름에 「백인 여성형」을 붙였다."""
    ok, bad = check_name_provenance([{
        "name": "백인 여성형 집사 로봇",
        "name_source_quote": "집사로봇이 위험에 처하면 바로 경찰 지구대로 연결되는 시스템",
        "shot_count": 8,
    }], TEXT)
    assert not ok and len(bad) == 1
    assert "근거 구절 안에 없다" in bad[0]["_provenance_reason"]


def test_the_grounded_name_passes():
    ok, bad = check_name_provenance([{
        "name": "집사로봇",
        "name_source_quote": "집사로봇이 위험에 처하면 바로 경찰 지구대로 연결되는 시스템",
        "shot_count": 8,
    }], TEXT)
    assert len(ok) == 1 and not bad


def test_line_breaks_do_not_break_the_check():
    """시나리오 PDF 는 줄이 접혀 온다 — 공백만 다른 같은 말을 걸러선 안 된다."""
    ok, bad = check_name_provenance([{
        "name": "노인 부부",
        "name_source_quote": "자고 있던 노인\n부부, 현우를 보고는",
        "shot_count": 2,
    }], TEXT)
    assert len(ok) == 1, bad


def test_a_made_up_quote_is_caught():
    ok, bad = check_name_provenance([{
        "name": "백발의 백인 노인 남편",
        "name_source_quote": "백발의 백인 노부부가 거실에 앉아 있다",
        "shot_count": 2,
    }], TEXT)
    assert not ok
    assert "본문에 없다" in bad[0]["_provenance_reason"]


def test_missing_quote_is_caught():
    ok, bad = check_name_provenance([{"name": "누군가", "shot_count": 1}], TEXT)
    assert not ok and "비었다" in bad[0]["_provenance_reason"]


def test_correction_block_names_what_went_wrong():
    """다시 물을 때 **무엇이 왜 틀렸는지** 보여야 같은 답이 또 안 온다."""
    _ok, bad = check_name_provenance([{
        "name": "백인 여성형 집사 로봇",
        "name_source_quote": "집사로봇이 위험에 처하면",
        "shot_count": 8,
    }], TEXT)
    blk = correction_block(bad)
    assert "백인 여성형 집사 로봇" in blk
    assert "인종" in blk
    assert correction_block([]) == "", "어긋난 게 없으면 한 글자도 안 붙는다"


def test_the_lister_actually_calls_the_gate():
    """만들고 안 읽으면 소용없다 — 인물 경로가 이 문을 지나는지."""
    import inspect

    from app.modules.pipeline import entity_lister

    src = inspect.getsource(entity_lister)
    assert "check_name_provenance" in src
    assert 'entity_type == "character"' in src, "인물에만 걸어야 한다"
    assert "correction_block" in src, "짚어서 다시 묻지 않으면 같은 답이 온다"


def test_the_pack_asks_for_the_quote():
    import json

    from app.modules.prompt_loader import (PROMPTS_BASE, load_prompt,
                                           resolve_effective)

    v = resolve_effective("entity_all", "character", kind="prompt")["version"]
    schema = json.loads(
        (PROMPTS_BASE / "entity_all" / v / "character_schema.json")
        .read_text(encoding="utf-8"))
    item = schema["properties"]["characters"]["items"]
    assert "name_source_quote" in item["properties"]
    assert "name_source_quote" in item["required"], "필수가 아니면 빼먹는다"
    t = load_prompt("entity_all", "character")
    assert "name_source_quote" in t
    assert "글자 그대로" in t


def test_the_variant_suffix_is_not_treated_as_invention():
    """★실측: 「앰버 (홀로그램)」 같은 **팩이 정한 변형 표기** 19개가 전부
    어긋남으로 잡혔다. 괄호 안은 이 저장소의 약속이지 본문 인용이 아니다."""
    from app.modules.pipeline.name_provenance import base_name

    assert base_name("앰버 (홀로그램)") == "앰버"
    assert base_name("현우 (어린 시절)") == "현우"
    assert base_name("집사로봇") == "집사로봇"

    ok, bad = check_name_provenance([{
        "name": "현우 (어린 시절)",
        "name_source_quote": "2층 계단을 뛰어오르는 현우.",
        "shot_count": 3,
    }], TEXT)
    assert len(ok) == 1, bad

    # 그래도 지어낸 말은 여전히 걸린다
    _ok, bad2 = check_name_provenance([{
        "name": "백인 집사로봇 (홀로그램)",
        "name_source_quote": "집사로봇이 위험에 처하면",
        "shot_count": 1,
    }], TEXT)
    assert len(bad2) == 1


# ── 「반드시 인종을 적어라」가 **네 곳**에 있었다 ────────────────────

def test_no_pack_forces_an_ethnicity_guess():
    """★뿌리를 한 곳만 고치고 「됐다」고 할 뻔했다.

    실측(2026-09-18): 대본엔 「아시아계」가 **0회**인데 `shot_extract` 산출에
    **32회** 있었다. 엔티티 단계는 그걸 물려받은 것이라 거기만 막아 봐야
    소용이 없다. 최신 팩 전수에서 「인종·국적 + 반드시/필수」를 찾는다.
    """
    import pathlib

    from app.modules.prompt_loader import PROMPTS_BASE

    def _latest(mod: pathlib.Path) -> pathlib.Path | None:
        vs = [d for d in mod.iterdir() if d.is_dir() and not d.name.startswith(".")]
        def key(d):
            p = d.name.split(".", 1)
            try:
                return (int(p[0]), p[1] if len(p) > 1 else "")
            except ValueError:
                return (0, d.name)
        return sorted(vs, key=key)[-1] if vs else None

    hits = []
    for mod in sorted(PROMPTS_BASE.iterdir()):
        if not mod.is_dir():
            continue
        v = _latest(mod)
        if v is None:
            continue
        for f in v.glob("*.md"):
            for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
                if ("인종" in line or "국적" in line) and ("반드시" in line or "필수" in line):
                    hits.append(f"{mod.name}/{v.name}/{f.name}:{i}")
    assert not hits, (
        "근거 없이 국적·인종을 고르게 만드는 절이 아직 있다 — "
        "이것이 지어낸 인종의 뿌리다: " + ", ".join(hits))


# ── ★재질문 끝점 — 문자열 검사로는 못 잡은 자리 (Codex BLOCK 1) ──────

def test_the_retry_path_actually_runs(monkeypatch):
    """★첫 답이 어긋나면 **다시 묻고 돌아오는 것까지** 태운다.

    실측 결함: 두 번째 검사만 `fulltext` 로 두었는데 그 함수에는 그런 이름이
    없다 — 재질문이 한 번이라도 돌면 **유료 호출을 마친 뒤** NameError 로
    죽는다. 「소스에 이 문자열이 있나」 시험은 이 가지를 통과시켰다.
    """
    from app.modules.pipeline import entity_lister as el

    calls: list = []

    def _fake_call(**kw):
        calls.append(kw["user_prompt"])
        if len(calls) == 1:                      # 첫 답: 지어낸 이름
            return {"characters": [
                {"name": "백인 집사로봇", "shot_count": 2,
                 "name_source_quote": "집사로봇이 문을 연다"}]}
        return {"characters": [                  # 재답: 본문 말로 고침
            {"name": "집사로봇", "shot_count": 2,
             "name_source_quote": "집사로봇이 문을 연다"}]}

    monkeypatch.setattr(el, "call_structured", _fake_call)
    monkeypatch.setattr(el, "load_prompt", lambda *a, **k: "시스템")
    monkeypatch.setattr(el, "load_schema", lambda *a, **k: {"type": "object"})

    out = el.list_entities_from_shots(
        shots_data=[{"scene_index": 1, "shots": [
            {"shot_index": 1, "description": "집사로봇이 문을 연다"}]}],
        entity_type="character",
    )
    assert len(calls) == 2, "재질문이 안 돌았다 — 이 시험이 뜻을 잃는다"
    assert "[수정 요청]" in calls[1], "무엇이 틀렸는지 안 알려 주면 같은 답이 온다"
    assert [c["name"] for c in out] == ["집사로봇"], out


# ── ★근거로 쓴 글이 **모델 자신의 앞 단계 산출**이었다 (2026-09-19) ──────

def test_the_character_slot_is_not_evidence(monkeypatch):
    """★실측 그대로 — 지어낸 이름이 **자기 자신을 근거로** 문을 지났다.

    `list_entities_from_shots` 는 샷 한 줄을 이렇게 만든다::

        Scene 5 (...) Shot 1: 고급주택 ... 젊은 여자를 응시하는 현우. [현우, 젊은 아시아계 여자 로봇]

    끝의 대괄호는 앞 단계(`shot_validator`)가 채운 **인물 이름 칸**이다.
    그 칸에 이미 지어낸 말이 들어 있어서, 모델이 그 대괄호를 그대로 인용하자
    「63개 전부 확인」으로 통과했다(로그 실측). 묘사문에는 「젊은 여자」뿐이고
    대본에 「아시아계」는 **0회**다.

    그래서 근거로 쓰는 글에서 **이름 칸을 뺀다**. 이 시험은 그 한 가지만 본다.
    """
    from app.modules.pipeline import entity_lister as el

    shots = [{"scene_index": 5, "scene_heading": "고급주택 거실", "shots": [
        {"shot_index": 1,
         "description": "통유리창 너머로 먼지를 터는 젊은 여자를 응시하는 현우의 모습.",
         "characters": ["현우", "젊은 아시아계 여자 로봇"]},
    ]}]

    calls: list = []

    def _fake_call(**kw):
        calls.append(kw["user_prompt"])
        if len(calls) == 1:
            return {"characters": [{
                "name": "젊은 아시아계 여자 로봇", "shot_count": 6,
                # ★모델이 실제로 낸 근거 — 제가 붙인 이름 칸을 그대로 베꼈다
                "name_source_quote": "[현우, 젊은 아시아계 여자 로봇]"}]}
        return {"characters": [{
            "name": "젊은 여자", "shot_count": 6,
            "name_source_quote": "먼지를 터는 젊은 여자를 응시하는 현우"}]}

    monkeypatch.setattr(el, "call_structured", _fake_call)
    monkeypatch.setattr(el, "load_prompt", lambda *a, **k: "시스템")
    monkeypatch.setattr(el, "load_schema", lambda *a, **k: {"type": "object"})

    out = el.list_entities_from_shots(shots_data=shots, entity_type="character")

    assert len(calls) == 2, "이름 칸을 근거로 인정해 그냥 통과했다"
    assert "[수정 요청]" in calls[1]
    assert [c["name"] for c in out] == ["젊은 여자"], out

    # 그리고 모델에게 **보여 주는 글**에서는 이름 칸을 빼지 않는다 —
    # 근거 확인에만 빼는 것이지, 볼 것을 줄이는 게 아니다.
    assert "젊은 아시아계 여자 로봇" in calls[0]


def _shots():
    return [{"scene_index": 5, "scene_heading": "고급주택 거실", "shots": [
        {"shot_index": 1,
         "description": "통유리창 너머로 먼지를 터는 젊은 여자를 응시하는 현우의 모습.",
         "characters": ["현우", "젊은 아시아계 여자 로봇"]},
    ]}]


def _wire(monkeypatch, answers):
    """`call_structured` 를 정해진 답 차례로 바꾼다. 나간 프롬프트를 돌려준다."""
    from app.modules.pipeline import entity_lister as el

    sent: list = []

    def _fake_call(**kw):
        sent.append(kw["user_prompt"])
        return {"characters": answers[min(len(sent) - 1, len(answers) - 1)]}

    monkeypatch.setattr(el, "call_structured", _fake_call)
    monkeypatch.setattr(el, "load_prompt", lambda *a, **k: "시스템")
    monkeypatch.setattr(el, "load_schema", lambda *a, **k: {"type": "object"})
    return el, sent


def test_when_the_second_answer_is_wrong_too_the_bad_name_still_ships(monkeypatch):
    """★Codex BLOCK (09-19): **검출**만 고쳤고 **채택**은 그대로다.

    두 번 다 같은 지어낸 이름이 오면 `len(_ok2) >= len(_ok)` 가 `0 >= 0` 이라
    어긋난 행이 그대로 나간다. 이 시험은 그것을 **고친 척하지 않고 적어 둔다** —
    지금 이 커밋이 막은 것은 「이름 칸을 베끼는 자기 인용」과 「교정 재질문」까지다.
    실패한 이름을 정본으로 안 올리는 부분실패 계약은 **아직 없다**.
    """
    bad = [{"name": "젊은 아시아계 여자 로봇", "shot_count": 6,
            "name_source_quote": "[현우, 젊은 아시아계 여자 로봇]"}]
    el, sent = _wire(monkeypatch, [bad, bad])

    out = el.list_entities_from_shots(shots_data=_shots(), entity_type="character")

    assert len(sent) == 2, "재질문은 돌아야 한다"
    assert [c["name"] for c in out] == ["젊은 아시아계 여자 로봇"], (
        "지금은 그대로 나간다 — 이 줄이 깨지면 채택 계약이 바뀐 것이니 "
        "위 설명과 커밋 범위를 같이 고쳐라")


def test_a_shrinking_retry_does_not_erase_the_other_characters(monkeypatch):
    """★재답이 **미확인 인물을 지워** 개수가 줄면 원래 답을 지킨다.

    고친 이름 하나를 얻자고 멀쩡한 인물을 잃으면 하류가 빈다.
    """
    first = [
        {"name": "현우", "shot_count": 1,
         "name_source_quote": "젊은 여자를 응시하는 현우의 모습"},
        {"name": "젊은 아시아계 여자 로봇", "shot_count": 6,
         "name_source_quote": "[현우, 젊은 아시아계 여자 로봇]"},
    ]
    shrunk = [{"name": "현우", "shot_count": 1,
               "name_source_quote": "젊은 여자를 응시하는 현우의 모습"}]
    el, sent = _wire(monkeypatch, [first, shrunk])

    out = el.list_entities_from_shots(shots_data=_shots(), entity_type="character")

    assert len(sent) == 2
    # 재답은 확인된 것이 1개, 첫 답도 확인된 것이 1개(현우) — 1 >= 1 이라 재답을 쓴다.
    # 즉 **줄어든 답을 받는다.** 이것이 지금의 계약이다.
    assert [c["name"] for c in out] == ["현우"], out


# ── 장부가 **체크포인트까지** 가는가 (2026-09-19) ────────────────────

def test_the_ledger_reaches_the_step_result(monkeypatch):
    """★「그대로 둔다」는 결정은 그대로지만, **보이게는 해야 한다.**

    근거가 안 맞는 이름을 버리지 않으므로, 산출만 보고는 그런 이름이 있는지
    알 길이 없었다 — 로그 한 줄로만 흘렀다. 그래서 「전부 확인」과 「아무도
    안 봤다」가 같아진다([[feedback-nothing-found-vs-never-looked]]).

    이 시험은 **실제 스텝의 `_execute` 를 태워** 결과 dict 에 장부가 실리는지
    본다. 소스에 문자열이 있나 보는 시험은 지난번 결함을 그대로 통과시켰다.
    """
    from app.core.steps import entity_steps as es
    from app.modules.pipeline import entity_lister as el

    shots = {"data": {"scenes": [{
        "scene_index": 5, "scene_heading": "고급주택 거실", "shots": [
            {"shot_index": 1,
             "description": "먼지를 터는 젊은 여자를 응시하는 현우의 모습.",
             "characters": ["현우", "젊은 아시아계 여자 로봇"]}]}]}}

    bad = [{"name": "젊은 아시아계 여자 로봇", "shot_count": 6,
            "name_source_quote": "[현우, 젊은 아시아계 여자 로봇]"}]
    monkeypatch.setattr(el, "call_structured", lambda **kw: {"characters": bad})
    monkeypatch.setattr(el, "load_prompt", lambda *a, **k: "시스템")
    monkeypatch.setattr(el, "load_schema", lambda *a, **k: {"type": "object"})
    monkeypatch.setattr(es, "_prior_roster", lambda *a, **k: ("", []))
    # ★발급기를 **흉내만 내지 않는다** — 실제로 번호를 붙여야 장부가
    #  그것을 집어 오는지 볼 수 있다. no-op 로 두면 short_id 가 None 이어도
    #  「키가 있다」로 통과한다(내가 처음 그렇게 썼다).
    def _fake_assign(_runner, ents, _kind):
        for i, e in enumerate(ents, 1):
            e["short_id"] = f"C{i:02d}"
    monkeypatch.setattr(es, "_assign_short_ids", _fake_assign)
    monkeypatch.setattr(
        "app.core.steps.shot_validator_step.assert_no_failed_scenes",
        lambda *a, **k: None)

    step = object.__new__(es.EntityAllCharacterStep)
    step.project_config = {}
    step._load_prev_checkpoint = lambda name: (
        shots if name == "shot_validator" else {"data": {}})
    step.build_opik_metadata = lambda: {}
    step._a0_candidates = lambda: []
    step._config_hash = lambda: "test"

    out = step._execute(mode="resume")

    prov = out["data"].get("name_provenance")
    assert prov, "장부가 체크포인트에 안 실렸다 — 이러면 아무도 못 본다"
    assert prov["checked"] == 1 and prov["verified"] == 0
    assert [u["name"] for u in prov["unverified"]] == ["젊은 아시아계 여자 로봇"]
    assert prov["evidence_source"] == "shot_descriptions"
    # ★자른 것을 「인용문」이라 부르지 않는다 — 감사하는 쪽이 원문으로 읽는다.
    assert "quote_preview" in prov["unverified"][0]
    assert "quote" not in prov["unverified"][0]
    # ★★이름이 아니라 **short_id 로** 이어야 한다 — 이 장부는 바로
    #  「이름이 수상하다」는 기록이라, 이름이 바뀌면 표식이 끊긴다.
    assert prov["unverified"][0].get("short_id") == "C01", (
        "발급된 short_id 를 안 실었다 — 이름으로만 이으면 표식이 끊긴다: "
        + repr(prov["unverified"][0]))
    # 그리고 인물은 **버리지 않는다** — 버리면 하류가 빈다.
    assert [c["name"] for c in out["data"]["characters"]] == [
        "젊은 아시아계 여자 로봇"]


def test_the_ledger_says_verified_when_nothing_is_wrong(monkeypatch):
    """어긋남 0 도 **적힌다** — 빈 장부와 「안 봤다」는 다르다."""
    from app.modules.pipeline import entity_lister as el

    monkeypatch.setattr(el, "call_structured", lambda **kw: {"characters": [
        {"name": "현우", "shot_count": 1,
         "name_source_quote": "젊은 여자를 응시하는 현우의 모습"}]})
    monkeypatch.setattr(el, "load_prompt", lambda *a, **k: "시스템")
    monkeypatch.setattr(el, "load_schema", lambda *a, **k: {"type": "object"})

    prov: dict = {}
    el.list_entities_from_shots(
        shots_data=_shots(), entity_type="character", provenance_out=prov)
    assert prov == {"checked": 1, "verified": 1, "unverified": [],
                    "evidence_source": "shot_descriptions"}
    # ★「필드가 있는 빈 장부」와 「장부 자체가 없다」는 다른 칸이다.
    #  장소·소품과 씬 체이닝 갈래는 아직 문이 없어 **장부가 아예 없다** —
    #  읽는 쪽이 `unverified == []` 만 보고 통과로 읽으면 안 된다.
    prov_prop: dict = {}
    el.list_entities_from_shots(
        shots_data=_shots(), entity_type="prop", provenance_out=prov_prop)
    assert prov_prop == {}, "인물 아닌 갈래는 장부가 **없어야** 한다"

