"""C(c) 한 판에서 **누가 도는가**. ★유료 0 — production 술어에게 묻는다.

Codex BLOCK-2 (2026-09-01) — 「fresh v2_chunk 한 판에서 old all/extract
provider 경계 0회, chunk producer 만 호출」.

★여기서는 **실행 계획**을 무료로 잰다. 실제 주행 실측은 격리 canary 몫이다.
"""
from __future__ import annotations

import pytest

from app.core import grounding_activation_contract as act
from app.core.grounding_mode import (GROUNDING_MODE_V2, GROUNDING_MODE_V2_CHUNK,
                                     buys_v2_research, uses_chunk_producer)
from app.core.step_manifest import STEP_MANIFEST as M

#: 술어 이름 → 그 모드에서 참인가. ★production 술어와 **같은 물음**이다.
_ANSWER = {
    "always": lambda m: True,
    "if_grounding_v2": buys_v2_research,
    "if_chunk_producer": uses_chunk_producer,
    "if_not_chunk_producer": lambda m: not uses_chunk_producer(m),
    "if_grounding_reference": lambda m: (buys_v2_research(m)
                                         or uses_chunk_producer(m)),
    "disabled": lambda m: False,
}


def _runs(step_id: str, mode: str):
    """이 모드에서 그 스텝이 도나. 모르는 술어면 **판단하지 않는다**."""
    rule = str(M[step_id].get("applicability") or "always")
    fn = _ANSWER.get(rule)
    return None if fn is None else bool(fn(mode))


class TestTheAnswersComeFromProduction:
    """★대역이 제 답을 지어내면 이 시험은 아무것도 안 잰다."""

    def test_the_predicate_names_are_the_real_ones(self):
        from app.core.applicability import APPLICABILITY_VALIDATORS

        for name in _ANSWER:
            if name in ("always", "disabled"):
                continue
            assert name in APPLICABILITY_VALIDATORS, name

    def test_the_two_mode_predicates_are_exclusive(self):
        for m in (GROUNDING_MODE_V2, GROUNDING_MODE_V2_CHUNK):
            assert not (buys_v2_research(m) and uses_chunk_producer(m)), m


class TestOnlyOneProducerRunsInTheChunkMode:
    MODE = GROUNDING_MODE_V2_CHUNK

    @pytest.mark.parametrize("sid", act.LEGACY_EXTRACT_STEPS)
    def test_no_old_extract_runs(self, sid):
        assert _runs(sid, self.MODE) is False, f"★{sid} 가 돈다 — 두 번 산다"

    @pytest.mark.parametrize("sid", act.LEGACY_PAID_STEPS)
    def test_no_old_paid_lane_runs(self, sid):
        assert _runs(sid, self.MODE) is False, f"★{sid} 가 돈다"

    def test_the_chunk_producer_runs(self):
        """★양성 대조 — 다 끄기만 하면 아무것도 안 나온다."""
        assert _runs(act.CHUNK_PRODUCER_STEP, self.MODE) is True
        assert _runs(act.SCREEN_STEP, self.MODE) is True
        assert _runs(act.CENTRAL_ACQUISITION_STEP, self.MODE) is True

    def test_exactly_one_grounding_producer_runs(self):
        """★고증 대상을 **내는** 스텝은 이 판에서 하나뿐이다."""
        producers = [act.CHUNK_PRODUCER_STEP, "grounding_a0"]
        on = [s for s in producers if _runs(s, self.MODE)]
        assert on == [act.CHUNK_PRODUCER_STEP]


class TestTheOldModeIsUntouched:
    """★★음성 대조 — 옛 판은 **한 글자도 안 달라진다**."""

    MODE = GROUNDING_MODE_V2

    @pytest.mark.parametrize("sid", act.LEGACY_EXTRACT_STEPS)
    def test_every_old_extract_still_runs(self, sid):
        assert _runs(sid, self.MODE) is True, f"★{sid} 를 껐다 — v2 가 깨진다"

    @pytest.mark.parametrize("sid", act.LEGACY_PAID_STEPS)
    def test_every_old_paid_lane_still_runs(self, sid):
        assert _runs(sid, self.MODE) is True, f"★{sid} 를 껐다"

    def test_the_new_producer_does_not_run(self):
        assert _runs(act.CHUNK_PRODUCER_STEP, self.MODE) is False
        assert _runs(act.SCREEN_STEP, self.MODE) is False

    def test_the_central_step_runs_in_both(self):
        """★참조 조사는 **두 판 모두** 돈다 — 갈래만 다르다."""
        for m in (GROUNDING_MODE_V2, GROUNDING_MODE_V2_CHUNK):
            assert _runs(act.CENTRAL_ACQUISITION_STEP, m) is True


