"""보냈는지 모르는 채로 끝난 요청을 **다시 보내지 않는다** (감사 0-A).

## 왜 이 시험이 있나

동기 이미지 요청은 서버가 요청을 받은 뒤 **응답만 유실**될 수 있다. 그 상태를
「접수 실패」로 읽고 다시 보내면 같은 이미지에 요금이 두 번 나가고, 기록에는
마지막 한 건만 남아 첫 시도의 비용과 결과 신원을 잃는다.

`reve_image_client` 는 2026-08-26 에 이 계약을 먼저 세웠는데
(`connection refused` 만 다시 보내고 나머지는 멈춘다), **Grok·Gemini 두
클라이언트만 안 따라갔다.** 종전 동작:

    grok   : 대기 시간 마감 → 재시도 사다리 → 두 번째 유료 요청
    gemini : 읽기 시간 초과 → **키를 바꿔** 같은 body 를 새 요청으로

지키는 계약은 넷이다.

1. 닿지 못한 것이 **확정된 것만** 다시 보낸다 (이름 못 찾음·연결 거부).
2. 그 밖의 모든 전송 실패는 `ImageSubmissionUnknown` — 보내는 횟수는 **1회**.
3. HTTP 응답을 받았어도 500·502·504 는 다시 보내지 않는다 — 상류가 이미
   작업을 시작한 뒤 실패했을 수 있다. 429·503 의 가부는 **제공자마다 다르다**
   (Gemini 는 공식 문서가 backoff 재시도를 권하고, OpenRouter 503 은 라우터가
   provider 까지 갔는지 알 수 없다).
4. 멈춘 시도는 `possible_charge` 로 기록에 남는다.
"""

import json
import socket
import urllib.error

import pytest

from app.modules.llm import gemini_image_client as gem
from app.modules.llm import grok_image_client as gic
from app.modules.llm.image_send_state import (
    ImageSubmissionUnknown,
    classify_http_status,
    classify_send_failure,
    http_status_is_resend_safe,
)


# ── 판정 자체 ────────────────────────────────────────────────────────────

@pytest.mark.parametrize("exc", [
    socket.gaierror("이름을 못 찾았다"),
    ConnectionRefusedError(),
    urllib.error.URLError(ConnectionRefusedError()),
    urllib.error.URLError(socket.gaierror("x")),
])
def test_닿지_못한_것이_확정된_것만_never_sent(exc):
    assert classify_send_failure(exc) == "never_sent"


@pytest.mark.parametrize("exc", [
    socket.timeout(),
    urllib.error.URLError(socket.timeout()),
    ConnectionResetError(),
    BrokenPipeError(),
    RuntimeError("무슨 일인지 모른다"),
])
def test_나머지는_전부_unknown(exc):
    """★열거로 가르지 않는다 — 모르면 요금 쪽으로 닫는다."""
    assert classify_send_failure(exc) == "unknown"


def test_500_502_504_는_어느_경로에서도_다시_보내지_않는다():
    """상류가 이미 받아 **작업을 시작한 뒤** 실패했을 수 있다."""
    for via in ("gemini", "openrouter"):
        assert not http_status_is_resend_safe(500, via=via)
        assert not http_status_is_resend_safe(502, via=via)
        assert not http_status_is_resend_safe(504, via=via)


def test_재시도_가부는_제공자마다_다르다():
    """★상태 코드만으로 「받지 않았다」를 단정할 수 없다 (RFC 9110 §9.2.2).

    제공자가 **스스로 재시도를 권하는 경우만** 재시도한다.
    OpenRouter 503 은 라우터가 이미 provider 까지 갔는지 알 수 없다 —
    그 판단에 필요한 metadata 를 우리가 요청하지 않기 때문이다.
    """
    assert http_status_is_resend_safe(429, via="gemini")
    assert http_status_is_resend_safe(503, via="gemini")
    assert http_status_is_resend_safe(429, via="openrouter")
    assert not http_status_is_resend_safe(503, via="openrouter")


