///|
/// An LLM client bound to an OpenAI-compatible endpoint.
///
/// Construct with `Client::new`, optionally overriding `base_url`.
/// The default `base_url` targets the OpenAI public API; point it at any
/// OpenAI-compatible gateway (Azure, local vLLM, router services, ...).
pub struct Client {
  api_key : String
  base_url : String
  timeout_ms : Int
  /// Extra headers sent with every request (e.g. `OpenAI-Organization`,
  /// Anthropic's `anthropic-version`, or a custom `x-api-key`).
  extra_headers : Map[String, String]
  /// The authentication scheme. Most OpenAI-compatible APIs use a bearer
  /// token; some (e.g. Anthropic) use an `x-api-key` header instead.
  auth : AuthScheme
}

///|
/// How the API key is presented to the server.
pub(all) enum AuthScheme {
  /// `Authorization: Bearer ` — the OpenAI default.
  Bearer
  /// `x-api-key: ` — used by Anthropic.
  ApiKeyHeader
  /// No automatic auth header (supply your own via extra headers).
  NoAuth
} derive(Eq, Debug)

///|
/// Create a client with the given API key.
///
/// - `base_url` defaults to `https://api.openai.com/v1`.
/// - `timeout_ms` defaults to 60_000.
pub fn Client::new(
  api_key : String,
  base_url? : String = "https://api.openai.com/v1",
  timeout_ms? : Int = 60000,
) -> Client {
  {
    api_key,
    base_url: normalize_base_url(base_url),
    timeout_ms,
    extra_headers: {},
    auth: Bearer,
  }
}

///|
/// Strip a single trailing slash so path joins are predictable.
fn normalize_base_url(base_url : String) -> String {
  if base_url.has_suffix("/") {
    base_url[:base_url.length() - 1].to_owned()
  } else {
    base_url
  }
}

///|
/// A builder for `Client`, for when you need more than the key and base URL:
/// custom headers, an organization id, a non-bearer auth scheme, or a
/// specific timeout.
pub struct ClientBuilder {
  mut api_key : String
  mut base_url : String
  mut timeout_ms : Int
  headers : Map[String, String]
  mut auth : AuthScheme
}

///|
/// Start building a client.
pub fn ClientBuilder::new() -> ClientBuilder {
  {
    api_key: "",
    base_url: "https://api.openai.com/v1",
    timeout_ms: 60000,
    headers: {},
    auth: Bearer,
  }
}

///|
pub fn ClientBuilder::api_key(
  self : ClientBuilder,
  key : String,
) -> ClientBuilder {
  self.api_key = key
  self
}

///|
pub fn ClientBuilder::base_url(
  self : ClientBuilder,
  url : String,
) -> ClientBuilder {
  self.base_url = url
  self
}

///|
pub fn ClientBuilder::timeout_ms(
  self : ClientBuilder,
  ms : Int,
) -> ClientBuilder {
  self.timeout_ms = ms
  self
}

///|
/// Add a header sent with every request.
pub fn ClientBuilder::header(
  self : ClientBuilder,
  name : String,
  value : String,
) -> ClientBuilder {
  self.headers[name] = value
  self
}

///|
/// Set the `OpenAI-Organization` header.
pub fn ClientBuilder::organization(
  self : ClientBuilder,
  org : String,
) -> ClientBuilder {
  self.headers["OpenAI-Organization"] = org
  self
}

///|
/// Set the authentication scheme.
pub fn ClientBuilder::auth(
  self : ClientBuilder,
  scheme : AuthScheme,
) -> ClientBuilder {
  self.auth = scheme
  self
}

///|
/// Configure the builder for the Anthropic Messages API: `x-api-key` auth and
/// the required `anthropic-version` header.
pub fn ClientBuilder::anthropic(
  self : ClientBuilder,
  version? : String = "2023-06-01",
) -> ClientBuilder {
  self.auth = ApiKeyHeader
  self.headers["anthropic-version"] = version
  if self.base_url == "https://api.openai.com/v1" {
    self.base_url = "https://api.anthropic.com/v1"
  }
  self
}

///|
/// Finalize the builder into a `Client`.
pub fn ClientBuilder::build(self : ClientBuilder) -> Client {
  {
    api_key: self.api_key,
    base_url: normalize_base_url(self.base_url),
    timeout_ms: self.timeout_ms,
    extra_headers: self.headers,
    auth: self.auth,
  }
}

