"""활성화 **안전문** — 하나라도 빠지면 조립이 선다. ★유료 0 · 활성화 0.

Codex (2026-09-01) — 「각 축 하나씩 빼는 음성 대조에서 `STEP_CATALOG =
_build()` 가 **provider 전에** 실패하도록 잠그십시오.」

★★안전문은 **양쪽**을 본다 — 켠 판이면 다섯 축을 다 갖췄나, 안 켠 판이면
그 새 경로가 **어떤 받는 mode 에서도 안 닿나**.
"""
from __future__ import annotations

import copy
from types import SimpleNamespace

import pytest

from app.core import grounding_activation_contract as act
from app.core import grounding_mode as gm
from app.core.step_catalog import STEP_CATALOG
from app.modules.pipeline.grounding_entity_contract import (
    MATERIALIZABLE_OWNER_TYPES, REFERENCE_SUPPORTED_OWNERS)

CHUNK = gm.GROUNDING_MODE_V2_CHUNK


def _e(sid, order, *, runner=object, applicability="always", depends=()):
    return SimpleNamespace(step_id=sid, order=order, runner_cls=runner,
                           applicability=applicability,
                           depends_on=list(depends))


def _switched_on():
    """켠 판의 **온전한** catalog. ★여기서 축을 하나씩 뺀다."""
    return {
        act.CHUNK_PRODUCER_STEP: _e(act.CHUNK_PRODUCER_STEP, 7.5),
        act.SCREEN_STEP: _e(act.SCREEN_STEP, 13.66),
        # ★옛 유료 셋은 **정적으로 못 끈다** — `v2` 가 여전히 받는 값이다.
        #  대신 그 술어(`if_grounding_v2`)를 쓰고, 그 술어가 `v2_chunk` 에서
        #  False 를 내는지를 안전문이 묻는다.
        act.PLAN_STEP: _e(act.PLAN_STEP, 13.65,
                          applicability=act.LEGACY_APPLICABILITY),
        "grounding_a0": _e("grounding_a0", 7.4,
                           applicability=act.LEGACY_APPLICABILITY),
        "grounding_research": _e("grounding_research", 13.67,
                                 applicability=act.LEGACY_APPLICABILITY),
        act.OUTLOOK_LAST_STEP: _e(act.OUTLOOK_LAST_STEP, 19.2),
        act.CENTRAL_ACQUISITION_STEP: _e(
            act.CENTRAL_ACQUISITION_STEP, 19.3,
            depends=(act.OUTLOOK_LAST_STEP, act.SCREEN_STEP)),
        act.SCENE_DETAIL_STEP: _e(act.SCENE_DETAIL_STEP, 21.0,
                                  depends=(act.CENTRAL_ACQUISITION_STEP,)),
        **{sid: _e(sid, 6.0, applicability=act.NOT_CHUNK_APPLICABILITY)
           for sid in act.LEGACY_EXTRACT_STEPS},
    }


def _check(catalog, *, accepted=(CHUNK,), planned=(),
           owners=MATERIALIZABLE_OWNER_TYPES, legacy=lambda m: False):
    act.assert_activation(catalog, accepted_modes=set(accepted),
                          planned_modes=set(planned), chunk_mode=CHUNK,
                          reference_owners=owners,
                          ledger_owners=MATERIALIZABLE_OWNER_TYPES,
                          buys_legacy_research=legacy)


