///|
#external
priv type JsHeaders

///|
extern "js" fn JsHeaders::new() -> JsHeaders =
  #| () => new Headers()

///|
extern "js" fn JsHeaders::append(
  headers : JsHeaders,
  name : String,
  value : String,
) =
  #| (headers, name, value) => headers.append(name, value)

///|
extern "js" fn JsHeaders::to_array(headers : JsHeaders) -> Array[Array[String]] =
  #| (headers) => Array.from(headers.entries())

///|
#external
priv type JsResponse

///|
extern "js" fn JsResponse::status(response : JsResponse) -> Int =
  #| (response) => response.status

///|
extern "js" fn JsResponse::status_text(response : JsResponse) -> String =
  #| (response) => response.statusText

///|
extern "js" fn JsResponse::headers(response : JsResponse) -> JsHeaders =
  #| (response) => response.headers

///|
extern "js" fn JsResponse::body(
  response : JsResponse,
) -> @js_async.JsReadableStream =
  #| (response) => response.body

///|
priv struct OngoingRequest {
  body_writer : @io.PipeWrite
  abort_controller : @js_async.AbortController
  response : @js_async.Promise[JsResponse]
}

///|
pub struct Client {
  priv host : String
  priv port : Int
  priv protocol : Protocol
  priv headers : Map[String, String]
  priv mut request : OngoingRequest?
  priv mut response_body : @js_async.ReadableStream?
}

///|
pub async fn Client::Client(
  uri : String,
  headers? : Map[String, String] = {},
  proxy? : Client,
  verify? : Bool = true,
) -> Client {
  Client::new(uri, headers~, proxy?, verify~)
}

///|
pub fn Client::close(self : Client) -> Unit {
  if self.request is Some(request) {
    request.body_writer.close()
    self.request = None
  }
  if self.response_body is Some(response_body) {
    response_body.close()
  }
}

///|
fn Client::connect(
  host : String,
  headers? : Map[String, String] = {},
  protocol? : Protocol = Https,
  port? : Int = protocol.default_port(),
  proxy? : Client,
) -> Client {
  ignore(proxy)
  { host, headers, protocol, port, request: None, response_body: None }
}

///|
/// Create a new HTTP client by connecting to a remote host.
/// Host should be specified via `protocol://host[:port]`,
/// where `protocol` is one of `http` or `https`.
///
/// `headers` can be used to specify persistent headers for the client,
/// i.e. all requests made from this client will share these headers.
/// The ownership of `headers` will be transferred to the new client,
/// so `headers` should not be used by the caller later.
/// The headers mentioned in
/// https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header
/// must not be set in `headers`.
///
/// The HTTP client make requests using native fetch API.
///
/// The `proxy` argument is not supported on JavaScript backend and has no effect.
///
/// `verify` is ignored on JS backend
#warnings("-unused_async")
pub async fn Client::new(
  uri : String,
  headers? : Map[String, String] = {},
  proxy? : Client,
  verify? : Bool = true,
) -> Client {
  ignore(proxy)
  ignore(verify)
  let (protocol, port, host, path) = resolve_url(uri)
  guard path is "/" else { raise InvalidFormat }
  Client::connect(host, protocol~, port~, headers~, proxy?)
}

///|
pub impl @io.Writer for Client with write_once(self, buf, offset~, len~) {
  guard self.request is Some(request)
  request.body_writer.write_once(buf, offset~, len~)
}

///|
#warnings("-unused_async")
pub async fn Client::flush(_ : Client) -> Unit {
  // no need to flush in JS backend
  ()
}

///|
pub impl @io.Reader for Client with _get_internal_buffer(self) {
  guard self.response_body is Some(stream)
  stream._get_internal_buffer()
}

///|
pub impl @io.Reader for Client with _direct_read(self, buf, offset~, max_len~) {
  guard self.response_body is Some(stream)
  stream._direct_read(buf, offset~, max_len~)
}

