"""GROUNDING-V2 §2-3.5 — A0 후보 수집.

★A0 는 **관찰용 sidecar 가 아니라 후보 승격 통로**다. `entity_all` 은
**shot description 만** 읽고(`entity_lister.py:170`) shot schema 는 `characters`
가 필수가 아니라, 한 번만 크게 나오는 대상은 **볼 기회조차 없다**.
"""
from unittest.mock import patch

import pytest

from app.modules.pipeline import grounding_a0 as a0

_FULLTEXT = (
    "S1. 버스 안. 정임이 종이 뭉치를 쥐고 있다.\n"
    "고무줄로 묶인 낡은 종이 승차권 뭉치다.\n"
    "S2. 골목. 낡은 자전거 체인이 바닥에 놓여 있다.\n"
)


def _cand(**over):
    base = {
        "surface_form": "종이 승차권 뭉치",
        "source_anchor": "S1",
        "source_quote": "고무줄로 묶인 낡은 종이 승차권 뭉치다.",
        "owner_type": "prop",
        "why_candidate": "시대 규격 인쇄물",
        "planned_occurrences": 1,
    }
    base.update(over)
    return base


class TestPackIsPinned:
    def test_pack_is_complete_and_pinned(self):
        pack = a0.load_pack()
        assert set(pack["stems"]) == {a0.SYSTEM_STEM, a0.SCHEMA_STEM}
        for r in pack["stems"].values():
            assert r["version"] == pack["version"]

    def test_missing_pack_fails_closed(self):
        with pytest.raises(FileNotFoundError):
            a0.load_pack(version="999.202601010000")

    def test_prompt_has_no_scenario_specific_terms(self):
        """★작품 고유명사를 프롬프트에 넣지 않는다 — 어떤 시나리오에도 써야 한다."""
        text = a0.load_pack()["stems"][a0.SYSTEM_STEM]["content"]
        for word in ("회수권", "요금통", "한복", "자전거", "막차", "정임"):
            assert word not in text, f"작품 고유명사 {word} 가 들어 있다"

    def test_prompt_says_to_catch_one_shot_items(self):
        """★「한 번만 나오면 뺀다」가 정확히 고증 대상을 거른다 — 반대로 적어야 한다."""
        text = a0.load_pack()["stems"][a0.SYSTEM_STEM]["content"]
        assert "한 번만 나와도 건집니다" in text
        assert "착용물" in text and "고정 설비" in text


class TestModelIsActuallySol:
    """★등록 안 하면 gemini-pro 로 떨어진다 — grounding_classify 에서 이미 겪었는데
    A0 에서 **또 그랬다.** 실제 주행 로그에 fallback 경고가 찍혀 드러났다."""

    def test_step_resolves_to_sol(self):
        from app.modules.llm.llm_client import _resolve_model
        assert _resolve_model(a0.STEP_NAME) == "gpt", \
            "grounding_a0 가 Sol 로 안 간다 — _PIPELINE_STEP_EXTENSIONS 등록 확인"


class TestInputIsNotSilentlyEmpty:
    """★빈 값으로 부르면 「후보 0」이 「깨끗함」으로 읽힌다."""

    def test_empty_fulltext_is_rejected(self):
        with pytest.raises(ValueError):
            a0.collect_candidates("  ", project_id="p", episode_id="e", era="1983년", region="KR")

    @pytest.mark.parametrize("era,region", [("", "KR"), ("1983년", ""), ("", "")])
    def test_missing_context_is_rejected(self, era, region):
        with pytest.raises(ValueError):
            a0.collect_candidates(_FULLTEXT, project_id="p", episode_id="e", era=era, region=region)

    def test_fulltext_is_not_truncated(self):
        """★원문을 자르지 않는다 — 프로젝트 절대 규칙."""
        long_text = "S1. " + ("가나다라 " * 5000) + "\n끝 문장이다."
        text = a0.build_user_prompt(long_text, era="1983년", region="KR")
        assert "끝 문장이다." in text, "원문 끝이 잘렸다"
        assert long_text in text


class TestHallucinatedQuotesAreDropped:
    """★원문에 없는 인용은 후보가 아니다 — 지어낸 것이다."""

    def _run(self, cands):
        with patch.object(a0, "_call_structured",
                          lambda **k: {"candidates": cands}):
            return a0.collect_candidates(_FULLTEXT, project_id="p", episode_id="e", era="1983년", region="대한민국")

    def test_a_real_quote_survives(self):
        out = self._run([_cand()])
        assert len(out["candidates"]) == 1 and out["hallucinated"] == []

    def test_an_invented_quote_is_dropped_with_a_reason(self):
        out = self._run([_cand(source_quote="원문에 없는 문장이다.")])
        assert out["candidates"] == []
        assert len(out["hallucinated"]) == 1
        assert "원문에 없다" in out["hallucinated"][0]["drop_reason"]

    def test_dropped_ones_are_kept_not_silently_lost(self):
        """★버린 것을 안 보이면 「후보가 적다」를 결함이 아니라 사실로 읽는다."""
        out = self._run([_cand(), _cand(source_quote="없는 말")])
        assert len(out["candidates"]) == 1 and len(out["hallucinated"]) == 1

    def test_no_search_is_bought(self):
        assert self._run([_cand()])["search_calls"] == 0

    def test_fingerprint_records_the_actual_judge(self):
        def _call(**kwargs):
            sink = kwargs.get("usage_sink")
            if sink is not None:
                sink["alias"] = "gpt"
                sink["physical_model"] = "openai/gpt-5.6-sol"
            return {"candidates": [_cand()]}

        with patch.object(a0, "_call_structured", _call):
            out = a0.collect_candidates(_FULLTEXT, project_id="p", episode_id="e", era="1983년", region="대한민국")
        fp = out["fingerprint"]
        assert fp["judge_physical_model"] == "openai/gpt-5.6-sol"
        assert fp["era"] == "1983년" and fp["prompt_version"] == a0.PROMPT_PACK_VERSION


