"""★2A — `era_research` 를 **경계만 나눈다.** 동작은 한 글자도 안 바뀐다.

Codex 판정: 「2A 순수 함수 분리 직후에는 **byte-identical** 이어야 한다.
2B coarse picker + 1회 retry 계약을 붙인 뒤에는 **반드시 달라져야** 한다.」

★그리고 「**이 행렬이 캐시 키 문자열 하나보다 실제 불변식을 더 정확히
잰다**」 — 아래 상태 전이·부작용을 실제 `cache_get`/`cache_put` 대역과
provider 대역으로 끝점화한다.
"""
from pathlib import Path
from unittest.mock import patch

import pytest

from app.modules.pipeline import era_research as er


class _Cache:
    """실제 cache_get/cache_put 대역 — **쓰기 순서까지** 본다."""

    def __init__(self):
        self.store = {}
        self.writes = []          # (key, value) 순서 그대로

    def get(self, key):
        return self.store.get(key)

    def put(self, key, value):
        self.store[key] = value
        self.writes.append((key, value))

    def keys_written(self):
        return [k for k, _ in self.writes]


def _run(tmp_path, *, subjects=None, assess_raises=False, meta=None,
         cache=None, memo=None, outcome=None, write_file=True):
    cache = cache if cache is not None else _Cache()
    calls = {"assess": 0, "research": 0}

    def _assess(**kw):
        calls["assess"] += 1
        if assess_raises:
            raise RuntimeError("판별이 터졌다")
        return list(subjects or [])

    def _research(*, subject, out_path, **kw):
        calls["research"] += 1
        if meta is None:
            return None
        if write_file:
            Path(out_path).write_bytes(b"\x89PNG fake")
        return dict(meta)

    with patch.object(er, "assess_subjects", _assess), \
            patch.object(er, "research_reference", _research):
        got = er.assess_and_research_cached(
            step_tag="t", subject_text="1983년 서울역 대합실",
            world_facts_block="KR / 1983", out_dir=tmp_path,
            cache_get=cache.get, cache_put=cache.put,
            failed_memo=memo, outcome=outcome)
    return got, cache, calls


_SUBJ = [{"subject_native": "1983년 서울역", "search_terms_native": ["가", "나"],
          "language_lock_native": "한국어로만 검색하세요", "reason_ko": "옛 역사"}]


def _meta(sha="abc"):
    import hashlib
    return {"subject": "1983년 서울역", "sha256":
            hashlib.sha256(b"\x89PNG fake").hexdigest(), "file": "x.png"}


class TestAssessTransitions:
    def test_miss_calls_once_and_stores_positive(self, tmp_path):
        _got, cache, calls = _run(tmp_path, subjects=_SUBJ, meta=_meta())
        assert calls["assess"] == 1
        a = [k for k in cache.keys_written() if k.startswith("era_assess::")]
        assert len(a) == 1
        assert cache.store[a[0]]["subjects"] == _SUBJ
        # ★무엇을 넣었는지도 남는다 — 산출만 남기면 재현이 안 된다
        assert cache.store[a[0]]["subject_text"] == "1983년 서울역 대합실"

    def test_empty_is_stored_and_not_re_asked(self, tmp_path):
        got, cache, calls = _run(tmp_path, subjects=[])
        assert got is None, "비대상은 None"
        a = [k for k in cache.keys_written() if k.startswith("era_assess::")]
        assert len(a) == 1 and cache.store[a[0]]["subjects"] == []
        # 같은 캐시로 다시 — 재판별 0
        _g2, _c2, calls2 = _run(tmp_path, subjects=[], cache=cache)
        assert calls2["assess"] == 0, "★빈 목록을 다시 샀다"

    def test_failure_stores_no_success_and_bumps_attempts(self, tmp_path):
        memo = set()
        got, cache, calls = _run(tmp_path, assess_raises=True, memo=memo)
        assert got is None and calls["assess"] == 1
        assert not [k for k in cache.keys_written()
                    if k.startswith("era_assess::")], "★실패를 성공으로 캐시했다"
        fails = [k for k in cache.keys_written() if k.startswith("era_fail::")]
        assert len(fails) == 1
        assert cache.store[fails[0]]["attempts"] == 1
        assert cache.store[fails[0]]["stage"] == "assess"
        assert memo, "★같은 걷기 재시도를 막는 memo 가 안 찼다"

    def test_the_same_walk_does_not_retry_a_failure(self, tmp_path):
        memo = set()
        _g, cache, _c = _run(tmp_path, assess_raises=True, memo=memo)
        _g2, _c2, calls2 = _run(tmp_path, assess_raises=True, memo=memo,
                                cache=cache)
        assert calls2["assess"] == 0, "★같은 걷기에서 또 샀다"

    def test_attempts_grow_monotonically(self, tmp_path):
        """★안 그러면 「무상한 재검색이 열린다」 — 지출 감지가 못 잡는다."""
        cache = _Cache()
        _run(tmp_path, assess_raises=True, cache=cache)
        _run(tmp_path, assess_raises=True, cache=cache)
        fails = [v for k, v in cache.writes if k.startswith("era_fail::")]
        assert [f["attempts"] for f in fails] == [1, 2]


