///|
/// Errors surfaced by the client.
pub(all) suberror ChromaError {
  /// The request could not be produced or sent (bad URL, transport failure).
  Transport(String)
  /// The server answered with a non-2xx status and a non-JSON body, or one
  /// that does not follow Chroma's error envelope.
  ///
  /// Prefer `Api` when the body is JSON: it carries the same information in
  /// parsed form. This variant stays for responses the client cannot parse.
  Status(Int32, String)
  /// The server answered with a non-2xx status and a documented error body.
  Api(ApiError)
  /// The response body was not the JSON shape this client expects.
  Decode(String)
  /// The arguments would be rejected by Chroma; nothing was sent.
  InvalidArgument(String)
  /// A chunked write stopped partway: `sent` records were accepted and the
  /// rest of the batch was not sent. Wraps the error that stopped it.
  PartialFailure(PartialFailure)
}

///|
/// A non-2xx response, split into the fields Chroma documents.
///
/// Callers get the message without writing a parser or a regular expression:
/// `error` is the short machine-readable label, `message` the human sentence,
/// and `details` whatever extra structure the server attached.
pub(all) struct ApiError {
  /// HTTP status code.
  status : Int32
  /// The `error` field, e.g. `"NotFound"` or `"InternalError"`. Empty when
  /// the server omitted it.
  error_code : String
  /// The `message` field, falling back to `error` when the server sent only
  /// a label.
  message : String
  /// The unparsed body, kept so a caller can still see exactly what arrived.
  body : String
  /// Extra `details` attached to the error body.
  details : Json
} derive(Eq, Debug)

///|
/// Decodes a non-2xx body into an `ApiError`.
///
/// Chroma answers with `{"error": "...", "message": "..."}` on most paths.
/// Both fields are optional in practice: a proxy or an older server may send
/// only one, or neither, so nothing here is treated as a hard decode failure
/// and the raw body is always preserved.
pub fn ApiError::from_json(status : Int32, body : String) -> ApiError {
  let parsed = @json.parse(body.trim()) catch { _ => Json::null() }
  let fields = match parsed {
    Json::Object(fields) => Some(fields)
    _ => None
  }
  let error_code = match fields {
    Some(fields) =>
      match fields.get("error") {
        Some(Json::String(s)) => s
        _ => ""
      }
    None => ""
  }
  let message = match fields {
    Some(fields) =>
      match fields.get("message") {
        Some(Json::String(s)) => s
        _ =>
          // Some endpoints put the sentence in `error` and skip `message`.
          match fields.get("error") {
            Some(Json::String(s)) => s
            _ => ""
          }
      }
    None => ""
  }
  let details = match fields {
    Some(fields) =>
      match fields.get("details") {
        Some(value) => value
        None => Json::null()
      }
    None => Json::null()
  }
  { status, error_code, message, body, details, }
}

///|
/// Builds the error to raise for a non-2xx response.
///
/// The body is probed for the Chroma error envelope; only when it carries
/// neither `error` nor `message` does the error stay a raw `Status`.
pub fn api_error(status : Int32, body : String) -> ChromaError {
  let parsed = ApiError::from_json(status, body)
  if parsed.error_code == "" && parsed.message == "" {
    ChromaError::Status(status, body)
  } else {
    ChromaError::Api(parsed)
  }
}

///|
impl Eq for ChromaError with fn equal(self, other) {
  match (self, other) {
    (Transport(a), Transport(b)) => a == b
    (Status(a_code, a_body), Status(b_code, b_body)) =>
      a_code == b_code && a_body == b_body
    (Api(a), Api(b)) => a == b
    (Decode(a), Decode(b)) => a == b
    (InvalidArgument(a), InvalidArgument(b)) => a == b
    (PartialFailure(a), PartialFailure(b)) => a == b
    _ => false
  }
}

///|
impl Show for ChromaError with fn output(self, logger) {
  match self {
    Transport(message) => logger.write_string("transport error: " + message)
    Status(code, body) =>
      logger.write_string(
        "server returned status " + code.to_string() + ": " + body,
      )
    Api(error) => logger.write_string(error.to_string())
    Decode(message) => logger.write_string("decode error: " + message)
    InvalidArgument(message) =>
      logger.write_string("invalid argument: " + message)
    PartialFailure(failure) =>
      logger.write_string(
        "partial write: " +
        failure.sent.to_string() +
        " records were stored, the rest were not: " +
        failure.cause.to_string(),
      )
  }
}