///|
/// Build the full URL for an endpoint path (e.g. `/embeddings`).
fn Client::endpoint(self : Client, path : String) -> String {
  self.base_url + path
}

///|
/// Perform a POST with a JSON body to `path` and return the parsed response
/// JSON. Shared by all non-streaming endpoints. Handles transport failures,
/// non-2xx status, and JSON parse errors uniformly.
pub async fn Client::post_json(
  self : Client,
  path : String,
  body : Json,
) -> Json raise LLMError {
  let http = self.http()
  let resp = http.post(self.endpoint(path)).json(body).send() catch {
      err => raise Transport(err.to_string())
    }
  let code = resp.response.code
  let text = resp.text() catch { err => raise Decode(err.to_string()) }
  guard code >= 200 && code < 300 else { raise ApiError(code~, message=text) }
  @json.parse(text) catch {
    err => raise Decode("invalid JSON: " + err.to_string())
  }
}

///|
/// Perform a GET request to `path` and return the parsed response JSON.
pub async fn Client::get_json(
  self : Client,
  path : String,
) -> Json raise LLMError {
  let http = self.http()
  let resp = http.get(self.endpoint(path)).send() catch {
      err => raise Transport(err.to_string())
    }
  let code = resp.response.code
  let text = resp.text() catch { err => raise Decode(err.to_string()) }
  guard code >= 200 && code < 300 else { raise ApiError(code~, message=text) }
  @json.parse(text) catch {
    err => raise Decode("invalid JSON: " + err.to_string())
  }
}

///|
/// Perform a DELETE request to `path` and return the parsed response JSON.
pub async fn Client::delete_json(
  self : Client,
  path : String,
) -> Json raise LLMError {
  let http = self.http()
  let resp = http.delete(self.endpoint(path)).send() catch {
      err => raise Transport(err.to_string())
    }
  let code = resp.response.code
  let text = resp.text() catch { err => raise Decode(err.to_string()) }
  guard code >= 200 && code < 300 else { raise ApiError(code~, message=text) }
  @json.parse(text) catch {
    err => raise Decode("invalid JSON: " + err.to_string())
  }
}

///|
/// Build a configured mio request client with auth, extra headers, and
/// timeout applied.
fn Client::http(self : Client) -> @mio.RequestClient {
  let builder = @mio.RequestClient::builder()
    .default_header("Content-Type", "application/json")
    .timeout(self.timeout_ms)
  match self.auth {
    Bearer =>
      builder.default_header("Authorization", "Bearer " + self.api_key)
      |> ignore
    ApiKeyHeader => builder.default_header("x-api-key", self.api_key) |> ignore
    NoAuth => ()
  }
  for name, value in self.extra_headers {
    builder.default_header(name, value) |> ignore
  }
  builder.build()
}

///|
/// Perform a non-streaming chat completion.
///
/// Raises `LLMError` on transport failure, non-2xx status, or decode failure.
pub async fn Client::chat(
  self : Client,
  request : ChatRequest,
) -> ChatResponse raise LLMError {
  request.stream = false
  let json = self.post_json("/chat/completions", request.to_json())
  @json.from_json(json) catch {
    err => raise Decode(err.to_string())
  }
}

///|
/// Whether an error is worth retrying: transport failures and
/// rate-limit / server errors (429, 5xx).
fn is_retryable(err : LLMError) -> Bool {
  match err {
    Transport(_) => true
    ApiError(code~, ..) => code == 429 || code >= 500
    _ => false
  }
}

///|
/// Perform a chat completion with automatic retries on transient failures.
///
/// Retries up to `max_retries` times with exponential backoff starting at
/// `base_delay_ms`. Non-retryable errors (4xx other than 429, decode errors)
/// are raised immediately.
pub async fn Client::chat_with_retry(
  self : Client,
  request : ChatRequest,
  max_retries? : Int = 3,
  base_delay_ms? : Int = 500,
) -> ChatResponse raise LLMError {
  let mut attempt = 0
  let mut delay = base_delay_ms
  for ;; {
    let result = Ok(self.chat(request)) catch { err => Err(err) }
    match result {
      Ok(resp) => return resp
      Err(err) => {
        if attempt >= max_retries || !is_retryable(err) {
          raise err
        }
        @async.sleep(delay) catch {
          e => raise Transport("interrupted during backoff: " + e.to_string())
        }
        attempt = attempt + 1
        delay = delay * 2
      }
    }
  }
}