class TestAcquireTransitions:
    def test_miss_acquires_once_and_stores_content_addressed(self, tmp_path):
        _got, cache, calls = _run(tmp_path, subjects=_SUBJ, meta=_meta())
        assert calls["research"] == 1
        r = [k for k in cache.keys_written() if k.startswith("era_ref::")]
        assert len(r) == 1
        sha = r[0].split("::", 1)[1]
        assert (tmp_path / f"eraref_{sha}.png").is_file()

    def test_a_cache_hit_with_matching_file_does_not_acquire(self, tmp_path):
        _g, cache, _c = _run(tmp_path, subjects=_SUBJ, meta=_meta())
        _g2, _c2, calls2 = _run(tmp_path, subjects=_SUBJ, meta=_meta(),
                                cache=cache)
        assert calls2["research"] == 0 and calls2["assess"] == 0

    def test_a_missing_file_is_not_faked_as_a_hit(self, tmp_path):
        """★파일이 사라졌는데 캐시 적중으로 속이면 없는 참조를 쓴다."""
        _g, cache, _c = _run(tmp_path, subjects=_SUBJ, meta=_meta())
        for f in tmp_path.glob("eraref_*.png"):
            f.unlink()
        _g2, _c2, calls2 = _run(tmp_path, subjects=_SUBJ, meta=_meta(),
                                cache=cache)
        assert calls2["research"] == 1, "★없는 파일을 적중으로 읽었다"

    def test_a_hash_mismatch_is_not_a_hit_either(self, tmp_path):
        _g, cache, _c = _run(tmp_path, subjects=_SUBJ, meta=_meta())
        for f in tmp_path.glob("eraref_*.png"):
            f.write_bytes("다른 내용".encode())
        _g2, _c2, calls2 = _run(tmp_path, subjects=_SUBJ, meta=_meta(),
                                cache=cache)
        assert calls2["research"] == 1

    def test_research_failure_stores_no_success_and_audits(self, tmp_path):
        memo = set()
        got, cache, calls = _run(tmp_path, subjects=_SUBJ, meta=None, memo=memo)
        assert got is None and calls["research"] == 1
        assert not [k for k in cache.keys_written()
                    if k.startswith("era_ref::")], "★실패를 성공으로 캐시했다"
        fails = [v for k, v in cache.writes if k.startswith("era_fail::")]
        assert fails and fails[-1]["stage"] == "research"
        assert memo

    def test_the_same_walk_does_not_retry_a_research_failure(self, tmp_path):
        memo = set()
        _g, cache, _c = _run(tmp_path, subjects=_SUBJ, meta=None, memo=memo)
        _g2, _c2, calls2 = _run(tmp_path, subjects=_SUBJ, meta=None,
                                memo=memo, cache=cache)
        assert calls2["research"] == 0