def test_모르는_경로는_재시도하지_않는다():
    """제공자 계약을 모르면 돈 쪽으로 닫는다."""
    assert not http_status_is_resend_safe(429, via="처음보는것")
    assert not http_status_is_resend_safe(503, via="")


# ── Grok ────────────────────────────────────────────────────────────────

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


_OK = {"choices": [{"message": {"images": [
    {"image_url": {"url": "data:image/png;base64,aGk="}}]}}]}


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


def _grok(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, "capture_generated_image", lambda *a, **k: None)
    monkeypatch.setattr(
        "app.modules.llm.image_tracer.get_image_tracer", lambda: _NullTracer())
    return client


def test_grok_대기_시간_마감이면_한_번만_보낸다(monkeypatch):
    """★worker 가 어디에 있는지 모른다 — body 가 나갔을 수 있다."""
    sends = []
    logged = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        raise socket.timeout()      # worker 안에서 오래 걸리는 것을 흉내

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gem, "log_llm_call",
                        lambda **kw: logged.append(kw) or "call-1")
    client = _grok(monkeypatch)

    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image("프롬프트")

    assert len(sends) == 1, f"유료 요청이 {len(sends)}회 나갔다"
    assert logged, "멈춘 시도가 기록에 안 남았다"
    assert logged[-1]["status"] == "submission_unknown"
    assert logged[-1]["metadata"]["possible_charge"] is True


def test_grok_연결_거부는_다시_보낸다(monkeypatch):
    """닿지 못한 것이 확정 — 요금이 안 나갔으므로 재시도가 옳다."""
    sends = []
    seq = [ConnectionRefusedError(), ConnectionRefusedError(), None]

    def _urlopen(req, timeout=None):
        sends.append(1)
        exc = seq[len(sends) - 1]
        if exc is not None:
            raise exc
        return _FakeResponse(_OK)

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gic, "log_llm_call", lambda **_kw: "call-1")
    client = _grok(monkeypatch)

    png, _ms = client.generate_image("프롬프트")
    assert png
    assert len(sends) == 3, "연결 거부는 재시도해야 한다"


def test_상태를_아는_거부와_모르는_실패를_가른다():
    """★bool 로는 안 된다 (Codex BLOCK-1).

    「재시도 불가」 하나에 400 같은 확정 거부와 500·502·504 같은 **상태를
    모르는 응답**이 함께 담기면, 후자가 일반 오류로 기록돼
    `possible_charge` 가 사라지고 다음 방문이 그냥 다시 보낸다.
    """
    assert classify_http_status(400, via="gemini") == "terminal"
    assert classify_http_status(404, via="openrouter") == "terminal"
    assert classify_http_status(500, via="gemini") == "submission_unknown"
    assert classify_http_status(502, via="openrouter") == "submission_unknown"
    assert classify_http_status(504, via="gemini") == "submission_unknown"
    assert classify_http_status(503, via="openrouter") == "submission_unknown"
    assert classify_http_status(503, via="gemini") == "retryable"
    assert classify_http_status(429, via="openrouter") == "retryable"


def test_grok_502_응답은_다시_보내지_않는다(monkeypatch):
    """HTTP 502 = 상류가 이미 받았을 수 있다 (본문 502 갈래와 다르다)."""
    sends = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        raise urllib.error.HTTPError(
            "u", 502, "Bad Gateway", {},  # type: ignore[arg-type]
            __import__("io").BytesIO(b"upstream died"))

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gic, "log_llm_call", lambda **_kw: "call-1")
    client = _grok(monkeypatch)

    # ★일반 오류가 아니라 `submission_unknown` 이어야 한다 (Codex BLOCK-1) —
    #  그래야 possible_charge 가 남는다.
    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image("프롬프트")
    assert len(sends) == 1, f"502 인데 {len(sends)}회 보냈다"


