///|
/// The asynchronous half of the client.
///
/// Every public method has an `_async` twin here. The bodies are the same
/// request-building and decoding code as the synchronous versions; only the
/// hop through the transport changes. Keeping the two side by side rather
/// than sharing a generic core is deliberate: MoonBit cannot abstract over
/// "sync or async" without making the synchronous API pay for a future it
/// never uses, and the request construction is short enough that duplicating
/// the call sites is cheaper to read than a stack of higher-order helpers.
///
/// Use these with `AsyncTransport` (native and wasm); use the
/// synchronous methods with `Transport` (tests, and any host that
/// already drives its own I/O).

///|
/// Sends a request through an async transport.
///
/// Mirrors `ChromaClient::send`, including the pass-through of errors an
/// inner transport already classified.
async fn[T : AsyncTransport] ChromaClient::send_async(
  self : ChromaClient[T],
  verb : Method,
  path : String,
  body~ : Json = Json::null(),
) -> Response raise ChromaError {
  let headers : Map[String, String] = { "content-type": "application/json" }
  for entry in self.config.headers {
    let (key, value) = entry
    headers[key] = value
  }
  let payload = match verb {
    Get | Delete => None
    _ => Some(body.stringify())
  }
  let request : Request = {
    verb,
    url: self.api_url(path),
    headers,
    body: payload,
  }
  let response = self.transport.send_async(request) catch {
    ChromaError::Api(_) as error => raise error
    ChromaError::Status(_, _) as error => raise error
    ChromaError::Decode(_) as error => raise error
    ChromaError::InvalidArgument(_) as error => raise error
    ChromaError::Transport(_) as error => raise error
    error => raise ChromaError::Transport(error.to_string())
  }
  if response.status < 200 || response.status >= 300 {
    raise api_error(response.status, response.body)
  }
  guard_response_body(response)
  response
}

///|
/// Sends a request and parses the response body as JSON.
async fn[T : AsyncTransport] ChromaClient::send_json_async(
  self : ChromaClient[T],
  verb : Method,
  path : String,
  body~ : Json = Json::null(),
) -> Json raise ChromaError {
  self.response_json_or_null(self.send_async(verb, path, body~))
}

///|
/// Checks that the server is reachable and returns its heartbeat timestamp.
pub async fn[T : AsyncTransport] ChromaClient::heartbeat_async(
  self : ChromaClient[T],
) -> Int64 raise ChromaError {
  self.heartbeat_value(self.send_json_async(Get, "heartbeat"))
}

///|
/// Returns the server version string.
pub async fn[T : AsyncTransport] ChromaClient::version_async(
  self : ChromaClient[T],
) -> String raise ChromaError {
  match self.send_json_async(Get, "version") {
    Json::String(s) => s
    _ => ""
  }
}

///|
/// Lists every collection in the configured tenant and database.
pub async fn[T : AsyncTransport] ChromaClient::list_collections_async(
  self : ChromaClient[T],
) -> Array[Collection] raise ChromaError {
  let response = self.send_json_async(
    Get,
    self.database_prefix() + "/collections",
  )
  let items = match response {
    Json::Array(items) => items
    _ => raise ChromaError::Decode("expected an array of collections")
  }
  let out : Array[Collection] = []
  for item in items {
    out.push(Collection::from_json(item))
  }
  out
}

///|
/// Counts the collections in the configured tenant and database.
pub async fn[T : AsyncTransport] ChromaClient::count_collections_async(
  self : ChromaClient[T],
) -> Int32 raise ChromaError {
  let response = self.send_json_async(
    Get,
    self.database_prefix() + "/collections_count",
  )
  decode_count(response, "collections_count")
}

///|
/// Returns a collection handle for `name`, creating it when it is missing.
pub async fn[T : AsyncTransport] ChromaClient::get_or_create_collection_async(
  self : ChromaClient[T],
  name : String,
  config~ : CollectionConfig = CollectionConfig::new(),
) -> Collection raise ChromaError {
  let path = self.database_prefix() + "/collections"
  Collection::from_json(
    self.send_json_async(
      Post,
      path,
      body=create_body(name, config, get_or_create=true),
    ),
  )
}