class TestTodayItIsOn:
    """★★**뒤집은 시험**이다 (2026-09-01 D 활성화).

    앞에는 「안 켠 판」을 잠갔는데, 그것이 바로 이 커밋이 바꾸는 것이다.
    지우지 않고 **반대로** 잠근다 — 되돌아가면 여기서 잡힌다.
    """

    def test_the_real_catalog_has_the_producer(self):
        assert STEP_CATALOG and act.CHUNK_PRODUCER_STEP in STEP_CATALOG
        assert STEP_CATALOG[act.CHUNK_PRODUCER_STEP].runner_cls is not None

    def test_the_mode_is_accepted_not_planned(self):
        assert CHUNK in gm.GROUNDING_MODES and CHUNK not in gm.PLANNED_MODES
        assert not gm.PLANNED_MODES, "★계획 집합에 남은 값이 있다"

    def test_runtime_config_now_takes_it(self):
        assert gm.resolve_grounding_mode({"grounding_mode": CHUNK}) == CHUNK

    def test_the_screen_step_runs_on_the_chunk_producer(self):
        assert str(STEP_CATALOG[act.SCREEN_STEP].applicability) == \
            "if_chunk_producer"

    def test_the_reference_gate_takes_all_five(self):
        assert set(REFERENCE_SUPPORTED_OWNERS) == set(
            MATERIALIZABLE_OWNER_TYPES)

    def test_the_old_paid_lanes_are_off_in_this_mode(self):
        """★★같은 판에서 옛 갈래가 같이 돌면 **두 번 산다**."""
        assert gm.buys_v2_research(CHUNK) is False
        assert gm.uses_chunk_producer(CHUNK) is True
        for sid in act.LEGACY_PAID_STEPS:
            assert str(STEP_CATALOG[sid].applicability) == \
                act.LEGACY_APPLICABILITY

    def test_the_central_step_runs_after_outlook_and_before_the_scene(self):
        c = STEP_CATALOG
        assert c[act.OUTLOOK_LAST_STEP].order < \
            c[act.CENTRAL_ACQUISITION_STEP].order < \
            c[act.SCENE_DETAIL_STEP].order
        assert act.OUTLOOK_LAST_STEP in \
            c[act.CENTRAL_ACQUISITION_STEP].depends_on

    def test_turning_only_the_producer_on_stops_the_assembly(self):
        """★음성 대조 — 새 producer 만 켜진 **반쪽 판**은 선다."""
        half = {act.CHUNK_PRODUCER_STEP: _e(act.CHUNK_PRODUCER_STEP, 7.5)}
        with pytest.raises(act.ActivationContractError, match="반쪽"):
            _check(half, accepted=(), planned=(CHUNK,))

    def test_turning_only_the_screen_on_stops_the_assembly(self):
        half = {act.SCREEN_STEP: _e(act.SCREEN_STEP, 13.66)}
        with pytest.raises(act.ActivationContractError):
            _check(half, accepted=(), planned=(CHUNK,))


