"""이미지 요청이 **제공자에게 닿았는가** — 세 클라이언트 공용 판정.

## 왜 필요한가

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

`reve_image_client` 는 2026-08-26 에 이 계약을 먼저 세웠다. 이 모듈은 그것을
**옮겨 온 것이지 새로 만든 것이 아니다** — Grok·Gemini 도 같은 판정을 쓰라고
공용 자리로 올렸다.

## ★이 모듈이 막는 범위 — 넓게 읽으면 안 된다

여기서 막는 것은 **같은 `generate_image()` 호출 안의 자동 재시도**뿐이다.
그 함수가 끝난 뒤 상위가 다시 부르면(resume·JIT 재방문) **또 보낸다** —
같은 논리 호출을 다시 알아보는 durable latch 가 아직 없기 때문이다
(2026-08-26 Codex BLOCK-4). `submission_unknown` 기록은 `llm_call_log`·Opik
에 best-effort 로 남을 뿐 다음 호출을 막지 않는다.

프로세스가 죽은 뒤 resume 이 같은 요청을 다시 보내는 갈래도 마찬가지로
**안 막힌다** — 기동 회수가 죽은 스텝을 `failed` 로 바꾸고 다음 resume 이
바로 가져간다(`step_lock.reclaim_dead_locks_on_startup`).

그것을 닫으려면 결정적 `logical_request_key` + unresolved 조회가 필요하고,
별도 판의 몫이다.

## 가르는 법

**열거로 가르지 않는다.** 「닿지 못한 것이 확정된 것」만 세고 나머지는 전부
`unknown` 이다(fail-closed, 요금 보호).

    never_sent  이름 못 찾음 · 연결 거부 · 「보낼 길이 없다」(EHOSTUNREACH·
                ENETUNREACH·EHOSTDOWN·ENETDOWN) — 커널이 되돌린 것이라
                제공자가 요청을 본 적이 없다
    unknown     그 밖의 모든 실패 — 읽기 시간 초과 · 연결 끊김(ECONNRESET) ·
                파이프 끊김 · 원격 조기 종료 · 대기 시간 마감. 나갔을 수 있다

★대기 시간 마감(`concurrent.futures.TimeoutError`)도 `unknown` 이다. 근거는
「거기까지 갔으니 body 는 나갔을 것」이 **아니다** — `Future.result(timeout)` 은
worker 가 DNS·연결·TLS·업로드·읽기 중 어디에 있는지 알려주지 않고, 도는 스레드는
취소되지도 않는다. **모르니까 보수적으로** `unknown` 인 것이다.

★`socket.timeout` 하나로 연결 단계와 읽기 단계를 가를 수 없다 —
`urlopen(timeout=...)` 은 여러 blocking 연산에 함께 걸린다. 그래서 timeout 은
전부 `unknown` 이다.
"""
import errno
import socket
from typing import Any, Dict, Literal, Optional

SendState = Literal["never_sent", "unknown"]


class ImageSubmissionUnknown(RuntimeError):
    """보냈는지 **모르는** 채로 끝났다 — 다시 보내면 요금이 두 번 나간다.

    호출자가 「일시 장애」와 구분해서 다룰 수 있게 별도 타입이다. 이 예외를
    받은 자리는 **같은 요청을 다시 보내면 안 된다**. 사람이 제공자 대시보드
    에서 요금이 나갔는지 보고 정한다.
    """

    def __init__(self, message: str, *, cause: str = "") -> None:
        self.cause = cause
        super().__init__(message)


