///|
/// Discord's retry rule for one logical call, as `resend_decision` decides.
/// `closed` is the client's own flag, so a closed client sends nothing more.
priv struct DiscordRetry {
  max_retries : Int32
  closed : Ref[Bool]
}

///|
impl @runtime.RetryDecider for DiscordRetry with fn decide(self, error, context) {
  resend_decision(
    error,
    context,
    max_retries=self.max_retries,
    closed=self.closed.val,
  )
}

///|
/// The request header that marks a call as safe to send again although its
/// method is not idempotent: a message create with a `nonce` and
/// `enforce_nonce: true`, which Discord answers with the message it already
/// created instead of creating another (`wire_request` sets it). It lives
/// only between the client and its transport: `ClosedGate` removes it from
/// every attempt before the transport sees the request, so it never reaches
/// Discord. The runtime's observer, and so telemetry, sees the request with
/// it.
const REPLAY_SAFE_HEADER : String = "x-discord-mbt-replay-safe"

///|
/// Discord's resend rule as a `gaato/http/retry` rule, up to `max_retries`
/// resends:
///
/// - a 429 for any method, after the delay Discord asked for (supplied by
///   `resend_decision`, uncapped: the caller's deadline bounds it);
/// - a 502 for any method: Discord documents it as "wait a bit and retry";
/// - any other 5xx only for an idempotent request, since Discord does not
///   promise that a failed `POST` or `PATCH` did nothing;
/// - a transport failure that proves nothing was sent, for any method, and
///   one after sending only for an idempotent request.
///
/// Other statuses, 401 and 403 among them, are Discord's answer. Retries
/// that the response does not time back off as the runtime's conservative
/// preset does: 500 ms doubling to 30 s, within ±20 %.
fn discord_retry_rule(max_retries : Int32) -> @retry.RetryRule {
  {
    max_retries,
    // First match wins: 502 before the idempotent-only 5xx range.
    statuses: [
      { statuses: [Code(429), Code(502)], methods: Any, },
      { statuses: [Range(500, 599)], methods: Idempotent, },
    ],
    sent_failures: Idempotent,
    unsent_failures: true,
    override_header: None,
    retry_after: {
      source: MsThenHeader,
      cap: NoCap,
      zero_means_backoff: false,
    },
    backoff: @runtime.RetryPolicy::conservative().rule.backoff,
  }
}

///|
/// Whether to send a call again after a failed attempt, and after how long.
/// Nothing is resent once the client is `closed`. Otherwise
/// `discord_retry_rule(max_retries)` decides, with two Discord-specific
/// inputs:
///
/// - a 429's delay is the body's `retry_after` (fractional seconds), then
///   `Retry-After-Ms` or `Retry-After`, then one second, plus one
///   millisecond; but a 429 that is not Discord's rate limit
///   (`@ratelimit.is_discord_rate_limit`: no JSON content type and no
///   `X-RateLimit-Scope` or `X-RateLimit-Global`, as from Cloudflare in front
///   of Discord) gives no wait to honour, and resending it only adds to the
///   address's invalid requests, so it fails at once;
/// - a call is idempotent when its method is (`GET`, `HEAD`, `PUT`,
///   `DELETE`, `OPTIONS`) or when it carries `REPLAY_SAFE_HEADER`.
fn resend_decision(
  error : @runtime.SdkError,
  context : @runtime.RetryContext,
  max_retries~ : Int32,
  closed~ : Bool,
) -> @retry.Decision {
  if closed {
    return Stop(NotRetryable)
  }
  guard error.outcome() is Some(outcome) else { return Stop(NotRetryable) }
  let server_delay_ms = match error {
    RateLimited(headers~, body~, ..) => {
      if !@ratelimit.is_discord_rate_limit(headers) {
        return Stop(NotRetryable)
      }
      let (retry_after_ms, _) = rate_limit_from_json(
        headers,
        json_body(body).unwrap_or(Json::null()),
      )
      Some(retry_after_ms.max(0L).min(2147483646L).to_int() + 1)
    }
    _ => None
  }
  let request = context.request
  discord_retry_rule(max_retries).decide(
    outcome,
    request,
    attempt=context.attempt,
    random=context.random,
    now_unix_ms=context.now_unix_ms,
    remaining_ms?=context.remaining_ms,
    server_delay_ms?,
    idempotent=@ghttp.is_idempotent(request) ||
      request.headers.contains(REPLAY_SAFE_HEADER),
    replayable=context.replayable,
  )
}

///|
/// A response body as JSON, or `None` when it is not JSON.
fn json_body(body : Bytes) -> Json? {
  if body.is_empty() {
    return None
  }
  Some(@json.parse(@utf8.decode_lossy(body))) catch {
    _ => None
  }
}