///|
/// A `ChromaClient` transport backed by `moonbitlang/async/http`.
///
/// This is the piece that turns the client's request objects into real
/// sockets. It implements `AsyncTransport`, so it plugs into the `*_async`
/// methods, and it is deliberately thin: it maps the client's
/// method/header vocabulary onto `@ah`, sends one request, and reads the
/// whole body back.
///
/// It lives in the library rather than in a test or example package because
/// a Chroma client that cannot reach a server is not useful: `preferred_target
/// = "wasm"` describes where the *client* runs, and this transport is how it
/// talks to Chroma from native and wasm alike.
///
/// One connection per request is enough for a demo and for a test suite. A
/// long-lived application that cares about latency should keep an `@ah.Client`
/// open and reuse it, because `@ah.Client` keeps the connection alive across
/// requests; see the "Native transport" section of the README.
///
/// The transport holds no state: the client already puts the full URL and the
/// deadline in every request, so there is nothing to join and nothing to
/// configure here — see `AsyncHttpTransport::from_config` if you would rather
/// build one from a `Config`.
///
/// # Timeouts and retry waits
///
/// A Chroma server does not implement `x-chroma-timeout-ms` or
/// `x-chroma-retry-delay-ms`: they are a contract between this client and
/// *a transport*, and the one that ships here honours them itself, because it
/// is the only place that owns a socket and can cancel or sleep.
///
/// - `x-chroma-timeout-ms: n` wraps the whole exchange (connect, send, read)
///   in `@async.with_timeout(n)`. Exceeding it raises
///   `ChromaError::Transport`, which is what a caller can catch.
/// - `x-chroma-retry-delay-ms: n` makes the transport sleep `n` milliseconds
///   *before* sending, which is what `RetryTransport` asks of it. A caller
///   that does not want to wait should not enable retries: the sleep happens
///   here rather than inside `RetryTransport` precisely so that a wrapped
///   transport without a runtime (a scripted double, say) never blocks.
///
/// Both headers are consumed locally and stripped before the request goes on
/// the wire, so a Chroma server never sees client-internal state as a header
/// it might reject.
///
/// # Redirects
///
/// `@ah.request` does not follow `3xx`, and `ChromaClient` maps every non-2xx
/// status to `Api` / `Status` — so a redirect (an http→https proxy in front of
/// Chroma, say) surfaces as an error rather than as a silently re-sent
/// request. Point `Config::endpoint` at the final URL instead.
pub struct AsyncHttpTransport {}

///|
/// Builds a transport.
pub fn AsyncHttpTransport::new() -> AsyncHttpTransport {
  AsyncHttpTransport::{ }
}

///|
/// Maps the client's verb onto the HTTP stack's.
fn to_ah_request_method(verb : Method) -> @ah.RequestMethod {
  match verb {
    Get => @ah.RequestMethod::Get
    Post => @ah.RequestMethod::Post
    Put => @ah.RequestMethod::Put
    Delete => @ah.RequestMethod::Delete
    Patch => @ah.RequestMethod::Patch
  }
}

///|
pub impl AsyncTransport for AsyncHttpTransport with fn send_async(self, request) {
  ignore(self)
  let request = without_internal_headers(request)
  // `@ah.Headers` is a `Map[CaseInsensitiveString, String]`, and
  // `CaseInsensitiveString` is implicitly convertible from `String`, so the
  // header names the client produced can be used as map keys unchanged.
  let headers : @ah.Headers = Map([])
  for entry in request.headers {
    let (key, value) = entry
    headers[key] = value
  }
  // A GET carries no request body in the client, and `@aio.Data` has no
  // "absent" case, so an empty byte string is the neutral value here.
  let payload : &@aio.Data = match request.body {
    Some(body) => body
    None => ""
  }
  // The retry delay is a wait the caller asked for, not a request the server
  // should see: sleeping here keeps `RetryTransport` itself free of I/O. The
  // wait lives in this one helper so it can be tested without a socket.
  match request_retry_delay_ms(request) {
    Some(delay) => sleep_async(delay)
    None => ()
  }
  let send = async fn() -> (@ah.Response, &@aio.Data) {
    @ah.request(
      request.url,
      to_ah_request_method(request.verb),
      headers,
      payload,
    )
  }
  // The whole exchange is inside the deadline: connecting is where most of a
  // cancelled request's time goes, so timing only the read would miss it.
  let (response, response_body) = match request_timeout_ms(request) {
    Some(timeout_ms) =>
      @async.with_timeout(
        timeout_ms,
        send,
        error=ChromaError::Transport(
          "request to " +
          request.url +
          " timed out after " +
          timeout_ms.to_string() +
          "ms",
        ),
      )
    None => send()
  }
  let out_headers : Map[String, String] = Map([])
  for entry in response.headers {
    let (key, value) = entry
    // `Map[String, String]` is what the client's `Response` expects; the
    // original casing is not preserved by the case-insensitive key, and
    // nothing in the client reads a header by name, so lowercasing loses
    // nothing.
    out_headers[key.to_string().to_lower()] = value
  }
  { status: response.code, headers: out_headers, body: response_body.text(), }
}