class TestOnceOnEveryAxisMustBeThere:
    """★★축을 **하나씩** 빼면 선다 — 그래야 이 문이 뜻이 있다."""

    def test_the_whole_thing_passes(self):
        _check(_switched_on())                    # ★양성 대조

    def test_a_missing_runner_stops(self):
        c = _switched_on()
        c[act.CENTRAL_ACQUISITION_STEP] = _e(
            act.CENTRAL_ACQUISITION_STEP, 19.3, runner=None,
            depends=(act.OUTLOOK_LAST_STEP,))
        with pytest.raises(act.ActivationContractError, match="runner"):
            _check(c)

    def test_a_step_still_disabled_stops(self):
        c = _switched_on()
        c[act.SCREEN_STEP] = _e(act.SCREEN_STEP, 13.66,
                                applicability=act.APPLICABILITY_DISABLED)
        with pytest.raises(act.ActivationContractError, match="안 돈다"):
            _check(c)

    @pytest.mark.parametrize("before", [act.SCREEN_STEP, act.PLAN_STEP,
                                        act.OUTLOOK_LAST_STEP])
    def test_researching_before_its_inputs_stops(self, before):
        c = _switched_on()
        c[before] = SimpleNamespace(**{**vars(c[before]), "order": 99.0})
        with pytest.raises(act.ActivationContractError, match="앞이다"):
            _check(c)

    def test_a_missing_direct_dependency_stops(self):
        """★차례만 맞고 의존이 없으면 **재개에서 어긋난다**."""
        c = _switched_on()
        c[act.CENTRAL_ACQUISITION_STEP] = _e(
            act.CENTRAL_ACQUISITION_STEP, 19.3, depends=(act.SCREEN_STEP,))
        with pytest.raises(act.ActivationContractError, match="직접 의존"):
            _check(c)

    def test_a_scene_that_does_not_depend_on_it_stops(self):
        """★★차례만 맞고 의존이 없으면 **옛 참조를 그대로 쓴다**."""
        c = _switched_on()
        c[act.SCENE_DETAIL_STEP] = _e(act.SCENE_DETAIL_STEP, 21.0)
        with pytest.raises(act.ActivationContractError, match="옛 참조"):
            _check(c)

    def test_researching_after_the_scene_stops(self):
        c = _switched_on()
        c[act.SCENE_DETAIL_STEP] = _e(act.SCENE_DETAIL_STEP, 19.1)
        with pytest.raises(act.ActivationContractError, match="참조 없이"):
            _check(c)

    @pytest.mark.parametrize("sid", act.LEGACY_PAID_STEPS)
    def test_an_old_paid_step_on_another_predicate_stops(self, sid):
        """★그 술어를 안 쓰면 **꺼지는지 알 수 없다**."""
        c = _switched_on()
        c[sid] = _e(sid, 13.6)                     # ★applicability 가 always
        with pytest.raises(act.ActivationContractError, match="안 쓴다"):
            _check(c)

    def test_a_predicate_still_true_under_the_chunk_mode_stops(self):
        """★★술어가 그 판에서 참이면 **옛 갈래와 새 갈래가 같이 돈다**."""
        with pytest.raises(act.ActivationContractError, match="두 번 산다"):
            _check(_switched_on(), legacy=lambda m: True)

    def test_the_real_predicate_is_false_under_the_chunk_mode(self):
        """★실물 확인 — `buys_v2_research` 는 `mode == v2` **정확 비교**다.

        그래서 `v2_chunk` 를 받는 집합에 넣는 순간 옛 셋이 자동으로 꺼진다.
        정적으로 `disabled` 로 바꾸면 **v2 주행이 깨진다**.
        """
        import inspect

        src = inspect.getsource(gm.buys_v2_research)
        assert "== GROUNDING_MODE_V2" in src
        assert gm.buys_v2_research(gm.GROUNDING_MODE_V2) is True

    @pytest.mark.parametrize("sid", act.LEGACY_EXTRACT_STEPS)
    def test_an_old_extract_left_always_on_stops(self, sid):
        """★★C(c) 는 같은 판독에서 엔티티 줄을 함께 낸다 — 옛 추출이 같이
        돌면 **같은 것을 두 번 산다**. 앞 판 안전문은 이것을 못 봤다."""
        c = _switched_on()
        c[sid] = _e(sid, 6.0)                      # ★applicability 가 always
        with pytest.raises(act.ActivationContractError, match="두 번 산다"):
            _check(c)

    def test_the_real_catalog_turns_all_six_off(self):
        for sid in act.LEGACY_EXTRACT_STEPS:
            assert str(STEP_CATALOG[sid].applicability) == \
                act.NOT_CHUNK_APPLICABILITY, sid

    def test_a_lane_the_gate_does_not_accept_stops(self):
        with pytest.raises(act.ActivationContractError, match="안 받는다"):
            _check(_switched_on(), owners=("character", "prop"))

    def test_accepting_all_five_passes(self):
        _check(_switched_on(), owners=MATERIALIZABLE_OWNER_TYPES)

    def test_being_in_both_sets_stops(self):
        with pytest.raises(act.ActivationContractError, match="둘 다"):
            _check(_switched_on(), accepted=(CHUNK,), planned=(CHUNK,))

    def test_a_step_missing_from_the_catalog_stops(self):
        for sid in (act.CHUNK_PRODUCER_STEP, act.SCREEN_STEP,
                    act.CENTRAL_ACQUISITION_STEP, act.SCENE_DETAIL_STEP):
            c = _switched_on()
            del c[sid]
            with pytest.raises(act.ActivationContractError):
                _check(c)