def unknown_send_metadata(
    *, cause: str, attempt_no: int, request_sha: str,
    base: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """`submission_unknown` 기록에 실을 표시 — 요금이 나갔을 수 있는 시도.

    ★이것은 **durable ledger 가 아니다.** 호출이 끝난 뒤 남기므로 프로세스가
     그 사이에 죽으면 행이 없고, 남아 있어도 **다음 호출을 막지 않는다**.
     막으려면 같은 논리 호출을 다시 알아보는 key 와 unresolved 조회가 있어야
     한다 — 별도 판의 몫이다(2026-08-26 Codex BLOCK-4). 이 한계를 이름으로
     속이지 않는다.
    """
    return {
        **(base or {}),
        "send_state": "unknown",
        "possible_charge": True,
        "unknown_cause": cause,
        "attempt_no": int(attempt_no),
        "effective_request_sha": request_sha,
    }

# 「연결이 아예 안 됐다」로 셀 수 있는 것 — 요청이 서버에 닿지 못한 경우만.
# 여기 없는 실패는 전부 「모른다」로 본다 (fail-closed, 요금 보호).
NEVER_SENT_ERRORS: tuple = (
    ConnectionRefusedError,
    # 이름을 못 찾았다 — 연결 자체를 시작도 못 했다.
    socket.gaierror,
)

# ★패킷이 목적지에 **닿지 못한 것이 확정된** errno (2026-08-26 자체 리뷰).
#  이것까지 `unknown` 으로 두면 흔한 망 장애에 샷이 그대로 죽는다 — 종전에는
#  재시도로 스스로 회복하던 자리다. 여기 있는 것들은 커널이 「보낼 길이
#  없다」고 되돌린 것이라 제공자가 요청을 본 적이 없다.
#  ★읽기 시간 초과·연결 리셋(`ECONNRESET`)·파이프 끊김은 **여기 없다** —
#   그것들은 이미 보낸 뒤일 수 있다.
_NEVER_SENT_ERRNO = frozenset({
    errno.ECONNREFUSED,   # 연결 거부
    errno.EHOSTUNREACH,   # 호스트로 가는 길이 없다
    errno.ENETUNREACH,    # 망으로 가는 길이 없다
    errno.EHOSTDOWN,      # 호스트가 내려가 있다
    errno.ENETDOWN,       # 망이 내려가 있다
})


def classify_send_failure(exc: BaseException) -> SendState:
    """전송 실패를 「안 보내졌다」와 「모른다」로 가른다.

    Returns: ``"never_sent"`` | ``"unknown"``

    ★가르는 기준은 **요청이 서버에 닿았을 가능성**이지 오류의 심각도가
     아니다. 연결 끊김·시간 초과는 가벼워 보여도 「닿았을 수 있다」쪽이다.

    `URLError` 가 원인을 감싸고 있을 수 있어 `.reason` 을 따라 들어간다.
    """
    seen = exc
    for _ in range(5):
        if isinstance(seen, NEVER_SENT_ERRORS):
            return "never_sent"
        # 커널이 「보낼 길이 없다」고 되돌린 것 — 제공자가 요청을 본 적 없다.
        if isinstance(seen, OSError) and seen.errno in _NEVER_SENT_ERRNO:
            return "never_sent"
        reason = getattr(seen, "reason", None)
        if not isinstance(reason, BaseException):
            break
        seen = reason
    return "unknown"


# ── HTTP 응답을 받은 뒤의 재시도 가부 ────────────────────────────────────
#
# 응답을 받았다는 것은 요청이 **닿았다**는 뜻이다. 그래도 다시 보내도 되는
# 상태가 있는데, **제공자마다 다르다.**
#
# ★상태 코드만으로 「받지 않았다」를 단정할 수 없다 (2026-08-26 Codex 지적,
#  수용). RFC 9110 §9.2.2 는 non-idempotent POST 를 「원 요청이 적용되지
#  않았다고 확인할 수 없는 한」 자동 재시도하지 말라고 한다. 429 도 요청을
#  받고 나서 되돌린 응답이고, 503 도 side effect 가 시작되지 않았다는 보장이
#  아니다. 그래서 **제공자가 스스로 재시도를 권하는 경우만** 재시도한다.
#
# 종전에는 세 클라이언트가 {429, 500, 502, 503, 504} 를 한 묶음으로 다시
# 보냈다. 500·502·504 는 상류가 이미 작업을 시작한 뒤 실패했을 수 있다.

# 직접 호출 — 공식 문서가 429·503 에 backoff 재시도를 권한다.
GEMINI_RESEND_SAFE = frozenset({429, 503})

# OpenRouter 경유 — 라우터가 실제 제공자를 몇 번 시도했는지는 응답
# metadata 에 실려 오고, 우리는 그것을 **요청하지 않는다.** 503 만 보고
# 「안 갔다」고 볼 수 없으므로 429 만 남긴다. (그 metadata 를 배선하면
# `attempt == 0` 인 503 을 되살릴 수 있다 — 이번 판의 범위 밖.)
OPENROUTER_RESEND_SAFE = frozenset({429})

#: xAI 직접 — 제공자와 우리 사이에 라우터가 없다. 429(속도 제한)와
#:  503(과부하)은 「처리 전에 거절」이라 다시 보내도 두 번 안 나간다
#:  (gemini 직접과 같은 근거). 500·502·504 는 여기 없다 — 상태를 모른다.
XAI_RESEND_SAFE = frozenset({429, 503})

_BY_ROUTE = {
    "gemini": GEMINI_RESEND_SAFE,
    "openrouter": OPENROUTER_RESEND_SAFE,
    "xai": XAI_RESEND_SAFE,
}

# 상태가 **명확한** 실패 — 요청은 닿았고 제공자가 거부를 확정했다.
# 다시 보내도 같은 자리에서 막히고, 무엇이 일어났는지는 안다.
# (검열 400 은 요금이 나갈 수 있지만 **재전송을 안 하므로** 두 번 나가지
#  않는다. 여기서 가르는 축은 「요금이 나갔나」가 아니라 「상태를 아나」다.)
_TERMINAL_STATUS = frozenset({400, 401, 403, 404, 405, 409, 413, 415, 422})

HttpVerdict = Literal["retryable", "submission_unknown", "terminal"]


def classify_http_status(code: int, *, via: str) -> HttpVerdict:
    """HTTP 응답을 **세 갈래**로 가른다.

    ★bool 로는 안 된다 (2026-08-26 Codex BLOCK-1). 「재시도 불가」 하나에
     400 같은 확정 거부와 500·502·504 같은 **상태를 모르는 응답**이 함께
     담기면, 후자가 일반 오류로 기록돼 `possible_charge` 가 사라진다.

        retryable          다시 보내도 요금이 두 번 안 나간다
        submission_unknown 처리됐을 수 있다 — 재전송 금지, 요금 표시를 남긴다
        terminal           제공자가 거부를 확정했다 — 상태를 안다

    `via` 는 호출 경로다 — `"gemini"`(직접) 또는 `"openrouter"`(경유).
    모르는 경로는 재시도하지 않는다(제공자 계약을 모르면 돈 쪽으로 닫는다).
    """
    c = int(code)
    if c in _BY_ROUTE.get(via, frozenset()):
        return "retryable"
    if c in _TERMINAL_STATUS:
        return "terminal"
    if 500 <= c < 600:
        # 상류가 이미 받아 작업을 시작한 뒤 실패했을 수 있다.
        return "submission_unknown"
    # 남은 4xx 는 확정 거부로 본다 — 요청이 처리되지 않았다는 뜻이다.
    if 400 <= c < 500:
        return "terminal"
    return "submission_unknown"


def http_status_is_resend_safe(code: int, *, via: str) -> bool:
    """이 상태에서 **같은 요청을 다시 보내도 요금이 두 번 안 나가는가**.

    ★프로덕션 호출부는 `classify_http_status` 를 직접 쓴다 — 「재시도 불가」
     하나로 뭉치면 「상태를 모르는 응답」이 일반 오류로 묻히기 때문이다
     (Codex BLOCK-1). 이 얇은 감싸개는 **시험이 읽기 쉬우라고** 남긴다.
    """
    return classify_http_status(code, via=via) == "retryable"
