"""우회 호출이 **토큰·비용·system 을 기록에 남기는가** (2026-08-27, 감사 2-B).

## 무엇을 잡으려는 것인가

Opik 실측: `still_recipe_fix_rejudge_openrouter:xai/grok4.6` 등 **137 span**이

    input   = {"prompt": <user 파트만>, "params": {...}}
    usage   = {}
    cost    = None

이었다. litellm 경로(`..._geminipro`)는 같은 판정에서 `sys 6,811 · user
13,610 · usage 채움`이다. 그래서 세 자리가 동시에 틀어졌다:

    ① 감사표 2-B 「네 단계 모두 system_prompt 가 0자」
       → 나가는 payload 는 아니다. `openrouter_vlm_client.py:95` 가
         `{"role":"system"}` 으로 싣는다. **기록의 빈칸을 사실로 읽었다.**
    ② 감사표 2-B 「캐시 0.0%」
       → `usage={}` 라 **못 잰 것**이다. grok 은 실제로
         `cached_tokens 2,304 / prompt 2,326` = 99% 적중을 보고한다.
    ③ 0-D 잔여 「openrouter 137건 미분류」
       → `cost=None` 이라 비용 집계에서 통째로 빠진다.

★**하나의 기록 결함이 세 자리를 만들었다.** 그래서 이 시험은 「기록에
 값이 들어갔나」가 아니라 **「그 값이 어디까지 흘러가나」**를 끝점에서 본다.
"""
from __future__ import annotations

import types

import pytest


# ─────────────────────────────────────────────────────────────────────
# ① 응답에서 토큰을 꺼내는 계약
# ─────────────────────────────────────────────────────────────────────


def _resp(**usage_fields):
    u = types.SimpleNamespace(**usage_fields)
    return types.SimpleNamespace(usage=u)


def test_it_pulls_the_cache_hit_out_of_the_nested_field():
    """★캐시는 `prompt_tokens_details.cached_tokens` 에 **중첩**돼 있다.

    이 칸을 안 꺼낸 것이 감사표가 「캐시 0%」로 적은 직접 원인이다.
    """
    from app.modules.llm.image_tracer import usage_of_openai_response

    got = usage_of_openai_response(_resp(
        prompt_tokens=2326, completion_tokens=5392, total_tokens=7718,
        prompt_tokens_details=types.SimpleNamespace(cached_tokens=2304)))
    assert got["cached_tokens"] == 2304
    assert got["prompt_tokens"] == 2326


def test_it_also_reads_the_flat_spelling():
    """provider 마다 이름이 다르다 — 평평한 `cached_tokens` 도 읽는다."""
    from app.modules.llm.image_tracer import usage_of_openai_response

    got = usage_of_openai_response(_resp(prompt_tokens=10, cached_tokens=7))
    assert got["cached_tokens"] == 7


def test_a_missing_number_is_left_out_not_zeroed():
    """★없는 칸을 **0 으로 채우지 않는다.**

    0 을 적으면 「안 썼다」로 읽혀 합산이 실제보다 작아진다. 없으면
    칸 자체가 없어야 「못 잰 것」과 「0 이었던 것」을 가를 수 있다.
    """
    from app.modules.llm.image_tracer import usage_of_openai_response

    got = usage_of_openai_response(_resp(prompt_tokens=10))
    assert "cached_tokens" not in got
    assert "completion_tokens" not in got


def test_no_usage_object_gives_an_empty_dict():
    from app.modules.llm.image_tracer import usage_of_openai_response

    assert usage_of_openai_response(types.SimpleNamespace()) == {}
    assert usage_of_openai_response(types.SimpleNamespace(usage=None)) == {}


def test_booleans_are_not_counted_as_numbers():
    """`True` 는 `int` 다 — 토큰 수로 새면 1 이 된다."""
    from app.modules.llm.image_tracer import usage_of_openai_response

    got = usage_of_openai_response(_resp(prompt_tokens=True))
    assert "prompt_tokens" not in got


# ─────────────────────────────────────────────────────────────────────
# ② span 에 실리는 칸의 계약
# ─────────────────────────────────────────────────────────────────────


def test_empty_usage_does_not_create_the_field():
    from app.modules.llm.image_tracer import _usage_kwargs

    assert _usage_kwargs(None, None) == {}
    assert _usage_kwargs({}, None) == {}


