"""HTTP 200 본문에 담겨 온 일시 서버 오류를 다시 보낸다.

## 왜 이 시험이 있나 (2026-08-19 실측)

xAI 는 OpenRouter 를 거치면서 **상태 200 에 오류를 본문으로 담아** 보낼
때가 있다. 그 갈래에 재시도가 없어서 한 번에 포기했다.

    {"message": "Image generation failed. Please try again later.",
     "code": 502}

08-19 15:43 `S88sh2` 가 이렇게 죽었고, 그 한 장 때문에 걷기 전체가 두
시간 다시 돌았다(20:09 재시도로 살아남았다 — 정말 일시적인 오류였다).
상태 코드로 올 때는 이미 다시 보내고 있었으므로, 갈래가 둘인데 한쪽만
막혀 있던 것이다.

지키는 계약은 셋이다.

1. 다시 보내면 될 수도 있는 코드는 다시 보낸다.
2. **검열은 다시 보내지 않는다** — 같은 자리에서 또 막히고, ★거부당해도
   요금이 나간다(거부 응답에 `cost_in_usd_ticks` 가 붙는다).
3. 재시도 한도를 다 쓰면 종전처럼 전파한다 — 조용히 삼키지 않는다.

★2026-08-26 (감사 0-A, Codex BLOCK-2): 「429·5xx 를 다 다시 보낸다」가
 **429 만**으로 좁아졌다. 이 갈래가 HTTPError 갈래의 제공자별 정책을 우회해,
 500·502·503·504 를 그대로 재전송하고 있었다 — 상류가 이미 작업을 시작한 뒤
 실패했을 수 있는 바로 그 경우다. 지금은 `submission_unknown` 으로 멈추고
 요금 표시를 남긴다. 그 계약은 `test_image_send_state.py` 가 잠근다.
"""

import json

import pytest

from app.modules.llm import grok_image_client as gic


class _FakeResponse:
    def __init__(self, payload):
        self._body = json.dumps(payload).encode("utf-8")

    def read(self):
        return self._body

    def __enter__(self):
        return self

    def __exit__(self, *_a):
        return False


class _SleepProbe:
    """그 모듈이 부르는 `time.sleep` 만 잰다 — 배경 스레드는 진짜로 잔다.

    ★`monkeypatch.setattr("time.sleep", …)` 도 `setattr(mod.time, "sleep", …)`
     도 **`time` 모듈 자체**를 바꾼다. 전체 시험을 돌리면 litellm/Opik 의
     배경 스레드가 주기적으로 자면서 그 sleep 까지 이 목록에 들어와,
     `sleeps == []` 같은 단언이 간헐적으로 깨졌다(2026-08-27 실측:
     `[0.1, 0.1]`). 모듈의 `time` **이름**만 대리물로 바꾸면 그 모듈의
     소비만 잡힌다.
    """

    def __init__(self, sink):
        self._sink = sink

    def sleep(self, seconds):
        self._sink.append(seconds)

    def __getattr__(self, name):
        import time as _real_time
        return getattr(_real_time, name)


def _install(monkeypatch, payloads, sleeps):
    """urlopen 이 payloads 를 차례로 돌려주게 하고 잠은 세기만 한다."""
    seq = list(payloads)
    calls = []

    def _urlopen(req, timeout=None):
        calls.append(timeout)
        return _FakeResponse(seq.pop(0) if seq else seq_last)

    seq_last = payloads[-1]
    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic, "time", _SleepProbe(sleeps))
    return calls


def _client(monkeypatch, max_retries=2):
    client = gic.GrokImageClient(api_key="k", model="m")
    monkeypatch.setattr(
        gic._settings, "llm_timeout_image_gen", 5, raising=False)
    monkeypatch.setattr(
        gic._settings, "llm_max_retries", max_retries, raising=False)
    monkeypatch.setattr(gic, "reserve_current_call", lambda **_kw: None)
    monkeypatch.setattr(gic, "log_llm_call", lambda **_kw: "call-1")
    monkeypatch.setattr(gic, "capture_generated_image", lambda *a, **k: None)

    class _NullTracer:
        def log(self, **_kw):
            return None

    monkeypatch.setattr(
        "app.modules.llm.image_tracer.get_image_tracer",
        lambda: _NullTracer())
    return client


_OK = {"choices": [{"message": {"images": [
    {"image_url": {"url": "data:image/png;base64,aGk="}}]}}]}
_E502 = {"error": {
    "message": "Image generation failed. Please try again later.",
    "code": 502}}
_E429 = {"error": {
    "message": "Rate limit exceeded. Please slow down.",
    "code": 429}}
_E400_MOD = {"error": {
    "message": "Generated image rejected by content moderation.",
    "code": 400}}
# 상류(xAI) 거부를 OpenRouter 가 5xx 로 감싸 보낸 모양 — 코드는 다시
# 보낼 만한 502 인데 내용은 검열이다. 문구 판별이 없으면 다시 보낸다.
_E502_MOD = {"error": {
    "message": "Provider returned error",
    "code": 502,
    "metadata": {"raw": json.dumps({
        "code": "imagine:content-moderated",
        "error": "Generated image rejected by content moderation."})},
}}


