"""현대 배경도 고증한다 — **시대 선언이 없어도** 두 축이면 대상이다. ★유료 0.

사용자 재지시 (2026-09-02): 「"주유소", 한국식 "편의점 + 앞쪽 벤치" 등 현대
배경이라도 고증이 필요한 경우는 되도록 진행해야 해」.

TASKS 「현대·지역 고증 축」 무료 acceptance 일곱 —

    ① era 비고 region 만 있는 현대 장소가 두 축으로 target
    ② 같은 배경의 쉬운 대상은 음성 대조로 비대상
    ③ 지역만 바꾸면 지시문·acquisition identity 가 바뀜
    ④ 동의어·새 이름으로 바꿔도 구조적 판단 유지 (키워드 매칭 아님)
    ⑤ `location`·`location_part` 가 중앙 조사 → 배경 sidecar → provider payload
    ⑥ `app/` 와 활성 팩에 fixture 고유명사·키워드 분기 **0**
    ⑦ 보고는 좌표 모양으로 따로 세되 집행 규칙은 **한 벌**

★무료 분석 결과 (2026-09-02, 내가 코드에서 확인): v2_chunk 활성 경로에는
시대를 요구하는 자리가 **없다**. 시대·지역 둘 다 없으면 raise 하는 다섯 자리
(`grounding_a0` · `grounding_claims_search` · `grounding_shadow` ·
`grounding_steps` plan/research)는 전부 `mode == v2` 전용이거나 offline 이다.
그래서 이 시험은 「막는 것을 걷어 낸다」가 아니라 **「안 막는다」를 잠근다** —
누가 나중에 시대를 자격으로 삼는 줄을 넣으면 여기서 먼저 붉어진다.

★모든 시험이 **production 함수**를 부른다. fixture 는 입력이지 판정이 아니다.
"""
from __future__ import annotations

import ast
import inspect
import pathlib
import textwrap
from typing import Any, Dict, List, Optional

import pytest

from app.modules.pipeline import grounding_acquisition_adapter as ga
from app.modules.pipeline import grounding_acquisition_ledger as gl
from app.modules.pipeline import grounding_bundle_projection as bp
from app.modules.pipeline import grounding_entity_contract as _ec
from app.modules.pipeline import grounding_fidelity_review as fr
from app.modules.pipeline import grounding_screen as _screen
from app.modules.pipeline import grounding_search_brief as gsb
from app.modules.pipeline import reference_acquisition as ra
from app.modules.pipeline import reference_acquisition_rounds as rr
from tests.grounding.fixtures import modern_episode as M
from tests.grounding.fixtures import period_episode as P
from tests.grounding.test_the_payload_probe_measures_the_exit import _cp, _row
from tools.grounding_audit import canary_bootstrap as cbs
from tools.grounding_audit import canary_payload_probe as pp
from tools.grounding_audit import coordinate_shape as cs

BACKEND = pathlib.Path(__file__).resolve().parents[2]
ACTIVE_PACKS = BACKEND.parent / "prompts" / "_base"


def _subject(key: str, *, hard: Any, notice: Any,
             name: Optional[str] = None) -> Dict[str, Any]:
    """fixture 표적 하나 → producer 가 냈을 subject 줄. ★두 축은 인자다."""
    t = next(x for x in M.EXPECTED_TARGETS if x["key"] == key)
    sc, word, n = t["at"][0]
    span = M.span_of(sc, word, n)
    payload: Dict[str, Any] = {}
    if hard is not None:
        payload["hard_to_generate"] = hard
    if notice is not None:
        payload["viewers_would_notice"] = notice
    return {"research_subject_id": f"rs_{key}", "owner_type": t["owner"],
            "surface_form": name or word, "source_anchor": span["segment_id"],
            "source_quote": word,
            _ec.PRODUCER_PAYLOAD: payload}