def test_it_keeps_only_numbers():
    from app.modules.llm.image_tracer import _usage_kwargs

    got = _usage_kwargs(
        {"prompt_tokens": 5, "model": "x-ai/grok", "ok": True}, 0.42)
    assert got["usage"] == {"prompt_tokens": 5}
    assert got["total_cost"] == 0.42


def test_the_span_actually_receives_them(monkeypatch):
    """★끝점 — `ImageTracer.log` 가 **span 인자로** 실어 보내는가.

    함수가 값을 만들기만 하고 안 넘기면 기록은 여전히 비어 있다.
    조립이 아니라 나가는 자리를 본다.
    """
    from app.modules.llm import image_tracer as it

    seen = {}

    class _Span:
        def end(self):
            pass

    class _Trace:
        uid = "t1"

        def span(self, **kw):
            seen.update(kw)
            return _Span()

        def end(self):
            pass

    class _Client:
        def trace(self, **kw):
            return _Trace()

    tr = it.ImageTracer.__new__(it.ImageTracer)
    tr._client = _Client()
    monkeypatch.setattr(tr, "_get_context", lambda: {}, raising=False)
    monkeypatch.setattr(
        it, "get_image_tracer", lambda: tr, raising=False)

    tr.log(step="s", model="x-ai/grok-4.6", prompt="p",
           usage={"prompt_tokens": 9, "cached_tokens": 8}, total_cost=1.5)

    assert seen.get("usage") == {"prompt_tokens": 9, "cached_tokens": 8}, (
        "span 에 usage 가 안 실렸다 — 기록은 여전히 비어 있다")
    assert seen.get("total_cost") == 1.5


def test_without_them_the_span_keys_are_absent(monkeypatch):
    """반대편 — 안 넘기면 칸이 **없어야** 한다(0 이 아니라)."""
    from app.modules.llm import image_tracer as it

    seen = {}

    class _Span:
        def end(self):
            pass

    class _Trace:
        uid = "t1"

        def span(self, **kw):
            seen.update(kw)
            return _Span()

        def end(self):
            pass

    class _Client:
        def trace(self, **kw):
            return _Trace()

    tr = it.ImageTracer.__new__(it.ImageTracer)
    tr._client = _Client()
    monkeypatch.setattr(tr, "_get_context", lambda: {}, raising=False)
    tr.log(step="s", model="m", prompt="p")
    assert "usage" not in seen and "total_cost" not in seen


# ─────────────────────────────────────────────────────────────────────
# ③ openrouter 클라이언트 — 나가는 것과 적히는 것
# ─────────────────────────────────────────────────────────────────────


@pytest.fixture
def _or_call(monkeypatch):
    """`ask_openrouter_structured` 를 한 번 돌리고 **기록 인자**를 돌려준다.

    ★`record_provider_call` 을 대역으로 잡는다 — 조립 함수를 직접 부르지
     않고 **공개 진입점**을 태운다. 안 그러면 배선이 빠져도 초록이다.
    """
    import app.modules.llm.image_tracer as it

    recorded = []

    class _Msg:
        content = '{"ok": true}'

    class _Choice:
        message = _Msg()

    class _Resp:
        choices = [_Choice()]
        usage = types.SimpleNamespace(
            prompt_tokens=2326, completion_tokens=5392, total_tokens=7718,
            prompt_tokens_details=types.SimpleNamespace(cached_tokens=2304))

    class _Completions:
        def create(self, **kw):
            recorded.append(("sent", kw))
            return _Resp()

    class _Chat:
        completions = _Completions()

    class _Client:
        chat = _Chat()

    monkeypatch.setattr(it, "record_provider_call",
                        lambda **kw: recorded.append(("rec", kw)))

    import app.modules.llm.openrouter_vlm_client as orc
    monkeypatch.setattr(orc, "_client", lambda: _Client())

    orc.ask_openrouter_structured(
        "still_recipe_fix_rejudge",
        "SYSTEM-BRIEF-MARKER the judge contract goes here",
        [{"type": "text", "text": "USER-PART-MARKER"}],
        {"type": "object", "properties": {"ok": {"type": "boolean"}}},
        model="x-ai/grok-4.6")
    return recorded


