// The client.
//
// It composes @api (bytes in, bytes out), @blocks (the message model) and
// @methods (the name and tier table), and it holds a `&Transport` rather than a
// socket -- so a browser host supplies `fetch`, a native host supplies
// marianoguerra/slack-http, and a test supplies
// @testing.FakeTransport.
//
// What it deliberately does NOT do is throttle or retry. Both need to sleep,
// sleeping needs an async runtime, and depending on one would make this package
// native-only -- which would put Block Kit and signature verification out of
// reach of a wasm-hosted app for the sake of two features a caller can drive
// themselves. `@ratectl` computes the delay; the caller waits it.

///|
pub struct Client {
  transport : &@api.Transport
  token : String
  base_url : String
  /// `TrueFalse` (node-slack-sdk) unless a caller has a reason.
  bool_style : @api.BoolStyle
  /// Merged into every request. A caller's `User-Agent` addition goes here.
  extra_headers : Map[String, String]
  /// Sent as `team_id` on every call, for an org-wide app that must say which
  /// workspace it means.
  team_id : String?
}

///|
pub fn Client::new(
  transport : &@api.Transport,
  token : String,
  base_url? : String = @api.default_api_url,
  bool_style? : @api.BoolStyle = TrueFalse,
  extra_headers? : Map[String, String] = Map([]),
  team_id? : String,
) -> Client {
  { transport, token, base_url, bool_style, extra_headers, team_id }
}

///|
/// Call any Web API method.
///
/// The escape hatch that keeps all 326 methods reachable: the typed builders
/// below cover the families most apps use, and everything else is this.
///
/// Raises rather than returning `ok: false` as a value. A refusal from Slack is
/// an error in every sense that matters to a caller -- they asked for something
/// and did not get it -- and making it a return value means every call site
/// either checks or silently proceeds on a message that was never posted.
/// `PlatformError` carries the whole response for the cases that want it.
pub async fn Client::call(
  self : Self,
  api_method : String,
  params : @api.Params,
) -> @api.ApiResponse raise @api.SlackError {
  let with_team = if self.team_id is Some(id) {
    let merged = @api.Params::new()
    for entry in params.entries {
      merged.put(entry.0, entry.1)
    }
    if !params.entries.iter().any(e => e.0 == "team_id") {
      merged.put_str("team_id", Some(id))
    }
    merged
  } else {
    params
  }
  let request = @api.build_request(
    self.base_url,
    api_method,
    with_team,
    token=self.token,
    extra_headers=self.extra_headers,
    bool_style=self.bool_style,
  )
  let response = self.transport.send(request) catch {
    e => raise @api.to_slack_error(e)
  }
  self.interpret(response)
}

///|
/// Turn one HTTP response into a result or an error.
///
/// Separate from `call` and synchronous, so the status-handling rules are
/// testable without a transport at all.
pub fn Client::interpret(
  self : Self,
  response : @api.HttpResponse,
) -> @api.ApiResponse raise @api.SlackError {
  ignore(self)
  interpret_response(response)
}

///|
/// The rules, as a free function.
///
/// Slack answers a refusal with HTTP 200 and `{"ok": false}`, so the status
/// code and `ok` are two different questions: 429 and 5xx are infrastructure,
/// `ok: false` is Slack declining.
pub fn interpret_response(
  response : @api.HttpResponse,
) -> @api.ApiResponse raise @api.SlackError {
  if response.status == 429 {
    // A 429 with no parseable `Retry-After` still has to produce a number, or
    // the caller has nothing to wait. 60 seconds is what slack-rs uses, and it
    // is the safe direction to be wrong in.
    let retry_after = match response.header("retry-after") {
      Some(header) => @api.parse_retry_after(header).unwrap_or(60)
      None => 60
    }
    raise @api.RateLimitedError(retry_after~)
  }
  if response.status < 200 || response.status >= 300 {
    raise @api.HttpError(
      status=response.status,
      headers=response.headers,
      body=response.body,
    )
  }
  let result = @api.ApiResponse::of_http(response)
  if !result.ok {
    raise @api.PlatformError(result~)
  }
  result
}

///|
/// Walk every page of a paginated method.
///
/// `on_page` is called with each page and returns `false` to stop early --
/// node-slack-sdk's `shouldStop`, inverted so that the common case is
/// `_ => true`. Returns the number of pages fetched.
///
/// A callback rather than an iterator because MoonBit's async iteration would
/// force this whole package to depend on a runtime; the callback costs one
/// closure and keeps @client buildable on wasm.
pub async fn Client::paginate(
  self : Self,
  api_method : String,
  params : @api.Params,
  on_page : (@api.ApiResponse) -> Bool,
  page_size? : Int = default_page_size,
  cursor? : String,
  max_pages? : Int,
) -> Int raise @api.SlackError {
  let paginator = Paginator::new(page_size~, cursor?)
  while paginator.next() is Some(page_params) {
    let response = self.call(api_method, merge_params(params, page_params))
    paginator.accept(response)
    if !on_page(response) {
      paginator.stop()
      break
    }
    if max_pages is Some(limit) && paginator.pages() >= limit {
      paginator.stop()
      break
    }
  }
  paginator.pages()
}

///|
/// Walk every page and concatenate the items under `key`.
///
/// `key` is the collection's name in the response -- `channels` for
/// `conversations.list`, `members` for `users.list`, `messages` for
/// `conversations.history`. Slack has no single name for it, so it cannot be
/// inferred.
pub async fn Client::paginate_collect(
  self : Self,
  api_method : String,
  params : @api.Params,
  key : String,
  page_size? : Int = default_page_size,
  max_pages? : Int,
) -> Array[Json] raise @api.SlackError {
  let out = []
  self.paginate(
    api_method,
    params,
    response => {
      for item in page_items(response, key) {
        out.push(item)
      }
      true
    },
    page_size~,
    max_pages?,
  )
  |> ignore
  out
}