"""★★참조 신원을 지배하는 것은 **한 곳에서만** 센다 (Codex).

앞쪽 스텝과 `image_steps` 가 각각 산식을 적으면 한쪽만 고쳐진다. 그러면 팩을
바꿔도 바깥 이미지 스텝이 stale 이 안 돼 **옛 참조가 영구 봉인**된다 —
`image_steps` 주석에 그 사고가 이미 적혀 있다.
"""
import pytest

from app.modules.pipeline import coarse_type_pick as ctp
from app.modules.pipeline import reference_acquisition as ra


class TestOnePlaceComputesIt:
    def test_it_is_stable(self):
        assert ra.acquisition_contract_sha(rounds=1) == ra.acquisition_contract_sha(rounds=1)

    def test_it_is_a_short_hex(self):
        got = ra.acquisition_contract_sha(rounds=1)
        assert len(got) == 16 and int(got, 16) >= 0


class TestItMovesWhenTheThingsThatDecideTheReferenceMove:
    """★팩 바이트 하나가 바뀌면 움직여야 한다."""

    def _with(self, monkeypatch, **attrs):
        for k, v in attrs.items():
            monkeypatch.setattr(ctp, k, v)
        return ra.acquisition_contract_sha(rounds=1)

    def test_the_actual_round_count_moves_it(self):
        """★상수가 아니라 **실제로 몇 번 찾는가**를 접는다."""
        assert ra.acquisition_contract_sha(rounds=1) \
            != ra.acquisition_contract_sha(rounds=2)

    def test_the_bound_itself_is_not_folded(self, monkeypatch):
        """★`MAX_ROUNDS` 는 상한이다. 접으면 「1라운드인데 키는 2」가 난다."""
        before = ra.acquisition_contract_sha(rounds=1)
        monkeypatch.setattr(ctp, "MAX_ROUNDS", 3)
        assert ra.acquisition_contract_sha(rounds=1) == before

    def test_the_per_round_cap_moves_it(self, monkeypatch):
        """★5장에서 고른 것과 12장에서 고른 것은 같은 참조가 아니다."""
        before = ra.acquisition_contract_sha(rounds=1)
        assert self._with(monkeypatch, PER_ROUND_CAP=12) != before

    def test_the_contract_version_moves_it(self, monkeypatch):
        before = ra.acquisition_contract_sha(rounds=1)
        monkeypatch.setattr(ra, "ACQUISITION_CONTRACT_VERSION", 99)
        assert ra.acquisition_contract_sha(rounds=1) != before

    def test_the_coarse_pack_moves_it(self, monkeypatch):
        """★없는 팩을 가리키면 stem 이 빈 값이 되어 해시가 달라진다."""
        before = ra.acquisition_contract_sha(rounds=1)
        assert ra.acquisition_contract_sha(rounds=1, coarse_version="0.없는것") != before

    def test_the_search_pack_moves_it(self, monkeypatch):
        from app.modules.pipeline import search_grounded_ref as sgr

        before = ra.acquisition_contract_sha(rounds=1)
        monkeypatch.setattr(sgr, "search_contract_sha",
                            lambda *a, **k: "다른값0000000000")
        assert ra.acquisition_contract_sha(rounds=1) != before


class TestItDoesNotFoldTheTargetSet:
    """★**어느 엔티티가 이 길을 타는가**는 안 접는다.

    그것은 대상 집합을 정할 뿐 **한 대상의 참조 산출에 기여하지 않는다** —
    접으면 다른 대상의 난이도가 바뀌었다고 이미 뽑아 둔 참조가 무효가 된다.
    """

    def test_the_planner_contract_is_not_folded(self, monkeypatch):
        from app.modules.pipeline import grounding_planner as gp

        before = ra.acquisition_contract_sha(rounds=1)
        monkeypatch.setattr(gp, "PLANNER_CONTRACT_VERSION",
                            gp.PLANNER_CONTRACT_VERSION + 1)
        assert ra.acquisition_contract_sha(rounds=1) == before

    def test_the_classifier_pack_is_not_folded(self, monkeypatch):
        from app.modules.pipeline import grounding_classifier as gc

        before = ra.acquisition_contract_sha(rounds=1)
        monkeypatch.setattr(gc, "PROMPT_PACK_VERSION", "99.없는팩")
        assert ra.acquisition_contract_sha(rounds=1) == before


