"""참조 획득 **라운드 loop** — 조각을 잇는 자리. ★유료 0.

여기서 재는 것은 「돈다」가 아니라 —

    ①「없다」와 「못 봤다」를 **가르나**
    ②좁혀 다시 찾을 때 **좌표를 안 버리나**
    ③후보·판정·고른 것을 **사람이 볼 수 있게 남기나**
    ④VLM 에게 **종류와 가시성 말고** 안 묻나

★대역은 **실제 함수와 같은 인자 이름**을 쓴다. 제 모양을 지어내면 실물에서
안 도는 것을 통과시킨다 — 이 저장소에서 여러 번 겪었다.
"""
from __future__ import annotations

import inspect
import json

import pytest

from app.modules.pipeline import coarse_type_pick as ctp
from app.modules.pipeline import reference_acquisition as ra
from app.modules.pipeline import reference_acquisition_rounds as rr
from app.modules.pipeline import search_grounded_ref as sgr

TARGET = {"subject_id": "rs_1", "directive_native": "찾을 것을 적은 지시문",
          "terms_native": ["낱말하나", "낱말둘"], "language_lock_native": "ko"}


def _found(n, *, tag="a"):
    return {"queries": [f"q{tag}"],
            "images": [{"image_url": f"https://x/{tag}{i}.jpg",
                        "thumbnail_url": f"https://x/{tag}{i}_t.jpg",
                        "source_website_url": f"https://s/{tag}{i}",
                        "caption": f"설명 {tag}{i}"} for i in range(n)]}


def _judge(verdicts):
    """심판 하나의 답을 만든다. ★`_parse_one` 이 요구하는 칸 그대로."""
    return {"verdicts": [{"index": i, "object_type_match": m, "visible": v}
                         for i, (m, v) in enumerate(verdicts, 1)]}


class TestTheFakesMatchTheRealSignatures:
    """★★대역이 실물과 갈리면 **실물에서 안 도는 것**을 통과시킨다."""

    def test_search_fake_uses_the_real_argument_names(self):
        want = set(inspect.signature(sgr.search_reference_images).parameters)
        want.discard("client")
        mine = set(inspect.signature(rr.SearchFn.__call__).parameters)
        mine.discard("self")
        assert mine <= want, f"★실물에 없는 인자를 쓴다: {mine - want}"
        assert {"directive_native", "terms_native", "language_lock_native",
                "max_results"} <= mine

    def test_download_fake_matches(self):
        p = list(inspect.signature(sgr.download_candidate).parameters)
        assert p[:3] == ["url", "dest", "fallback_url"]


class TestItPicksWhenTheKindMatches:
    def test_a_matching_candidate_is_selected(self, tmp_path):
        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: _found(3),
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": _judge([("no", True), ("yes", True),
                                           ("no", True)])})
        assert got["status"] == ra.STATUS_SELECTED
        assert got["chosen"]["index"] == 2
        assert got["downstream_blocked"] is False
        assert len(got["rounds"]) == 1, "★골랐는데 또 찾았다"

    def test_the_lowest_eligible_index_wins(self, tmp_path):
        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: _found(3),
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": _judge([("yes", True), ("yes", True),
                                           ("yes", True)])})
        assert got["chosen"]["index"] == 1, "★순위는 검색이 정한다"