def _fixture_subjects() -> List[Dict[str, Any]]:
    return [_subject(k, hard=v["hard"], notice=v["notice"])
            for k, v in M.AXIS_BASIS.items()]


def _screens(subjects) -> Dict[str, str]:
    got = _screen.project_from_producer(subjects, dispositions={})
    return {r["research_subject_id"]: r["screen"] for r in got["rows"]}


def _source_tree(fn):
    return ast.parse(textwrap.dedent(inspect.getsource(fn)))


def _non_docstring_literals(tree) -> List[str]:
    """문자열 리터럴만 — docstring 은 벗긴다. ★주석은 AST 에 없다."""
    doc_ids = set()
    for node in ast.walk(tree):
        if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
                             ast.AsyncFunctionDef)):
            body = getattr(node, "body", None) or []
            if body and isinstance(body[0], ast.Expr) \
                    and isinstance(body[0].value, ast.Constant) \
                    and isinstance(body[0].value.value, str):
                doc_ids.add(id(body[0].value))
    return [n.value for n in ast.walk(tree)
            if isinstance(n, ast.Constant) and isinstance(n.value, str)
            and id(n) not in doc_ids]


class TestTheFixtureIsWhatItSaysItIs:
    def test_it_declares_a_region_and_no_era(self):
        M.assert_planted()
        assert M.ERA == "" and M.REGION.strip()

    def test_it_is_a_canary_fixture(self):
        assert cbs.load_fixture("modern_episode") is M

    def test_it_is_not_the_period_fixture_in_disguise(self):
        """★두 원고는 좌표 모양이 다르다 — 그래야 ⑦ 이 뜻이 있다."""
        assert P.ERA and M.ERA == "" and P.REGION == M.REGION


class TestAModernPlaceIsATargetWithoutAnEra:
    """① era 비고 region 만 있는 현대 장소가 **두 축으로** target."""

    def test_two_true_axes_make_an_obligation(self):
        by = _screens(_fixture_subjects())
        for key in M.acquisition_targets():
            assert by[f"rs_{key}"] == _screen.SCREEN_OBLIGATION, (key, by)

    def test_no_model_is_asked_again(self):
        got = _screen.project_from_producer(_fixture_subjects(),
                                            dispositions={})
        assert got["assess_bought"] == 0 and got["assess_reused"] == 0

    def test_those_rows_are_buyable(self):
        got = _screen.project_from_producer(_fixture_subjects(),
                                            dispositions={})
        bought = 0
        for row in got["rows"]:
            if row["screen"] != _screen.SCREEN_OBLIGATION:
                continue
            ga.assert_buyable({**row, "status": gl.RESOLVED})   # 안 선다
            bought += 1
        assert bought == len(M.acquisition_targets())

    def test_the_provider_gate_asks_only_for_the_declared_region(self):
        """★시대가 없으면 **만들지도 요구하지도** 않는다 — 지역은 의무다."""
        co = gsb.coordinates_of({"era": M.ERA, "region": M.REGION})
        assert co == {"era": "", "region": M.REGION}
        directive = f"{M.REGION} 국도변 기름 넣는 곳 평지붕 섬 기계"
        rr.assert_coordinates_carried(directive, [directive],
                                      coordinates=co)          # 안 선다

    def test_but_a_query_that_drops_the_region_is_refused(self):
        """★양성 대조 — 지역을 빼면 provider 앞에서 선다."""
        co = gsb.coordinates_of({"era": M.ERA, "region": M.REGION})
        with pytest.raises(rr.CoordinatesMissing):
            rr.assert_coordinates_carried("평지붕 섬 기계", ["평지붕 섬 기계"],
                                          coordinates=co)


