///|
/// Lists the collections in the configured tenant and database.
///
/// `limit` and `offset` page the listing, the same way chroma-go's
/// `ListWithLimit` / `ListWithOffset` do. Both default to "not sent", which
/// returns everything.
pub fn[T : Transport] ChromaClient::list_collections(
  self : ChromaClient[T],
  limit~ : Int32 = 0,
  offset~ : Int32 = 0,
) -> Array[Collection] raise ChromaError {
  let response = self.send_json(Get, self.list_collections_path(limit, offset))
  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, and `metadata` of `None`
/// keeps the current metadata; `Some([])` is how a caller clears it. `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 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` defaults to 10 and is clamped to `PEEK_LIMIT` (1..=100) on the way
/// out, so a stray large value cannot turn a preview into a full scan of the
/// collection. `include_fields` defaults to `Include::get_default()`
/// (documents and metadata), the same default as `get`: `peek` posts to
/// `POST /get`, and Chroma rejects that endpoint when it carries `distances`.
///
/// It is a `get` with one page and no filter: `POST /get` gets a `limit` and
/// an `include`, nothing else, so there is no `where` clause to pass. Use
/// `get(collection_id, filter~)` when the sample has to be filtered.
pub fn[T : Transport] ChromaClient::peek(
  self : ChromaClient[T],
  collection_id : String,
  limit~ : Int32 = 10,
  include_fields~ : Include = Include::get_default(),
) -> QueryResult raise ChromaError {
  let body = peek_body(limit, include_fields)
  let path = self.collection_path(collection_id) + "/get"
  QueryResult::from_json(self.send_json(Post, path, body~))
}

///|
/// Builds the `POST /get` body `peek` sends: a clamped limit plus `include`.
///
/// `peek` is a `get` with one page and no filter, so the body has the same
/// shape `get` builds — it just always carries a limit.
fn peek_body(limit : Int32, include_fields : Include) -> Json {
  let body : Map[String, Json] = Map([])
  body["limit"] = Json::number(clamp_peek_limit(limit).to_double())
  body["include"] = include_fields.to_json()
  Json::object(body)
}

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

///|
/// Creates the database this client is configured for.
///
/// Chroma keeps tenants and databases as ordinary resources: a tenant is
/// created on demand but a database is not, so writing into a database that
/// does not exist answers 400. This is the one call a caller needs to prepare
/// a namespace; a 409 means it already exists.
///
/// The async twin is `create_database_async`.
pub fn[T : Transport] ChromaClient::create_database(
  self : ChromaClient[T],
) -> Unit raise ChromaError {
  ignore(
    self.send_json(
      Post,
      self.create_database_path(),
      body=self.create_database_body(),
    ),
  )
}

///|
/// The path `POST /tenants/{tenant}/databases` lives at.
///
/// A database is created under a tenant, not under a database, so this is the
/// one resource path that does not start with `database_prefix`. The async
/// twin builds it the same way, through this function.
fn[T] ChromaClient::create_database_path(self : ChromaClient[T]) -> String {
  "tenants/" + encode_path_segment(self.config.tenant) + "/databases"
}

///|
/// The body of a `create_database` request: the database's name.
///
/// Chroma reads `name` here, not `database`, and the name comes from the
/// client's own config — there is no argument, because a client can only
/// prepare the namespace it is pointed at.
fn[T] ChromaClient::create_database_body(self : ChromaClient[T]) -> Json {
  Json::object({ "name": Json::string(self.config.database) })
}

///|
/// 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
  }
}

///|
/// Builds the collections listing path, including the optional paging query.
fn[T] ChromaClient::list_collections_path(
  self : ChromaClient[T],
  limit : Int32,
  offset : Int32,
) -> String {
  let path = self.database_prefix() + "/collections"
  let params : Array[(String, String)] = []
  if limit > 0 {
    params.push(("limit", limit.to_string()))
  }
  if offset > 0 {
    params.push(("offset", offset.to_string()))
  }
  append_query(path, params)
}