///|
pub extend AsyncHttpTransport with AsyncTransport::{send_async}

///|
/// Builds a transport from a `Config`, applying `config.timeout_ms` as a
/// deadline on the socket.
///
/// This is the wiring `Config::timeout_ms` was meant to have: the header
/// `TimeoutTransport` stamps is honoured by `AsyncHttpTransport` above, and a
/// client built with this transport also stamps the header itself (see
/// `ChromaClient::send_async`), so both routes end up at the same value.
///
/// A non-positive `timeout_ms` means "no deadline".
pub fn AsyncHttpTransport::from_config(
  config : Config,
) -> TimeoutAsyncTransport[AsyncHttpTransport] {
  TimeoutAsyncTransport::new(AsyncHttpTransport::new(), config.timeout_ms)
}

///|
/// The async counterpart of `TimeoutTransport`: stamps a deadline that an
/// `AsyncTransport` honours.
///
/// It exists because `Config::timeout_ms` has to reach the socket through the
/// async half too. `TimeoutTransport` wraps the synchronous `Transport` trait,
/// which none of the `*_async` methods use, so a client built as
/// `ChromaClient::new(TimeoutTransport::new(AsyncHttpTransport::new(), 5000))`
/// would not even compile: the async methods need an `AsyncTransport`.
///
/// `AsyncHttpTransport` turns the header into a real `@async.with_timeout`,
/// so wrapping it is what makes the deadline effective; wrapping a double
/// that ignores the header is harmless.
pub(all) struct TimeoutAsyncTransport[T] {
  inner : T
  timeout_ms : Int32
}

///|
/// Wraps `inner`, asking for a `timeout_ms` deadline on every request. A
/// non-positive value passes requests through untouched, so the wrapper can be
/// driven straight from `Config::timeout_ms`.
pub fn[T] TimeoutAsyncTransport::new(
  inner : T,
  timeout_ms : Int32,
) -> TimeoutAsyncTransport[T] {
  { inner, timeout_ms, }
}

///|
/// Builds a deadline wrapper from a client configuration.
pub fn[T] TimeoutAsyncTransport::from_config(
  inner : T,
  config : Config,
) -> TimeoutAsyncTransport[T] {
  { inner, timeout_ms: config.timeout_ms, }
}

///|
/// Returns the deadline this transport asks for.
pub fn[T] TimeoutAsyncTransport::timeout_ms(
  self : TimeoutAsyncTransport[T],
) -> Int32 {
  self.timeout_ms
}

///|
pub impl[T : AsyncTransport] AsyncTransport for TimeoutAsyncTransport[T] with fn send_async(
  self,
  request,
) {
  if self.timeout_ms <= 0 {
    return self.inner.send_async(request)
  }
  let headers = request.headers.copy()
  headers[TIMEOUT_HEADER] = self.timeout_ms.to_string()
  self.inner.send_async({ ..request, headers, })
}

///|
pub extend TimeoutAsyncTransport with AsyncTransport::{send_async}

///|
/// Waits `delay_ms` milliseconds before a retry.
///
/// `RetryTransport` deliberately never sleeps; this is the other half of that
/// contract, in the only place a real runtime exists. A non-positive delay is
/// a no-op, which is what "no backoff configured" means.
async fn sleep_async(delay_ms : Int32) -> Unit {
  if delay_ms > 0 {
    @async.sleep(delay_ms)
  }
}