def test_body_429_is_retried_then_succeeds(monkeypatch):
    """★rate limit 은 제공자가 재시도를 권하는 자리라 그대로 남는다."""
    sleeps = []
    calls = _install(monkeypatch, [_E429, _OK], sleeps)
    client = _client(monkeypatch)
    monkeypatch.setattr(gic, "ensure_png_bytes", lambda b, context="": b"png")

    out, _ms = client.generate_image("p", labeled_references=[("L", b"x")])
    assert out == b"png"
    assert len(calls) == 2, "본문 429 는 한 번 더 보내야 한다"
    assert sleeps == [2]


def test_body_502_no_longer_retried(monkeypatch):
    """★2026-08-26 동작 변경 — 502 는 **한 번만** 보낸다.

    종전에는 이 갈래가 {429,500,502,503,504} 를 통째로 다시 보내
    HTTPError 갈래의 제공자별 정책을 우회했다. 상류가 이미 작업을 시작한 뒤
    실패했을 수 있으므로 재전송하면 요금이 두 번 나간다.
    """
    from app.modules.llm.image_send_state import ImageSubmissionUnknown

    sleeps = []
    calls = _install(monkeypatch, [_E502, _E502, _E502], sleeps)
    client = _client(monkeypatch, max_retries=2)

    with pytest.raises(ImageSubmissionUnknown) as err:
        client.generate_image("p", labeled_references=[("L", b"x")])
    assert "502" in str(err.value)
    assert len(calls) == 1, f"502 인데 {len(calls)}회 보냈다"
    assert sleeps == [], "재전송을 안 하므로 기다리지도 않는다"


def test_body_moderation_is_not_retried(monkeypatch):
    """거부당해도 요금이 나간다 — 될 리 없는 것을 다시 보내지 않는다."""
    sleeps = []
    calls = _install(monkeypatch, [_E400_MOD, _OK], sleeps)
    client = _client(monkeypatch)

    with pytest.raises(RuntimeError) as err:
        client.generate_image("p", labeled_references=[("L", b"x")])
    assert "moderation blocked" in str(err.value)
    assert len(calls) == 1, "검열은 한 번만 보낸다"
    assert sleeps == []


def test_body_moderation_with_retryable_code_is_not_retried(monkeypatch):
    """다시 보낼 만한 코드라도 내용이 검열이면 보내지 않는다.

    위의 `test_body_moderation_is_not_retried` 는 코드가 400 이라 코드
    집합(`GROK_RETRYABLE_BODY_CODES`) 에서 이미 걸린다 — 문구 판별
    (`is_moderation_text`) 을 한 번도 지나지 않는다. 실측으로 확인했다:
    구현의 재시도 조건에서 문구 판별 항을 지워도 그 시험은 초록이다.
    그래서 코드와 문구가 **엇갈리는** 이 자리가 문구 판별을 지키는
    유일한 시험이다.
    """
    # 이 시험이 조용히 「안 지나는 시험」이 되지 않게 먼저 못을 박는다 —
    # 502 가 집합에서 빠지면 코드 판별에서 걸려 문구 판별을 안 지난다.
    assert 502 in gic.GROK_RETRYABLE_BODY_CODES, (
        "502 가 재시도 대상이 아니면 이 시험은 문구 판별을 지나지 않는다")

    sleeps = []
    calls = _install(monkeypatch, [_E502_MOD, _OK], sleeps)
    client = _client(monkeypatch)

    with pytest.raises(RuntimeError) as err:
        client.generate_image("p", labeled_references=[("L", b"x")])
    assert "moderation blocked" in str(err.value)
    assert len(calls) == 1, "검열은 코드가 502 라도 한 번만 보낸다"
    assert sleeps == [], "다시 보내지 않으니 기다리지도 않는다"


def test_non_retryable_body_code_is_not_retried(monkeypatch):
    sleeps = []
    calls = _install(
        monkeypatch,
        [{"error": {"message": "Bad request.", "code": 400}}, _OK], sleeps)
    client = _client(monkeypatch)

    with pytest.raises(RuntimeError):
        client.generate_image("p", labeled_references=[("L", b"x")])
    assert len(calls) == 1


def test_body_error_without_code_is_not_retried(monkeypatch):
    """코드를 못 읽으면 다시 보내지 않는다 — 짐작으로 돈을 쓰지 않는다.

    ★2026-08-26: 「안 보낸다」에 더해 **무엇으로 기록되는지**까지 잠근다.
     상태 200 을 받았다는 것은 요청이 OpenRouter 까지 닿았다는 뜻이라,
     일반 오류로 남기면 `possible_charge` 가 사라져 다음 방문이 오독한다.
     `RuntimeError` 만 기대하면 `ImageSubmissionUnknown` 이 그 하위라
     **계약이 바뀌어도 이 시험이 안 잡는다.**
    """
    from app.modules.llm.image_send_state import ImageSubmissionUnknown

    sleeps = []
    calls = _install(
        monkeypatch, [{"error": {"message": "something went wrong"}}, _OK],
        sleeps)
    client = _client(monkeypatch)

    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image("p", labeled_references=[("L", b"x")])
    assert len(calls) == 1


def test_body_error_code_reader():
    assert gic._body_error_code({"code": 502}) == 502
    assert gic._body_error_code({"code": "502"}) is None
    assert gic._body_error_code({"code": True}) is None
    assert gic._body_error_code({}) is None
    assert gic._body_error_code("not a dict") is None