///|
impl Show for ApiError with fn output(self, logger) {
  logger.write_string("server returned status " + self.status.to_string())
  if self.error_code != "" {
    logger.write_string(" (" + self.error_code + ")")
  }
  if self.message != "" {
    logger.write_string(": " + self.message)
  } else if self.body.trim() != "" {
    logger.write_string(": " + self.body)
  }
}

///|
/// Extracts the numeric status of any server-side response error.
///
/// `None` means the error never reached the server, so retrying it is the
/// caller's decision rather than a given.
pub fn ChromaError::status_code(self : ChromaError) -> Int32? {
  match self {
    Status(code, _) => Some(code)
    Api(error) => Some(error.status)
    // A partial write is still an answer from the server when the chunk that
    // failed was; asking the wrapped error keeps "429 mid-batch is retryable"
    // true for the caller that inspects the outer error.
    PartialFailure(failure) => failure.cause.status_code()
    _ => None
  }
}

///|
/// Whether the status `code` is worth retrying: 429 and 5xx, i.e. the server
/// was reachable but too busy or broken.
///
/// This is the single spelling of the rule. `Api` and `Status` both answer
/// through it, and `RetryTransport` uses it to decide whether to re-send, so
/// the error a caller inspects and the decision the transport takes cannot
/// drift apart.
pub fn is_retryable_status(code : Int32) -> Bool {
  code == 429 || (code >= 500 && code < 600)
}

///|
/// Whether the error is worth retrying: 429 and 5xx, i.e. the server was
/// reachable but too busy or broken. Client errors (4xx except 429) are
/// deterministic and repeating them only wastes a round-trip.
///
/// The verdict comes from `status_code()` for every variant that carries a
/// numeric status, so a `Status` (an unparsed body, a proxy page) keeps the
/// retry semantics its code implies instead of degrading to "not retryable"
/// just because the body was not Chroma's. Only `Transport`, `Decode` and
/// `InvalidArgument` have no status and answer `false`.
pub fn ChromaError::is_retryable(self : ChromaError) -> Bool {
  match self.status_code() {
    Some(code) => is_retryable_status(code)
    None => false
  }
}

///|
/// The message a caller can show a user, without the transport wrapper.
pub fn ChromaError::message(self : ChromaError) -> String {
  match self {
    Transport(text) => text
    Status(_, body) => body
    Api(error) =>
      if error.message != "" {
        error.message
      } else {
        error.error_code
      }
    Decode(text) => text
    InvalidArgument(text) => text
    PartialFailure(failure) =>
      failure.sent.to_string() +
      " records were stored before the write stopped: " +
      failure.cause.message()
  }
}

///|
/// Validates a pagination window on a `get`.
///
/// Only the signs are checked, which is what chroma-go does (`limit must be
/// greater than or equal to 0`, `offset must be greater than or equal to 0`).
/// `limit` and `offset` are independent: `limit=100, offset=200` is a legal
/// page. An earlier version of this client also required `offset < limit`,
/// which rejected the very paging pattern chroma-go documents.
pub fn validate_limit_offset(
  limit : Int32?,
  offset : Int32?,
) -> Unit raise ChromaError {
  match limit {
    Some(value) =>
      if value < 0 {
        raise ChromaError::InvalidArgument("limit must not be negative")
      }
    None => ()
  }
  match offset {
    Some(value) =>
      if value < 0 {
        raise ChromaError::InvalidArgument("offset must not be negative")
      }
    None => ()
  }
}

///|
/// The `get` spelling of `validate_limit_offset`, kept as its own name so the
/// call site reads as "this is paging a result set".
pub fn validate_paging(
  limit : Int32?,
  offset : Int32?,
) -> Unit raise ChromaError {
  validate_limit_offset(limit, offset)
}

///|
pub extend ChromaError with Eq::{not_equal, equal}

///|
pub extend ChromaError with Show::{to_string, output}

///|
pub extend ApiError with Eq::{not_equal, equal}

///|
pub extend ApiError with @debug.Debug::{to_repr}

///|
pub extend ApiError with Show::{to_string, output}