// The HTTP layer every API group goes through. It joins the configured
// scheme/address with a path and query pairs, attaches the ACL token header,
// drives the `Transport`, maps a non-2xx status to `Refused`, and lifts the
// blocking-query metadata out of the response headers.

///|
/// Join `scheme://address` with `path` and the URL-encoded `query` pairs.
fn Client::build_url(
  self : Client,
  path : String,
  query : Array[(String, String)],
) -> String {
  let mut url = self.config.scheme + "://" + self.config.address + path
  let mut first = true
  for pair in query {
    let (k, v) = pair
    let sep = if first { "?" } else { "&" }
    url = url + sep + url_encode(k) + "=" + url_encode(v)
    first = false
  }
  url
}

///|
/// The request headers: the ACL token, when one is configured or passed.
fn Client::auth_headers(self : Client, token : String?) -> Map[String, String] {
  let h : Map[String, String] = Map([])
  let t = match token {
    Some(s) => if s == "" { self.config.token } else { Some(s) }
    None => self.config.token
  }
  match t {
    Some(tok) => h["X-Consul-Token"] = tok
    None => ()
  }
  h
}

///|
/// The effective ACL token: the call's own, else the client's.
fn Client::resolve_token(self : Client, token : String?) -> String? {
  match token {
    Some(s) => if s == "" { self.config.token } else { Some(s) }
    None => self.config.token
  }
}

///|
/// Push `(key, value)` only when `value` is `Some` and non-empty.
fn push_q(out : Array[(String, String)], key : String, value : String?) -> Unit {
  match value {
    Some(s) => if s != "" { out.push((key, s)) }
    None => ()
  }
}

///|
/// The query pairs a read carries: datacenter, namespace, consistency,
/// blocking index/wait, `near`, `filter`, `node-meta`, then any extras.
fn Client::read_query(
  self : Client,
  opt : QueryOptions,
  extra : Array[(String, String)],
) -> Array[(String, String)] {
  let out : Array[(String, String)] = []
  push_q(
    out,
    "dc",
    match opt.datacenter {
      Some(s) => Some(s)
      None => self.config.datacenter
    },
  )
  push_q(
    out,
    "ns",
    match opt.ns {
      Some(s) => Some(s)
      None => self.config.ns
    },
  )
  match opt.consistency {
    Consistent => out.push(("consistent", ""))
    Stale => out.push(("stale", ""))
    Default => ()
  }
  match opt.wait_index {
    Some(i) => out.push(("index", i.to_string()))
    None => ()
  }
  push_q(out, "hash", opt.wait_hash)
  match opt.wait_time {
    Some(w) => out.push(("wait", w.to_string() + "ns"))
    None => ()
  }
  push_q(out, "near", opt.near)
  push_q(out, "filter", opt.filter)
  match opt.node_meta {
    Some(mm) =>
      for k, v in mm {
        out.push(("node-meta", k + ":" + v))
      }
    None => ()
  }
  for p in extra {
    out.push(p)
  }
  out
}

///|
/// The query pairs a write carries: datacenter, namespace, then any extras.
fn Client::write_query(
  self : Client,
  opt : WriteOptions,
  extra : Array[(String, String)],
) -> Array[(String, String)] {
  let out : Array[(String, String)] = []
  push_q(
    out,
    "dc",
    match opt.datacenter {
      Some(s) => Some(s)
      None => self.config.datacenter
    },
  )
  push_q(
    out,
    "ns",
    match opt.ns {
      Some(s) => Some(s)
      None => self.config.ns
    },
  )
  for p in extra {
    out.push(p)
  }
  out
}

///|
/// Send one request and parse its body as JSON (a `Null` for an empty body).
async fn Client::do_request(
  self : Client,
  verb : String,
  path : String,
  query : Array[(String, String)],
  body : Bytes,
  token : String?,
) -> (Response, Json) raise Refused {
  let url = self.build_url(path, query)
  let headers = self.auth_headers(token)
  let resp = self.transport.send(Request::new_full(verb, url, headers, body))
  let ok = resp.status >= 200 && resp.status < 300
  let json = if !ok || resp.body.length() == 0 {
    json_null()
  } else {
    let text = @utf8.decode_lossy(resp.body[:])
    @json.parse(text) catch {
      e => raise Refused::Json(e.to_string())
    }
  }
  (resp, json)
}

