///|
pub struct RequestResult {
  status_code : Int
  data : Json
  headers : Map[String, String]
  cookies : Array[String]
}

///|
fn headers(fields : Map[String, Json]) -> Map[String, String] {
  let output : Map[String, String] = Map([])
  match fields.get("header") {
    Some(Object(values)) =>
      for name, value in values {
        output[name] = match value {
          String(value) => value
          value => value.stringify()
        }
      }
    _ => ()
  }
  output
}

///|
fn cookies(fields : Map[String, Json]) -> Array[String] {
  match fields.get("cookies") {
    Some(Array(values)) =>
      values.filter_map(value => {
        match value {
          String(value) => Some(value)
          _ => None
        }
      })
    _ => []
  }
}

///|
fn decode_request(value : Json) -> Result[RequestResult, HostError] {
  let capability = Request
  match object_result(capability, value) {
    Err(error) => Err(error)
    Ok(fields) =>
      match fields.get("statusCode") {
        Some(Number(status, ..)) =>
          match host_int32(status) {
            Some(status_code) =>
              Ok({
                status_code,
                data: fields.get("data").unwrap_or(Json::null()),
                headers: headers(fields),
                cookies: cookies(fields),
              })
            None =>
              Err(
                invalid_payload(
                  capability, "wx.request result.statusCode must be an integer from 0 to 2147483647",
                  value,
                ),
              )
          }
        Some(_) =>
          Err(
            invalid_payload(
              capability, "wx.request result.statusCode must be an integer from 0 to 2147483647",
              value,
            ),
          )
        None =>
          Err(
            invalid_payload(
              capability, "wx.request result.statusCode is missing", value,
            ),
          )
      }
  }
}

///|
pub(all) enum HttpMethod {
  Get
  Post
  Put
  Delete
  Head
  Options
} derive(Debug, Eq)

///|
pub(all) enum RequestBody {
  JsonBody(Json)
  FormBody(Array[(String, String)])
  TextBody(String)
}

///|
fn HttpMethod::wire(self : HttpMethod) -> String {
  match self {
    Get => "GET"
    Post => "POST"
    Put => "PUT"
    Delete => "DELETE"
    Head => "HEAD"
    Options => "OPTIONS"
  }
}

///|
fn http_lower(value : String) -> String {
  value.iter().map(c => c.to_ascii_lowercase().to_string()).to_array().join("")
}

///|
fn http_port_valid(port : StringView) -> Bool {
  if port.is_empty() {
    return false
  }
  let mut value = 0
  for c in port {
    if c < '0' || c > '9' {
      return false
    }
    value = value * 10 + c.to_int() - 48
    if value > 65535 {
      return false
    }
  }
  value > 0
}

///|
fn http_url_valid(url : String) -> Bool {
  if !url.has_prefix("https://") || url.contains("#") {
    return false
  }
  for c in url {
    if c <= ' ' || c == '\u{7f}' || c == '\\' {
      return false
    }
  }
  let authority = url[8:]
    .split("/")
    .next()
    .unwrap()
    .split("?")
    .next()
    .unwrap()
    .to_owned()
  if authority == "" || authority.contains("@") {
    return false
  }
  // Require a host; leave DNS resolution and TLS verification to wx.request.
  if authority.has_prefix(":") || authority.has_suffix(":") {
    return false
  }
  if !authority.has_prefix("[") {
    let parts = authority.split(":").to_array()
    if parts.length() > 2 {
      return false
    }
    if parts.length() == 2 {
      if !http_port_valid(parts[1]) {
        return false
      }
    }
    if authority.contains("[") || authority.contains("]") {
      return false
    }
  } else {
    guard authority.split_once("]") is Some((host, suffix)) else {
      return false
    }
    if !host.contains(":") || host.contains(":::") {
      return false
    }
    for c in host[1:] {
      if !((c >= '0' && c <= '9') ||
        (c >= 'a' && c <= 'f') ||
        (c >= 'A' && c <= 'F') ||
        c == ':' ||
        c == '.') {
        return false
      }
    }
    if suffix != "" && (!suffix.has_prefix(":") || !http_port_valid(suffix[1:])) {
      return false
    }
  }
  for c in authority {
    if !((c >= 'a' && c <= 'z') ||
      (c >= 'A' && c <= 'Z') ||
      (c >= '0' && c <= '9') ||
      "-.:[]".contains(c.to_string())) {
      return false
    }
  }
  true
}

