///|
fn hex(byte : Byte) -> Int32 {
  match byte {
    b'0'..<=b'9' => byte.to_int() - b'0'.to_int()
    b'a'..<=b'f' => byte.to_int() - b'a'.to_int() + 10
    b'A'..<=b'F' => byte.to_int() - b'A'.to_int() + 10
    _ => -1
  }
}

///|
fn request_path(target : String) -> String? {
  guard target.has_prefix("/") else { return None }
  let raw = @utf8.encode(target.split("?").next().unwrap())
  let bytes : Array[Byte] = []
  let mut i = 0
  while i < raw.length() {
    if raw[i] == b'%' {
      guard i + 2 < raw.length() else { return None }
      let high = hex(raw[i + 1])
      let low = hex(raw[i + 2])
      guard high >= 0 && low >= 0 else { return None }
      bytes.push((high * 16 + low).to_byte())
      i += 3
    } else {
      bytes.push(raw[i])
      i += 1
    }
  }
  let path = @utf8.decode(Bytes::from_array(bytes), ignore_bom=false) catch {
    _ => return None
  }
  for c in path {
    if c < ' ' || c == '\u{7f}' || c == '\\' || c == ':' {
      return None
    }
  }
  let parts : Array[String] = []
  for part in path.split("/") {
    if part == "" {
      continue
    }
    // Windows aliases and device names must not bypass regular-file checks.
    let stem = part.split(".").next().unwrap().to_lower().to_owned()
    if part == "." ||
      part == ".." ||
      part.has_suffix(".") ||
      part.has_suffix(" ") ||
      [
        "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6",
        "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7",
        "lpt8", "lpt9",
      ].contains(stem) {
      return None
    }
    parts.push(part.to_owned())
  }
  Some(parts.join("/"))
}

///|
fn inside_root(root : String, path : String) -> Bool {
  let separator = if @async.platform is Windows { "\\" } else { "/" }
  let prefix = if root.has_suffix(separator) { root } else { root + separator }
  path == root || path.has_prefix(prefix)
}

///|
fn mime_type(path : String) -> String {
  match path.split(".").last().unwrap_or("").to_lower() {
    "html" | "htm" => "text/html"
    "css" => "text/css"
    "js" | "mjs" => "text/javascript"
    "json" | "map" => "application/json"
    "txt" | "md" | "log" => "text/plain"
    "xml" => "application/xml"
    "wasm" => "application/wasm"
    "svg" => "image/svg+xml"
    "png" => "image/png"
    "jpg" | "jpeg" => "image/jpeg"
    "gif" => "image/gif"
    "webp" => "image/webp"
    "avif" => "image/avif"
    "ico" => "image/vnd.microsoft.icon"
    "pdf" => "application/pdf"
    "zip" => "application/zip"
    "gz" => "application/gzip"
    "woff" => "font/woff"
    "woff2" => "font/woff2"
    "ttf" => "font/ttf"
    "otf" => "font/otf"
    "mp3" => "audio/mpeg"
    "ogg" => "audio/ogg"
    "wav" => "audio/wav"
    "mp4" => "video/mp4"
    "webm" => "video/webm"
    _ => "application/octet-stream"
  }
}

///|
async fn empty_response(
  connection : @http.ServerConnection,
  code : Int32,
  reason : String,
) -> Unit {
  connection.send_response(code, reason, extra_headers={ "Content-Length": "0" })
}

///|
async fn serve_file(
  root : String,
  request : @http.Request,
  connection : @http.ServerConnection,
) -> Unit {
  guard request.meth is (Get | Head) else {
    connection.send_response(405, "Method Not Allowed", extra_headers={
      "Content-Length": "0",
      "Allow": "GET, HEAD",
    })
    return
  }
  guard request_path(request.path) is Some(relative) else {
    empty_response(connection, 400, "Bad Request")
    return
  }
  // Resolve and check the actual target, including index.html symlinks.
  // Path-based public APIs cannot close concurrent replacement races.
  let opened = try {
    let mut path = @fs.realpath(root + "/" + relative)
    guard inside_root(root, path) else { fail("outside root") }
    let index = @fs.kind(path) is Directory
    if index {
      path = @fs.realpath(path + "/index.html")
      guard inside_root(root, path) else { fail("outside root") }
    }
    guard @fs.kind(path) is Regular else { fail("not a regular file") }
    Some((@fs.open(path, mode=ReadOnly), index))
  } catch {
    _ => None
  }
  guard opened is Some((file, index)) else {
    empty_response(connection, 404, "Not Found")
    return
  }
  defer file.close()
  let raw_path = request.path.split("?").next().unwrap()
  if index && !raw_path.has_suffix("/") {
    connection.send_response(301, "Moved Permanently", extra_headers={
      "Content-Length": "0",
      "Location": "/" +
      raw_path.trim_start(chars="/").to_owned() +
      "/" +
      request.path[raw_path.length():].to_owned(),
    })
    return
  }
  guard file.kind() is Regular else {
    empty_response(connection, 404, "Not Found")
    return
  }
  let size = file.size()
  let mime = mime_type(if index { "index.html" } else { relative })
  connection.send_response(200, "OK", extra_headers={
    "Content-Length": size.to_string(),
    "Content-Type": mime,
    "X-Content-Type-Options": "nosniff",
  })
  if request.meth is Head {
    return
  }
  let buffer : FixedArray[Byte] = FixedArray::make(65536, 0)
  let bytes = buffer.unsafe_reinterpret_as_bytes()
  let mut remaining = size
  while remaining > 0 {
    let n = file.read(
      buffer,
      max_len=remaining.min(buffer.length().to_int64()).to_int(),
    )
    guard n > 0 else { fail("file shortened during response") }
    connection.write(bytes[:n])
    remaining -= n.to_int64()
  }
}