def test_the_recorded_prompt_carries_the_system_text(_or_call):
    """★감사표가 「system 0자」라고 읽은 바로 그 자리.

    나가는 payload 엔 system 이 있었는데 **기록엔 없었다.**
    """
    recs = [kw for kind, kw in _or_call if kind == "rec"]
    assert recs, "기록이 한 건도 안 남았다"
    prompt = recs[-1]["prompt"]
    assert "SYSTEM-BRIEF-MARKER" in prompt, (
        "system 이 기록에서 빠졌다 — 다음 사람이 또 「0자」로 읽는다")
    assert "USER-PART-MARKER" in prompt


def test_the_recorded_call_carries_the_token_counts(_or_call):
    recs = [kw for kind, kw in _or_call if kind == "rec"]
    usage = recs[-1].get("usage") or {}
    assert usage.get("cached_tokens") == 2304, (
        "캐시 적중이 기록에 안 남는다 — 감사표가 「0%」로 읽는다")
    assert usage.get("prompt_tokens") == 2326


def test_the_wire_still_carries_system_as_its_own_role(_or_call):
    """★기록을 고치다 **나가는 것**을 건드리지 않았는지 본다.

    system 을 user 로 옮겨 붙이는 「수리」였다면 모델 거동이 바뀐다.
    """
    sent = [kw for kind, kw in _or_call if kind == "sent"]
    msgs = sent[-1]["messages"]
    roles = [m["role"] for m in msgs]
    assert roles == ["system", "user"]
    assert "SYSTEM-BRIEF-MARKER" in msgs[0]["content"]
    assert "SYSTEM-BRIEF-MARKER" not in str(msgs[1]["content"])


def test_a_paid_roundtrip_that_fails_late_still_records_tokens(monkeypatch):
    """★유료 왕복이 **끝난 뒤**의 실패도 토큰을 남긴다.

    스키마 위반은 이미 돈을 낸 왕복이다. 실패라고 usage 를 안 적으면
    그 왕복이 비용 집계에서 사라진다 — 「기록 없는 유료 왕복 금지」
    계약이 이 모듈에 이미 있는데 토큰만 빠져 있었다.
    """
    import types as _t
    import app.modules.llm.image_tracer as it
    import app.modules.llm.openrouter_vlm_client as orc

    recs = []

    class _Resp:
        choices = [_t.SimpleNamespace(
            message=_t.SimpleNamespace(content='{"wrong": 1}'))]
        usage = _t.SimpleNamespace(prompt_tokens=100, completion_tokens=5)

    class _C:
        chat = _t.SimpleNamespace(
            completions=_t.SimpleNamespace(create=lambda **kw: _Resp()))

    monkeypatch.setattr(it, "record_provider_call",
                        lambda **kw: recs.append(kw))
    monkeypatch.setattr(orc, "_client", lambda: _C())

    with pytest.raises(Exception):
        orc.ask_openrouter_structured(
            "t", "sys", [{"type": "text", "text": "u"}],
            {"type": "object", "required": ["ok"],
             "properties": {"ok": {"type": "boolean"}}},
            model="m", max_retry=0)

    errs = [r for r in recs if r.get("status") == "error"]
    assert errs, "실패 기록이 없다"
    assert (errs[-1].get("usage") or {}).get("prompt_tokens") == 100, (
        "유료 왕복이 끝난 실패인데 토큰이 안 남았다 — 비용에서 사라진다")


def test_a_failure_before_the_roundtrip_records_no_tokens(monkeypatch):
    """반대편 — 왕복 **전에** 터진 것은 토큰이 없어야 한다.

    없는 값을 지어내면 안 낸 돈을 센다.
    """
    import types as _t
    import app.modules.llm.image_tracer as it
    import app.modules.llm.openrouter_vlm_client as orc

    recs = []

    def _boom(**kw):
        raise RuntimeError("503 upstream")

    monkeypatch.setattr(it, "record_provider_call",
                        lambda **kw: recs.append(kw))
    monkeypatch.setattr(orc, "_client", lambda: _t.SimpleNamespace(
        chat=_t.SimpleNamespace(
            completions=_t.SimpleNamespace(create=_boom))))

    with pytest.raises(Exception):
        orc.ask_openrouter_structured(
            "t", "sys", [{"type": "text", "text": "u"}],
            {"type": "object"}, model="m", max_retry=0)

    assert recs, "실패 기록이 없다"
    assert not (recs[-1].get("usage") or {}), (
        "왕복 전 실패인데 토큰을 적었다 — 안 낸 돈을 센다")