class TestNothingWaitsForAPerson:
    """★★**사람 대기를 만들지 않는다** (사용자 확정 2026-08-31).

    「궁극적 목적은 자동화이니 HITL을 무조건 필요한 요소로 하면 안 된다.
    차후 UI에서 수동 수정하게 고칠 예정이니 지금은 무조건 HITL이 없어야 한다.」

    ★앞 계약은 「참조를 못 구하면 하류를 막는다」였다. 그러면 그 자리에서
    **주행이 사람을 기다린다** — 그것이 HITL 이다. 시험을 지우지 않고
    **뒤집는다**: 지키려던 것(참조 없이 조용히 내려가지 않기)은
    **기록으로** 지킨다.
    """

    def test_missing_reference_does_not_stop_the_run(self):
        assert ra.ALLOW_MISSING_REFERENCE is True

    def test_nothing_blocks_downstream(self):
        for st in (ra.STATUS_SELECTED, ra.STATUS_NO_MATCH,
                   ra.STATUS_RETRYABLE):
            assert ra.downstream_blocked(st) is False, f"★{st} 가 막는다"

    def test_the_outcome_is_recorded_not_silent(self):
        """★막지 않는 대신 **처지를 남긴다** — 조용히 내려가는 것이 아니다."""
        assert ra.acquisition_outcome(ra.STATUS_SELECTED) == ra.STATUS_SELECTED
        for st in (ra.STATUS_NO_MATCH, ra.STATUS_RETRYABLE):
            assert ra.acquisition_outcome(st) == ra.STATUS_UNAVAILABLE

    def test_the_two_failure_kinds_stay_distinguishable(self):
        """★하류는 둘 다 참조 없이 가지만 **기록에는 그대로 남는다**."""
        assert ra.STATUS_NO_MATCH != ra.STATUS_RETRYABLE

    def test_the_blocking_question_is_asked_in_one_place(self):
        """★정책이 또 바뀌어도 한 곳만 고치게 — 함수를 지우지 않았다."""
        import inspect

        assert callable(ra.downstream_blocked)
        assert "사람 대기" in inspect.getsource(ra.downstream_blocked)

    def test_no_match_and_retryable_are_different_states(self):
        """★「다 보고 없었다」와 「다 못 봤다」는 다음에 할 일이 다르다."""
        assert ra.STATUS_NO_MATCH != ra.STATUS_RETRYABLE


class TestTheOuterImageStepFoldsTheSameCallable:
    """★★`image_steps` 가 **산식을 다시 적으면** 한쪽만 고쳐진다.

    그 파일 주석에 사고가 이미 적혀 있다 — 「팩만 바뀐 운영에서 완료 스텝이
    outer SKIP 돼 참조 신원 재계산이 안 돌고 **옛 참조·배경이 영구 봉인**된다」.
    """

    @staticmethod
    def _src() -> str:
        from pathlib import Path

        import app.core.steps.image_steps as m
        return Path(m.__file__).read_text(encoding="utf-8")

    def test_it_calls_the_exported_callable(self):
        src = self._src()
        assert "acquisition_contract_sha" in src
        assert "reference_acquisition_contract" in src

    def test_it_does_not_recompute_the_formula(self):
        """★해시를 여기서 다시 만들면 두 벌이 된다."""
        src = self._src()
        for own in ("PER_ROUND_CAP", "MAX_ROUNDS", "coarse_type_pick"):
            assert own not in src, f"★{own} 을 바깥 스텝이 직접 본다 — 두 벌이다"

    def test_the_old_search_contract_is_still_folded(self):
        """★기존 것을 **빼지 않는다** — legacy 참조 신원이 그것에 매여 있다."""
        assert "era_search_contract" in self._src()