///|
/// Require a 2xx status, mapping 404 to `NotFound` and anything else to
/// `Status(status, body)`.
fn unwrap_response(resp : Response, json : Json) -> Json raise Refused {
  if resp.status >= 200 && resp.status < 300 {
    json
  } else if resp.status == 404 {
    raise Refused::NotFound
  } else {
    raise Refused::Status(resp.status, @utf8.decode_lossy(resp.body[:]))
  }
}

///|
/// The blocking-query metadata carried by a response's headers.
fn meta_of(resp : Response) -> QueryMeta {
  let idx = match resp.header("X-Consul-Index") {
    Some(s) => parse_u64(s)
    None => 0UL
  }
  let contact = match resp.header("X-Consul-Lastcontact") {
    Some(s) => parse_i64(s)
    None => 0L
  }
  let known = match resp.header("X-Consul-Knownleader") {
    Some(s) => s.to_lower() == "true"
    None => false
  }
  {
    last_index: idx,
    last_hash: resp.header("X-Consul-Contenthash"),
    last_contact: contact,
    known_leader: known,
    request_time: 0L,
  }
}

///|
/// A GET with `opt` and extra query pairs, returning the parsed body and the
/// query metadata.
pub async fn Client::do_get(
  self : Client,
  path : String,
  opt : QueryOptions,
  extra : Array[(String, String)],
) -> (Json, QueryMeta) raise Refused {
  let query = self.read_query(opt, extra)
  let (resp, json) = self.do_request(
    "GET",
    path,
    query,
    b"",
    self.resolve_token(opt.token),
  )
  let j = unwrap_response(resp, json)
  (j, meta_of(resp))
}

///|
/// A GET that takes a 404 for an answer: `None`, with the index the agent gave
/// it, which is what a blocking read of something absent waits from.
pub async fn Client::do_get_found(
  self : Client,
  path : String,
  opt : QueryOptions,
  extra : Array[(String, String)],
) -> (Json?, QueryMeta) raise Refused {
  let query = self.read_query(opt, extra)
  let (resp, json) = self.do_request(
    "GET",
    path,
    query,
    b"",
    self.resolve_token(opt.token),
  )
  if resp.status == 404 {
    return (None, meta_of(resp))
  }
  let j = unwrap_response(resp, json)
  (Some(j), meta_of(resp))
}

///|
/// A PUT with `opt` and extra query pairs, returning the parsed body and a
/// fresh write metadata.
pub async fn Client::do_put(
  self : Client,
  path : String,
  body : Bytes,
  opt : WriteOptions,
  extra : Array[(String, String)],
) -> (Json, WriteMeta) raise Refused {
  let query = self.write_query(opt, extra)
  let (resp, json) = self.do_request(
    "PUT",
    path,
    query,
    body,
    self.resolve_token(opt.token),
  )
  let j = unwrap_response(resp, json)
  (j, { request_time: 0L, })
}

///|
/// A DELETE with `opt` and extra query pairs, returning the parsed body and a
/// fresh write metadata.
pub async fn Client::do_delete(
  self : Client,
  path : String,
  opt : WriteOptions,
  extra : Array[(String, String)],
) -> (Json, WriteMeta) raise Refused {
  let query = self.write_query(opt, extra)
  let (resp, json) = self.do_request(
    "DELETE",
    path,
    query,
    b"",
    self.resolve_token(opt.token),
  )
  let j = unwrap_response(resp, json)
  (j, { request_time: 0L, })
}

///|
/// A PUT over a query-style call (e.g. `txn`): returns the parsed body and the
/// blocking-query metadata instead of a `WriteMeta`.
pub async fn Client::do_put_q(
  self : Client,
  path : String,
  body : Bytes,
  opt : WriteOptions,
  extra : Array[(String, String)],
) -> (Json, QueryMeta) raise Refused {
  let query = self.write_query(opt, extra)
  let (resp, json) = self.do_request(
    "PUT",
    path,
    query,
    body,
    self.resolve_token(opt.token),
  )
  let j = unwrap_response(resp, json)
  (j, meta_of(resp))
}

///|
/// A POST with `opt` and extra query pairs (used by the ACL login/logout
/// endpoints), returning the parsed body and a fresh write metadata.
pub async fn Client::do_post(
  self : Client,
  path : String,
  body : Bytes,
  opt : WriteOptions,
  extra : Array[(String, String)],
) -> (Json, WriteMeta) raise Refused {
  let query = self.write_query(opt, extra)
  let (resp, json) = self.do_request(
    "POST",
    path,
    query,
    body,
    self.resolve_token(opt.token),
  )
  let j = unwrap_response(resp, json)
  (j, { request_time: 0L, })
}