///|
fn http_payload(
  url : String,
  verb : HttpMethod,
  query : Array[Query],
  headers : Map[String, String],
  body : RequestBody?,
  timeout_ms : Int?,
) -> Result[Json, String] {
  guard http_url_valid(url) else {
    return Err(
      "request requires an absolute HTTPS URL without credentials or fragment",
    )
  }
  if (verb == Get || verb == Head) && body is Some(_) {
    return Err("GET and HEAD requests cannot have a body")
  }
  if timeout_ms is Some(ms) && ms <= 0 {
    return Err("request timeout must be positive")
  }
  let normalized : Map[String, Json] = Map([])
  let names = headers.keys().to_array()
  names.sort()
  for name in names {
    let lower = http_lower(name)
    guard name != "" && lower != "referer" && !normalized.contains(lower) else {
      return Err("invalid, duplicate, or forbidden request header")
    }
    for c in name {
      guard (c >= 'a' && c <= 'z') ||
        (c >= 'A' && c <= 'Z') ||
        (c >= '0' && c <= '9') ||
        "!#$%&'*+-.^_`|~".contains(c.to_string()) else {
        return Err("invalid request header name")
      }
    }
    let value = headers[name]
    for c in value {
      if (c < ' ' && c != '\t') || c == '\u{7f}' {
        return Err("invalid request header value")
      }
    }
    normalized[lower] = Json::string(value)
  }
  let fields : Map[String, Json] = Map([])
  match body {
    Some(body) => {
      let (data, content_type) = match body {
        JsonBody(json) => (json.stringify(), "application/json")
        FormBody(pairs) =>
          (
            pairs
            .map(pair => percent_encode(pair.0) + "=" + percent_encode(pair.1))
            .join("&"),
            "application/x-www-form-urlencoded",
          )
        TextBody(text) => (text, "text/plain; charset=utf-8")
      }
      match normalized.get("content-type") {
        Some(String(value)) => {
          let media = http_lower(
            value.split(";").next().unwrap().trim().to_owned(),
          )
          let compatible = match body {
            JsonBody(_) =>
              media == "application/json" ||
              (
                media.has_prefix("application/") &&
                media.has_suffix("+json") &&
                media.length() > 17
              )
            FormBody(_) => media == "application/x-www-form-urlencoded"
            TextBody(_) => true
          }
          guard compatible else {
            return Err("request body conflicts with Content-Type")
          }
        }
        _ => normalized["content-type"] = Json::string(content_type)
      }
      fields["data"] = Json::string(data)
    }
    None => ()
  }
  let suffix = query
    .map(q => percent_encode(q.name) + "=" + percent_encode(q.value))
    .join("&")
  let separator = if url.contains("?") {
    if url.has_suffix("?") || url.has_suffix("&") {
      ""
    } else {
      "&"
    }
  } else {
    "?"
  }
  fields["url"] = Json::string(
    if suffix == "" {
      url
    } else {
      url + separator + suffix
    },
  )
  fields["method"] = Json::string(verb.wire())
  fields["header"] = Json::object(normalized)
  if timeout_ms is Some(ms) {
    fields["timeout"] = ms.to_json()
  }
  Ok(Json::object(fields))
}

///|
/// Encodes and snapshots arguments now; delivers validation errors only when run.
/// HTTP error status codes remain successful transport results.
pub fn request(
  url : String,
  resolve : Emit[Result[RequestResult, HostError]],
  http_method? : HttpMethod = Get,
  query? : Array[Query] = [],
  headers? : Map[String, String] = Map([]),
  body? : RequestBody,
  timeout_ms? : Int,
) -> Cmd {
  match http_payload(url, http_method, query, headers, body, timeout_ms) {
    Ok(payload) => host_effect(Request, payload, decode_request, resolve)
    Err(message) =>
      Cmd(
        @val.CmdMessage(() => {
          resolve(Err(invalid_payload(Request, message, Json::null()))).0
        }),
      )
  }
}