// The response envelope every Slack Web API method shares.
//
// The shape is `{"ok": true, ...}` or `{"ok": false, "error": "..."}`, always
// with HTTP 200. A non-200 from `slack.com/api` means infrastructure, not a
// refusal -- which is why `ok` and the status code are two separate questions
// throughout this library.

///|
/// The `response_metadata` object, plus the three things Slack puts in headers
/// rather than in the body.
///
/// Merging the headers in here follows node-slack-sdk: a caller who has to
/// reach back into the raw response to find out which scopes their token was
/// missing will simply not bother, and the whole point of `needed`/`provided`
/// and `scopes` is to make that diagnosable.
pub(all) struct ResponseMetadata {
  /// The cursor for the next page. Absent OR empty means "no more pages" --
  /// Slack sends `""` at the end, and treating that as a cursor is an infinite
  /// loop. See `Paginator` in @client.
  next_cursor : String?
  warnings : Array[String]
  /// Free-form diagnostics, each prefixed `[ERROR]` or `[WARN]`. This is where
  /// "unsupported type: sections [json-pointer:/blocks/0/type]" arrives -- on
  /// an `"ok": true` response. See `diagnostics`.
  messages : Array[String]
  /// From the `x-oauth-scopes` header: what the token actually has.
  scopes : Array[String]
  /// From `x-accepted-oauth-scopes`: what this method would accept.
  accepted_scopes : Array[String]
  /// From `retry-after`. Present on a 200 as well as a 429, following
  /// node-slack-sdk -- Slack sometimes advises a backoff before it starts
  /// enforcing one, and a client that only looks at 429s throws that away.
  retry_after : Int?
  /// Any other key inside `response_metadata`.
  extra : Map[String, Json]
} derive(Eq, Debug)

///|
pub fn ResponseMetadata::empty() -> ResponseMetadata {
  {
    next_cursor: None,
    warnings: [],
    messages: [],
    scopes: [],
    accepted_scopes: [],
    retry_after: None,
    extra: Map([]),
  }
}

///|
/// How seriously Slack meant a `response_metadata.messages` entry.
pub(all) enum Severity {
  Err
  Warn
} derive(Eq, Debug)

///|
/// Prints as `ERROR` / `WARN` -- the prefixes Slack itself uses in
/// `response_metadata.messages`, so a line logged from here reads the same way
/// as the line Slack sent.
pub impl Show for Severity with fn output(self, logger) {
  logger.write_string(
    match self {
      Err => "ERROR"
      Warn => "WARN"
    },
  )
}

///|
/// Split `messages` into `(severity, text)` pairs.
///
/// Returned rather than logged. This library has no logger -- node-slack-sdk's
/// equivalent writes to one and a caller who wants to fail the request on an
/// `[ERROR]` has to intercept logging to find out. An entry with no recognised
/// prefix is a `Warn`, which is the safer default for something Slack chose to
/// tell you about.
pub fn ResponseMetadata::diagnostics(self : Self) -> Array[(Severity, String)] {
  let out = []
  for m in self.messages {
    if m.has_prefix("[ERROR]") {
      out.push((Severity::Err, m[7:].trim().to_owned()))
    } else if m.has_prefix("[WARN]") {
      out.push((Severity::Warn, m[6:].trim().to_owned()))
    } else {
      out.push((Severity::Warn, m))
    }
  }
  out
}

///|
/// One decoded Slack response.
pub(all) struct ApiResponse {
  ok : Bool
  error : String?
  /// The top-level `warning` field, which is a different thing from
  /// `response_metadata.warnings` and is what java-slack-sdk surfaces.
  warning : String?
  /// On `missing_scope`: the scope this method wanted.
  needed : String?
  /// On `missing_scope`: the scopes the token had.
  provided : String?
  response_metadata : ResponseMetadata
  /// The whole decoded body.
  ///
  /// Every field above is a convenience view of this, and every typed accessor
  /// this library has not written yet is one `raw` lookup away. That is the
  /// deal that keeps 260-odd methods reachable without 260 response types.
  raw : Json
} derive(Eq, Debug)

///|
/// Read a field out of the raw body.
pub fn ApiResponse::get(self : Self, key : String) -> Json? {
  guard self.raw is Object(o) else { return None }
  o.get(key)
}

///|
/// Read a string field out of the raw body, `None` if absent or not a string.
pub fn ApiResponse::get_str(self : Self, key : String) -> String? {
  match self.get(key) {
    Some(String(s)) => Some(s)
    _ => None
  }
}