class TestTheBoundaryIsExplicit:
    """★2A 는 **경계만** 나눈다 — 그 경계가 어디인지 코드에 적혀 있어야
    다음 사람이 19.x 를 그 자리에 붙인다."""

    @staticmethod
    def _src():
        return Path(er.__file__).read_text(encoding="utf-8")

    def test_the_two_halves_are_real_callables(self):
        """★★주석만으로는 **앞쪽이 판별만 하고 멈출 수 없다** (Codex BLOCK-2).

        내 첫 2A 보고가 코드보다 셌다 — 경계 주석과 `build_assess_plan` 만
        더했고 monolith 는 그대로 둘을 연달아 불렀다.
        """
        import inspect

        assert callable(er.assess_plan_cached)
        assert callable(er.acquire_from_plan_cached)
        # ★판별 쪽은 획득을 **안 부른다** — 부르면 앞쪽이 멈출 수 없다
        assert "research_reference" not in inspect.getsource(er.assess_plan_cached)
        # ★획득 쪽은 판별을 **안 부른다** — 부르면 중앙이 또 판별한다
        assert "assess_subjects" not in inspect.getsource(
            er.acquire_from_plan_cached)

    def test_the_old_entry_is_now_a_thin_wrapper(self):
        """★기존 공개 함수는 **둘을 순서대로 부르는** 것뿐이어야 한다."""
        import inspect

        src = inspect.getsource(er.assess_and_research_cached)
        body = src[src.index('"""', src.index('"""') + 3) + 3:]
        assert "assess_plan_cached(" in body
        assert "acquire_from_plan_cached(" in body
        # ★키 산식을 wrapper 가 **또 적으면** 두 벌이 된다
        assert "_cache_sha(" not in body
        assert "era_assess::" not in body and "era_ref::" not in body

    def test_the_plan_is_durable_enough_to_replay(self, tmp_path):
        """★`subjects[0]` 만 두면 **같은 판별을 되짚을 수 없다** (Codex)."""
        out = {}
        _run(tmp_path, subjects=_SUBJ, meta=_meta(), outcome=out)
        plan = out["assess_plan"]
        assert plan["subjects"] == _SUBJ
        assert plan["assess_key"].startswith("era_assess::")
        assert plan["assess_sha"]
        c = plan["assess_contract"]
        assert c["model_alias"] == er.ASSESS_MODEL
        assert c["model_physical"] and c["pack"] and c["pack_content_hash"]

    def test_the_plan_records_which_identity_was_used(self, tmp_path):
        """★canonical 인지 fallback 인지 — 「잘못 합치는 것이 중복 조사보다
        나쁘다」의 근거가 남아야 한다."""
        out = {}
        _run(tmp_path, subjects=_SUBJ, meta=_meta(), outcome=out)
        plan = out["assess_plan"]
        assert plan.get("identity_fallback") or plan.get("identity")

    def test_the_public_return_shape_did_not_change(self, tmp_path):
        """★2A 는 호환 refactor 다 — late caller 가 읽는 칸이 그대로다."""
        got, _c, _calls = _run(tmp_path, subjects=_SUBJ, meta=_meta())
        assert set(_meta()).issubset(got)
        assert "path" in got and got["path"].endswith(".png")


class TestTheKeysAreByteIdentical:
    """★★2A 의 통과 조건 — **키 산식이 한 글자도 안 바뀌었다** (Codex).

    `era_assess::` 도 `era_ref::` 도 그대로다. 2B(coarse picker + 1회 retry)
    를 붙인 **뒤에야** `era_ref::` 가 달라져야 하고, 그때는 **달라지는 것이
    통과 조건**이다 — 옛 picker 가 고른 참조를 새 계약의 결과인 척 재사용하면
    사용자 계약을 영구 우회한다.
    """

    def test_the_assess_key_still_folds_the_same_things(self):
        """★판별 계약은 안 바뀌었으니 옛 판별·빈 목록을 그대로 써야 한다.

        ★2026-08-31: 키 조립이 `assess_cache_key` 로 나왔다 — 앞쪽 판별이
        **사기 전에** 「이미 있나」를 알아야 하는데, 조립을 베끼면 두 곳이
        갈린다. 소스 위치가 아니라 **값**으로 잠근다.
        """
        import inspect

        src = inspect.getsource(er.assess_cache_key)
        for part in ("world_facts_block", "ERA_RESEARCH_POLICY_VERSION",
                     "pack", "era_pack_content_hash()", "ASSESS_MODEL",
                     "resolve_model_physical(ASSESS_MODEL)"):
            assert part in src, part

    def test_the_assess_key_bytes_did_not_move(self):
        """★★**옛 조립과 같은 값**인지 — 소스 문자열이 아니라 값으로.

        여기가 갈리면 이미 산 판별·빈 목록을 통째로 다시 산다.
        """
        pack = er.resolve_era_pack()
        for scope in (("L1", "interior", "abc"), (None, None, None)):
            got = er.assess_cache_key(
                subject_text="대상", world_facts_block="W",
                canonical_scope_id=scope[0], canonical_scope_role=scope[1],
                canonical_scope_sha=scope[2], pack=pack)
            ok, _n = er._identity_of(*scope)
            parts = (tuple(str(x) for x in scope) if ok else ("대상",))
            want = "era_assess::" + er._cache_sha(
                *parts, "W", er.ERA_RESEARCH_POLICY_VERSION, pack,
                er.era_pack_content_hash(), er.ASSESS_MODEL,
                er.resolve_model_physical(er.ASSESS_MODEL))
            assert got == want, f"★키가 움직였다 (scope={scope})"

    def test_the_split_and_the_key_helper_agree(self):
        """★판별이 실제로 쓰는 키가 helper 가 내는 키와 **같은가**.

        갈리면 앞쪽이 「공짜다」로 읽고 상한 밖에서 불렀다가 **실제로 산다**.
        """
        seen = []
        er.assess_plan_cached(
            step_tag="t", subject_text="대상", world_facts_block="W",
            cache_get=lambda k: seen.append(k) or {"subjects": []},
            cache_put=lambda k, v: None,
            canonical_scope_id="L1", canonical_scope_role="interior",
            canonical_scope_sha="abc")
        assert seen == [er.assess_cache_key(
            subject_text="대상", world_facts_block="W",
            canonical_scope_id="L1", canonical_scope_role="interior",
            canonical_scope_sha="abc")]

    def test_the_ref_key_still_folds_the_same_things(self):
        """★2A 에서는 **아직** 그대로. 2B 에서 여기에 coarse 계약이 들어간다."""
        import inspect

        src = inspect.getsource(er.acquire_from_plan_cached)
        i = src.index("r_sha = _cache_sha(")
        line = src[i:src.index("r_key", i)]
        for part in ("ref_parts", "world_facts_block",
                     "ERA_RESEARCH_POLICY_VERSION", "pack",
                     "era_pack_content_hash()", "search_contract_sha()",
                     "PICK_MODEL", "resolve_model_physical(PICK_MODEL)"):
            assert part in line, part

    def test_2b_is_now_wired(self):
        """★★2B — **획득 계약이 `r_sha` 에 들어갔다.**

        없으면 **옛 picker(시대·국적·평범함을 판정하던 것)가 고른 참조**가
        새 계약에서 그대로 재사용된다. 그건 「바꿨다」가 거짓이 되는 자리다.
        """
        import inspect

        src = inspect.getsource(er.acquire_from_plan_cached)
        i = src.index("r_sha = _cache_sha(")
        line = src[i:src.index("r_key", i)]
        assert "acquisition_contract_sha(" in line


