// HTTP transport shared by every adapter.
//
// Wraps `moonbitlang/async`'s HTTP client with JSON/text ergonomics: a plain
// `Map[String, String]` header API, typed errors, and one automatic retry on
// transient (connection-level) failures — mirroring Hermes' `_send_with_retry`
// in `gateway/platforms/base.py`.

///|
/// A failed raw HTTP exchange.
pub(all) struct ApiError {
  /// HTTP status code, or -1 when the connection itself failed.
  code : Int32
  /// Response/error body (truncated for logs).
  body : String
} derive(Debug)

///|
pub fn ApiError::network(body : String) -> ApiError {
  { code: -1, body, }
}

///|
/// An HTTP-level failure (`code` is the status code).
pub fn ApiError::http(code : Int32, body : String) -> ApiError {
  { code, body, }
}

///|
pub fn ApiError::is_transient(self : ApiError) -> Bool {
  self.code < 0 || classify_send_error(self.body) == Transient
}

///|
/// Convert a plain header map into the case-insensitive map the async HTTP
/// client expects.
pub fn headers_of(
  headers : Map[String, String],
) -> Map[@http.CaseInsensitiveString, String] {
  let out : Map[@http.CaseInsensitiveString, String] = Map([])
  for k, v in headers {
    out.set(@http.CaseInsensitiveString(k), v)
  }
  out
}

///|
/// Perform one HTTP exchange and return the raw response body text on 2xx.
pub async fn request_text(
  meth : @http.RequestMethod,
  url : String,
  body : String,
  headers : Map[String, String],
) -> Result[String, ApiError] {
  let (resp, data) = @http.request(url, meth, headers_of(headers), body) catch {
    _ => return Err(ApiError::network("connection failed: \{url}"))
  }
  let text = data.text() catch { _ => "" }
  if resp.code >= 200 && resp.code < 300 {
    Ok(text)
  } else {
    Err({ code: resp.code, body: truncate(text, 200), })
  }
}

///|
/// Perform one HTTP exchange and decode the response body as JSON. An empty
/// 2xx body decodes to `Json::null()`.
pub async fn request_json(
  meth : @http.RequestMethod,
  url : String,
  body : String,
  headers : Map[String, String],
) -> Result[Json, ApiError] {
  match request_text(meth, url, body, headers) {
    Err(e) => Err(e)
    Ok(text) =>
      if text.is_blank() {
        Ok(Json::null())
      } else {
        Ok(@json.parse(text)) catch {
          _ => Err({ code: 200, body: "invalid JSON response", })
        }
      }
  }
}

///|
/// POST ``payload`` as JSON; returns the decoded JSON body on 2xx.
pub async fn post_json(
  url : String,
  payload : Json,
  headers : Map[String, String],
) -> Result[Json, ApiError] {
  request_json(@http.Post, url, payload.stringify(), headers)
}

///|
/// PUT ``payload`` as JSON; returns the decoded JSON body on 2xx.
pub async fn put_json(
  url : String,
  payload : Json,
  headers : Map[String, String],
) -> Result[Json, ApiError] {
  request_json(@http.Put, url, payload.stringify(), headers)
}

///|
/// PATCH ``payload`` as JSON; returns the decoded JSON body on 2xx.
pub async fn patch_json(
  url : String,
  payload : Json,
  headers : Map[String, String],
) -> Result[Json, ApiError] {
  request_json(@http.Patch, url, payload.stringify(), headers)
}

///|
/// GET a JSON endpoint; returns the decoded JSON body on 2xx.
pub async fn get_json(
  url : String,
  headers : Map[String, String],
) -> Result[Json, ApiError] {
  request_json(@http.Get, url, "", headers)
}

///|
/// POST a JSON payload and return the raw text body (XML/plain platforms).
pub async fn post_text(
  url : String,
  payload : Json,
  headers : Map[String, String],
) -> Result[String, ApiError] {
  request_text(@http.Post, url, payload.stringify(), headers)
}

///|
/// Raw response: status code, response headers and the body as bytes.
pub(all) struct RawHttpResult {
  code : Int32
  headers : Map[@http.CaseInsensitiveString, String]
  body_bytes : Bytes
} derive(Debug)

///|
/// One HTTP exchange returning status + headers + raw body bytes (the
/// WeChat CDN paths need the `x-encrypted-param` response header, which the
/// text/json helpers drop).
pub async fn request_raw(
  meth : @http.RequestMethod,
  url : String,
  body : Bytes,
  headers : Map[String, String],
) -> Result[RawHttpResult, ApiError] {
  let (resp, data) = @http.request(url, meth, headers_of(headers), body) catch {
    _ => return Err(ApiError::network("connection failed: \{url}"))
  }
  let bytes = data.binary()
  Ok({ code: resp.code, headers: resp.headers, body_bytes: bytes, })
}

///|
/// GET a URL and return the raw body bytes on 2xx.
pub async fn get_bytes(
  url : String,
  headers : Map[String, String],
) -> Result[Bytes, ApiError] {
  match request_raw(@http.Get, url, Bytes::new(0), headers) {
    Err(e) => Err(e)
    Ok(r) =>
      if r.code >= 200 && r.code < 300 {
        Ok(r.body_bytes)
      } else {
        Err({ code: r.code, body: truncate(r.body_bytes.to_string(), 200), })
      }
  }
}

///|
/// POST an opaque byte body (`Content-Type: application/octet-stream`) and
/// return the raw result — Web/WeChat CDN uploads return the confirmation in
/// a response header rather than the body.
pub async fn post_octet(
  url : String,
  body : Bytes,
  headers : Map[String, String],
) -> Result[RawHttpResult, ApiError] {
  request_raw(@http.Post, url, body, headers)
}

///|
/// DELETE an endpoint (many platforms accept an optional JSON body).
pub async fn delete_json(
  url : String,
  payload : Json,
  headers : Map[String, String],
) -> Result[String, ApiError] {
  request_text(@http.Delete, url, payload.stringify(), headers)
}

///|
/// DELETE an endpoint with no body.
pub async fn delete_empty(
  url : String,
  headers : Map[String, String],
) -> Result[String, ApiError] {
  request_text(@http.Delete, url, "", headers)
}