"""검색 기록이 **응답을 읽고** 적히는가 (2026-09-08).

## 무엇이 결함이었나

`search_reference_images` 와 `search_typology_answers` 는 호출이 돌아오자마자
이렇게 적었다:

    record_provider_call(..., status="success",
                         output_text="[web search completed]")

응답을 **보기 전**이다. `web_search_call` 이 하나도 없어도, 있어도 실패했어도
똑같이 「완료」로 남는다. 그러면 기록만으로는 **「찾았는데 없다」와 「안
찾았다」를 못 가른다** — 둘은 고칠 곳이 다르다.

★관측된 미검색은 **0건**이다. 실제 주행(아마겟돈 3편)에서는 이미지 검색
 질의 14개·회수 32장, 조사 문항 4/4 가 전부 출처를 달고 왔다. 여기서 고치는
 것은 검색이 안 됐다는 것이 아니라 **기록이 그것을 증명하지 못한다**는 것이다.
 그래서 요청은 안 바꾼다(`tool_choice` 를 강제하지 않는다).

## 여기서 재는 네 칸 (Codex 2026-09-08)

    ① 미실행            — 완료된 검색 0 → `SearchNotPerformed`, 기록은 error
    ② 실행완료 + 결과 0 — 정상적인 빈 결과 (예외 아님)
    ③ 실행완료 + 결과   — 그대로 돌려준다
    ④ 입력이 없어 생략  — 호출조차 안 한다. N/A 지 결함이 아니다

★대역을 **SDK 타입으로** 만든다. `SimpleNamespace` 로 흉내 내면 필드 이름이
 틀려도 안 막힌다 — 실제로 그 부류로 한 번 데었다.
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional

import pytest
from openai.types.responses import ResponseFunctionWebSearch

from app.modules.pipeline.search_grounded_ref import (
    SearchNotPerformed,
    search_reference_images,
    web_search_call_counts,
)


# ── 대역 ──────────────────────────────────────────────────────────────

def _search_call(status: str, *, queries: Optional[List[str]] = None,
                 images: Optional[List[Dict[str, Any]]] = None
                 ) -> ResponseFunctionWebSearch:
    """진짜 SDK 타입으로 만든다 — `status` 의 값도 SDK 가 잠근 것뿐이다."""
    call = ResponseFunctionWebSearch(
        id="ws_1", type="web_search_call", status=status,
        action={"type": "search", "query": " ".join(queries or ["q"])},
    )
    # `results` 는 `include=["web_search_call.results"]` 일 때만 붙는
    # **추가 필드**다 — SDK 모델에 선언되어 있지 않다.
    object.__setattr__(call, "results", list(images or []))
    object.__setattr__(call, "action", _Action(queries or ["q"]))
    return call


class _Action:
    def __init__(self, queries: List[str]) -> None:
        self.queries = list(queries)
        self.query = queries[0] if queries else ""


class _Message:
    type = "message"

    def __init__(self, text: str = "") -> None:
        self.content = [_Text(text)]


class _Text:
    def __init__(self, text: str) -> None:
        self.text = text


class _Reasoning:
    def __init__(self, effort: str) -> None:
        self.effort = effort


class _Resp:
    def __init__(self, output: List[Any], effort: str = "medium") -> None:
        self.output = output
        self.reasoning = _Reasoning(effort)


class _Client:
    """`responses.create` 만 갖는다 — 프로덕션이 부르는 그 이름."""

    def __init__(self, resp: Any) -> None:
        self._resp = resp
        self.calls: List[Dict[str, Any]] = []
        self.responses = self

    def create(self, **kwargs: Any) -> Any:
        self.calls.append(kwargs)
        return self._resp


@pytest.fixture
def recorded(monkeypatch):
    """`record_provider_call` 이 실제로 받은 인자를 모은다 (끝점)."""
    seen: List[Dict[str, Any]] = []

    def _rec(**kw: Any) -> str:
        seen.append(kw)
        return "trace-1"

    monkeypatch.setattr("app.modules.llm.image_tracer.record_provider_call",
                        _rec)
    return seen


_IMG = {"type": "image_result", "image_url": "https://example.invalid/a.jpg",
        "thumbnail_url": "", "source_website_url": "https://example.invalid",
        "caption": "c"}


def _run(resp: Any) -> Dict[str, Any]:
    return search_reference_images(
        _Client(resp), directive_native="find photographs of an ordinary thing")


# ── 세는 법 자체 ──────────────────────────────────────────────────────

class TestTheCounting:
    def test_type_만_세면_실패한_호출도_완료로_센다(self):
        """★양성 — 이 구분이 없으면 실패 호출이 성공으로 접힌다."""
        resp = _Resp([_search_call("failed"), _search_call("completed")])
        total, done = web_search_call_counts(resp)
        assert (total, done) == (2, 1), "실패한 호출을 완료로 셌다"

    def test_status_가_없는_모양은_완료로_안_센다(self):
        """모르는 것을 성공으로 접지 않는다."""
        class _NoStatus:
            type = "web_search_call"

        total, done = web_search_call_counts(_Resp([_NoStatus()]))
        assert (total, done) == (1, 0)


# ── ①~③ 이미지 검색 ──────────────────────────────────────────────────

class TestImageSearch:
    def test_1_완료된_검색이_없으면_빈손을_결과로_안_돌려준다(self, recorded):
        with pytest.raises(SearchNotPerformed):
            _run(_Resp([_Message("네 개입니다")]))
        assert recorded and recorded[-1]["status"] == "error", (
            f"검색이 안 돌았는데 성공으로 적혔다: {recorded[-1] if recorded else None}")
        assert "completed=0" in recorded[-1]["output_text"]

    def test_1b_호출은_있었는데_전부_실패해도_미실행이다(self, recorded):
        with pytest.raises(SearchNotPerformed):
            _run(_Resp([_search_call("failed"), _Message("x")]))
        assert recorded[-1]["status"] == "error"
        assert "calls=1 completed=0" in recorded[-1]["output_text"]

    def test_2_완료됐고_결과가_0이면_정상적인_빈_결과다(self, recorded):
        got = _run(_Resp([_search_call("completed"), _Message("없었다")]))
        assert got["images"] == [] and got["search_completed"] == 1
        assert recorded[-1]["status"] == "success"

    def test_3_완료됐고_결과가_있으면_그대로_돌려준다(self, recorded):
        got = _run(_Resp([_search_call("completed", images=[_IMG]),
                          _Message("찾았다")]))
        assert [i["image_url"] for i in got["images"]] == [_IMG["image_url"]]
        assert got["search_calls"] == 1 and got["search_completed"] == 1

    def test_기록이_응답에서_읽은_수와_적용된_강도를_담는다(self, recorded):
        _run(_Resp([_search_call("completed", images=[_IMG]), _Message("x")],
                   effort="high"))
        out = recorded[-1]["output_text"]
        assert "calls=1 completed=1 images=1" in out, out
        # ★우리는 강도를 **안 보낸다** — 그래서 요청은 omitted, 적용값은 응답에서.
        assert "effort_requested=omitted" in out and "effort_effective=high" in out

    def test_요청은_안_바뀐다_tool_choice_도_reasoning_도_안_보낸다(self):
        """★이번 변경은 **기록만** 고친다. 요청이 바뀌면 완료된 CP 가
        지문이 어긋나 검색을 다시 산다 (Codex 2026-09-08)."""
        client = _Client(_Resp([_search_call("completed"), _Message("x")]))
        search_reference_images(client, directive_native="d")
        sent = client.calls[0]
        assert "tool_choice" not in sent, "요청에 tool_choice 가 실렸다"
        assert "reasoning" not in sent, "요청에 reasoning 이 실렸다"


# ── ④ 조사 — 물어볼 것이 없으면 호출조차 안 한다 ─────────────────────

class TestTypologyResearch:
    def test_4_문항이_없으면_호출도_안_하고_N_A_다(self, recorded):
        from app.modules.pipeline.typology_prior import search_typology_answers

        client = _Client(_Resp([]))
        got = search_typology_answers(client, questions=[], model="m")
        assert client.calls == [], "물어볼 것이 없는데 호출했다"
        assert got["search_calls"] == 0 and got["answers"] == []
        assert recorded == [], "안 부른 호출을 기록에 적었다"

    def test_1_조사도_완료된_검색이_없으면_선다(self, recorded):
        from app.modules.pipeline.typology_prior import search_typology_answers

        resp = _Resp([_Message('{"answers": [{"index": 1, "answer_en": "x", '
                               '"found": true, "sources": []}]}')])
        with pytest.raises(SearchNotPerformed):
            search_typology_answers(
                _Client(resp),
                questions=[{"question_native": "q", "target_native": "t"}],
                model="m")
        assert recorded[-1]["status"] == "error"

    def test_3_조사가_돌았으면_답을_그대로_돌려준다(self, recorded):
        from app.modules.pipeline.typology_prior import search_typology_answers

        resp = _Resp([_search_call("completed"),
                      _Message('{"answers": [{"index": 1, "answer_en": "flat roof", '
                               '"found": true, "sources": ["https://a.invalid"]}]}')])
        got = search_typology_answers(
            _Client(resp),
            questions=[{"question_native": "q", "target_native": "t"}],
            model="m")
        assert got["answers"][0]["found"] is True
        assert got["answers"][0]["sources"] == ["https://a.invalid"]
        assert got["search_completed"] == 1
        assert recorded[-1]["status"] == "success"