class TestLegacyBuysNoGroundingAtAll:
    def test_none_of_the_grounding_steps_run(self):
        from app.core.grounding_mode import GROUNDING_MODE_LEGACY

        for sid in (act.CHUNK_PRODUCER_STEP, act.SCREEN_STEP,
                    act.CENTRAL_ACQUISITION_STEP, *act.LEGACY_PAID_STEPS):
            assert _runs(sid, GROUNDING_MODE_LEGACY) is False, sid

    def test_but_the_old_extracts_do(self):
        """★음성 대조 — legacy 는 제 몫의 추출을 그대로 한다."""
        from app.core.grounding_mode import GROUNDING_MODE_LEGACY

        for sid in act.LEGACY_EXTRACT_STEPS:
            assert _runs(sid, GROUNDING_MODE_LEGACY) is True, sid


# ─────────────────────────────────────────────────────────────────────
# ★★★「옛 것이 꺼졌다」만 재면 **대체 산출이 없는 것**을 못 본다
#  (Codex 2026-09-01). 아래는 **새 것이 실제로 내는지**를 잰다.
# ─────────────────────────────────────────────────────────────────────

CHUNK_CP = {"data": {
    "characters": [{"short_id": "C01", "name": "가"}],
    "locations": [{"short_id": "L01", "name": "나"}],
    "props": [{"short_id": "P01", "name": "다"}],
    "location_parts": [{"short_id": "LP01", "name": "라"}],
    "contracts": {"adapter": "3.202609012800"},
}}


class _Step:
    """CP 를 손으로 주는 대역 — **스텝 함수는 진짜**를 쓴다."""

    project_config = {"grounding_mode": GROUNDING_MODE_V2_CHUNK}

    def __init__(self, cps):
        self._cps = cps

    def _load_prev_checkpoint(self, sid):
        return self._cps.get(sid)

    def _config_hash(self):
        return "h"


class TestTheMergeProjectsInsteadOfLosingEverything:
    """★★★Codex 재현 (09-01) — 옛 추출을 끄고 **대체 산출을 안 이으면**
    `total=0` 이라 빈 배열을 정상 completed 로 내고 엔티티가 통째로 사라진다.
    「중복 호출을 막았다」가 아니라 **전부 잃은 것**이다.
    """

    def _merge(self, cps):
        from app.core.steps.entity_steps import EntityMergeStep as E

        obj = _Step(cps)
        obj._chunk_projection = E._chunk_projection.__get__(obj)
        obj._execute = E._execute.__get__(obj)
        return obj

    def test_every_lane_survives_into_the_merge(self):
        got = self._merge({"grounding_chunk": CHUNK_CP})._execute()["data"]
        assert [e["short_id"] for e in got["characters"]] == ["C01"]
        assert [e["short_id"] for e in got["locations"]] == ["L01"]
        assert [e["short_id"] for e in got["props"]] == ["P01"]
        assert [e["short_id"] for e in got["location_parts"]] == ["LP01"]

    def test_it_calls_no_model(self):
        """★호출 0 투영 — 모델을 부르는 이름이 이 갈래에 없다."""
        import ast
        import inspect
        import textwrap

        from app.core.steps.entity_steps import EntityMergeStep as E

        src = textwrap.dedent(inspect.getsource(E._chunk_projection))
        calls = {ast.unparse(n.func) for n in ast.walk(ast.parse(src))
                 if isinstance(n, ast.Call)}
        for banned in ("call_structured", "call_llm", "_completion"):
            assert not [c for c in calls if banned in c], banned

    def test_it_says_where_it_came_from(self):
        got = self._merge({"grounding_chunk": CHUNK_CP})._execute()["data"]
        assert got["projected_from"] == "grounding_chunk"
        assert got["projection_contract"] == "3.202609012800"

    def test_a_missing_producer_output_stops(self):
        """★★음성 대조 — 없으면 **빈 배열로 정상 완료하면 안 된다**."""
        from app.core.errors import AppError

        with pytest.raises(AppError, match="grounding_chunk"):
            self._merge({})._execute()

    def test_the_merge_is_invalidated_by_the_producer(self):
        assert "grounding_chunk" in M["entity_merge"]["depends_on"]