def test_grok_429_응답은_다시_보낸다(monkeypatch):
    sends = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        if len(sends) < 3:
            raise urllib.error.HTTPError(
                "u", 429, "Too Many Requests", {},  # type: ignore[arg-type]
                __import__("io").BytesIO(b"slow down"))
        return _FakeResponse(_OK)

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gic, "log_llm_call", lambda **_kw: "call-1")
    client = _grok(monkeypatch)

    png, _ms = client.generate_image("프롬프트")
    assert png
    assert len(sends) == 3


# ── Gemini ──────────────────────────────────────────────────────────────

def _gemini(monkeypatch, max_retries=2):
    client = gem.GeminiImageClient(api_key="k", model="m")
    monkeypatch.setattr(
        gem._settings, "llm_timeout_image_gen", 5, raising=False)
    monkeypatch.setattr(
        gem._settings, "llm_max_retries", max_retries, raising=False)
    monkeypatch.setattr(gem, "reserve_current_call", lambda **_kw: None)
    monkeypatch.setattr(gem, "capture_generated_image", lambda *a, **k: None)
    monkeypatch.setattr(
        "app.modules.llm.image_tracer.get_image_tracer", lambda: _NullTracer())
    return client


def test_gemini_읽기_시간_초과면_키를_바꿔_다시_보내지_않는다(monkeypatch):
    """★종전에는 키까지 바꿔 보냈다 — 서버 쪽에서도 같은 요청인지 모른다."""
    sends = []
    keys = []
    logged = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        raise urllib.error.URLError(socket.timeout())

    monkeypatch.setattr(gem.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gem.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gem, "get_next_key",
                        lambda: keys.append(1) or "key-new")
    monkeypatch.setattr(gem, "log_llm_call",
                        lambda **kw: logged.append(kw) or "call-1")
    client = _gemini(monkeypatch)
    client._fixed_api_key = None    # 키 풀 경로를 태운다

    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image("프롬프트")

    assert len(sends) == 1, f"유료 요청이 {len(sends)}회 나갔다"
    assert logged[-1]["status"] == "submission_unknown"
    assert logged[-1]["metadata"]["possible_charge"] is True


def test_gemini_연결_거부는_다시_보낸다(monkeypatch):
    sends = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        if len(sends) < 3:
            raise urllib.error.URLError(ConnectionRefusedError())
        return _FakeResponse({"candidates": [{"content": {"parts": [
            {"inlineData": {"data": "aGk="}}]}}]})

    monkeypatch.setattr(gem.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gem.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gem, "get_next_key", lambda: "key")
    monkeypatch.setattr(gem, "log_llm_call", lambda **_kw: "call-1")
    monkeypatch.setattr(gem, "ensure_png_bytes", lambda b, **_kw: b)
    client = _gemini(monkeypatch)
    client._fixed_api_key = None

    png, _ms = client.generate_image("프롬프트")
    assert png
    assert len(sends) == 3


# ── reve 405 진단 (감사 0-C) ─────────────────────────────────────────────

def test_reve_405_진단이_모델명을_바꾸라고_권하지_않는다():
    """★멀쩡한 값을 틀린 것으로 바꾸게 만들던 안내를 지웠다.

    `config.reve_image_model` 주석이 근거다 — fal 이 직접 호스팅하는 모델만
    `fal-ai/` 접두를 쓰고 제휴 모델은 제공자 이름으로 시작한다. 실측으로
    `reve/2.1/edit` 로 변환이 성공한 기록이 있다.
    """
    import io

    from app.modules.llm import reve_image_client as rev

    client = rev.ReveImageClient(api_key="k", model="reve/2.1/edit")
    exc = urllib.error.HTTPError(
        "u", 405, "Method Not Allowed", {},  # type: ignore[arg-type]
        io.BytesIO(b""))
    with pytest.raises(Exception) as caught:
        client._raise_http(exc)

    text = str(caught.value)
    assert "fal-ai/" not in text, "모델명을 바꾸라는 안내가 남아 있다"
    assert "reve/2.1/edit" in text, "무엇을 보냈는지가 안 적혔다"
    assert "status=405" in text, "본 것(상태)이 안 적혔다"