class TestTheRefKeyMovesWithTheAcquisitionContract:
    """★★★Codex 가 요구한 **positive control** — 하나씩 바꿔 `r_sha` 가
    움직이는지 본다.

    「2B 의 `r_sha` 에는 최소 coarse prompt/schema/narrow 원문 hash, coarse
    계약 버전, `MAX_ROUNDS`/`PER_ROUND_CAP`, 실제 picker alias+physical model,
    기존 `search_contract_sha` 가 **모두** 들어가야 합니다.」
    """

    @staticmethod
    def _sha(rounds: int = 1):
        """★`rounds` 를 **명시**한다 — 기본값이 없다(Codex BLOCK-1)."""
        from app.modules.pipeline.reference_acquisition import (
            acquisition_contract_sha)
        return acquisition_contract_sha(rounds=rounds)

    @pytest.mark.parametrize("stem", ["system", "pick_schema",
                                     "narrow_retry_hint"])
    def test_one_byte_of_each_coarse_stem_moves_it(self, stem, monkeypatch):
        """★★**없는 팩과 견주는 것보다 정확하다** (Codex).

        「없는 팩」은 세 stem 이 **전부 빈 값**이 되는 것이라 **어느 stem 이
        실제로 접히는지를 못 가른다**. 각 stem 의 **내용 한 바이트**를 바꿔
        각각 움직이는지 본다.
        """
        import app.modules.prompt_loader as pl

        before = self._sha()
        real = pl.resolve_effective

        def _tweaked(module, name, *a, **k):
            got = real(module, name, *a, **k)
            if module == "coarse_type_pick" and name == stem:
                return {**(got or {}),
                        "content": str((got or {}).get("content")) + "."}
            return got

        monkeypatch.setattr(pl, "resolve_effective", _tweaked)
        assert self._sha() != before, f"★{stem} 이 안 접힌다"

    def test_the_coarse_contract_version_moves_it(self, monkeypatch):
        from app.modules.pipeline import reference_acquisition as ra
        before = self._sha()
        monkeypatch.setattr(ra, "ACQUISITION_CONTRACT_VERSION", 99)
        assert self._sha() != before

    def test_the_actual_round_count_moves_it(self):
        """★★접히는 것은 **실제로 몇 번 찾는가**이지 상수가 아니다.

        `MAX_ROUNDS` 는 이제 **상한**일 뿐이다 — 그것을 접으면 「1라운드로
        찾았는데 키는 2라운드」가 다시 난다(Codex BLOCK-1).
        """
        assert self._sha(rounds=1) != self._sha(rounds=2)

    def test_the_per_round_cap_moves_it(self, monkeypatch):
        from app.modules.pipeline import coarse_type_pick as ctp
        before = self._sha()
        monkeypatch.setattr(ctp, "PER_ROUND_CAP", 9)
        assert self._sha() != before

    def test_the_search_contract_moves_it(self, monkeypatch):
        from app.modules.pipeline import search_grounded_ref as sgr
        before = self._sha()
        monkeypatch.setattr(sgr, "search_contract_sha",
                            lambda *a, **k: "다른값0000000000")
        assert self._sha() != before

    def test_the_picker_model_is_folded_by_the_ref_key_itself(self):
        """★picker alias·physical model 은 `r_sha` 가 **직접** 접는다."""
        import inspect

        src = inspect.getsource(er.acquire_from_plan_cached)
        i = src.index("r_sha = _cache_sha(")
        line = src[i:src.index("r_key", i)]
        assert "PICK_MODEL" in line
        assert "resolve_model_physical(PICK_MODEL)" in line

    def test_every_required_element_is_present(self):
        """★Codex 가 든 다섯 갈래가 **전부** 접히는가."""
        import inspect

        src = inspect.getsource(er.acquire_from_plan_cached)
        i = src.index("r_sha = _cache_sha(")
        line = src[i:src.index("r_key", i)]
        assert "acquisition_contract_sha(" in line   # coarse 팩·계약·라운드
        assert "search_contract_sha()" in line        # 기존 검색 계약
        assert "PICK_MODEL" in line                   # picker alias
        assert "resolve_model_physical(PICK_MODEL)" in line  # physical