class TestTheGateRunsAtAssembly:
    """★★문이 **조립 자리**에 있어야 provider 전에 선다."""

    def test_the_build_calls_it(self):
        import ast
        import inspect

        from app.core import step_catalog as sc

        tree = ast.parse(inspect.getsource(sc._build))
        calls = {ast.unparse(n.func) for n in ast.walk(tree)
                 if isinstance(n, ast.Call)}
        assert "_act.assert_activation" in calls

    def test_it_runs_before_the_catalog_is_returned(self):
        import ast
        import inspect

        from app.core import step_catalog as sc

        body = ast.parse(inspect.getsource(sc._build)).body[0].body
        idx = {}
        for i, n in enumerate(body):
            src = ast.unparse(n)
            if "assert_activation" in src:
                idx["gate"] = i
            if isinstance(n, ast.Return):
                idx["return"] = i
        assert idx["gate"] < idx["return"], "★문이 반환 뒤에 있다"

    def test_the_contract_module_builds_no_second_list(self):
        """★새 SOT 를 안 만든다 — 집합은 **인자로** 받는다."""
        import ast
        import inspect

        tree = ast.parse(inspect.getsource(act))
        for n in ast.walk(tree):
            if not isinstance(n, (ast.Import, ast.ImportFrom)):
                continue
            mods = ([n.module] if isinstance(n, ast.ImportFrom)
                    else [a.name for a in n.names])
            for m in mods:
                assert "grounding_mode" not in str(m or ""), \
                    "★모드 집합을 import 했다 — 인자로 받아야 한다"
                assert "step_manifest" not in str(m or ""), \
                    "★manifest 를 import 했다 — 조립물을 받아야 한다"


class TestTheExecutionChainIsActuallyWired:
    """★★★Codex 재현 (2026-09-01) — 「모드가 켜졌다」는 선언이지 실행이 아니다.

    앞 판은 mode·manifest 만 켜고 **실행 사슬을 안 이었다** —
    `central_obligations` 는 시험에서만 불렸고 `write_for_shot` 은 부르는 쪽이
    **0곳**이었다. 즉 조사도 안 하고 sidecar 도 안 적었다.
    """

    def test_the_step_dispatches_to_the_central_branch(self):
        import ast
        import inspect

        from app.core.steps import reference_acquisition_step as step

        tree = ast.parse(textwrap_dedent(
            inspect.getsource(step.ReferenceAcquisitionStep._execute)))
        calls = {ast.unparse(n.func) for n in ast.walk(tree)
                 if isinstance(n, ast.Call)}
        assert "uses_chunk_producer" in calls, "★모드를 안 묻는다"
        assert "self._central" in calls, "★중앙 갈래를 안 부른다"

    def test_the_central_branch_calls_every_public_endpoint(self):
        import ast
        import inspect

        from app.core.steps import reference_acquisition_step as step

        tree = ast.parse(textwrap_dedent(
            inspect.getsource(step.ReferenceAcquisitionStep._central)))
        calls = {ast.unparse(n.func) for n in ast.walk(tree)
                 if isinstance(n, ast.Call)}
        for want in ("self.central_obligations", "self.central_result",
                     "self.central_wrap"):
            assert want in calls, f"★{want} 를 안 부른다"

    def test_the_provider_parts_are_the_existing_ones(self):
        """★새 구매 구현을 안 짓는다 — 기존 부품을 부른다."""
        import ast
        import inspect

        from app.core.steps import reference_acquisition_step as step

        # ★2026-09-03: 스텝 메서드는 모듈 공장(make_search/make_download/make_judge)의 wrapper 다 —
        #  야외 보충(21.915)이 같은 공장을 부른다. 부품 검사는 공장 본문에 한다.
        for n in ("_search", "_download", "_judge"):
            m_src = textwrap_dedent(inspect.getsource(getattr(step.ReferenceAcquisitionStep, n)))
            assert "make_" in m_src, f"★{n} 이 공장을 안 부른다"
        src = "\n".join(textwrap_dedent(inspect.getsource(fn))
                        for fn in (step.make_search, step.make_download, step.make_judge))
        for want in ("search_reference_images", "download_candidate",
                     "coarse_type_pick", "call_structured"):
            assert want in src, f"★{want} 를 안 쓴다"
        assert "urlopen" not in src and "requests" not in src

    def test_the_sidecar_writer_has_a_production_caller(self):
        """★★`write_for_shot` 을 부르는 곳이 **0곳**이면 sidecar 는 안 적힌다."""
        import ast
        from pathlib import Path

        root = Path(__file__).resolve().parents[2] / "app"
        hits = []
        for f in root.rglob("*.py"):
            if f.name == "grounding_sidecar_writer.py":
                continue
            try:
                tree = ast.parse(f.read_text(encoding="utf-8"))
            except SyntaxError:                     # noqa: PERF203
                continue
            for n in ast.walk(tree):
                if (isinstance(n, ast.Call)
                        and ast.unparse(n.func).endswith("write_for_shot")):
                    hits.append(f.name)
        assert hits, "★sidecar 를 적는 production 자리가 없다"

    def test_the_sidecar_is_written_before_the_card_hash(self):
        """★★해시 **뒤**에 적으면 sidecar 가 지문에 안 들어간다."""
        import inspect

        from app.core.steps import detail_steps as ds

        src = inspect.getsource(ds)
        w = src.index("_write_grounding_sidecar(self,")
        h = src.index('result["render_prompt_card_hash"]', w)
        assert w < h

    def test_the_scene_depends_on_the_central_checkpoint(self):
        from app.core.step_manifest import STEP_MANIFEST as M

        assert "reference_acquisition" in M["scene_detail"]["depends_on"]


