///|
/// Lists every collection in the configured tenant and database.
pub fn[T : Transport] ChromaClient::list_collections(
  self : ChromaClient[T],
) -> Array[Collection] raise ChromaError {
  let response = self.send_json(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 fn[T : Transport] ChromaClient::count_collections(
  self : ChromaClient[T],
) -> Int32 raise ChromaError {
  let response = self.send_json(
    Get,
    self.database_prefix() + "/collections_count",
  )
  decode_count(response, "collections_count")
}

///|
/// Returns a collection handle for `name`, creating it when it is missing.
///
/// Sends `get_or_create: true`, so calling it twice finds the collection
/// instead of colliding on the second call.
pub fn[T : Transport] ChromaClient::get_or_create_collection(
  self : ChromaClient[T],
  name : String,
  config~ : CollectionConfig = CollectionConfig::new(),
) -> Collection raise ChromaError {
  let path = self.database_prefix() + "/collections"
  Collection::from_json(
    self.send_json(
      Post,
      path,
      body=create_body(name, config, get_or_create=true),
    ),
  )
}

///|
/// Creates a collection, failing when `name` is already taken.
///
/// Sends `get_or_create: false`, so the server answers 409 rather than
/// silently returning a collection that may have a different
/// configuration. Use `get_or_create_collection` when either outcome is fine.
pub fn[T : Transport] ChromaClient::create_collection(
  self : ChromaClient[T],
  name : String,
  config~ : CollectionConfig = CollectionConfig::new(),
) -> Collection raise ChromaError {
  let path = self.database_prefix() + "/collections"
  Collection::from_json(
    self.send_json(
      Post,
      path,
      body=create_body(name, config, get_or_create=false),
    ),
  )
}

///|
/// Fetches an existing collection by name, returning `None` on 404.
pub fn[T : Transport] ChromaClient::get_collection(
  self : ChromaClient[T],
  name : String,
) -> Collection? raise ChromaError {
  let path = self.database_prefix() +
    "/collections/" +
    encode_path_segment(name)
  let response = self.send_json(Get, path) catch {
    // The server may report the miss as a bare status or as a parsed
    // `Api` error; both mean "no such collection".
    error =>
      if error.status_code() == Some(404) {
        return None
      } else {
        raise error
      }
  }
  Some(Collection::from_json(response))
}

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

///|
/// 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.
pub fn[T : Transport] ChromaClient::modify_collection(
  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(name)
  let path = self.database_prefix() + "/collections/" + encode_path_segment(id)
  ignore(self.send_json(Put, path, body=Json::object(body)))
}

///|
/// Builds the `UpdateCollection` body, including only the fields given.
///
/// Chroma's model names the metadata field `new_metadata`;
/// sending `metadata` is silently ignored by the server.
fn modify_body(new_name : String?, metadata : Metadata?) -> Map[String, Json] {
  let body : Map[String, Json] = Map([])
  match new_name {
    Some(value) => body["new_name"] = Json::string(value)
    None => ()
  }
  match metadata {
    Some(value) => body["new_metadata"] = metadata_to_json(value)
    None => ()
  }
  body
}

///|
/// Resolves an id-or-name argument to the collection's id.
///
/// `PUT /collections/{id}` rejects anything that is not a UUIDv4 with a 400
/// ("Collection ID is not a valid UUIDv4"), unlike the `GET`/`DELETE` routes
/// below the same path, which also accept a name. Callers pass whichever they
/// have, so a value that already looks like an id is used as-is and a name is
/// looked up through `get_collection`.
fn[T : Transport] ChromaClient::resolve_collection_id(
  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(name_or_id) {
    Some(collection) => collection.id
    None =>
      raise ChromaError::InvalidArgument("no such collection: " + name_or_id)
  }
}

///|
/// Whether `value` is a canonical UUIDv4 string.
///
/// Only the shape matters: a name can never be 36 characters of hex and
/// hyphens in the 8-4-4-4-12 layout, so this tells the two apart without a
/// round trip.
fn is_uuid_v4(value : String) -> Bool {
  if value.length() != 36 {
    return false
  }
  let chars = value.to_array()
  for i, c in chars {
    let dash = i == 8 || i == 13 || i == 18 || i == 23
    if dash {
      if c != '-' {
        return false
      }
    } else if !((c >= '0' && c <= '9') ||
      (c >= 'a' && c <= 'f') ||
      (c >= 'A' && c <= 'F')) {
      return false
    }
  }
  // The version nibble of a v4 UUID is `4`; the server checks it.
  chars[14] == '4'
}

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

///|
/// 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 fn[T : Transport] ChromaClient::peek(
  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(Post, path, body=Json::object(body)))
}

///|
/// The largest `limit` `peek` will send.
pub const PEEK_LIMIT : Int32 = 100

///|
/// Clamps a peek limit into `1..=PEEK_LIMIT`.
fn clamp_peek_limit(limit : Int32) -> Int32 {
  if limit < 1 {
    1
  } else if limit > PEEK_LIMIT {
    PEEK_LIMIT
  } else {
    limit
  }
}