# ── OpenRouter 가 HTTP 200 본문에 담아 주는 오류 (Codex BLOCK-2) ─────────

def test_grok_본문_502_는_다시_보내지_않고_요금_표시를_남긴다(monkeypatch):
    """★이 갈래가 HTTPError 갈래의 제공자 정책을 우회하고 있었다.

    OpenRouter 는 상류 실패를 **상태 200 + 본문 오류**로 줄 때가 있다.
    종전에는 {429,500,502,503,504} 를 통째로 다시 보내, 바로 아래 HTTP
    갈래가 막기로 한 바로 그 경우를 우회했다.
    """
    sends = []
    logged = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        return _FakeResponse({"error": {
            "message": "Image generation failed. Please try again later.",
            "code": 502}})

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gem, "log_llm_call",
                        lambda **kw: logged.append(kw) or "call-1")
    client = _grok(monkeypatch)

    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image("프롬프트")

    assert len(sends) == 1, f"본문 502 인데 {len(sends)}회 보냈다"
    assert logged[-1]["status"] == "submission_unknown"
    assert logged[-1]["metadata"]["possible_charge"] is True


def test_grok_본문_429_는_그대로_다시_보낸다(monkeypatch):
    """rate limit 은 제공자가 재시도를 권하는 자리다."""
    sends = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        if len(sends) < 3:
            return _FakeResponse({"error": {"message": "slow", "code": 429}})
        return _FakeResponse(_OK)

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gic, "log_llm_call", lambda **_kw: "call-1")
    client = _grok(monkeypatch)

    png, _ms = client.generate_image("프롬프트")
    assert png
    assert len(sends) == 3


def test_본문_검열은_상태와_무관하게_다시_안_보낸다(monkeypatch):
    """★거부당해도 요금이 나간다 — 다시 보내면 같은 자리에서 또 막힌다."""
    sends = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        return _FakeResponse({"error": {
            "message": "Generated image rejected by content moderation.",
            "code": 502}})

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gic, "log_llm_call", lambda **_kw: "call-1")
    client = _grok(monkeypatch)

    with pytest.raises(RuntimeError):
        client.generate_image("프롬프트")
    assert len(sends) == 1


def test_본문_오류_코드를_못_읽으면_모른다로_둔다(monkeypatch):
    """★상태 200 을 받았다 = 요청은 **닿았다** (2026-08-26 Codex 재리뷰).

    본문에 오류가 있는데 코드가 없거나 예상 밖 형식(문자열 `"502"` 등)이면
    상류가 처리했는지·요금이 나갔는지 모른다. terminal 로 두면
    `possible_charge` 가 사라져 다음 방문이 오독한다.
    """
    for bad in ({"message": "무슨 일인지 모른다"},          # code 없음
                {"message": "x", "code": "502"},            # 문자열
                {"message": "x", "code": True}):            # bool
        sends = []
        logged = []

        def _urlopen(req, timeout=None, _b=bad):
            sends.append(1)
            return _FakeResponse({"error": _b})

        monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
        monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
        monkeypatch.setattr(gem, "log_llm_call",
                            lambda **kw: logged.append(kw) or "call-1")
        client = _grok(monkeypatch)

        with pytest.raises(ImageSubmissionUnknown):
            client.generate_image("프롬프트")
        assert len(sends) == 1, f"{bad} 인데 {len(sends)}회 보냈다"
        assert logged[-1]["status"] == "submission_unknown"
        assert logged[-1]["metadata"]["possible_charge"] is True