class TestThePickerNoLongerSeesEraOrRegion:
    """★★★사용자 확정 — 「**해당 오브젝트 종류만** 보는 것뿐이야.
    상세히는 인간도 몰라 전문가가 아니면. 자동차인지, 화폐인지 등등 만」

    옛 경로는 심판에게 `REGION AND ERA` 를 주고 「다른 나라나 다른 시대면
    탈락」·「관광지·꾸민 것이면 탈락」·「방문객용으로 개조됐나」를 판정시켰다.

    ★시대·지역은 **검색 질의에는 그대로 남는다.** VLM 이 검증하지 않을 뿐이다.
    """

    def _call(self, tmp_path, verdicts):
        """`research_reference` 를 태우고 **심판에게 실제로 간 것**을 잡는다."""
        seen = {}

        def _structured(tag, sys_prompt, parts, schema, **kw):
            seen["system"] = sys_prompt
            seen["parts"] = parts
            seen["schema"] = schema
            return {"verdicts": verdicts}

        def _search(**kw):
            seen["queries"] = kw
            return [{"url": "https://a/1.png"}]

        def _download(url, dest, **kw):
            Path(dest).write_bytes(b"\x89PNG fake")
            return True

        import app.modules.pipeline.search_grounded_ref as sgr
        from app.modules.llm import llm_client

        with patch.object(llm_client, "call_structured", _structured), \
                patch.object(sgr, "search_reference_images", _search), \
                patch.object(sgr, "download_candidate", _download), \
                patch.object(er, "png_part", lambda p: {"type": "image"},
                             create=True):
            try:
                er.research_reference(
                    subject=_SUBJ[0], world_facts_block="KR / 1983",
                    out_path=tmp_path / "r.png", step_tag="t", audit={})
            except Exception:                       # noqa: BLE001
                pass
        return seen

    def test_the_prompt_is_the_coarse_one(self):
        """★공용 지문이 「종류만」 묻는 것인지 — 팩에서 직접 본다."""
        from app.modules.pipeline.coarse_type_pick import (SYSTEM_STEM,
                                                           load_pack)
        t = load_pack()["stems"][SYSTEM_STEM]["content"]
        assert "object_type_match" in t
        assert "What you are NOT asked" in t
        for gone in ("REGION AND ERA", "tourist attraction", "another era"):
            assert gone not in t, gone

    def test_the_source_no_longer_builds_the_old_head(self):
        """★`build_pick_user_head` 가 심판에게 `REGION AND ERA` 를 준다."""
        import inspect

        src = inspect.getsource(er.research_reference)
        # ★주석에 「옛것이 이랬다」로는 남아 있어도 된다 — **부르지 않는지**를 본다
        assert "build_pick_user_head(" not in src
        assert "THE KIND OF THING" in src

    def test_the_search_query_still_keeps_era_and_region(self):
        """★★좁힌다는 것은 **다른 것들**을 덜어내는 것이지, 대상을 못박는
        좌표를 버리는 것이 아니다. 질의에는 그대로 남아야 한다."""
        import inspect

        src = inspect.getsource(er.research_reference)
        i = src.index("search_reference_images")
        assert "queries" in src[:i], "★질의 저작이 검색보다 앞에 있어야 한다"
        # 원어 질의·잠금문은 subject 가 들고 온다
        assert "language_lock" in src or "search_terms" in src