class TestItNarrowsOnceThenStops:
    """★없으면 **한 번만** 좁혀 다시 찾는다."""

    def test_it_retries_narrowed_and_then_gives_up(self, tmp_path):
        seen = []

        def _search(**kw):
            seen.append(kw["directive_native"])
            return _found(2, tag=f"r{len(seen)}")

        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path, search=_search,
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": _judge([("no", True)] * len(c))},
            narrow_hint="좁히는 문안")
        # ★사용자 5단계 ⑤ (2026-09-03): 두 라운드 다 종류가 안 맞아도 **가장 닮은 한 장**을 고른다
        assert got["status"] == ra.STATUS_SELECTED and got["match_quality"] == "closest"
        assert got["chosen"]["forced_pick"] is True
        # ★사람 대기를 안 만든다 — 자동으로 간다
        assert got["downstream_blocked"] is False
        assert len(seen) == 2, f"★{len(seen)}번 찾았다 — 최대 2라운드다"

    def test_the_narrowed_query_keeps_the_original_coordinates(self, tmp_path):
        """★★좁힌다는 것은 **다른 것들을 덜어내는 것**이지 대상을 못박는
        좌표(시대·지역·모델)를 버리는 것이 아니다."""
        seen = []

        def _search(**kw):
            seen.append(kw)
            return _found(2, tag=f"r{len(seen)}")

        rr.acquire_one(TARGET, workdir=tmp_path, rel_root=tmp_path,
                       search=_search,
                       download=lambda u, d, f="": (d.write_bytes(b"x"),
                                                    True)[1],
                       judge=lambda c: {"j1": _judge([("no", True)] * len(c))},
                       narrow_hint="좁히는 문안")
        assert TARGET["directive_native"] in seen[1]["directive_native"], \
            "★좁히면서 원래 지시문을 버렸다"
        assert "좁히는 문안" in seen[1]["directive_native"]
        assert seen[1]["terms_native"] == TARGET["terms_native"], \
            "★질의 낱말을 버렸다"
        assert seen[1]["language_lock_native"] == "ko"

    def test_a_second_round_with_no_new_candidates_stops_there(self, tmp_path):
        """★같은 것만 또 오면 **그 자리에서** 끝낸다."""
        def _search(**kw):
            return _found(2)          # ★두 라운드 다 **같은 URL**

        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path, search=_search,
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": _judge([("no", True)] * len(c))},
            narrow_hint="좁힘")
        # ★새 후보가 없어도 앞 라운드 후보가 있으면 그중 가장 닮은 것을 고른다 (2026-09-03)
        assert got["status"] == ra.STATUS_SELECTED and got["match_quality"] == "closest"
        assert got["rounds"][-1]["decision"]["next"] == ctp.NEXT_SELECT_CLOSEST
        assert got["rounds"][-1]["duplicate"] == 2, "★중복을 안 남겼다"


class TestNotSeenIsNotAbsent:
    """★★「없다」와 「못 봤다」를 가른다 — 이것이 이 loop 의 핵심이다."""

    def test_a_search_failure_is_retryable_not_no_match(self, tmp_path):
        def _boom(**kw):
            raise RuntimeError("검색 죽음")

        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path, search=_boom,
            download=lambda u, d, f="": True, judge=lambda c: {})
        assert got["status"] == ra.STATUS_RETRYABLE, \
            "★못 본 것을 「없다」로 적었다"
        assert got["downstream_blocked"] is False, "★사람을 기다린다"
        assert "검색 실패" in got["rounds"][0]["decision"]["error"]

    def test_a_judge_failure_is_recorded_and_the_run_still_ends_with_one(self, tmp_path):
        """★Codex BLOCK 3 (2026-09-03): 판정이 죽어도 받아 둔 사진이 있으면 멈추지 않고 한 장을 고른다.
        실패는 라운드 기록(`decision.error`)에 남는다 — 조용히 넘어가는 것이 아니다."""
        def _boom(c):
            raise RuntimeError("판정 죽음")

        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: _found(2),
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=_boom)
        assert got["status"] == ra.STATUS_SELECTED
        assert got["chosen"]["forced_reason"] == "judge_unavailable"
        assert "판정 실패" in got["rounds"][0]["decision"]["error"]

    def test_no_judge_survived_still_picks_one_and_says_why(self, tmp_path):
        """★Codex BLOCK 3 (2026-09-03): 심판이 두 라운드 다 무효여도 받아 둔 사진이 있으면 **한 장**을
        고른다 — 조용히가 아니라 `forced_reason=judge_unavailable` 로 적고서."""
        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: _found(2, tag=f"r{kw.get('directive_native','')[:1]}"),
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": {"verdicts": "망가진 모양"}})
        assert got["status"] == ra.STATUS_SELECTED and got["match_quality"] == "closest"
        assert got["chosen"]["forced_pick"] is True
        assert got["chosen"]["forced_reason"] == "judge_unavailable"
        assert got["rounds"][0]["decision"]["next"] == ctp.NEXT_NARROW_RETRY

    def test_an_empty_directive_buys_nothing(self, tmp_path):
        calls = []
        got = rr.acquire_one(
            {**TARGET, "directive_native": "  "}, workdir=tmp_path,
            rel_root=tmp_path,
            search=lambda **kw: calls.append(1) or _found(2),
            download=lambda u, d, f="": True, judge=lambda c: {})
        assert calls == [], "★무엇을 찾을지 모르는 채로 샀다"
        assert got["status"] == ra.STATUS_RETRYABLE