def test_코드가_없어도_검열이면_terminal(monkeypatch):
    """★검열은 무슨 일이 있었는지 **아는** 경우다 — 요금 표시 대상이 아니다."""
    sends = []

    def _urlopen(req, timeout=None):
        sends.append(1)
        return _FakeResponse({"error": {
            "message": "Generated image rejected by content moderation."}})

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gic, "log_llm_call", lambda **_kw: "call-1")
    client = _grok(monkeypatch)

    with pytest.raises(RuntimeError) as err:
        client.generate_image("프롬프트")
    assert not isinstance(err.value, ImageSubmissionUnknown)
    assert len(sends) == 1


# ── 자체 code-review 로 찾은 갈래 (2026-08-26) ──────────────────────────

def test_보낼_길이_없다는_커널_응답은_다시_보낸다():
    """★이것까지 `unknown` 으로 두면 흔한 망 장애에 샷이 그대로 죽는다.

    커널이 「보낼 길이 없다」고 되돌린 것은 제공자가 요청을 **본 적이 없다**
    는 뜻이라 요금이 안 나간다 — 재시도가 옳다.
    """
    import errno as _e

    for code in (_e.ECONNREFUSED, _e.EHOSTUNREACH, _e.ENETUNREACH,
                 _e.EHOSTDOWN, _e.ENETDOWN):
        assert classify_send_failure(OSError(code, "x")) == "never_sent", code
        # URLError 가 감싸도 원인을 따라 들어간다.
        assert classify_send_failure(
            urllib.error.URLError(OSError(code, "x"))) == "never_sent", code


def test_보낸_뒤일_수_있는_errno_는_그대로_모른다():
    """★`ECONNRESET`·`EPIPE` 는 **이미 보낸 뒤**일 수 있다."""
    import errno as _e

    for code in (_e.ECONNRESET, _e.EPIPE, _e.ETIMEDOUT):
        assert classify_send_failure(OSError(code, "x")) == "unknown", code


def test_reve_예외가_공용_예외의_하위다():
    """★이름으로 판정하던 소비자가 공용 예외를 못 알아보던 결함.

    `cine_transform` 이 `type(exc).__name__ == "ReveSubmissionUnknown"` 로
    판정했는데, Grok·Gemini 가 던지는 것은 이름이 다르다. 그리고 **grok 이
    기본 변환 제공자**다 — 즉 재요청 차단이 기본 경로에서 열려 있었다.
    """
    from app.modules.llm.reve_image_client import ReveSubmissionUnknown

    assert issubclass(ReveSubmissionUnknown, ImageSubmissionUnknown)


def test_상속해도_cause_가_안_지워진다():
    """★상속으로 바꾼 직후 `cause` 가 전부 빈 문자열이 됐다.

    자식이 `self.cause` 를 넣고 `super().__init__(message)` 만 부르면
    **부모가 기본값으로 덮어쓴다.** 자식 `__init__` 을 지워 고쳤다.
    """
    from app.modules.llm.reve_image_client import ReveSubmissionUnknown

    assert ReveSubmissionUnknown("m", cause="submit_ack_missing").cause == (
        "submit_ack_missing")
    assert ImageSubmissionUnknown("m", cause="wall_timeout").cause == (
        "wall_timeout")


def test_모른다_사유에_원래_예외_타입이_남는다(monkeypatch):
    """열거로 가르지 않는 대신 **무엇이었는지**를 기록에 남긴다."""
    logged = []

    def _urlopen(req, timeout=None):
        raise ValueError("본문이 이상하다")

    monkeypatch.setattr(gic.urllib.request, "urlopen", _urlopen)
    monkeypatch.setattr(gic.time, "sleep", lambda _s: None)
    monkeypatch.setattr(gem, "log_llm_call",
                        lambda **kw: logged.append(kw) or "call-1")
    client = _grok(monkeypatch)

    with pytest.raises(ImageSubmissionUnknown):
        client.generate_image("프롬프트")
    assert logged[-1]["metadata"]["unknown_cause"].endswith(":ValueError")