///|
/// Creates a collection, failing when `name` is already taken.
pub async fn[T : AsyncTransport] ChromaClient::create_collection_async(
  self : ChromaClient[T],
  name : String,
  config~ : CollectionConfig = CollectionConfig::new(),
) -> Collection raise ChromaError {
  let path = self.database_prefix() + "/collections"
  Collection::from_json(
    self.send_json_async(
      Post,
      path,
      body=create_body(name, config, get_or_create=false),
    ),
  )
}

///|
/// Fetches an existing collection by name, returning `None` on 404.
pub async fn[T : AsyncTransport] ChromaClient::get_collection_async(
  self : ChromaClient[T],
  name : String,
) -> Collection? raise ChromaError {
  let path = self.database_prefix() +
    "/collections/" +
    encode_path_segment(name)
  let response = self.send_json_async(Get, path) catch {
    error =>
      if error.status_code() == Some(404) {
        return None
      } else {
        raise error
      }
  }
  Some(Collection::from_json(response))
}

///|
/// Deletes a collection by name.
pub async fn[T : AsyncTransport] ChromaClient::delete_collection_async(
  self : ChromaClient[T],
  name : String,
) -> Unit raise ChromaError {
  let path = self.database_prefix() +
    "/collections/" +
    encode_path_segment(name)
  ignore(self.send_json_async(Delete, path))
}

///|
/// Counts the records in a collection.
pub async fn[T : AsyncTransport] ChromaClient::count_async(
  self : ChromaClient[T],
  collection_id : String,
) -> Int32 raise ChromaError {
  let path = self.collection_path(collection_id) + "/count"
  decode_count(self.send_json_async(Get, path), "collections/{id}/count")
}

///|
/// Adds records to a collection.
pub async fn[T : AsyncTransport] ChromaClient::add_async(
  self : ChromaClient[T],
  collection_id : String,
  records : Array[Record],
) -> Unit raise ChromaError {
  validate_records(records)
  let path = self.collection_path(collection_id) + "/add"
  ignore(self.send_json_async(Post, path, body=records_to_columns(records)))
}

///|
/// Inserts or replaces records by id.
pub async fn[T : AsyncTransport] ChromaClient::upsert_async(
  self : ChromaClient[T],
  collection_id : String,
  records : Array[Record],
) -> Unit raise ChromaError {
  validate_records(records)
  let path = self.collection_path(collection_id) + "/upsert"
  ignore(self.send_json_async(Post, path, body=records_to_columns(records)))
}

///|
/// Updates the document, metadata or embedding of existing records.
///
/// Records are matched by id; ids that do not exist are ignored by Chroma.
pub async fn[T : AsyncTransport] ChromaClient::update_async(
  self : ChromaClient[T],
  collection_id : String,
  records : Array[Record],
  filter~ : Filter = Filter::new(),
) -> Unit raise ChromaError {
  // An update patches the records it names, so a missing embedding or
  // document is the normal case rather than an error: a `null` column entry
  // tells Chroma to leave that field alone.
  validate_records(records, require_embedding=false)
  let body : Map[String, Json] = match records_to_columns(records) {
    Json::Object(fields) => fields.copy()
    _ => Map([])
  }
  match filter.where_clause {
    Some(value) => body["where"] = value
    None => ()
  }
  match filter.where_document_clause {
    Some(value) => body["where_document"] = value
    None => ()
  }
  let path = self.collection_path(collection_id) + "/update"
  ignore(self.send_json_async(Post, path, body=Json::object(body)))
}