class TestOnlyOnePlaceBuys:
    """★★앞뒤가 각각 사면 **같은 것을 두 번 산다.**

    Codex: 「v2 에서는 early+late 이중 호출 0 을 먼저 잠그십시오.」
    """

    def test_v2_with_a_front_checkpoint_makes_the_front_the_owner(self):
        assert ra.acquisition_owner("v2", front_checkpoint_exists=True) \
            == ra.OWNER_FRONT

    def test_v2_without_one_falls_back_to_the_outdoor_step(self):
        """★이관 중 **명시적 legacy fallback**. 앞쪽이 아직 안 돈 주행이 있다."""
        # ★뒤집음 (2026-09-03, 실측 4398a55dc0bb): 사는 모드에서 앞쪽이 없으면 legacy 가 아니라 **선다**
        with pytest.raises(ra.FrontCheckpointMissing):
            ra.acquisition_owner("v2", front_checkpoint_exists=False)

    def test_legacy_always_uses_the_outdoor_step(self):
        assert ra.acquisition_owner("legacy", front_checkpoint_exists=True) \
            == ra.OWNER_OUTDOOR

    def test_shadow_plan_does_not_buy_v2_research(self):
        assert ra.acquisition_owner("shadow_plan", front_checkpoint_exists=True) \
            == ra.OWNER_OUTDOOR

    def test_v2_chunk_with_a_front_checkpoint_makes_the_front_the_owner(self):
        """★★실측 2026-09-03: v2_chunk 에선 중앙 CP 가 있어도 야외가 다시 샀다."""
        assert ra.acquisition_owner("v2_chunk", front_checkpoint_exists=True) \
            == ra.OWNER_FRONT

    def test_the_door_and_the_manifest_predicate_share_one_function(self):
        """★같은 규칙이 두 곳에 있으면 한쪽만 고쳐진다 — 둘 다 `buys_reference` 를
        **이름으로** 부르는지 AST 로 본다 (문자열이 아니라 호출)."""
        import ast
        import inspect
        import textwrap

        from app.core import applicability as ap

        def _calls(fn):
            tree = ast.parse(textwrap.dedent(inspect.getsource(fn)))
            return {n.func.id for n in ast.walk(tree)
                    if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
        assert "buys_reference" in _calls(ra.acquisition_owner)
        assert "buys_reference" in _calls(ap._if_grounding_reference)
        assert "buys_v2_research" not in _calls(ra.acquisition_owner)
        assert "buys_v2_research" not in _calls(ap._if_grounding_reference)

    def test_the_existence_flag_is_explicit_not_guessed(self):
        """★「v2 면 앞쪽이 있겠지」로 유추하면, 앞쪽이 안 돈 주행에서 야외가
        조용히 아무것도 못 사고 순수 T2I 로 내려간다."""
        import inspect

        sig = inspect.signature(ra.acquisition_owner)
        assert "front_checkpoint_exists" in sig.parameters
        assert sig.parameters["front_checkpoint_exists"].kind \
            == inspect.Parameter.KEYWORD_ONLY


class TestBuyingSomewhereElseStops:
    def test_the_owner_may_buy(self):
        ra.assert_not_buying(ra.OWNER_FRONT, ra.OWNER_FRONT)

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

        with pytest.raises(AppError) as exc:
            ra.assert_not_buying(ra.OWNER_FRONT, ra.OWNER_OUTDOOR)
        assert exc.value.code == "reference_acquisition.not_the_owner"

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

        with pytest.raises(AppError) as exc:
            ra.assert_not_buying(ra.OWNER_OUTDOOR, ra.OWNER_FRONT)
        assert "두 번 산다" in str(exc.value)


class TestTheOutdoorStepConsultsTheOwner:
    """★잠그지 않으면 다음 사람이 야외 스텝에서 그대로 또 산다."""

    @staticmethod
    def _src() -> str:
        from pathlib import Path

        import app.core.steps.outdoor_structure_form_reference_step as m
        return Path(m.__file__).read_text(encoding="utf-8")

    def test_it_asks_who_the_owner_is(self):
        src = self._src()
        assert "acquisition_owner" in src
        assert "reference_acquisition" in src

    def test_it_returns_without_buying_when_it_is_not_the_owner(self):
        """★★글자 순서로 재면 안 된다 — helper 가 파일 **앞**에 있다.
        **동작**으로 잰다: 앞쪽이 소유자면 검색에 **닿기 전에** 서야 한다."""
        from unittest.mock import patch

        import app.core.steps.outdoor_structure_form_reference_step as m
        from app.modules.pipeline import search_grounded_ref as sgr

        st = m.OutdoorStructureFormReferenceStep.__new__(
            m.OutdoorStructureFormReferenceStep)
        st.project_config = {"project_id": "p", "grounding_mode": "v2"}
        st._load_prev_checkpoint = lambda sid: (
            {"data": {"records": []}} if sid == "reference_acquisition" else None)

        def _boom(*a, **k):                      # 검색에 닿으면 여기서 터진다
            raise AssertionError("★소유자가 아닌데 검색을 불렀다")

        from app.core.errors import AppError

        with patch.object(sgr, "search_reference_images", _boom):
            got = st._execute()
        # ★야외 그룹이 없으면 투영할 것도 없다 — 검색에는 안 닿았다(닿으면 _boom 이 선다)
        assert got["applicable_count"] == 0 and got["data"]["groups"] == {}

    def test_it_does_not_guess_v2_when_there_is_no_config(self):
        """★★없는 설정을 v2 로 짐작하면 **정상 주행이 통째로 막힌다.**

        실제로 그렇게 해서 야외 시험 54건이 깨졌다 — `project_config` 가 없는
        자리(시험 대역·부분 조립)가 있다.
        """
        assert 'getattr(self, "project_config", None) or {}' in self._src()

    def test_the_projection_does_not_invent_a_shape(self):
        """★앞쪽이 산출을 내기 전에 모양을 지어내면 그 모양이 계약이 된다."""
        src = self._src()
        assert "_project_front_checkpoint" in src
        assert "front_output_missing" in src, \
            "★필수 group 이 없으면 fail-closed — 빈 CP 로 낮추지 않는다"