class TestTheKeyFoldsWhatThatCallerActuallyDoes:
    """★★★키는 **그 호출이 실제로 하는 것**을 접어야 한다 (Codex).

    상수를 그대로 접었더니 키는 **2라운드 계약**을 접었는데 **실제 late
    경로는 1라운드**여서 어긋났다. 그러면 「2라운드로 찾은 참조」와
    「1라운드로 찾은 참조」가 **같은 키**를 갖는다.

    ★재검색은 **중앙 획득에만** 넣는다 — late 는 이관 대상이라 비용을 늘릴
    이유가 없다.
    """

    def test_one_round_and_two_rounds_are_different_keys(self):
        from app.modules.pipeline.reference_acquisition import (
            acquisition_contract_sha)
        assert acquisition_contract_sha(rounds=1) \
            != acquisition_contract_sha(rounds=2)

    def test_the_late_path_declares_one_round(self):
        assert er.LATE_PATH_ROUNDS == 1

    def test_the_late_key_folds_one_round(self):
        """★late 가 실제로 도는 수를 접는다 — 상수(2)가 아니라."""
        import inspect

        src = inspect.getsource(er.acquire_from_plan_cached)
        i = src.index("r_sha = _cache_sha(")
        line = src[i:src.index("r_key", i)]
        assert "acquisition_contract_sha(rounds=max_rounds)" in line

    def test_an_impossible_round_count_is_refused(self):
        """★0 이나 3 을 접으면 「무엇을 했나」가 거짓이 된다."""
        from app.modules.pipeline.reference_acquisition import (
            acquisition_contract_sha)
        for bad in (0, 3, -1):
            with pytest.raises(ValueError):
                acquisition_contract_sha(rounds=bad)

    def test_there_is_no_default(self):
        """★★**기본값을 두면 안 된다** (Codex BLOCK-1).

        내가 「필수로 바꿨다」고 보고했는데 코드는 `Optional[int] = None` 이었고,
        시험이 그 기본값을 **정답으로 잠그고** 있었다. 보고와 정확히 반대다.
        """
        import pytest as _pytest

        from app.modules.pipeline.reference_acquisition import (
            acquisition_contract_sha)
        with _pytest.raises(TypeError):
            acquisition_contract_sha()

    def test_a_string_or_bool_is_not_a_round_count(self):
        """★`int()` 변환을 하면 `"1"` 과 `True` 가 통과한다."""
        from app.modules.pipeline.reference_acquisition import (
            acquisition_contract_sha)
        for bad in ("1", True, 1.0):
            with pytest.raises(ValueError):
                acquisition_contract_sha(rounds=bad)


class TestTheSplitPathEqualsTheOldWrapper:
    """★★★「지운 줄 0」 소스 시험은 **실제 분리를 막는다** (Codex).
    그것을 지우고 **등가성 끝점**으로 대체한다.

    같은 fixture 에 ①기존 공개 wrapper ②`assess_plan_cached` →
    `acquire_from_plan_cached` 를 각각 태워, **반환·쓰기 순서·호출 수·
    파일 bytes** 가 같은지 본다.
    """

    def _both(self, tmp_path, **kw):
        """wrapper 판과 split 판을 각각 태우고 (반환, 캐시, 호출수) 를 돌려준다."""
        outs = []
        for use_split in (False, True):
            cache = _Cache()
            calls = {"assess": 0, "research": 0}
            d = tmp_path / ("split" if use_split else "whole")
            d.mkdir(exist_ok=True)

            def _assess(**k):
                calls["assess"] += 1
                return list(kw.get("subjects") or [])

            def _research(*, subject, out_path, **k):
                calls["research"] += 1
                if kw.get("meta") is None:
                    return None
                Path(out_path).write_bytes(b"\x89PNG fake")
                return dict(kw["meta"])

            with patch.object(er, "assess_subjects", _assess), \
                    patch.object(er, "research_reference", _research):
                common = dict(step_tag="t", subject_text="1983년 서울역 대합실",
                              world_facts_block="KR / 1983",
                              cache_get=cache.get, cache_put=cache.put)
                if use_split:
                    env = er.assess_plan_cached(**common)
                    got = (er.acquire_from_plan_cached(
                        env["plan"], step_tag="t", out_dir=d,
                        cache_get=cache.get, cache_put=cache.put,
                        max_rounds=er.LATE_PATH_ROUNDS)
                        if env["status"] == er.PLAN_OK else None)
                else:
                    got = er.assess_and_research_cached(out_dir=d, **common)
            outs.append((got, cache, calls, d))
        return outs

    def test_the_success_path_is_equivalent(self, tmp_path):
        (a, ca, na, da), (b, cb, nb, db) = self._both(
            tmp_path, subjects=_SUBJ, meta=_meta())
        assert na == nb, f"★호출 수가 다르다 {na} vs {nb}"
        # 반환 — 경로만 디렉토리가 달라 파일 이름으로 견준다
        assert Path(a["path"]).name == Path(b["path"]).name
        assert {k: v for k, v in a.items() if k != "path"} \
            == {k: v for k, v in b.items() if k != "path"}
        # ★쓰기 **순서**까지 같아야 한다
        assert ca.keys_written() == cb.keys_written()
        assert ca.store == cb.store
        # 파일 bytes
        fa = sorted(da.glob("eraref_*.png"))
        fb = sorted(db.glob("eraref_*.png"))
        assert [p.name for p in fa] == [p.name for p in fb]
        assert fa[0].read_bytes() == fb[0].read_bytes()

    def test_the_empty_path_is_equivalent(self, tmp_path):
        (a, ca, na, _), (b, cb, nb, _) = self._both(tmp_path, subjects=[])
        assert a is b is None
        assert na == nb and ca.keys_written() == cb.keys_written()
        assert ca.store == cb.store

    def test_the_failure_path_is_equivalent(self, tmp_path):
        (a, ca, na, _), (b, cb, nb, _) = self._both(
            tmp_path, subjects=_SUBJ, meta=None)
        assert a is b is None
        assert na == nb
        assert ca.keys_written() == cb.keys_written()
        # 실패 감사 내용도 같아야 한다
        assert ca.store == cb.store

    def test_the_front_half_can_stop_without_buying(self, tmp_path):
        """★★이것이 **분리의 목적**이다 — 앞쪽이 판별만 하고 멈춘다."""
        cache = _Cache()
        calls = {"assess": 0, "research": 0}

        def _assess(**k):
            calls["assess"] += 1
            return list(_SUBJ)

        def _research(**k):
            calls["research"] += 1
            return {}

        with patch.object(er, "assess_subjects", _assess), \
                patch.object(er, "research_reference", _research):
            env = er.assess_plan_cached(
                step_tag="t", subject_text="x", world_facts_block="KR",
                cache_get=cache.get, cache_put=cache.put)
        assert env["status"] == er.PLAN_OK
        assert calls["research"] == 0, "★판별만 했는데 샀다"
        assert env["plan"]["subjects"] == _SUBJ