class TestScreeningBuysNothingInTheChunkMode:
    """★★★Codex BLOCK-B — 입력만 바꾸고 `screen_subjects` 를 그대로 부르면
    **판별 LLM/VLM 을 또 산다**. 같은 뜻을 다시 판단하지 않는다.
    """

    SUBJECTS = [
        {"research_subject_id": "rs_a", "owner_type": "prop",
         "grounding_producer_payload": {"hard_to_generate": True,
                                        "viewers_would_notice": True}},
        {"research_subject_id": "rs_b", "owner_type": "prop",
         "grounding_producer_payload": {"hard_to_generate": True,
                                        "viewers_would_notice": False}},
        {"research_subject_id": "rs_c", "owner_type": "prop",
         "grounding_producer_payload": {}},
    ]

    def _projected(self):
        from app.modules.pipeline import grounding_screen as gs

        return gs.project_from_producer(self.SUBJECTS, dispositions={})

    def test_the_two_axes_decide_it(self):
        from app.modules.pipeline import grounding_screen as gs

        rows = {r["research_subject_id"]: r["screen"]
                for r in self._projected()["rows"]}
        assert rows["rs_a"] == gs.SCREEN_OBLIGATION
        assert rows["rs_b"] == gs.SCREEN_NOT_TARGET

    def test_a_missing_axis_is_unresolved_not_no(self):
        """★음성 대조 — producer 가 **안 낸 것**을 「아니다」로 접지 않는다."""
        from app.modules.pipeline import grounding_screen as gs

        rows = {r["research_subject_id"]: r["screen"]
                for r in self._projected()["rows"]}
        assert rows["rs_c"] == gs.SCREEN_UNRESOLVED

    def test_it_bought_nothing(self):
        got = self._projected()
        assert got["assess_bought"] == 0 and got["assess_reused"] == 0
        assert got["projected_from"] == "grounding_chunk"

    def test_it_calls_no_assess_function(self):
        """★★폭탄 대역 — 판별 함수가 불리면 **터진다**."""
        from app.modules.pipeline import era_research, grounding_screen as gs

        def _boom(*a, **k):
            raise AssertionError("★판별을 또 샀다")

        old = era_research.assess_plan_cached
        era_research.assess_plan_cached = _boom
        try:
            assert gs.project_from_producer(self.SUBJECTS,
                                            dispositions={})["rows"]
        finally:
            era_research.assess_plan_cached = old

    def test_the_step_takes_that_branch(self):
        import ast
        import inspect
        import textwrap

        from app.core.steps import grounding_screen_step as step

        src = textwrap.dedent(
            inspect.getsource(step.GroundingScreenStep._execute))
        calls = {ast.unparse(n.func) for n in ast.walk(ast.parse(src))
                 if isinstance(n, ast.Call)}
        assert "_screen.project_from_producer" in calls
        assert "uses_chunk_producer" in calls


class TestThePartOfRelationIsProjectedNotAsked:
    """★★★Codex BLOCK-C (09-01) — `EntityRelationStep` 에 C(c) 갈래가 없어서
    `location_part` 의 `part_of` RelationFact 가 **활성 경로에서 아예 안
    생겼다**. `project_part_of` 의 production caller 도 0이었다.
    """

    CP = {"data": {"grounding_part_of": [{"part": "LP01", "whole": "L01"}]}}

    def _step(self, cps):
        from app.core.steps.entity_relation_step import EntityRelationStep as R

        obj = _Step(cps)
        obj._chunk_projection = R._chunk_projection.__get__(obj)
        obj._execute = R._execute.__get__(obj)
        return obj

    def test_the_relation_lands(self):
        from app.modules.pipeline import grounding_relation_projection as rp

        got = self._step({"grounding_chunk": self.CP})._execute()["data"]
        assert len(got["relations"]) == 1
        r = got["relations"][0]
        assert r["relation_type"] == rp.RELATION_PART_OF
        assert {p["short_id"] for p in r["participants"]} == {"LP01", "L01"}

    def test_it_asks_no_model(self):
        import ast
        import inspect
        import textwrap

        from app.core.steps.entity_relation_step import EntityRelationStep as R

        src = textwrap.dedent(inspect.getsource(R._chunk_projection))
        calls = {ast.unparse(n.func) for n in ast.walk(ast.parse(src))
                 if isinstance(n, ast.Call)}
        assert "rp.project_part_of" in calls
        for banned in ("extract_entity_relations", "call_structured"):
            assert not [c for c in calls if banned in c], banned

    def test_an_unsupported_pair_is_recorded_not_fatal(self):
        """★2026-09-02 뒤집었다 — 안 받는 짝은 **주행을 안 세운다**.

        producer 의 `part_of` 는 한 벌인데 소비자가 둘이다. 아웃룩을 인물에
        붙이는 재료(`grounding_facet_binding`)가 같은 목록을 쓰므로,
        `O01→C01` 이 오는 것이 **옳다**. 좁은 것은 DB 투영이었고 앞 판은
        그것이 받아서 **실제 유료 주행을 세웠다**(2026-09-02 실측).
        ★조용히 버리지도 않는다 — 갈래와 까닭이 남는다.
        """
        from app.modules.pipeline import grounding_relation_projection as rp

        other = {"data": {"grounding_part_of": [
            {"part": "O01", "whole": "C01"},
            {"part": "LP01", "whole": "L01"}]}}
        got = self._step({"grounding_chunk": other})._execute()["data"]
        assert len(got["relations"]) == 1, "★받는 짝이 안 실렸다"
        left = got["part_of_not_for_db"]
        assert [r["owners"] for r in left] == [["outlook", "character"]]
        assert left[0]["why"]
        assert rp.PROJECTION_CONTRACT_VERSION

    def test_the_sync_path_is_still_strict(self):
        """★★음성 대조 — **DB 로 갈 때는** 여전히 같은 함수가 막는다."""
        from app.modules.pipeline import grounding_relation_projection as rp

        with pytest.raises(rp.RelationProjectionError):
            rp.project_part_of([{"part": "O01", "whole": "C01"}])

    def test_a_missing_producer_output_stops(self):
        from app.core.errors import AppError

        with pytest.raises(AppError, match="grounding_chunk"):
            self._step({})._execute()


