///|
/// QdrantClient: a minimal, dependency-light MoonBit client for the
/// Qdrant vector database REST API.
///
/// Transport is provided by `gaato/mttp_async` (HTTP/1.1 with retries,
/// deadlines and decompression over the `moonbitlang/core/async` runtime);
/// JSON is handled by `@json` from core. This file intentionally keeps the
/// API surface small and explicit so each method maps 1:1 to a documented
/// Qdrant REST endpoint.

///|
/// HTTP verbs used by the Qdrant REST API.
priv enum Method {
  Get
  Post
  Put
  Delete
  Patch
}

///|
fn Method::to_string(self : Method) -> String {
  match self {
    Get => "GET"
    Post => "POST"
    Put => "PUT"
    Delete => "DELETE"
    Patch => "PATCH"
  }
}

///|
/// Configuration for a QdrantClient.
pub struct QdrantClient {
  base_url : String
  api_key : String?
  http : @client.Client
}

///|
/// Create a client for the given base URL.
///
/// Example: `QdrantClient::new("http://localhost:6333")`
///
/// `api_key` is optional and, when set, is sent as the `api-key` header
/// expected by Qdrant's API-key authentication.
///
/// `timeout_ms` is the per-attempt deadline; the whole call (retries
/// included) is bounded by 12x that, with a floor of mttp_async's 120 s
/// default when the per-attempt timeout is large.
pub fn QdrantClient::new(
  base_url : String,
  api_key~ : String? = None,
  timeout_ms~ : Int32 = 10_000,
) -> QdrantClient {
  let base = if base_url.has_suffix("/") {
    base_url[:base_url.length() - 1].to_owned()
  } else {
    base_url
  }
  let total = timeout_ms * 12
  let http = @mttp_async.client(
    base_url=base,
    timeout_ms~,
    total_ms=if total > 120_000 { total } else { 120_000 },
  )
  { base_url: base, api_key, http, }
}

///|
/// Release the client's connection pool. The client is unusable afterwards.
pub fn QdrantClient::close(self : QdrantClient) -> Unit {
  self.http.close()
}

///|
/// Join a path fragment onto the base URL, keeping a single slash between them.
pub fn join_url(base : String, path : String) -> String {
  if path.has_prefix("/") {
    base + path
  } else {
    base + "/" + path
  }
}

