"""검색·받기 문이 **네트워크 앞에서** 서나. ★유료 0.

Codex (2026-09-02): 「preflight에 적고 사후 journal과 대조하는 것만으로는
문이 아닙니다.」 — 글/VLM 예산은 이 두 경로를 **못 센다**.
"""
from __future__ import annotations

import pytest

from tools.grounding_audit import canary_outbound_gates as og


class TestTheTextBudgetCannotSeeThesePaths:
    """★★왜 따로 세우나 — 그 예산이 **못 보는** 자리다."""

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

        from app.modules.pipeline import search_grounded_ref as sg

        src = textwrap.dedent(inspect.getsource(sg.search_reference_images))
        names = {ast.unparse(n.func) for n in ast.walk(ast.parse(src))
                 if isinstance(n, ast.Call)}
        assert any("responses.create" in n for n in names), (
            "★검색이 `responses.create` 를 직접 안 부른다 — 전제가 바뀌었다")
        assert not any("call_structured" in n for n in names)


class TestTheSearchGateStopsBeforeTheNetwork:

    def test_it_lets_the_approved_number_through(self, monkeypatch):
        from app.modules.pipeline import search_grounded_ref as sg

        sent = []
        monkeypatch.setattr(sg, "search_reference_images",
                            lambda **k: sent.append(1) or {})
        with og.canary_outbound_scope(search_cap=3, download_cap=0) as sc:
            for _ in range(3):
                sg.search_reference_images(directive_native="x")
            assert sc["search"].snapshot()["used"] == 3
        assert len(sent) == 3

    def test_the_next_one_never_reaches_the_provider(self, monkeypatch):
        from app.modules.pipeline import search_grounded_ref as sg

        sent = []
        monkeypatch.setattr(sg, "search_reference_images",
                            lambda **k: sent.append(1) or {})
        with og.canary_outbound_scope(search_cap=2, download_cap=0) as sc:
            for _ in range(2):
                sg.search_reference_images(directive_native="x")
            with pytest.raises(og.OutboundDenied):
                sg.search_reference_images(directive_native="x")
        assert len(sent) == 2, "★막았는데 나갔다"
        assert sc["search"].snapshot()["denied"] == 1


class TestTheDownloadGateCountsOperations:

    def test_an_operation_is_one_reservation(self, monkeypatch):
        from app.modules.pipeline import search_grounded_ref as sg

        monkeypatch.setattr(sg, "download_candidate", lambda *a, **k: True)
        with og.canary_outbound_scope(search_cap=0, download_cap=2) as sc:
            sg.download_candidate("u", "d", "f")
            sg.download_candidate("u", "d", "f")
            with pytest.raises(og.OutboundDenied):
                sg.download_candidate("u", "d", "f")
        assert sc["download"].snapshot() == {"cap": 2, "used": 2,
                                             "denied": 1, "remaining": 0}

    def test_raw_fetches_are_observed_not_capped(self, monkeypatch):
        """★★★한 operation 이 원본과 fallback 을 **차례로** 친다.

        그래서 40 은 operation 수이지 raw HTTP 40 이 아니다. redirect 는
        여기서 안 보이므로 「total raw HTTP 상한」이라고 쓰지 않는다.
        """
        from app.modules.pipeline import search_grounded_ref as sg

        monkeypatch.setattr(sg, "_fetch_safe", lambda u: None)
        with og.canary_outbound_scope(search_cap=0, download_cap=1) as sc:
            sg.download_candidate("원본", __import__("pathlib").Path("/tmp/x"),
                                  "fallback")
            got = sc["raw"].snapshot()
        assert got["source_fetch_attempts"] == 2, got
        assert "redirect 는 안 센다" in got["★means"]

    def test_the_real_function_tries_both_urls(self):
        """★근거 — 계약이 아니라 **그 함수**가 둘을 친다."""
        import ast
        import inspect
        import textwrap

        from app.modules.pipeline import search_grounded_ref as sg

        src = textwrap.dedent(inspect.getsource(sg.download_candidate))
        loops = [n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.For)]
        assert any("[url, fallback_url]" in ast.unparse(n.iter)
                   for n in loops), "★두 URL 을 도는 loop 가 없다"


class TestEverythingIsPutBack:
    def test_production_is_restored(self):
        from app.modules.pipeline import search_grounded_ref as sg

        before = (sg.search_reference_images, sg.download_candidate,
                  sg._fetch_safe)
        with og.canary_outbound_scope(search_cap=1, download_cap=1):
            assert sg.search_reference_images is not before[0]
        assert (sg.search_reference_images, sg.download_candidate,
                sg._fetch_safe) == before


class TestABadCapIsRefused:
    @pytest.mark.parametrize("cap", [-1, True, 1.5, "3", None])
    def test_it_stops(self, cap):
        with pytest.raises(og.OutboundDenied):
            og.OutboundBudget("x", cap)


class TestTheOwnerCoverageGateStandsBeforeBuying:
    """★★★다른 것을 재고 있으면 **조사를 시작도 안 한다**."""

    def _ledger(self, owners):
        from app.modules.pipeline import grounding_acquisition_ledger as gl

        rows = [gl.row(owner_type=o, research_subject_id=f"rs-{o}",
                       screen="obligation", status=gl.RESOLVED,
                       final_id={"character": "C01", "prop": "P01",
                                 "location": "L01", "location_part": "LP01",
                                 "outlook": "O01"}[o])
                for o in owners]
        return {"rows": rows}

    def test_all_five_pass(self):
        from app.core.steps.reference_acquisition_step import (
            assert_owner_coverage)

        five = ["character", "prop", "location", "location_part", "outlook"]
        got = assert_owner_coverage(self._ledger(five), five)
        assert got["present"] == sorted(five)

    def test_a_missing_lane_refuses(self):
        from app.core.steps.reference_acquisition_step import (
            OwnerCoverageRefused, assert_owner_coverage)

        four = ["character", "prop", "location", "location_part"]
        with pytest.raises(OwnerCoverageRefused) as e:
            assert_owner_coverage(self._ledger(four),
                                  four + ["outlook"])
        assert "outlook" in str(e.value)

    def test_the_gate_runs_before_the_first_call(self):
        import inspect

        from app.core.steps.reference_acquisition_step import (
            ReferenceAcquisitionStep as S)

        src = inspect.getsource(S._central)
        assert src.index("assert_owner_coverage") < src.index("central_result")