///|
pub async fn Client::end_request(self : Client) -> Response {
  guard self.request is Some(request)
  self.request = None
  request.body_writer.close()
  let js_response : JsResponse = request.response.wait(
    abort_controller=request.abort_controller,
  )
  self.response_body = Some(
    @js_async.ReadableStream::from_js(js_response.body()),
  )
  let headers = {}
  for entry in js_response.headers().to_array() {
    headers[entry[0].to_lower()] = entry[1]
  }
  {
    code: js_response.status(),
    reason: js_response.status_text(),
    headers,
    cookies: [],
  }
}

///|
extern "js" fn Client::request_ffi(
  uri : String,
  meth : String,
  headers~ : JsHeaders,
  body~ : @js_async.JsReadableStream,
  signal~ : @js_async.AbortSignal,
) -> @js_async.Promise[JsResponse] =
  #| (uri, method, headers, body, signal) => {
  #|   const fixed_body = (method === "GET" || method === "HEAD") ? null : body
  #|   return fetch(
  #|     uri,
  #|     {
  #|       body: fixed_body,
  #|       method: method,
  #|       headers: headers,
  #|       signal: signal,
  #|       duplex: 'half',
  #|     },
  #|   )
  #| }

///|
/// Send a HTTP request to the server.
/// Only the header of the request will be sent,
/// request body can be sent by using `Client` as a `@io.Writer`.
/// Once request body has been sent,
/// `end_request` must be called to complete the request and obtain response from the server.
///
/// After performing a request,
/// the next request MUST NOT be made before the request is completed via `end_request`.
///
/// In addition to headers in `Client::new`,
/// extra HTTP headers can be passed via `extra_headers`.
/// The headers mentioned in
/// https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header
/// must not be set in `extra_headers`.
#warnings("-unused_async")
pub async fn Client::request(
  self : Client,
  meth : RequestMethod,
  path : String,
  extra_headers? : Map[String, String] = {},
) -> Unit {
  guard self.request is None
  let protocol = match self.protocol {
    Http => "http://"
    Https => "https://"
  }
  let path = if path is ['/', ..] { path } else { "/\{path}" }
  let port = if self.port == self.protocol.default_port() {
    ""
  } else {
    ":\{self.port}"
  }
  let uri = "\{protocol}\{self.host}\{port}\{path}"
  let meth = match meth {
    Get => "GET"
    Head => "HEAD"
    Post => "POST"
    Put => "PUT"
    Delete => "DELETE"
    Connect => "CONNECT"
    Options => "OPTIONS"
    Trace => "TRACE"
    Patch => "PATCH"
  }
  let headers = JsHeaders::new()
  for k, v in self.headers {
    headers.append(k, v)
  }
  for k, v in extra_headers {
    headers.append(k, v)
  }
  let abort_controller = @js_async.AbortController::new()
  let (body, we_write) = @js_async.JsReadableStream::new_pipe()
  let response_promise = Client::request_ffi(
    uri,
    meth,
    headers~,
    body~,
    signal=abort_controller.signal(),
  )
  let request : OngoingRequest = {
    abort_controller,
    body_writer: we_write,
    response: response_promise,
  }
  self.request = Some(request)
}

///|
/// Perform a `GET` request to the server, see `Client::request` for more details.
pub async fn Client::get(
  self : Client,
  path : String,
  extra_headers? : Map[String, String] = {},
  body? : &@io.Data,
) -> Response {
  self.request(Get, path, extra_headers~)
  if body is Some(body) {
    self.write(body)
  }
  self.end_request()
}

///|
/// Perform a `PUT` request to the server, see `Client::request` for more details.
pub async fn Client::put(
  self : Client,
  path : String,
  body : &@io.Data,
  extra_headers? : Map[String, String] = {},
) -> Response {
  self..request(Put, path, extra_headers~)..write(body).end_request()
}

///|
/// Perform a `POST` request to the server, see `Client::request` for more details.
pub async fn Client::post(
  self : Client,
  path : String,
  body : &@io.Data,
  extra_headers? : Map[String, String] = {},
) -> Response {
  self..request(Post, path, extra_headers~)..write(body).end_request()
}