class TestTheSyncMarkerOpensTheFourthLane:
    """★★★표식이 없으면 `owner_keys` 가 옛 셋만 내고 **LP 행이 조용히
    버려진다**. 표식을 내는 production 자리가 0이었다.
    """

    def _merged(self):
        from app.core.steps.entity_steps import EntityMergeStep as E

        obj = _Step({"grounding_chunk": CHUNK_CP})
        obj._chunk_projection = E._chunk_projection.__get__(obj)
        return obj._chunk_projection()

    def test_the_merge_stamps_it_at_the_top(self):
        """★표식은 CP **최상위**다 — `is_chunk_marked` 가 거기를 본다."""
        from app.modules.pipeline import grounding_entity_sync_ext as x

        got = self._merged()
        assert got[x.CHUNK_SCHEMA_MARKER] == x.CHUNK_SCHEMA_VERSION
        assert x.CHUNK_SCHEMA_MARKER not in got["data"]

    def test_the_sync_then_reads_the_fourth_lane(self):
        """★끝점 — sync 가 실제로 그 갈래를 연다."""
        from app.modules.pipeline import grounding_entity_sync_ext as x

        assert "location_parts" in x.owner_keys(self._merged())

    def test_without_the_marker_it_stays_three(self):
        """★음성 대조 — legacy CP 는 그 갈래를 **아예 안 본다**."""
        from app.modules.pipeline import grounding_entity_sync_ext as x

        assert x.owner_keys({"data": {"characters": []}}) == [
            "characters", "locations", "props"]

    def test_the_t2i_step_carries_it_forward(self):
        """★★sync 가 읽는 CP 는 `entity_t2i` 다 — 거기까지 가야 한다."""
        from app.core.steps.entity_steps import _carry_chunk_marker
        from app.modules.pipeline import grounding_entity_sync_ext as x

        obj = _Step({"entity_merge": self._merged()})
        assert _carry_chunk_marker(obj) == {
            x.CHUNK_SCHEMA_MARKER: x.CHUNK_SCHEMA_VERSION}

    def test_a_missing_marker_fails_closed_in_this_mode(self):
        """★★**뒤집었다** — 앞에는 「빈 dict 를 낸다」였는데, 그러면 저장·
        직렬화 한 칸이 빠져도 C/L/P 만 성공하고 **LP 만 조용히 사라진다**
        (Codex 2026-09-01). C(c) 판에서는 **선다**.
        """
        from app.core.errors import AppError
        from app.core.steps.entity_steps import _carry_chunk_marker

        with pytest.raises(AppError, match="조용히 사라진다"):
            _carry_chunk_marker(_Step({}))

    def test_legacy_still_passes_through(self):
        """★음성 대조 — 옛 판은 표식이 없어도 그대로 지나간다."""
        from app.core.steps.entity_steps import _carry_chunk_marker

        class _Legacy(_Step):
            project_config = {"grounding_mode": "legacy"}

        assert _carry_chunk_marker(_Legacy({})) == {}