class TestItLeavesWhatAHumanNeedsToSee:
    """★★URL 만 남기거나 숨은 임시 파일이면 **비교 화면을 못 만든다**."""

    def _run(self, tmp_path):
        return rr.acquire_one(
            TARGET, workdir=tmp_path / "refs", rel_root=tmp_path,
            search=lambda **kw: _found(3),
            download=lambda u, d, f="": (d.parent.mkdir(parents=True,
                                                        exist_ok=True),
                                         d.write_bytes(b"x"), True)[2],
            judge=lambda c: {"j1": _judge([("no", True), ("yes", True),
                                           ("no", True)])})

    def test_every_downloaded_candidate_is_recorded_with_its_file(self,
                                                                  tmp_path):
        got = self._run(tmp_path)
        cands = got["rounds"][0]["downloaded_candidates"]
        assert len(cands) == 3, "★받은 후보를 다 안 남겼다"
        for c in cands:
            assert c["path"] and not c["path"].startswith("/"), \
                "★절대 경로를 적었다 — 다른 기계에서 화면이 깨진다"
            assert (tmp_path / c["path"]).exists(), "★적은 파일이 없다"
            assert c["url"] and c["source_website_url"]

    def test_the_chosen_reference_has_a_file_path(self, tmp_path):
        got = self._run(tmp_path)
        assert got["chosen_path"], "★고른 참조의 파일이 없다"
        assert (tmp_path / got["chosen_path"]).exists()

    def test_each_candidate_carries_what_each_judge_said(self, tmp_path):
        got = self._run(tmp_path)
        table = got["rounds"][0]["eligibility"]
        assert len(table) == 3
        assert all("by_judge" in row for row in table)
        assert got["rounds"][0]["judges"] == ["j1"]

    def test_a_failed_download_is_recorded_not_dropped(self, tmp_path):
        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: _found(3),
            download=lambda u, d, f="": False,
            judge=lambda c: {"j1": _judge([])})
        kinds = {c["disposition"] for c in got["candidates"]}
        assert "download_failed" in kinds, "★못 받은 것을 조용히 버렸다"
        assert got["rounds"][0]["downloaded"] == 0


class TestItAsksTheVlmOnlyTwoThings:
    """★시대·국가·제조사·모델·고증 정확성·좋고 나쁨은 **안 묻는다**."""

    def test_the_combine_contract_only_reads_kind_and_visibility(self):
        src = inspect.getsource(ctp.combine_coarse_verdicts)
        assert "object_type_match" in src and "visible" in src
        for banned in ("era", "year", "country", "brand", "quality",
                       "accuracy", "score"):
            assert banned not in src, f"★{banned} 를 본다"

    def test_this_module_does_not_interpret_the_verdict_itself(self):
        """★판정 해석은 **계약 함수**가 한다 — 여기서 다시 하면 두 벌이다."""
        import ast

        tree = ast.parse(inspect.getsource(rr))
        for n in ast.walk(tree):
            if isinstance(n, ast.Constant) and isinstance(n.value, str):
                assert "object_type_match" not in n.value, \
                    "★판정 칸을 이 모듈이 직접 읽는다"