def textwrap_dedent(src: str) -> str:
    import textwrap

    return textwrap.dedent(src)


class TestEveryGateHasSomeoneWhoEnforcesIt:
    """★★선언만 있고 **집행하는 모듈이 없는** 값이 있으면 조립이 선다.

    Codex 2026-09-01: 「선언만 있고 consumer 0 인 값도 activation assertion 이
    잡아야 합니다.」 — 「누락을 안다」는 자동화 완료가 아니다.
    """

    def test_all_three_gates_are_implemented_today(self):
        from app.core import grounding_activation_contract as ac
        from app.modules.pipeline import grounding_entity_contract as gc

        assert set(gc.ENFORCEMENT_GATES) == ac.implemented_gates()

    def test_a_gate_with_no_module_stops(self, monkeypatch):
        from app.core import grounding_activation_contract as ac
        from app.modules.pipeline import grounding_entity_contract as gc

        monkeypatch.setattr(gc, "ENFORCEMENT_GATES",
                            tuple(gc.ENFORCEMENT_GATES) + ("아무도_안_함",))
        with pytest.raises(ac.ActivationContractError) as e:
            ac._assert_every_gate_is_implemented()
        assert "아무도_안_함" in str(e.value)

    def test_a_module_declaring_an_unknown_gate_stops(self, monkeypatch):
        from app.core import grounding_activation_contract as ac
        from app.modules.pipeline import grounding_sidecar_writer as sw

        monkeypatch.setattr(sw, "ENFORCES_GATE", "모르는_집행자")
        with pytest.raises(ac.ActivationContractError):
            ac._assert_every_gate_is_implemented()

    def test_each_implementor_is_actually_called_from_app(self):
        """★★선언한 모듈의 **집행 함수를 실제로 부르는 자리**가 있나.

        ★선언은 선언일 뿐이다 — 아무도 안 부르면 그 갈래는 조용히 사라진다
        (`feedback-a-contract-with-no-producer` 부류를 하루에 세 번 만났다).
        """
        import ast
        from pathlib import Path as _P

        root = _P(__file__).resolve().parents[2] / "app"
        #: 집행자 → 그 문을 실제로 여는 함수 이름
        DOORS = {"policy": "_central_forced_short_ids",
                 "background_sidecar": "members_from_rows",
                 "character_outlook_attachment": "outlook_members_for_shot"}
        called = set()
        for f in root.rglob("*.py"):
            tree = ast.parse(f.read_text(encoding="utf-8"))
            for n in ast.walk(tree):
                if not isinstance(n, ast.Call):
                    continue
                fn = n.func
                name = (fn.attr if isinstance(fn, ast.Attribute)
                        else getattr(fn, "id", ""))
                called.add(str(name))
        missing = [g for g, fn in DOORS.items() if fn not in called]
        assert missing == [], (
            f"★{missing} 의 집행 함수를 `app/` 에서 부르는 자리가 없다 — "
            f"선언만 있고 아무도 안 연다")