class TestStrictSingleAttempt:
    def test_seal_is_passed_when_asked(self):
        seen = {}

        def _call(**kwargs):
            seen.update(kwargs)
            return {"candidates": []}

        with patch.object(a0, "_call_structured", _call):
            a0.collect_candidates(_FULLTEXT, project_id="p", episode_id="e", era="1983년", region="KR",
                                  strict_single_attempt=True)
        assert seen.get("enable_fallback") is False and seen.get("num_retries") == 0

    def test_default_leaves_production_behaviour_alone(self):
        seen = {}

        def _call(**kwargs):
            seen.update(kwargs)
            return {"candidates": []}

        with patch.object(a0, "_call_structured", _call):
            a0.collect_candidates(_FULLTEXT, project_id="p", episode_id="e", era="1983년", region="KR")
        assert "enable_fallback" not in seen and "num_retries" not in seen


class TestOwnerVocabularyIsOneSource:
    """★스키마 enum 과 프롬프트 표가 **갈리면** 모델이 없는 값을 낸다.

    실제로 `location_part` 를 enum 에 넣으면서 프롬프트 본문 한 줄은 옛
    갈래(`location`)로 남겨 **표와 반대**가 됐다 (Codex 지적).
    여기서 보는 것은 **낱말의 뜻이 아니라 두 파일이 같은 값 집합을 쓰는가**다.
    """

    @staticmethod
    def _pack():
        from app.modules.pipeline import grounding_a0 as a0
        return a0.load_pack(db=None)

    def test_every_enum_value_appears_in_the_prompt(self):
        import json

        pack = self._pack()
        schema = pack["stems"]["a0_schema"]["content"]
        if isinstance(schema, str):
            schema = json.loads(schema)
        enum = set(schema["properties"]["candidates"]["items"]
                   ["properties"]["owner_type"]["enum"])
        text = pack["stems"]["system"]["content"]
        missing = {v for v in enum if f"`{v}`" not in text}
        assert missing == set(), f"프롬프트가 안 가르쳐 주는 값: {missing}"

    def test_the_prompt_uses_no_value_outside_the_enum(self):
        """★반대 방향 — 프롬프트가 enum 밖의 값을 쓰면 모델이 그걸 낸다."""
        import json
        import re

        pack = self._pack()
        schema = pack["stems"]["a0_schema"]["content"]
        if isinstance(schema, str):
            schema = json.loads(schema)
        enum = set(schema["properties"]["candidates"]["items"]
                   ["properties"]["owner_type"]["enum"])
        text = pack["stems"]["system"]["content"]
        # owner_type 표의 오른쪽 칸에 오는 `값` 만 본다 — 표 줄은 `| ... | \`x\` |`
        used = set()
        for line in text.split("\n"):
            m = re.match(r"^\|.*\|\s*`([a-z_]+)`\s*\|$", line.strip())
            if m:
                used.add(m.group(1))
        assert used, "owner_type 표를 못 찾았다 — 좌표가 틀렸다"
        assert used <= enum, f"enum 밖의 값을 가르친다: {used - enum}"

    def test_the_rule_of_thumb_agrees_with_the_table(self):
        """★표와 산문이 어긋나면 모델은 **뒤에 오는 산문**을 따른다.

        표에서 어떤 값에 배정된 판별 문구가, 산문 요약에서 **다른 값**에
        붙어 있으면 안 된다. 여기서는 표의 오른쪽 값과 산문의 값을 맞춘다.
        """
        import re

        text = self._pack()["stems"]["system"]["content"]
        rows = {}
        for line in text.split("\n"):
            m = re.match(r"^\|(.+)\|\s*`([a-z_]+)`\s*\|$", line.strip())
            if m:
                rows[m.group(2)] = m.group(1)
        assert "location_part" in rows and "location" in rows
        # 표에서 「들고 나갈 수 없는」이 걸린 값
        fixed = [v for v, desc in rows.items() if "들고 나갈 수 없는" in desc]
        assert fixed == ["location_part"], fixed
        # 산문에서 같은 판별을 어떤 값에 붙였나
        prose = [ln for ln in text.split("\n") if "그 자리에 남으면" in ln]
        assert prose, "산문 요약 줄을 못 찾았다"
        joined = "\n".join(text.split("\n")[text.split("\n").index(prose[0]):][:3])
        assert "`location_part`" in joined, \
            "산문이 고정 설비를 표와 다른 값에 붙였다"