class TestTheRefactorDidNotChangeTheKeyMaterial:
    """★★★**refactor 가 키 재료를 바꾸면 안 된다** (Codex BLOCK-3).

    실제로 그럴 뻔했다 — 옛 `_ref_parts` 는 `search_terms` 를
    `"\\n".join` 했는데 새 함수에서 내가 `"|".join` 으로 바꿨다.
    그러면 **coarse 계약과 무관하게** fallback 키가 또 바뀐다.

    ★등가성 시험으로는 **못 잡는다** — wrapper 와 split 이 둘 다 새 함수를
    타기 때문이다. 그래서 **옛 조립을 독립 oracle 로** 세워 견준다.
    """

    @staticmethod
    def _old_ref_parts(subj, *, scope_ok, canon):
        """★54ac83e9 **이전**의 조립. 코드에서 옮겨 적은 독립 oracle 이다."""
        if scope_ok:
            return (str(canon["id"]), str(canon["role"]), str(canon["sha"]))
        return (str(subj.get("subject_native") or "").strip(),
                "\n".join(str(t) for t in
                          (subj.get("search_terms_native") or [])),
                str(subj.get("language_lock_native") or ""))

    def _new_ref_parts(self, subj, *, scope_ok, canon):
        """★지금 코드가 **실제로 키에 넣는** 것 — `_cache_sha` 가 받는 인자를
        그대로 잡는다. 소스 파싱보다 정확하다."""
        seen = {}
        real = er._cache_sha

        def _spy(*parts):
            seen.setdefault("parts", parts)
            return real(*parts)

        plan = {"subjects": [subj], "world_facts_block": "KR / 1983",
                "scope_ok": scope_ok, "canonical_scope": canon}
        with patch.object(er, "_cache_sha", _spy), \
                patch.object(er, "research_reference", lambda **k: None):
            er.acquire_from_plan_cached(
                plan, step_tag="t", out_dir=Path("/tmp"),
                cache_get=lambda k: None, cache_put=lambda k, v: None,
                max_rounds=1)
        return tuple(seen["parts"][:3])

    _CANON = {"id": "L01", "role": "location_interior", "sha": "deadbeef"}

    def test_the_fallback_assembly_is_unchanged(self):
        """★정본이 없는 판 — 여기가 바뀌면 **모든 fallback 키**가 바뀐다."""
        assert self._new_ref_parts(_SUBJ[0], scope_ok=False, canon={}) \
            == self._old_ref_parts(_SUBJ[0], scope_ok=False, canon={})

    def test_the_canonical_assembly_is_unchanged(self):
        assert self._new_ref_parts(_SUBJ[0], scope_ok=True, canon=self._CANON) \
            == self._old_ref_parts(_SUBJ[0], scope_ok=True, canon=self._CANON)

    def test_the_separator_is_a_newline_not_a_pipe(self):
        """★내가 바꿨던 바로 그 자리."""
        got = self._new_ref_parts(_SUBJ[0], scope_ok=False, canon={})
        assert got[1] == "가\n나"
        assert "|" not in got[1]

    def test_multiple_terms_join_the_old_way(self):
        subj = {**_SUBJ[0], "search_terms_native": ["가", "나", "다"]}
        assert self._new_ref_parts(subj, scope_ok=False, canon={})[1] \
            == "가\n나\n다"