///|
/// Decode a response body, merging in the headers Slack answers with.
///
/// Never raises. A body that is not JSON becomes `{ok: false, error: }` -- node-slack-sdk's behaviour, and the right one: a proxy's HTML
/// error page or a captive portal's login screen is far more useful surfaced as
/// the error than swallowed into "malformed response".
pub fn ApiResponse::of_http(resp : HttpResponse) -> ApiResponse {
  let body = @json.parse(resp.body) catch {
    _ =>
      Json::object({
        "ok": Json::boolean(false),
        "error": Json::string(resp.body),
      })
  }
  let base = ApiResponse::of_json(body)
  let meta = base.response_metadata
  if resp.header("x-oauth-scopes") is Some(v) {
    meta.scopes.append(split_scopes(v))
  }
  if resp.header("x-accepted-oauth-scopes") is Some(v) {
    meta.accepted_scopes.append(split_scopes(v))
  }
  let retry_after = match resp.header("retry-after") {
    Some(v) => parse_retry_after(v)
    None => meta.retry_after
  }
  { ..base, response_metadata: { ..meta, retry_after, } }
}

///|
/// Decode an already-parsed body. Split out from `of_http` because the corpus
/// tests and the paginator both work from JSON with no HTTP around it.
pub fn ApiResponse::of_json(body : Json) -> ApiResponse {
  guard body is Object(o) else {
    // A bare array or string where an object was promised. Not JSON we can act
    // on, so it is reported the same way a non-JSON body is.
    return {
      ok: false,
      error: Some(body.stringify()),
      warning: None,
      needed: None,
      provided: None,
      response_metadata: ResponseMetadata::empty(),
      raw: body,
    }
  }
  {
    ok: match o.get("ok") {
      Some(True) => true
      _ => false
    },
    error: str_of(o, "error"),
    warning: str_of(o, "warning"),
    needed: str_of(o, "needed"),
    provided: str_of(o, "provided"),
    response_metadata: match o.get("response_metadata") {
      Some(Object(m)) => metadata_of(m)
      // node-slack-sdk synthesises `{}` when the field is absent, so that a
      // caller can always read `response_metadata.next_cursor` without a guard.
      _ => ResponseMetadata::empty()
    },
    raw: body,
  }
}

///|
let metadata_known_fields : Array[String] = [
  "next_cursor", "warnings", "messages",
]

///|
fn metadata_of(m : Map[String, Json]) -> ResponseMetadata {
  let extra : Map[String, Json] = Map([])
  for key, value in m {
    if !metadata_known_fields.contains(key) {
      extra[key] = value
    }
  }
  {
    next_cursor: str_of(m, "next_cursor"),
    warnings: str_array_of(m, "warnings"),
    messages: str_array_of(m, "messages"),
    scopes: [],
    accepted_scopes: [],
    retry_after: None,
    extra,
  }
}

///|
fn str_of(o : Map[String, Json], key : String) -> String? {
  match o.get(key) {
    Some(String(s)) => Some(s)
    _ => None
  }
}

///|
fn str_array_of(o : Map[String, Json], key : String) -> Array[String] {
  let out = []
  if o.get(key) is Some(Array(items)) {
    for item in items {
      if item is String(s) {
        out.push(s)
      }
    }
  }
  out
}

///|
/// Split an `x-oauth-scopes`-style header on commas, ignoring surrounding
/// space.
///
/// Exposed because the rule (`/\s*,\s*/` after a trim, per node-slack-sdk) is
/// exactly the kind of thing a caller re-derives slightly wrong and then
/// compares against a scope name with a stray space in it.
pub fn split_scopes(header : String) -> Array[String] {
  let out = []
  for part in header.trim().split(",") {
    let s = part.trim().to_owned()
    if !s.is_empty() {
      out.push(s)
    }
  }
  out
}

///|
/// Parse a `Retry-After` header holding a delay in seconds.
///
/// Prefix-greedy and lenient, matching node-slack-sdk's `Number.parseInt`:
/// `"120"` and `"120s"` are both 120, and anything that does not start with a
/// digit is `None` rather than zero. Zero would mean "retry immediately",
/// which is the worst possible reading of a header you failed to understand.
///
/// Slack always sends seconds here. The HTTP-date form the RFC also allows is
/// deliberately not supported: parsing it would need a clock, and this package
/// does not have one.
pub fn parse_retry_after(header : String) -> Int? {
  let s = header.trim()
  let mut i = 0
  let mut value = 0
  let mut digits = 0
  while i < s.length() {
    let c = s[i].to_int()
    if c < '0'.to_int() || c > '9'.to_int() {
      break
    }
    value = value * 10 + (c - '0'.to_int())
    digits += 1
    i += 1
  }
  if digits == 0 {
    None
  } else {
    Some(value)
  }
}