class TestEasyThingsInTheSameSettingAreNotTargets:
    """② 같은 배경의 쉬운 대상은 **음성 대조**로 비대상 — 현대라서가 아니라."""

    def test_false_axes_are_not_target(self):
        by = _screens(_fixture_subjects())
        negatives = [k for k, v in M.AXIS_BASIS.items()
                     if not (v["hard"] and v["notice"])]
        assert negatives, "★음성 대조가 없다"
        for key in negatives:
            assert by[f"rs_{key}"] == _screen.SCREEN_NOT_TARGET, (key, by)

    def test_a_not_target_row_is_not_buyable(self):
        got = _screen.project_from_producer(
            [_subject("plain_prop", hard=False, notice=False)],
            dispositions={})
        with pytest.raises(ga.NotBuyable):
            ga.assert_buyable({**got["rows"][0], "status": gl.RESOLVED})

    def test_one_true_axis_is_not_enough(self):
        by = _screens([_subject("fuel_station", hard=True, notice=False),
                       _subject("store", hard=False, notice=True)])
        assert set(by.values()) == {_screen.SCREEN_NOT_TARGET}

    def test_a_missing_axis_is_unresolved_not_a_no(self):
        """★producer 가 안 낸 것을 「아니다」로 접지 않는다."""
        by = _screens([_subject("fuel_station", hard=None, notice=None)])
        assert by["rs_fuel_station"] == _screen.SCREEN_UNRESOLVED


class TestTheRegionAloneChangesDirectiveAndIdentity:
    """③ 지역만 바꾸면 지시문·acquisition identity 가 바뀐다."""

    def test_identity_inputs_differ_by_region_only(self):
        a = gsb.identity_inputs(world_facts=M.WORLD_FACTS, era=M.ERA,
                                region=M.REGION)
        b = gsb.identity_inputs(world_facts=M.WORLD_FACTS, era=M.ERA,
                                region="다른 지역")
        assert a != b
        assert {k for k in a if a[k] != b[k]} == {"region"}

    def test_the_writer_carries_the_region_and_no_era_tokens(self):
        def _never(*_a, **_k):
            pytest.fail("★저작기가 만들 때 모델을 불렀다")

        w = gsb.make_writer(world_facts=M.WORLD_FACTS,
                            source_text=M.manuscript(), era=M.ERA,
                            region=M.REGION, call=_never)
        assert w.coordinates == {"era": "", "region": M.REGION}
        assert w.era_tokens == [], "★없는 시대에서 조각을 만들었다"
        assert w.identity_inputs["region"] == M.REGION
        assert w.identity_inputs["era"] == ""

    def _target(self):
        return {"subject_id": "rs_fuel_station", "owner_type": "location",
                "coarse_type_label": "roadside fuel station",
                "surface_form": "주유소",
                "visual_brief": "기둥 넷 위 평지붕과 섬 위의 기계"}

    def test_the_outbound_prompt_carries_the_region_and_invents_no_era(self):
        """★나가는 user prompt 에 지역은 실리고, 시대는 **지어내지 않는다**."""
        o = gsb.outbound(self._target(), world_facts=M.WORLD_FACTS,
                         source_text=M.manuscript(), narrow=False,
                         era=M.ERA, region=M.REGION)
        assert M.REGION in o["user"]
        # ★양성 대조 — 시대를 **주면** 실린다. 그러니 안 준 판에 없는 것은
        #  「안 실은 것」이지 「못 싣는 것」이 아니다.
        with_era = gsb.outbound(self._target(), world_facts=M.WORLD_FACTS,
                                source_text=M.manuscript(), narrow=False,
                                era=P.ERA, region=M.REGION)
        assert P.ERA in with_era["user"]
        assert P.ERA not in o["user"]

    def test_the_outbound_prompt_changes_with_the_region(self):
        a = gsb.outbound(self._target(), world_facts=M.WORLD_FACTS,
                         source_text=M.manuscript(), narrow=False,
                         era=M.ERA, region=M.REGION)
        b = gsb.outbound(self._target(), world_facts=M.WORLD_FACTS,
                         source_text=M.manuscript(), narrow=False,
                         era=M.ERA, region="다른 지역")
        assert a["user"] != b["user"] and a["system"] == b["system"]