class TestTheAcquireHalfRefusesRoundsItCannotRun:
    """★★★「키만 2라운드로 만들 수 있고 실행은 없다」 상태를 **금지**한다
    (Codex BLOCK-2)."""

    def test_one_round_is_allowed(self, tmp_path):
        cache = _Cache()
        with patch.object(er, "research_reference", lambda **k: None):
            got = er.acquire_from_plan_cached(
                {"subjects": _SUBJ, "world_facts_block": "KR"},
                step_tag="t", out_dir=tmp_path,
                cache_get=cache.get, cache_put=cache.put, max_rounds=1)
        assert got is None          # 실패는 실패대로

    def test_two_rounds_is_refused_until_the_loop_exists(self, tmp_path):
        cache = _Cache()
        with pytest.raises(ValueError) as exc:
            er.acquire_from_plan_cached(
                {"subjects": _SUBJ, "world_facts_block": "KR"},
                step_tag="t", out_dir=tmp_path,
                cache_get=cache.get, cache_put=cache.put, max_rounds=2)
        assert "라운드 loop 가 아직 없다" in str(exc.value)

    def test_it_refuses_before_spending_anything(self, tmp_path):
        """★거절이 **provider 를 부르기 전**이어야 한다."""
        calls = {"n": 0}

        def _boom(**k):
            calls["n"] += 1
            return None

        cache = _Cache()
        with patch.object(er, "research_reference", _boom):
            with pytest.raises(ValueError):
                er.acquire_from_plan_cached(
                    {"subjects": _SUBJ, "world_facts_block": "KR"},
                    step_tag="t", out_dir=tmp_path,
                    cache_get=cache.get, cache_put=cache.put, max_rounds=2)
        assert calls["n"] == 0
        assert cache.writes == [], "★거절인데 캐시를 건드렸다"


class TestNoSubjectAndFailureAreDifferentStates:
    """★★★둘을 `None` 하나로 접으면 앞쪽 caller 가 **실패를 정상 빈 목록으로
    오독**한다 (Codex BLOCK-4).

    내가 「갈라 돌려준다」고 보고했는데 코드는 둘 다 `None` 이었고, optional
    `outcome` 을 넘겼을 때만 곁에 적혔다. 그것을 안 넘기는 caller 는 못 본다.
    """

    def _plan(self, tmp_path, **kw):
        cache = _Cache()
        def _assess(**k):
            if kw.get("raises"):
                raise RuntimeError("터졌다")
            return list(kw.get("subjects") or [])
        with patch.object(er, "assess_subjects", _assess):
            return er.assess_plan_cached(
                step_tag="t", subject_text="x", world_facts_block="KR",
                cache_get=cache.get, cache_put=cache.put,
                failed_memo=kw.get("memo"))

    def test_no_subject_says_so(self, tmp_path):
        got = self._plan(tmp_path, subjects=[])
        assert got["status"] == er.PLAN_NO_SUBJECT
        assert got["plan"] is None

    def test_a_failure_says_so(self, tmp_path):
        got = self._plan(tmp_path, raises=True)
        assert got["status"] == er.PLAN_FAILED
        assert "assess_error" in got["reason"]

    def test_a_memo_hit_is_a_failure_not_an_empty_list(self, tmp_path):
        """★같은 걷기에서 이미 실패한 것 — 「era 없음」이 아니다."""
        memo = set()
        self._plan(tmp_path, raises=True, memo=memo)
        got = self._plan(tmp_path, raises=True, memo=memo)
        assert got["status"] == er.PLAN_FAILED
        assert got["reason"] == "same_walk_memo"

    def test_an_empty_subject_name_is_a_failure(self, tmp_path):
        got = self._plan(tmp_path, subjects=[{"subject_native": "  "}])
        assert got["status"] == er.PLAN_FAILED

    def test_the_status_does_not_need_the_outcome_channel(self, tmp_path):
        """★★`outcome` 을 **안 넘겨도** 상태를 안다 — 그게 요점이다."""
        for kw in ({"subjects": []}, {"raises": True}):
            got = self._plan(tmp_path, **kw)
            assert "status" in got

    def test_the_legacy_wrapper_still_folds_both_to_none(self, tmp_path):
        """★기존 비차단은 **wrapper 만** 유지한다."""
        for kw in ({"subjects": []}, {"assess_raises": True}):
            got, _c, _n = _run(tmp_path, **kw)
            assert got is None