///|
/// Deletes records matching the filter.
pub async fn[T : AsyncTransport] ChromaClient::delete_async(
  self : ChromaClient[T],
  collection_id : String,
  filter~ : Filter = Filter::new(),
) -> Unit raise ChromaError {
  let body : Map[String, Json] = Map([])
  match filter.where_clause {
    Some(value) => body["where"] = value
    None => ()
  }
  match filter.where_document_clause {
    Some(value) => body["where_document"] = value
    None => ()
  }
  if filter.ids.length() > 0 {
    let ids : Array[Json] = []
    for id in filter.ids {
      ids.push(Json::string(id))
    }
    body["ids"] = Json::array(ids)
  }
  let path = self.collection_path(collection_id) + "/delete"
  ignore(self.send_json_async(Post, path, body=Json::object(body)))
}

///|
/// Fetches records by filter, or the whole collection when the filter is
/// empty.
pub async fn[T : AsyncTransport] ChromaClient::get_async(
  self : ChromaClient[T],
  collection_id : String,
  filter~ : Filter = Filter::new(),
) -> QueryResult raise ChromaError {
  validate_limit_offset(filter.limit, filter.offset)
  let path = self.collection_path(collection_id) + "/get"
  QueryResult::from_json(
    self.send_json_async(Post, path, body=filter.to_json()),
  )
}

///|
/// Searches a collection with caller-supplied query vectors.
pub async fn[T : AsyncTransport] ChromaClient::query_embeddings_async(
  self : ChromaClient[T],
  collection_id : String,
  embeddings : Array[Embedding],
  n_results~ : Int32 = 10,
  include_fields~ : Include = Include::default(),
  filter~ : Filter = Filter::new(),
) -> QueryResult raise ChromaError {
  validate_n_results(n_results)
  validate_limit_offset(filter.limit, filter.offset)
  let items : Array[Json] = []
  for embedding in embeddings {
    items.push(embedding_to_json(embedding))
  }
  validate_query_inputs("query_embeddings", items)
  let body : Map[String, Json] = Map([])
  body["query_embeddings"] = Json::array(items)
  body["n_results"] = Json::number(n_results.to_double())
  body["include"] = include_fields.to_json()
  append_filter(body, filter)
  let path = self.collection_path(collection_id) + "/query"
  let response = self.send_async(Post, path, body=Json::object(body))
  QueryResult::from_query_json(self.response_json_or_null(response))
}

///|
/// Returns a small sample of the records in a collection.
///
/// `limit` is capped at `peek_limit` so a stray large value cannot turn a
/// preview into a full scan of the collection.
pub async fn[T : AsyncTransport] ChromaClient::peek_async(
  self : ChromaClient[T],
  collection_id : String,
  limit~ : Int32 = 10,
  include_fields~ : Include = Include::default(),
) -> QueryResult raise ChromaError {
  let limit = clamp_peek_limit(limit)
  let body : Map[String, Json] = Map([])
  body["limit"] = Json::number(limit.to_double())
  body["include"] = include_fields.to_json()
  let path = self.collection_path(collection_id) + "/get"
  QueryResult::from_json(
    self.send_json_async(Post, path, body=Json::object(body)),
  )
}

///|
/// Updates a collection's name and/or metadata.
///
/// `new_name` of `None` keeps the current name. `name` may be the collection's
/// name or its id; a name is looked up first, because the server's `PUT`
/// endpoint only accepts an id (see `resolve_collection_id`).
pub async fn[T : AsyncTransport] ChromaClient::modify_collection_async(
  self : ChromaClient[T],
  name : String,
  new_name~ : String? = None,
  metadata~ : Metadata? = None,
) -> Unit raise ChromaError {
  let body = modify_body(new_name, metadata)
  let id = self.resolve_collection_id_async(name)
  let path = self.database_prefix() + "/collections/" + encode_path_segment(id)
  ignore(self.send_json_async(Put, path, body=Json::object(body)))
}

///|
/// The async counterpart of `resolve_collection_id`.
async fn[T : AsyncTransport] ChromaClient::resolve_collection_id_async(
  self : ChromaClient[T],
  name_or_id : String,
) -> String raise ChromaError {
  if is_uuid_v4(name_or_id) {
    return name_or_id
  }
  match self.get_collection_async(name_or_id) {
    Some(collection) => collection.id
    None =>
      raise ChromaError::InvalidArgument("no such collection: " + name_or_id)
  }
}