class TestTheJudgementIsStructuralNotLexical:
    """④ 동의어·새 이름으로 바꿔도 판단이 같다 — 키워드 매칭이 아니다."""

    def test_renaming_the_subject_keeps_the_screen(self):
        a = _screens([_subject("fuel_station", hard=True, notice=True)])
        b = _screens([_subject("fuel_station", hard=True, notice=True,
                               name="연료 보급소 제7호")])
        assert a["rs_fuel_station"] == b["rs_fuel_station"] \
            == _screen.SCREEN_OBLIGATION

    def test_and_renaming_does_not_rescue_a_non_target(self):
        """★이름에 표적 낱말을 넣어도 축이 거짓이면 비대상이다."""
        by = _screens([_subject("plain_prop", hard=False, notice=False,
                                name="주유소 편의점 종이컵")])
        assert by["rs_plain_prop"] == _screen.SCREEN_NOT_TARGET

    def test_the_projection_reads_no_name_field_and_no_regex(self):
        tree = _source_tree(_screen.project_from_producer)
        literals = set(_non_docstring_literals(tree))
        assert not literals & {"surface_form", "subject_native", "name",
                               "label", "source_quote"}, literals
        attrs = {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
        assert not attrs & {"search", "match", "findall", "compile",
                            "fullmatch"}, attrs


class TestLocationAndItsPartReachTheProviderPayload:
    """⑤ `location`·`location_part` 가 중앙 조사 → sidecar → 실제 payload."""

    @pytest.fixture
    def world(self, tmp_path):
        pics = tmp_path / "pics"
        pics.mkdir()
        (pics / "L01.png").write_bytes(b"\x89PNG\r\n\x1a\nSTATION")
        (pics / "LP01.png").write_bytes(b"\x89PNG\r\n\x1a\nISLAND")
        (pics / "LP02.png").write_bytes(b"\x89PNG\r\n\x1a\nSEAT")
        rows = [_row("L01", "context", "location", b"s"),
                _row("LP01", "detail", "location_part", b"i"),
                _row("LP02", "detail", "location_part", b"t",
                     verified=False)]
        return tmp_path, _cp(tmp_path, rows)

    def test_both_owners_attach_when_visible_and_verified(self, world):
        root, cp = world
        got = pp.measure_shot(cp, ["L01", "LP01"], root=root)
        subs = sorted(s for r in got["refs"] for s in r["subjects"])
        assert subs == ["L01", "LP01"], f"★{subs}"
        assert got["attached"] == 2
        assert {r["ref_kind"] for r in got["refs"]} == {"background"}
        purposes = sorted(p for r in got["refs"] for p in r["purposes"])
        assert purposes == ["context", "detail"]

    def test_an_unverified_modern_reference_attaches(self, world):
        """★HITL 0 — 미확인이어도 자동 선택은 붙는다."""
        root, cp = world
        got = pp.measure_shot(cp, ["LP02"], root=root)
        assert got["attached"] == 1 and [s for r in got["refs"] for s in r["subjects"]] == ["LP02"]

    def test_only_the_visible_one_attaches(self, world):
        """★보이는 샷에만 붙는다 — 확인됐어도 안 보이면 0."""
        root, cp = world
        got = pp.measure_shot(cp, ["LP01"], root=root)
        assert [s for r in got["refs"] for s in r["subjects"]] == ["LP01"]

    def test_the_human_review_binding_accepts_an_empty_era(self, world):
        """★사람 판정 입력 신원이 시대 없이도 선다 — 지역만 접힌다."""
        root, cp = world
        items = fr.review_inputs_for(
            cp, project_id="p", episode_id="e",
            coordinates={"era": M.ERA, "region": M.REGION},
            sha_of=lambda _r: "0" * 64, strict=False)
        assert items, "★볼 것이 없다"
        for one in items:
            assert not one.get("fault"), one
            assert one["payload"]["region"] == M.REGION
            assert one["payload"]["era"] == ""


class TestNoFixtureWordLeaksIntoProductionOrActivePacks:
    """⑥ `app/` 문자열 리터럴과 활성 팩 본문에 fixture 낱말 **0**.

    ★주석은 안 본다 — `app/` 세 파일의 주석에 지난 실측 기록으로 같은
    낱말이 있고, 그것은 규칙이 아니다. 금지를 글자로 걸면 그 금지를 적은
    주석이 걸린다.
    """

    def test_app_string_literals_carry_none_of_the_terms(self):
        hits = []
        for py in sorted((BACKEND / "app").rglob("*.py")):
            try:
                tree = ast.parse(py.read_text(encoding="utf-8"))
            except SyntaxError:
                continue
            for s in _non_docstring_literals(tree):
                for term in M.FIXTURE_ONLY_TERMS:
                    if term in s:
                        hits.append((str(py.relative_to(BACKEND)), term))
        assert not hits, f"★production 리터럴에 fixture 낱말: {hits[:6]}"

    def test_active_packs_carry_none_of_the_terms(self):
        hits = []
        for f in sorted(ACTIVE_PACKS.rglob("*")):
            if not f.is_file() or f.suffix not in (".md", ".txt", ".json"):
                continue
            text = f.read_text(encoding="utf-8", errors="replace")
            for term in M.FIXTURE_ONLY_TERMS:
                if term in text:
                    hits.append((str(f.relative_to(ACTIVE_PACKS)), term))
        assert not hits, f"★활성 팩에 fixture 낱말: {hits[:6]}"

    def test_the_ratchet_itself_can_see(self):
        """★양성 대조 — 리터럴에 넣으면 잡힌다 (docstring 은 안 잡힌다)."""
        tree = ast.parse('def f():\n    """주유소"""\n    return "편의점 앞"\n')
        got = _non_docstring_literals(tree)
        assert got == ["편의점 앞"]


class TestTheReportSplitsByShapeButEnforcementDoesNot:
    """⑦ 보고는 좌표 모양으로 따로 세되 집행 규칙은 **한 벌**."""

    def test_the_two_fixtures_land_in_different_shapes(self):
        assert cs.shape_of({"era": P.ERA, "region": P.REGION}) == cs.SHAPE_BOTH
        assert cs.shape_of({"era": M.ERA, "region": M.REGION}) \
            == cs.SHAPE_REGION_ONLY
        got = cs.count_by_shape([{"era": P.ERA, "region": P.REGION},
                                 {"era": M.ERA, "region": M.REGION},
                                 {"era": "", "region": ""}])
        assert got == {cs.SHAPE_BOTH: 1, cs.SHAPE_REGION_ONLY: 1,
                       cs.SHAPE_ERA_ONLY: 0, cs.SHAPE_NONE: 1}

    def test_the_shape_reads_no_meaning(self):
        """★값이 무엇이든 — 비었나 찼나만 본다."""
        assert cs.shape_of({"era": "2020년대", "region": M.REGION}) \
            == cs.SHAPE_BOTH
        assert cs.shape_of({"era": "   ", "region": M.REGION}) \
            == cs.SHAPE_REGION_ONLY

    @pytest.mark.parametrize("fn", [
        _screen.project_from_producer,   # 판별 투영
        ga.assert_buyable,               # 살 자격
        ra.usable_as_reference,          # 붙일 자격
        bp.members_from_rows,            # sidecar 멤버
    ], ids=lambda f: f.__name__)
    def test_enforcement_never_reads_era_or_region(self, fn):
        tree = _source_tree(fn)
        names = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
        attrs = {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
        literals = set(_non_docstring_literals(tree))
        seen = (names | attrs | literals) & {"era", "region", "era_declaration",
                                             "region_declaration"}
        assert not seen, f"★{fn.__name__} 가 좌표를 읽는다: {seen}"