///|
/// Percent-encode a path segment for use in a Qdrant URL. Qdrant accepts a
/// broad character set in collection, field and snapshot names (not just
/// ASCII alphanumerics), so unreserved characters pass through and everything
/// else is percent-encoded.
pub fn url_encode(input : String) -> String {
  let mut out = ""
  for i in 0.. Bool {
  let n = c.to_int()
  (n >= 48 && n <= 57) ||
  (n >= 65 && n <= 90) ||
  (n >= 97 && n <= 122) ||
  c == '-' ||
  c == '_' ||
  c == '.' ||
  c == '~'
}

///|
/// Percent-encode one UTF-8 character as `%XX` per byte. The input is a
/// UTF-16 code unit from `s[i]`; ASCII passes through as one byte, BMP
/// characters are encoded as 2-3 UTF-8 bytes. (Supplementary-plane
/// characters arrive as surrogate pairs and are out of scope for path
/// segments.)
fn encode_char(c : UInt16) -> String {
  let cp = c.to_int()
  if cp < 0x80 {
    "%" + hex_digit(cp / 16) + hex_digit(cp % 16)
  } else if cp < 0x800 {
    let b1 = 0xC0 | (cp >> 6)
    let b2 = 0x80 | (cp & 0x3F)
    "%" +
    hex_digit(b1 / 16) +
    hex_digit(b1 % 16) +
    "%" +
    hex_digit(b2 / 16) +
    hex_digit(b2 % 16)
  } else {
    let b1 = 0xE0 | (cp >> 12)
    let b2 = 0x80 | ((cp >> 6) & 0x3F)
    let b3 = 0x80 | (cp & 0x3F)
    "%" +
    hex_digit(b1 / 16) +
    hex_digit(b1 % 16) +
    "%" +
    hex_digit(b2 / 16) +
    hex_digit(b2 % 16) +
    "%" +
    hex_digit(b3 / 16) +
    hex_digit(b3 % 16)
  }
}

///|
/// One uppercase hex digit for a nibble value in 0..=15.
fn hex_digit(v : Int32) -> String {
  let hex = "0123456789ABCDEF"
  Int32::unsafe_to_char(hex[v].to_int()).to_string()
}

///|
/// Send a request and return the HTTP status code together with the raw
/// text body (before JSON parsing). The path is resolved against the
/// client's base URL. Network failures raise; non-2xx responses are
/// returned like any other status so callers can inspect the error body.
async fn QdrantClient::send_raw(
  self : QdrantClient,
  verb : Method,
  path : String,
  body : Json?,
) -> (Int32, String) {
  let request = match verb {
    Get => @mttp.Request::get(path)
    Post => @mttp.Request::post(path)
    Put => @mttp.Request::put(path)
    Delete => @mttp.Request::delete(path)
    Patch => @mttp.Request::patch(path)
  }
  let request = match self.api_key {
    Some(key) => request.header("api-key", key)
    None => request
  }
  // mttp_async decompresses gzip/deflate/br/zstd transparently, so no
  // `Accept-Encoding: identity` workaround is needed (unlike the old mio
  // transport, whose deflate path failed on Qdrant payloads).
  let request = match body {
    Some(json) => request.body_json(json)
    None => request
  }
  // `any_status` keeps non-2xx responses as values: Qdrant reports errors
  // (404, 400, ...) with a JSON body that callers parse, not as failures.
  let reply = @mttp_async.request(request)
    .response(@mttp_async.as_string().any_status())
    .send(self.http) catch {
      error =>
        raise Failure(
          "transport error on \{verb.to_string()} \{path}: \{error}",
        )
    }
  match reply.body {
    Ok(text) => (reply.status, text)
    Err(failure) => (failure.head().status, "\{failure}")
  }
}

///|
/// Truncate a raw response body for inclusion in an error message.
fn json_preview(text : String) -> String {
  if text.length() > 200 {
    text[:200].to_owned()
  } else {
    text
  }
}

///|
/// Send a request and return the HTTP status code together with the parsed
/// JSON body.
async fn QdrantClient::send(
  self : QdrantClient,
  verb : Method,
  path : String,
  body : Json?,
) -> (Int32, Json) {
  let (code, text) = self.send_raw(verb, path, body)
  let json = @json.parse(text) catch {
    err =>
      raise Failure(
        "invalid JSON in Qdrant response (HTTP " +
        code.to_string() +
        " on " +
        path +
        "): " +
        err.to_string() +
        " (raw: " +
        json_preview(text) +
        ")",
      )
  }
  (code, json)
}

///|
/// Send a request and return only the HTTP status code, without touching
/// the body. Used by endpoints that answer with plain text (e.g. /healthz).
async fn QdrantClient::send_status(
  self : QdrantClient,
  verb : Method,
  path : String,
  body : Json?,
) -> Int32 {
  let (code, _) = self.send_raw(verb, path, body)
  code
}

///|
/// Send a request and return the parsed JSON body.
/// Raises on transport errors or when the server answers with a non-2xx code.
async fn QdrantClient::request_json(
  self : QdrantClient,
  verb : Method,
  path : String,
  body : Json?,
) -> Json {
  let (code, json) = self.send(verb, path, body)
  if code < 200 || code >= 300 {
    raise Failure(
      "Qdrant API error " +
      code.to_string() +
      " on " +
      path +
      ": " +
      json.stringify(),
    )
  }
  json
}

///|
/// Check the server health via `GET /healthz`.
/// Qdrant answers this endpoint with plain text ("healthz check passed"),
/// so only the HTTP status code is inspected: 200 means healthy. Transport
/// failures (server not reachable) also report unhealthy instead of raising.
pub async fn QdrantClient::health(self : QdrantClient) -> Bool {
  let code = self.send_status(Get, "/healthz", None) catch { _ => return false }
  code == 200
}

///|
/// List all collection names via `GET /collections`.
pub async fn QdrantClient::list_collections(
  self : QdrantClient,
) -> Array[String] {
  let json = self.request_json(Get, "/collections", None)
  match CollectionList::from_json(json) {
    Some(list) => list.names
    None =>
      raise Failure("unexpected /collections response: " + json.stringify())
  }
}

///|
/// Create a collection via `PUT /collections/{name}`.
pub async fn QdrantClient::create_collection(
  self : QdrantClient,
  name : String,
  config : CollectionConfig,
) -> Unit {
  let path = "/collections/" + url_encode(name)
  let json = self.request_json(Put, path, Some(config.to_json()))
  // Qdrant answers with {"result": true} on success; treat any 2xx as success.
  ignore(json)
}

///|
/// Get detailed information about a collection via `GET /collections/{name}`.
/// Raises when the collection does not exist or the server reports an error.
pub async fn QdrantClient::collection_info(
  self : QdrantClient,
  name : String,
) -> CollectionInfo {
  let path = "/collections/" + url_encode(name)
  let json = self.request_json(Get, path, None)
  match CollectionInfo::from_json(json) {
    Some(info) => info
    None =>
      raise Failure("unexpected collection info response: " + json.stringify())
  }
}

///|
/// Delete a collection via `DELETE /collections/{name}`.
/// Raises when the collection does not exist or the server reports an error.
pub async fn QdrantClient::delete_collection(
  self : QdrantClient,
  name : String,
) -> Unit {
  let path = "/collections/" + url_encode(name)
  let json = self.request_json(Delete, path, None)
  ignore(json)
}

///|
/// Check whether a collection exists via `GET /collections/{name}`.
/// Returns false when the server answers 404; raises on other errors.
pub async fn QdrantClient::collection_exists(
  self : QdrantClient,
  name : String,
) -> Bool {
  let path = "/collections/" + url_encode(name)
  let (code, _) = self.send(Get, path, None)
  if code >= 200 && code < 300 {
    true
  } else if code == 404 {
    false
  } else {
    raise Failure("Qdrant API error " + code.to_string())
  }
}

///|
/// Upsert points into a collection via `PUT /collections/{name}/points`.
/// Pass `wait=false` to return immediately; the operation may still be
/// propagating on the server.
pub async fn QdrantClient::upsert_points(
  self : QdrantClient,
  name : String,
  points : Array[PointStruct],
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points?wait=" +
    wait.to_string()
  let point_json : Array[Json] = []
  for p in points {
    point_json.push(p.to_json())
  }
  let body : Json = { "points": Json::array(point_json) }
  let json = self.request_json(Put, path, Some(body))
  ignore(json)
}

///|
/// Upsert points through the batch (column-oriented) format via
/// `PUT /collections/{name}/points`.
/// The batch format packs ids, vectors and payloads into parallel arrays,
/// which Qdrant applies as a single request. This is the classic batch
/// loading endpoint; `/points/batch` is the newer multi-operation endpoint
/// (body `{"operations": [...]}`) and is NOT used here.
pub async fn QdrantClient::upsert_points_batch(
  self : QdrantClient,
  name : String,
  points : Array[PointStruct],
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points?wait=" +
    wait.to_string()
  if points.is_empty() {
    return
  }
  let ids : Array[Json] = []
  let payloads : Array[Json] = []
  for p in points {
    ids.push(int64_to_json(p.id))
    payloads.push(p.payload)
  }
  // The batch endpoint accepts a transposed vector layout: for dense points
  // `vectors` is an array of per-point vectors; for named points it is an
  // object mapping each vector name to the array of per-point vectors.
  let vectors = match points[0].named_vectors {
    Some(first_named) => {
      let by_name : Map[String, Json] = Map([])
      for name in first_named.keys() {
        let rows : Array[Json] = []
        for p in points {
          match p.named_vectors {
            Some(named) =>
              match named.get(name) {
                Some(vec) => rows.push(vector_to_json(vec))
                None =>
                  raise Failure(
                    "inconsistent named vectors in batch: missing \"" +
                    name +
                    "\"",
                  )
              }
            None => raise Failure("mixed dense/named points in batch upsert")
          }
        }
        by_name[name] = Json::array(rows)
      }
      Json::object(by_name)
    }
    None => {
      let rows : Array[Json] = []
      for p in points {
        if p.named_vectors is Some(_) {
          raise Failure("mixed dense/named points in batch upsert")
        }
        rows.push(vector_to_json(p.vector))
      }
      Json::array(rows)
    }
  }
  let body : Json = {
    "batch": {
      "ids": Json::array(ids),
      "vectors": vectors,
      "payloads": Json::array(payloads),
    },
  }
  let json = self.request_json(Put, path, Some(body))
  ignore(json)
}

///|
/// Fetch a single point via `GET /collections/{name}/points/{id}`.
/// Returns None when the point does not exist (404).
pub async fn QdrantClient::get_point(
  self : QdrantClient,
  name : String,
  id : Int64,
  with_payload~ : Bool = true,
  with_vector~ : Bool = true,
) -> PointStruct? {
  let path = "/collections/" +
    url_encode(name) +
    "/points/" +
    id.to_string() +
    "?with_payload=" +
    with_payload.to_string() +
    "&with_vector=" +
    with_vector.to_string()
  let (code, json) = self.send(Get, path, None)
  if code >= 200 && code < 300 {
    match parse_result(json) {
      Some(result) => PointStruct::from_json(result)
      None => None
    }
  } else if code == 404 {
    None
  } else {
    raise Failure(
      "Qdrant API error " + code.to_string() + ": " + json.stringify(),
    )
  }
}

///|
/// Serialize point ids as a JSON number array without losing precision.
///
/// Qdrant point ids are u64 values. Above 2^53 a Double can no longer hold
/// every integer: `Json::number(id.to_double())` turns 9007199254740993 into
/// 9007199254740992, so a lookup returns the neighbouring point and a delete
/// removes it. `int64_to_json` keeps the exact decimal text in the JSON
/// number's `repr`, which is what the wire serializer emits.
pub fn point_ids_to_json(ids : Array[Int64]) -> Json {
  let out : Array[Json] = []
  for id in ids {
    out.push(int64_to_json(id))
  }
  Json::array(out)
}

///|
/// Delete points by id via `POST /collections/{name}/points/delete`.
/// Pass `wait=false` to return immediately.
pub async fn QdrantClient::delete_points(
  self : QdrantClient,
  name : String,
  ids : Array[Int64],
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points/delete?wait=" +
    wait.to_string()
  let body : Json = { "points": point_ids_to_json(ids) }
  let json = self.request_json(Post, path, Some(body))
  ignore(json)
}

///|
/// Search the most similar points via `POST /collections/{name}/points/search`.
///
/// `limit` bounds how many hits are returned. `filter` is an optional
/// Qdrant filter JSON object, for example
/// `{ "must": [{ "key": "tag", "match": { "value": "alpha" } }] }`.
///
/// Advanced options: `score_threshold` keeps only hits above the score,
/// `offset` paginates search results, `using_vector` selects which named
/// vector to query against, and `search_params` carries search-time tuning
/// (`hnsw_ef`, `exact`).
pub async fn QdrantClient::search_points(
  self : QdrantClient,
  name : String,
  vector : Array[Double],
  limit : Int32,
  filter~ : Json? = None,
  with_payload~ : Bool = true,
  with_vector~ : Bool = false,
  score_threshold~ : Double? = None,
  offset~ : Int64? = None,
  using_vector~ : String? = None,
  search_params~ : SearchParams? = None,
) -> Array[ScoredPoint] {
  self.search_points_raw(
    name,
    vector_to_json(vector),
    limit,
    filter,
    with_payload,
    with_vector,
    score_threshold,
    offset,
    using_vector,
    search_params,
  )
}

///|
/// Search with a named vector as input: the query vector is matched against
/// the named vector with the given name (Qdrant supports several vectors per
/// point). `using` is implied by the name carried in `named_vector`.
pub async fn QdrantClient::search_points_named(
  self : QdrantClient,
  name : String,
  named_vector : NamedVector,
  limit : Int32,
  filter~ : Json? = None,
  with_payload~ : Bool = true,
  with_vector~ : Bool = false,
  score_threshold~ : Double? = None,
  offset~ : Int64? = None,
  search_params~ : SearchParams? = None,
) -> Array[ScoredPoint] {
  self.search_points_raw(
    name,
    named_vector.to_json(),
    limit,
    filter,
    with_payload,
    with_vector,
    score_threshold,
    offset,
    None,
    search_params,
  )
}

///|
/// Core search implementation shared by the dense and named variants.
/// `vector` is the already-serialized vector field of the request body.
async fn QdrantClient::search_points_raw(
  self : QdrantClient,
  name : String,
  vector : Json,
  limit : Int32,
  filter : Json?,
  with_payload : Bool,
  with_vector : Bool,
  score_threshold : Double?,
  offset : Int64?,
  using_vector : String?,
  search_params : SearchParams?,
) -> Array[ScoredPoint] {
  let path = "/collections/" + url_encode(name) + "/points/search"
  let base : Map[String, Json] = Map([])
  // On a collection with named vectors the search API requires the vector
  // name inside the vector object ({"name": ..., "vector": [...]}); there is
  // no top-level "using" field in the search request (that is a query-API
  // field).
  base["vector"] = match using_vector {
    Some(u) => Json::object({ "name": Json::string(u), "vector": vector })
    None => vector
  }
  // `limit` is a required JSON body field for the search API (it is not a
  // query parameter).
  base["limit"] = Json::number(limit.to_double())
  base["with_payload"] = Json::boolean(with_payload)
  base["with_vector"] = Json::boolean(with_vector)
  match filter {
    Some(f) => base["filter"] = f
    None => ()
  }
  match score_threshold {
    Some(t) => base["score_threshold"] = Json::number(t)
    None => ()
  }
  match offset {
    Some(o) => base["offset"] = int64_to_json(o)
    None => ()
  }
  match search_params {
    Some(p) => base["params"] = p.to_json()
    None => ()
  }
  let body = Json::object(base)
  let json = self.request_json(Post, path, Some(body))
  match parse_search_result(json) {
    Some(hits) => hits
    None => raise Failure("unexpected search response: " + json.stringify())
  }
}

///|
/// Search with a type-safe `Filter` built from `Condition` helpers.
/// This is the preferred way to build payload filters; the raw-`Json`
/// `search_points` variant remains for advanced cases.
pub async fn QdrantClient::search_points_with_filter(
  self : QdrantClient,
  name : String,
  vector : Array[Double],
  limit : Int32,
  filter : Filter,
  with_payload~ : Bool = true,
  with_vector~ : Bool = false,
  score_threshold~ : Double? = None,
  offset~ : Int64? = None,
  using_vector~ : String? = None,
  search_params~ : SearchParams? = None,
) -> Array[ScoredPoint] {
  self.search_points(
    name,
    vector,
    limit,
    filter=Some(filter.to_json()),
    with_payload~,
    with_vector~,
    score_threshold~,
    offset~,
    using_vector~,
    search_params~,
  )
}

///|
/// Scroll through all points of a collection via
/// `POST /collections/{name}/points/scroll`.
///
/// Returns one page of points together with `next_page_offset`, which is
/// Some(offset) when more pages remain and None on the last page. Pass it
/// back as `offset` to fetch the following page.
pub async fn QdrantClient::scroll_points(
  self : QdrantClient,
  name : String,
  limit : Int32,
  filter~ : Filter? = None,
  offset~ : Int64? = None,
  with_payload~ : Bool = true,
  with_vector~ : Bool = true,
) -> ScrollResult {
  let path = "/collections/" + url_encode(name) + "/points/scroll"
  let body = scroll_request_body(
    limit, filter, offset, with_payload, with_vector,
  )
  let json = self.request_json(Post, path, Some(body))
  match parse_result(json) {
    Some(result) =>
      match ScrollResult::from_json(result) {
        Some(page) => page
        None => raise Failure("unexpected scroll response: " + json.stringify())
      }
    None => raise Failure("unexpected scroll response: " + json.stringify())
  }
}

///|
/// Count points in a collection via
/// `POST /collections/{name}/points/count`.
///
/// When `exact` is false (default) Qdrant answers with an estimate from
/// the index; pass `exact=true` for an exact count, which is slower on
/// large collections. An optional filter narrows the counted subset.
pub async fn QdrantClient::count_points(
  self : QdrantClient,
  name : String,
  filter~ : Filter? = None,
  exact~ : Bool = false,
) -> Int32 {
  let path = "/collections/" + url_encode(name) + "/points/count"
  let body = count_request_body(filter, exact)
  let json = self.request_json(Post, path, Some(body))
  match parse_result(json) {
    Some(result) =>
      match parse_count_result(result) {
        Some(n) => n
        None => raise Failure("unexpected count response: " + json.stringify())
      }
    None => raise Failure("unexpected count response: " + json.stringify())
  }
}

///|
/// Fetch several points at once by id via
/// `POST /collections/{name}/points`.
///
/// Points that do not exist are simply omitted from the returned list;
/// check the length against the requested ids to detect missing ones.
pub async fn QdrantClient::retrieve_points(
  self : QdrantClient,
  name : String,
  ids : Array[Int64],
  with_payload~ : Bool = true,
  with_vector~ : Bool = true,
) -> Array[PointStruct] {
  let path = "/collections/" + url_encode(name) + "/points"
  let body : Json = {
    "ids": point_ids_to_json(ids),
    "with_payload": Json::boolean(with_payload),
    "with_vector": Json::boolean(with_vector),
  }
  let json = self.request_json(Post, path, Some(body))
  match parse_retrieve_result(json) {
    Some(points) => points
    None => raise Failure("unexpected retrieve response: " + json.stringify())
  }
}

///|
/// Apply alias change actions via `POST /collections/aliases`.
async fn QdrantClient::apply_alias_actions(
  self : QdrantClient,
  actions : Array[Json],
) -> Unit {
  let body : Json = { "actions": Json::array(actions) }
  let json = self.request_json(Post, "/collections/aliases", Some(body))
  ignore(json)
}

///|
/// Create an alias for a collection: `POST /collections/aliases` with a
/// `create_alias` action.
pub async fn QdrantClient::create_alias(
  self : QdrantClient,
  alias_name : String,
  collection : String,
) -> Unit {
  let action : Json = {
    "action": Json::string("create_alias"),
    "create_alias": {
      "collection_name": Json::string(collection),
      "alias_name": Json::string(alias_name),
    },
  }
  self.apply_alias_actions([action])
}

///|
/// Delete an alias: `POST /collections/aliases` with a `delete_alias` action.
pub async fn QdrantClient::delete_alias(
  self : QdrantClient,
  alias_name : String,
) -> Unit {
  let action : Json = {
    "action": Json::string("delete_alias"),
    "delete_alias": { "alias_name": Json::string(alias_name) },
  }
  self.apply_alias_actions([action])
}

///|
/// List all collection aliases via `GET /aliases`.
pub async fn QdrantClient::list_aliases(
  self : QdrantClient,
) -> Array[AliasInfo] {
  let json = self.request_json(Get, "/aliases", None)
  match parse_aliases(json) {
    Some(aliases) => aliases
    None => raise Failure("unexpected aliases response: " + json.stringify())
  }
}

///|
/// Delete points selected by a filter via
/// `POST /collections/{name}/points/delete`.
///
/// This is the bulk-delete variant: every point matching `filter` is
/// removed in one operation, without listing ids first.
pub async fn QdrantClient::delete_points_by_filter(
  self : QdrantClient,
  name : String,
  filter : Filter,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points/delete?wait=" +
    wait.to_string()
  let body : Json = { "filter": filter.to_json() }
  let json = self.request_json(Post, path, Some(body))
  ignore(json)
}

///|
/// Run several searches in one round trip via
/// `POST /collections/{name}/points/search/batch`.
///
/// Each element is a search request JSON object, for example
/// `{ "vector": [...], "limit": 5, "with_payload": true }`; the returned
/// array has one entry (itself an array of hits) per request.
pub async fn QdrantClient::search_points_batch(
  self : QdrantClient,
  name : String,
  searches : Array[Json],
) -> Array[Array[ScoredPoint]] {
  let path = "/collections/" + url_encode(name) + "/points/search/batch"
  let body : Json = { "searches": Json::array(searches) }
  let json = self.request_json(Post, path, Some(body))
  let result : Array[Array[ScoredPoint]]? = match json {
    Object(members) =>
      match members.get("result") {
        Some(Array(groups)) => {
          let out : Array[Array[ScoredPoint]] = []
          for group in groups {
            match group {
              Array(hits_json) => {
                let hits : Array[ScoredPoint] = []
                for item in hits_json {
                  match ScoredPoint::from_json(item) {
                    Some(hit) => hits.push(hit)
                    None =>
                      raise Failure(
                        "unexpected batch search hit: " + json.stringify(),
                      )
                  }
                }
                out.push(hits)
              }
              _ =>
                raise Failure(
                  "unexpected batch search response: " + json.stringify(),
                )
            }
          }
          Some(out)
        }
        _ => None
      }
    _ => None
  }
  match result {
    Some(out) => out
    None =>
      raise Failure("unexpected batch search response: " + json.stringify())
  }
}

///|
/// Set (merge) a payload on the given points via
/// `POST /collections/{name}/points/payload`.
///
/// Pass either `ids` or `filter` to select the target points. The payload
/// fields are merged into each point's existing payload; fields not in the
/// given object are kept.
pub async fn QdrantClient::set_payload(
  self : QdrantClient,
  name : String,
  payload : Json,
  ids~ : Array[Int64]? = None,
  filter~ : Filter? = None,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points/payload?wait=" +
    wait.to_string()
  let body = payload_operation_body(payload, ids, filter)
  let json = self.request_json(Post, path, Some(body))
  ignore(json)
}

///|
/// Overwrite the payload of the given points via
/// `PUT /collections/{name}/points/payload`.
///
/// Unlike `set_payload`, the whole payload is replaced by the given object;
/// existing fields not in the given object are removed.
pub async fn QdrantClient::overwrite_payload(
  self : QdrantClient,
  name : String,
  payload : Json,
  ids~ : Array[Int64]? = None,
  filter~ : Filter? = None,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points/payload?wait=" +
    wait.to_string()
  let body = payload_operation_body(payload, ids, filter)
  let json = self.request_json(Put, path, Some(body))
  ignore(json)
}

///|
/// Delete the whole payload of the selected points via
/// `POST /collections/{name}/points/payload/clear`.
///
/// Pass either `ids` or `filter` to select the target points. To remove only
/// some keys, use `delete_payload_by_keys`.
pub async fn QdrantClient::delete_payload(
  self : QdrantClient,
  name : String,
  ids~ : Array[Int64]? = None,
  filter~ : Filter? = None,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points/payload/clear?wait=" +
    wait.to_string()
  let body = point_selector_body(ids, filter)
  let json = self.request_json(Post, path, Some(body))
  ignore(json)
}

///|
/// Delete only the listed payload keys from the selected points via
/// `POST /collections/{name}/points/payload/delete`.
///
/// Pass either `ids` or `filter` to select the target points.
pub async fn QdrantClient::delete_payload_by_keys(
  self : QdrantClient,
  name : String,
  keys : Array[String],
  ids~ : Array[Int64]? = None,
  filter~ : Filter? = None,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/points/payload/delete?wait=" +
    wait.to_string()
  let key_json : Array[Json] = []
  for key in keys {
    key_json.push(Json::string(key))
  }
  let body = point_selector_body(ids, filter)
  let members = match body {
    Object(m) => m
    _ => Map([])
  }
  members["keys"] = Json::array(key_json)
  let json = self.request_json(Post, path, Some(Json::object(members)))
  ignore(json)
}

///|
/// Build the shared point selector for payload delete/clear operations:
/// either `{ "points": [ids] }` or `{ "filter": ... }`. Unlike the write
/// operations, delete/clear bodies must not carry a `"payload"` field.
fn point_selector_body(ids : Array[Int64]?, filter : Filter?) -> Json {
  let members : Map[String, Json] = Map([])
  match ids {
    Some(ids_arr) => members["points"] = point_ids_to_json(ids_arr)
    None => ()
  }
  match filter {
    Some(f) => members["filter"] = f.to_json()
    None => ()
  }
  Json::object(members)
}

///|
/// Build the shared body for payload write operations: `{ "payload": ... }`
/// plus either `"points": [ids]` or `"filter": ...`.
fn payload_operation_body(
  payload : Json,
  ids : Array[Int64]?,
  filter : Filter?,
) -> Json {
  let members : Map[String, Json] = Map([])
  members["payload"] = payload
  match ids {
    Some(ids_arr) => members["points"] = point_ids_to_json(ids_arr)
    None => ()
  }
  match filter {
    Some(f) => members["filter"] = f.to_json()
    None => ()
  }
  Json::object(members)
}

///|
/// Update collection settings via `PATCH /collections/{name}`.
///
/// See `CollectionUpdate` for which settings can be changed.
pub async fn QdrantClient::update_collection(
  self : QdrantClient,
  name : String,
  update : CollectionUpdate,
) -> Unit {
  let path = "/collections/" + url_encode(name)
  let json = self.request_json(Patch, path, Some(update.to_json()))
  ignore(json)
}

///|
/// Create a payload index on a field via
/// `PUT /collections/{name}/index?wait={wait}`.
///
/// `schema` is the field schema, for example `"keyword"`, `"integer"`,
/// `"float"`, `"geo"`, `"bool"`, `"text"` or `"uuid"`. Creating an index
/// speeds up filtered access to that payload field.
pub async fn QdrantClient::create_payload_index(
  self : QdrantClient,
  name : String,
  field : String,
  schema : String,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/index?wait=" +
    wait.to_string()
  let body : Json = {
    "field_name": Json::string(field),
    "field_schema": Json::string(schema),
  }
  let json = self.request_json(Put, path, Some(body))
  ignore(json)
}

///|
/// Delete a payload index via `DELETE /collections/{name}/index/{field}`.
pub async fn QdrantClient::delete_payload_index(
  self : QdrantClient,
  name : String,
  field : String,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/index/" +
    url_encode(field) +
    "?wait=" +
    wait.to_string()
  let json = self.request_json(Delete, path, None)
  ignore(json)
}

///|
/// List the payload indexes of a collection via
/// `GET /collections/{name}` (the `payload_schema` field). Qdrant has no
/// dedicated list-indexes endpoint.
pub async fn QdrantClient::list_payload_indexes(
  self : QdrantClient,
  name : String,
) -> Array[PayloadIndexInfo] {
  let path = "/collections/" + url_encode(name)
  let json = self.request_json(Get, path, None)
  match parse_payload_indexes(json) {
    Some(indexes) => indexes
    None =>
      raise Failure("unexpected payload indexes response: " + json.stringify())
  }
}

///|
/// List the snapshots of a collection via
/// `GET /collections/{name}/snapshots`.
pub async fn QdrantClient::list_snapshots(
  self : QdrantClient,
  name : String,
) -> Array[SnapshotInfo] {
  let path = "/collections/" + url_encode(name) + "/snapshots"
  let json = self.request_json(Get, path, None)
  match parse_snapshots(json) {
    Some(snapshots) => snapshots
    None => raise Failure("unexpected snapshots response: " + json.stringify())
  }
}

///|
/// Create a snapshot of a collection via `POST /collections/{name}/snapshots`.
/// Snapshots can be downloaded from the storage and used to restore or
/// migrate a collection.
pub async fn QdrantClient::create_snapshot(
  self : QdrantClient,
  name : String,
  wait~ : Bool = true,
) -> SnapshotInfo {
  let path = "/collections/" +
    url_encode(name) +
    "/snapshots?wait=" +
    wait.to_string()
  let json = self.request_json(Post, path, None)
  match parse_result(json) {
    Some(result) =>
      match SnapshotInfo::from_json(result) {
        Some(info) => info
        None =>
          raise Failure("unexpected snapshot response: " + json.stringify())
      }
    None => raise Failure("unexpected snapshot response: " + json.stringify())
  }
}

///|
/// Delete a snapshot via `DELETE /collections/{name}/snapshots/{snapshot}`.
pub async fn QdrantClient::delete_snapshot(
  self : QdrantClient,
  name : String,
  snapshot : String,
  wait~ : Bool = true,
) -> Unit {
  let path = "/collections/" +
    url_encode(name) +
    "/snapshots/" +
    url_encode(snapshot) +
    "?wait=" +
    wait.to_string()
  let json = self.request_json(Delete, path, None)
  ignore(json)
}

///|
/// Run a query against the Qdrant query API via
/// `POST /collections/{name}/points/query`.
///
/// `query` is the raw query JSON, for example
/// `{ "nearest": [0.1, 0.2, 0.3, 0.4] }` or
/// `{ "recommend": { "positive": [[...]], "negative": [[...]] } }`.
/// Prefer the `query_nearest` / `query_recommend` helpers for common cases.
pub async fn QdrantClient::query_points(
  self : QdrantClient,
  name : String,
  query : Json,
  limit : Int32,
  filter~ : Filter? = None,
  using_vector~ : String? = None,
  score_threshold~ : Double? = None,
  with_payload~ : Bool = true,
  with_vector~ : Bool = false,
) -> Array[ScoredPoint] {
  let path = "/collections/" + url_encode(name) + "/points/query"
  let members : Map[String, Json] = Map([])
  members["query"] = query
  members["limit"] = Json::number(limit.to_double())
  members["with_payload"] = Json::boolean(with_payload)
  members["with_vector"] = Json::boolean(with_vector)
  match filter {
    Some(f) => members["filter"] = f.to_json()
    None => ()
  }
  match using_vector {
    Some(u) => members["using"] = Json::string(u)
    None => ()
  }
  match score_threshold {
    Some(t) => members["score_threshold"] = Json::number(t)
    None => ()
  }
  let body = Json::object(members)
  let json = self.request_json(Post, path, Some(body))
  match parse_query_result(json) {
    Some(hits) => hits
    None => raise Failure("unexpected query response: " + json.stringify())
  }
}

///|
/// Nearest-neighbor query via the query API: find points closest to `vector`.
/// Equivalent to `search_points` but routed through `/points/query`.
pub async fn QdrantClient::query_nearest(
  self : QdrantClient,
  name : String,
  vector : Array[Double],
  limit : Int32,
  filter~ : Filter? = None,
  using_vector~ : String? = None,
  score_threshold~ : Double? = None,
  with_payload~ : Bool = true,
  with_vector~ : Bool = false,
) -> Array[ScoredPoint] {
  self.query_points(
    name,
    { "nearest": vector_to_json(vector) },
    limit,
    filter~,
    using_vector~,
    score_threshold~,
    with_payload~,
    with_vector~,
  )
}

///|
/// Recommendation query via the query API: find points similar to the
/// `positive` inputs and dissimilar to the `negative` ones.
///
/// Each element of `positive` / `negative` is either a dense vector
/// (`Json::array([...])`) or a point id (`Json::number(id)`).
pub async fn QdrantClient::query_recommend(
  self : QdrantClient,
  name : String,
  positive : Array[Json],
  negative~ : Array[Json]? = None,
  limit : Int32,
  filter~ : Filter? = None,
  using_vector~ : String? = None,
  score_threshold~ : Double? = None,
  with_payload~ : Bool = true,
  with_vector~ : Bool = false,
) -> Array[ScoredPoint] {
  let rec : Map[String, Json] = Map([])
  rec["positive"] = Json::array(positive)
  match negative {
    Some(neg) => rec["negative"] = Json::array(neg)
    None => ()
  }
  self.query_points(
    name,
    { "recommend": Json::object(rec) },
    limit,
    filter~,
    using_vector~,
    score_threshold~,
    with_payload~,
    with_vector~,
  )
}

///|
/// Count the distinct values of a payload field via
/// `POST /collections/{name}/facet`.
///
/// `key` is the payload field to facet on; `limit` bounds how many distinct
/// values are returned (by descending count). Pass `exact=true` for exact
/// counts instead of the faster approximate estimation.
pub async fn QdrantClient::facet_points(
  self : QdrantClient,
  name : String,
  key : String,
  limit : Int32,
  filter~ : Filter? = None,
  exact~ : Bool = false,
) -> FacetResult {
  let path = "/collections/" + url_encode(name) + "/facet"
  let members : Map[String, Json] = Map([])
  members["key"] = Json::string(key)
  members["limit"] = Json::number(limit.to_double())
  members["exact"] = Json::boolean(exact)
  match filter {
    Some(f) => members["filter"] = f.to_json()
    None => ()
  }
  let body = Json::object(members)
  let json = self.request_json(Post, path, Some(body))
  match parse_result(json) {
    Some(result) =>
      match FacetResult::from_json(result) {
        Some(fr) => fr
        None => raise Failure("unexpected facet response: " + json.stringify())
      }
    None => raise Failure("unexpected facet response: " + json.stringify())
  }
}

///|
/// Grouped search via `POST /collections/{name}/points/search/groups`.
///
/// Points are grouped by the payload field `group_by`; `limit` bounds the
/// hits returned per group and `group_size` bounds the number of groups.
/// The result is one entry per group with its top hits.
pub async fn QdrantClient::search_points_groups(
  self : QdrantClient,
  name : String,
  vector : Array[Double],
  limit : Int32,
  group_by : String,
  group_size : Int32,
  filter~ : Json? = None,
  with_payload~ : Bool = true,
  with_vector~ : Bool = false,
  score_threshold~ : Double? = None,
  using_vector~ : String? = None,
  search_params~ : SearchParams? = None,
) -> Array[ScoredPointGroup] {
  let path = "/collections/" + url_encode(name) + "/points/search/groups"
  let members : Map[String, Json] = Map([])
  // Like the search API, the groups endpoint has no top-level "using"
  // field; on a named-vector collection the name goes inside the vector
  // object.
  members["vector"] = match using_vector {
    Some(u) =>
      Json::object({ "name": Json::string(u), "vector": vector_to_json(vector) })
    None => vector_to_json(vector)
  }
  members["limit"] = Json::number(limit.to_double())
  members["group_by"] = Json::string(group_by)
  members["group_size"] = Json::number(group_size.to_double())
  members["with_payload"] = Json::boolean(with_payload)
  members["with_vector"] = Json::boolean(with_vector)
  match filter {
    Some(f) => members["filter"] = f
    None => ()
  }
  match score_threshold {
    Some(t) => members["score_threshold"] = Json::number(t)
    None => ()
  }
  match search_params {
    Some(p) => members["params"] = p.to_json()
    None => ()
  }
  let body = Json::object(members)
  let json = self.request_json(Post, path, Some(body))
  match parse_search_groups(json) {
    Some(groups) => groups
    None =>
      raise Failure("unexpected grouped search response: " + json.stringify())
  }
}

///|
/// Read the global write lock state via `GET /locks`. Qdrant locks are
/// service-wide (not per-collection); when `write` is true the service
/// rejects write operations.
pub async fn QdrantClient::get_locks(self : QdrantClient) -> CollectionLock {
  let json = self.request_json(Get, "/locks", None)
  match parse_result(json) {
    Some(result) =>
      match CollectionLock::from_json(result) {
        Some(lock) => lock
        None => raise Failure("unexpected lock response: " + json.stringify())
      }
    None => raise Failure("unexpected lock response: " + json.stringify())
  }
}

///|
/// Set the global write lock state via `POST /locks`.
///
/// When `write` is true the service rejects write operations; provide an
/// optional `error_message` returned to clients on rejected writes.
pub async fn QdrantClient::set_locks(
  self : QdrantClient,
  write : Bool,
  error_message~ : String? = None,
) -> Unit {
  let members : Map[String, Json] = Map([])
  members["write"] = Json::boolean(write)
  match error_message {
    Some(msg) => members["error_message"] = Json::string(msg)
    None => ()
  }
  let body = Json::object(members)
  let json = self.request_json(Post, "/locks", Some(body))
  ignore(json)
}

///|
/// Read server info via `GET /`: the Qdrant version and commit hash.
pub async fn QdrantClient::service_info(self : QdrantClient) -> ServiceInfo {
  let json = self.request_json(Get, "/", None)
  match ServiceInfo::from_json(json) {
    Some(info) => info
    None =>
      raise Failure("unexpected service info response: " + json.stringify())
  }
}

///|
/// Build the request body for a collection cluster operation
/// (`POST /collections/{name}/cluster`).
///
/// Qdrant's `ClusterOperations` is an untagged enum of single-key objects:
/// the operation name is the key and its parameters are the value, e.g.
/// `{"replicate_shard": {"shard_id": 0, "from_peer_id": 1, "to_peer_id": 2}}`.
/// A flat `{"method": "replicate_shard", ...}` body matches no variant and is
/// rejected with HTTP 400. `op` must be a real Qdrant operation name such as
/// `replicate_shard`, `move_shard`, `drop_replica` or `abort_transfer`.
pub fn collection_cluster_op_body(
  op : String,
  shard_id : Int64,
  peer_id~ : Int64? = None,
  from_peer_id~ : Int64? = None,
  to_peer_id~ : Int64? = None,
) -> Json {
  let params : Map[String, Json] = Map([])
  params["shard_id"] = int64_to_json(shard_id)
  match peer_id {
    Some(v) => params["peer_id"] = int64_to_json(v)
    None => ()
  }
  match from_peer_id {
    Some(v) => params["from_peer_id"] = int64_to_json(v)
    None => ()
  }
  match to_peer_id {
    Some(v) => params["to_peer_id"] = int64_to_json(v)
    None => ()
  }
  let members : Map[String, Json] = Map([])
  members[op] = Json::object(params)
  Json::object(members)
}

///|
/// Read the overall cluster state via `GET /cluster`: status, local peer id,
/// the peer map and raft consensus info.
pub async fn QdrantClient::cluster_info(self : QdrantClient) -> ClusterInfo {
  let json = self.request_json(Get, "/cluster", None)
  match parse_result(json) {
    Some(result) =>
      match ClusterInfo::from_json(result) {
        Some(info) => info
        None =>
          raise Failure("unexpected cluster response: " + json.stringify())
      }
    None => raise Failure("unexpected cluster response: " + json.stringify())
  }
}

///|
/// Read the per-collection cluster layout via
/// `GET /collections/{name}/cluster`: local and remote shard replicas plus
/// in-flight transfers.
pub async fn QdrantClient::collection_cluster_info(
  self : QdrantClient,
  name : String,
) -> CollectionClusterInfo {
  let path = "/collections/" + url_encode(name) + "/cluster"
  let json = self.request_json(Get, path, None)
  match parse_result(json) {
    Some(result) =>
      match CollectionClusterInfo::from_json(result) {
        Some(info) => info
        None =>
          raise Failure(
            "unexpected collection cluster response: " + json.stringify(),
          )
      }
    None =>
      raise Failure(
        "unexpected collection cluster response: " + json.stringify(),
      )
  }
}

///|
/// Run a collection cluster operation via `POST /collections/{name}/cluster`
/// and return whether Qdrant reported the operation as started.
async fn QdrantClient::cluster_op(
  self : QdrantClient,
  name : String,
  body : Json,
) -> Bool {
  let path = "/collections/" + url_encode(name) + "/cluster"
  let json = self.request_json(Post, path, Some(body))
  match parse_result(json) {
    Some(True) => true
    Some(False) => false
    _ => raise Failure("unexpected cluster op response: " + json.stringify())
  }
}

///|
/// Replicate a shard to another peer via
/// `POST /collections/{name}/cluster` with the `replicate_shard` operation.
pub async fn QdrantClient::replicate_shard(
  self : QdrantClient,
  name : String,
  shard_id : Int64,
  from_peer_id : Int64,
  to_peer_id : Int64,
) -> Bool {
  self.cluster_op(
    name,
    collection_cluster_op_body(
      "replicate_shard",
      shard_id,
      from_peer_id=Some(from_peer_id),
      to_peer_id=Some(to_peer_id),
    ),
  )
}

///|
/// Pick a peer that already holds `shard_id` as the replication source.
/// `local_shards` belong to `layout.peer_id`; remote entries carry their own
/// peer id. Falls back to the local peer when the shard is not listed.
pub fn shard_source_peer(
  layout : CollectionClusterInfo,
  shard_id : Int64,
) -> Int64 {
  for shard in layout.remote_shards {
    if shard.shard_id == shard_id {
      return shard.peer_id
    }
  }
  for shard in layout.local_shards {
    if shard.shard_id == shard_id {
      return layout.peer_id
    }
  }
  layout.peer_id
}

///|
/// Add a replica of a shard on `peer_id`.
///
/// Qdrant has no `create_shard_replica` cluster operation: a replica is added
/// with `replicate_shard`, which needs a source peer that holds the shard.
/// When `from_peer_id~` is omitted the client looks one up through
/// `GET /collections/{name}/cluster`; pass it explicitly to skip the lookup.
pub async fn QdrantClient::create_shard_replica(
  self : QdrantClient,
  name : String,
  shard_id : Int64,
  peer_id : Int64,
  from_peer_id~ : Int64? = None,
) -> Bool {
  let source = match from_peer_id {
    Some(v) => v
    None => shard_source_peer(self.collection_cluster_info(name), shard_id)
  }
  self.cluster_op(
    name,
    collection_cluster_op_body(
      "replicate_shard",
      shard_id,
      from_peer_id=Some(source),
      to_peer_id=Some(peer_id),
    ),
  )
}

///|
/// Drop a shard replica from `peer_id` via the `drop_replica` operation.
/// `delete_shard_replica` is kept as the public method name, but Qdrant's
/// cluster operation is called `drop_replica`.
pub async fn QdrantClient::delete_shard_replica(
  self : QdrantClient,
  name : String,
  shard_id : Int64,
  peer_id : Int64,
) -> Bool {
  self.cluster_op(
    name,
    collection_cluster_op_body("drop_replica", shard_id, peer_id=Some(peer_id)),
  )
}