///|
fn is_unreserved_byte(byte : Byte) -> Bool {
  let n = byte.to_int()
  (n >= 0x41 && n <= 0x5A) ||
  (n >= 0x61 && n <= 0x7A) ||
  (n >= 0x30 && n <= 0x39) ||
  n == 0x2D ||
  n == 0x2E ||
  n == 0x5F ||
  n == 0x7E
}

///|
pub fn percent_encode(value : String) -> String {
  let out = StringBuilder::new(size_hint=value.length())
  for byte in @utf8.encode(value) {
    if is_unreserved_byte(byte) {
      out.write_char(byte.to_char())
    } else {
      out.write_char('%')
      out.write_string(byte.to_hex().to_upper())
    }
  }
  out.to_string()
}

///|
pub fn encode_query(query : ArrayView[(String, String)]) -> String {
  let out = StringBuilder::new()
  let mut first = true
  for pair in query {
    let (key, value) = pair
    if first {
      first = false
    } else {
      out.write_char('&')
    }
    out.write_string(percent_encode(key))
    out.write_char('=')
    out.write_string(percent_encode(value))
  }
  out.to_string()
}

///|
pub fn append_query(
  path : String,
  query : ArrayView[(String, String)],
) -> String {
  if query.length() == 0 {
    path
  } else {
    path + "?" + encode_query(query)
  }
}

///|
pub fn encode_ndjson(items : ArrayView[Json]) -> String {
  let out = StringBuilder::new()
  for item in items {
    out.write_string(item.stringify())
    out.write_char('\n')
  }
  out.to_string()
}

///|
fn query_json_value_to_string(value : Json) -> String {
  match value {
    String(text) => text
    True => "true"
    False => "false"
    Array(items) => {
      let out = StringBuilder::new()
      let mut first = true
      for item in items {
        if first {
          first = false
        } else {
          out.write_char(',')
        }
        out.write_string(query_json_value_to_string(item))
      }
      out.to_string()
    }
    Null => ""
    _ => value.stringify()
  }
}

///|
fn push_query_json(
  pairs : Array[(String, String)],
  name : String,
  value : Json,
) -> Unit {
  if value is Null {
    return
  }
  pairs.push((name, query_json_value_to_string(value)))
}

///|
fn has_required_params(
  required : ArrayView[String],
  params : Map[String, String],
) -> Bool {
  required.all(fn(key) { params.get(key) is Some(_) })
}

///|
pub fn render_path_template(
  template : String,
  params : Map[String, String],
) -> String raise EsError {
  let mut rendered = template
  for key, value in params {
    rendered = rendered.replace_all(old="{\{key}}", new=percent_encode(value))
  }
  if rendered.contains("{") || rendered.contains("}") {
    raise Request("unbound path parameter in `\{template}`")
  }
  rendered
}

///|
pub fn select_path(
  variants : ArrayView[PathVariant],
  params : Map[String, String],
) -> String raise EsError {
  let mut selected : PathVariant? = None
  for variant in variants {
    if selected is None && has_required_params(variant.required, params) {
      selected = Some(variant)
    }
  }
  match selected {
    Some(variant) => render_path_template(variant.template, params)
    None => raise Request("no matching path variant")
  }
}