class TestTheBriefIsWrittenNotHandBuilt:
    """★★검색 지시문은 **저작기가 원어로 쓴다**. 코드가 짓지 않는다.

    이 단계를 건너뛰었더니 두 가지가 났다 (2026-08-31 실측) —

        ①잔부분에 잔부분 질의가 나갔다 — 저작 팩의 「주 대상 하나만」이 없었다
        ②**2라운드가 죽었다** — 좁힘 문안이 **전부 영어**인데 그것을 한국어
          지시문 뒤에 붙였다. 검색 팩은 「영어 한 단어도 쓰지 마라」다.
          실측: R1 은 64%가 질의를 냈는데 **R2 는 10%**
    """

    def test_the_written_directive_is_used_not_the_raw_name(self, tmp_path):
        seen = {}

        def _search(**kw):
            seen.update(kw)
            return _found(2)

        rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path, search=_search,
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": _judge([("yes", True)] * len(c))},
            write_brief=lambda t, narrow: {
                "search_directive_native": "저작기가 쓴 지시문",
                "search_terms_native": ["저작 낱말"],
                "language_lock_native": "저작 잠금"})
        assert seen["directive_native"] == "저작기가 쓴 지시문"
        assert seen["terms_native"] == ["저작 낱말"]
        assert seen["language_lock_native"] == "저작 잠금"

    def test_the_narrow_flag_reaches_the_writer_not_the_query(self, tmp_path):
        """★★영어 좁힘 문안이 **원어 지시문에 안 붙는다**."""
        calls, sent = [], []

        rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: sent.append(kw) or _found(2, tag=f"r{len(sent)}"),
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": _judge([("no", True)] * len(c))},
            write_brief=lambda t, narrow: (
                calls.append(narrow) or
                {"search_directive_native": f"지시문 narrow={narrow}",
                 "search_terms_native": ["낱말"],
                 "language_lock_native": "ko"}))
        assert calls == [False, True], f"★저작기에 narrow 가 안 갔다: {calls}"
        for kw in sent:
            assert "narrow" not in kw["directive_native"] or \
                kw["directive_native"].startswith("지시문"), \
                "★영어 문안이 지시문에 붙었다"

    def test_a_writer_failure_is_retryable_not_no_match(self, tmp_path):
        def _boom(t, narrow):
            raise RuntimeError("저작 죽음")

        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: _found(2),
            download=lambda u, d, f="": True, judge=lambda c: {},
            write_brief=_boom)
        assert got["status"] == ra.STATUS_RETRYABLE
        assert "저작 실패" in got["rounds"][0]["decision"]["why"]

    def test_without_a_writer_it_still_works(self, tmp_path):
        """★positive control — 저작기 없이도 돈다(권장은 아니다)."""
        got = rr.acquire_one(
            TARGET, workdir=tmp_path, rel_root=tmp_path,
            search=lambda **kw: _found(2),
            download=lambda u, d, f="": (d.write_bytes(b"x"), True)[1],
            judge=lambda c: {"j1": _judge([("yes", True)] * len(c))})
        assert got["status"] == ra.STATUS_SELECTED

    def test_the_runner_passes_a_writer(self):
        import inspect

        from tools.grounding_audit import ref_canary as rc

        assert "write_brief=" in inspect.getsource(rc.main)

    def test_the_narrow_round_actually_carries_the_hint(self):
        """★**나가는 것**을 본다 — 어느 함수가 붙이는지가 아니라."""
        from app.modules.pipeline import grounding_ref_brief as grb
        from tools.grounding_audit import ref_canary as rc

        t = {"surface_form": "표기", "owner_type": "prop",
             "coarse_type_label": "부류", "visual_brief": "설명"}
        kw = {"world_facts": "W", "source_text": "S"}
        assert grb.load_narrow_retry_hint() not in \
            rc.brief_outbound(t, narrow=False, **kw)["user"]
        assert grb.load_narrow_retry_hint() in \
            rc.brief_outbound(t, narrow=True, **kw)["user"]
