//
// Copyright (c) 2008, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
//   24 Dec 08  Brian Frank       Almost Christmas!
//   15 Jan 13  Nicholas Harker   Added SSL Support
//   21 Jan 13  Nicholas Harker   Added Proxy Exclusion Support
//   03 Aug 15  Matthew Giannini  RFC6265
//

using inet

**
** The 'WebClient' class is used to manage client side HTTP requests
** and responses.  The basic lifecycle of WebClient:
**   1. configure request fields such as 'reqUri', 'reqMethod', and 'reqHeaders'
**   2. send request headers via 'writeReq'
**   3. optionally write request body via 'reqOut'
**   4. read response status and headers via 'readRes'
**   5. process response fields such as 'resCode' and 'resHeaders'
**   6. optionally read response body via 'resIn'
**
** Using the low level methods 'writeReq' and 'readRes' enables HTTP
** pipelining (multiple requests and responses on the same TCP socket
** connection).  There are also a series of convenience methods which
** make common cases easier.
**
** See [pod doc]`pod-doc#webClient` and [examples]`examples::web-client`.
**
class WebClient
{

//////////////////////////////////////////////////////////////////////////
// Constructor
//////////////////////////////////////////////////////////////////////////

  **
  ** Construct with optional request URI.
  **
  new make(Uri? reqUri := null)
  {
    if (reqUri != null) this.reqUri = reqUri

    // default headers
    reqHeaders["Accept-Encoding"] = "gzip"
  }

  ** The `inet::SocketConfig` to use for creating sockets
  SocketConfig socketConfig := SocketConfig.cur

//////////////////////////////////////////////////////////////////////////
// Request
//////////////////////////////////////////////////////////////////////////

  **
  ** The absolute URI of request.
  **
  Uri reqUri := ``
  {
    set
    {
      if (!it.isAbs) throw ArgErr("Request URI not absolute: `$it`")
      if (it.scheme != "http" && it.scheme != "https") throw ArgErr("Request URI is not http or https: `$it`")
      &reqUri = it
    }
  }

  **
  ** The HTTP method for the request.  Defaults to "GET".
  **
  Str reqMethod := "GET" { set { &reqMethod = it.upper } }

  **
  ** HTTP version to use for request must be 1.0 or 1.1.
  ** Default is 1.1.
  **
  Version reqVersion := ver11

  **
  ** The HTTP headers to use for the next request.  This map uses
  ** case insensitive keys.  The "Host" header is implicitly defined
  ** by 'reqUri' and must not be defined in this map.
  **
  Str:Str reqHeaders := CaseInsensitiveMap<Str,Str>() // { caseInsensitive = true }

  **
  ** Get the output stream used to write the request body.  This
  ** stream is only available if the request headers included a
  ** "Content-Type" header.  If an explicit "Content-Length" was
  ** specified then this is a fixed length output stream, otherwise
  ** the request is automatically configured to use a chunked
  ** transfer encoding.  This stream should be closed once the
  ** content has been fully written.
  **
  OutStream reqOut()
  {
    if (reqOutStream == null) throw IOErr("No output stream for request")
    return reqOutStream
  }

//////////////////////////////////////////////////////////////////////////
// Response
//////////////////////////////////////////////////////////////////////////

  **
  ** HTTP version returned by response.
  **
  Version resVersion := ver11

  **
  ** HTTP status code returned by response.
  **
  Int resCode

  **
  ** HTTP status reason phrase returned by response.
  **
  Str resPhrase := ""

  **
  ** HTTP headers returned by response.
  **
  Str:Str resHeaders := noHeaders

  **
  ** Get a response header.  If not found and checked
  ** is false then return true, otherwise throw Err.
  **
  Str? resHeader(Str key, Bool checked := true)
  {
    val := resHeaders[key]
    if (val != null || !checked) return val
    throw Err("Missing HTTP header '$key'")
  }

  **
  ** Input stream to read response content.  The input stream
  ** will correctly handle end of stream when the content has been
  ** fully read.  If the "Content-Length" header was specified the
  ** end of stream is based on the fixed number of bytes.  If the
  ** "Transfer-Encoding" header defines a chunked encoding, then
  ** chunks are automatically handled.  If the response has no
  ** content body, then throw IOErr.
  **
  ** The response input stream is automatically configured with
  ** the correct character encoding if one is specified in the
  ** "Content-Type" response header.
  **
  ** Also see convenience methods: `resStr` and `resBuf`.
  **
  InStream resIn()
  {
    if (resInStream == null) throw IOErr("No input stream for response $resCode")
    return resInStream
  }

  **
  ** Return the entire response back as an in-memory string.
  ** Convenience for 'resIn.readAllStr'.
  **
  Str resStr()
  {
    return resIn.readAllStr
  }

  **
  ** Return the entire response back as an in-memory byte buffer.
  ** Convenience for 'resIn.readAllBuf'.
  **
  Buf resBuf()
  {
    return resIn.readAllBuf
  }

//////////////////////////////////////////////////////////////////////////
// Cookies
//////////////////////////////////////////////////////////////////////////

  **
  ** Cookies to pass for "Cookie" request header.  If set to an empty
  ** list then the "Cookie" request header is removed.  After a request
  ** has been completed if the "Set-Cookie" response header specified
  ** one or more cookies then this field is automatically updated with
  ** the server specified cookies.
  **
  Cookie[] cookies := List.defVal
  {
    set
    {
      // save field
      &cookies = it

      // set reqHeaders (RFC 6265 ยง 4.2.1)
      if (it.isEmpty) { reqHeaders.remove("Cookie"); return }
      reqHeaders["Cookie"] =
        it.size == 1 ?
        it.first.toNameValStr :
        it.join("; ") |c| { c.toNameValStr }
    }
  }

//////////////////////////////////////////////////////////////////////////
// Networking
//////////////////////////////////////////////////////////////////////////

  @Deprecated { msg = "Use WebClient.socketConfig to configure sockets" }
  once SocketOptions socketOptions()
  {
    TcpSocket().options
  }

  **
  ** When set to true a 3xx response with a Location header
  ** will automatically update the `reqUri` field and retry the
  ** request using the alternate URI.  Redirects are not followed
  ** if the request has a content body.
  **
  Bool followRedirects := true

//////////////////////////////////////////////////////////////////////////
// Proxy Support
//////////////////////////////////////////////////////////////////////////

  **
  ** If non-null, then all requests are routed through this
  ** proxy address (host and port).  Default is configured
  ** in "etc/web/config.props" with the key "proxy".  Proxy
  ** exceptions can be configured with the "proxy.exceptions"
  ** config key as comma separated list of Regex globs.
  **
  Uri? proxy := proxyDef

  private static Uri? proxyDef()
  {
    try
      return Uri.toUri(WebClient#.pod.config("proxy"))
    catch (Err e)
      e.trace
    return null
  }

  private Bool isProxy(Uri uri)
  {
    proxy != null && !proxyExceptions.any |re| { re.matches(uri.host.toStr) }
  }

  private once Regex[] proxyExceptions()
  {
    try
      return WebClient#.pod.config("proxy.exceptions")?.split(',')?.map |tok->Regex| { Regex.glob(tok) } ?: Regex[,]
    catch (Err e)
      e.trace
    return Regex[,]
  }

//////////////////////////////////////////////////////////////////////////
// Authentication
//////////////////////////////////////////////////////////////////////////

  **
  ** Authenticate request using HTTP Basic with given username
  ** and password.
  **
  This authBasic(Str username, Str password)
  {
    enc := "${username}:${password}".toBuf.toBase64
    reqHeaders["Authorization"] = "Basic ${enc}"
    return this
  }

//////////////////////////////////////////////////////////////////////////
// Get
//////////////////////////////////////////////////////////////////////////

  **
  ** Make a GET request and return the response content as
  ** an in-memory string.  The web client is automatically closed.
  ** Throw IOErr is response is not 200.
  **
  Str getStr()
  {
    try
      return getIn.readAllStr
    finally
      close
  }

  **
  ** Make a GET request and return the response content as
  ** an in-memory byte buffer.  The web client is automatically closed.
  ** Throw IOErr is response is not 200.
  **
  Buf getBuf()
  {
    try
      return getIn.readAllBuf
    finally
      close
  }

  **
  ** Make a GET request and return the input stream to the
  ** response or throw IOErr if response is not 200.  It is the
  ** caller's responsibility to close this web client.
  **
  InStream getIn()
  {
    reqMethod = "GET"
    writeReq
    readRes
    if (resCode != 200) throw IOErr("Bad HTTP response $resCode $resPhrase")
    return resIn
  }

//////////////////////////////////////////////////////////////////////////
// Post/Patch
//////////////////////////////////////////////////////////////////////////

  **
  ** Convenience for 'writeForm("POST", form).readRes'
  **
  This postForm([Str:Str] form)
  {
    writeForm("POST", form).readRes
  }

  **
  ** Convenience for 'writeStr("POST", content).readRes'
  **
  This postStr(Str content)
  {
    writeStr("POST", content).readRes
  }

  **
  ** Convenience for 'writeFile("POST", file).readRes'
  **
  This postFile(File file)
  {
    writeFile("POST", file).readRes
  }

  **
  ** Make a request with the given HTTP method to the URI with the given form data.
  ** Set the Content-Type to application/x-www-form-urlencoded.
  ** This method does not support the ["Expect" header]`pod-doc#expectContinue` (it
  ** writes all form data before reading response). Should primarily be used for POST
  ** and PATCH requests.
  **
  This writeForm(Str method, [Str:Str] form)
  {
    if (reqHeaders["Expect"] != null) throw UnsupportedErr("'Expect' header")
    body := Uri.encodeQuery(form)
    reqMethod = method
    reqHeaders["Content-Type"] = "application/x-www-form-urlencoded"
    reqHeaders["Content-Length"] = body.size.toStr // encoded form is ASCII
    writeReq
    reqOut.print(body).close
    return this
  }

  **
  ** Make a request with the given HTTP method to the URI using UTF-8 encoding of given
  ** string.  If Content-Type is not already set, then set it
  ** to "text/plain; charset=utf-8".  This method does not support the
  ** ["Expect" header]`pod-doc#expectContinue` (it writes full string
  ** before reading response). Should primarily be used for "POST" and "PATCH"
  ** requests.
  **
  This writeStr(Str method, Str content)
  {
    if (reqHeaders["Expect"] != null) throw UnsupportedErr("'Expect' header")
    body := Buf().print(content).flip
    reqMethod = method
    ct := reqHeaders["Content-Type"]
    if (ct == null)
      reqHeaders["Content-Type"] = "text/plain; charset=utf-8"
    reqHeaders["Content-Length"] = body.size.toStr
    writeReq
    reqOut.writeBuf(body).close
    return this
  }

  **
  ** Write a file using the given HTTP method to the URI.  If Content-Type header is not already
  ** set, then it is set from the file extension's MIME type. This method does
  ** not support the ["Expect" header]`pod-doc#expectContinue` (it
  ** writes full file before reading response). Should primarily be used for "POST" and
  ** "PATCH" requests.
  **
  This writeFile(Str method, File file)
  {
    if (reqHeaders["Expect"] != null) throw UnsupportedErr("'Expect' header")
    reqMethod = method
    ct := reqHeaders["Content-Type"]
    if (ct == null)
      reqHeaders["Content-Type"] = file.mimeType?.toStr ?: "application/octet-stream"
    if (file.size > 0)
      reqHeaders["Content-Length"] = file.size.toStr
    writeReq
    file.in.pipe(reqOut, file.size)
    reqOut.close
    return this
  }

//////////////////////////////////////////////////////////////////////////
// Service
//////////////////////////////////////////////////////////////////////////

  **
  ** Write the request line and request headers.  Once this method
  ** completes the request body may be written via `reqOut`, or the
  ** response may be immediately read via `readRes`.  Throw IOErr
  ** if there is a network or protocol error.  Return this.
  **
  This writeReq()
  {
    // sanity checks
    if (!reqUri.isAbs || reqUri.scheme == null || reqUri.host == null) throw Err("reqUri is not absolute: `$reqUri`")
    if (reqHeaders isnot CaseInsensitiveMap) throw Err("reqHeaders must be case insensitive")
    if (reqHeaders.containsKey("Host")) throw Err("reqHeaders must not define 'Host'")

    // connect to the host:port if we aren't already connected
    isHttps := reqUri.scheme == "https"
    defPort := isHttps ? 443 : 80
    usingProxy := isProxy(reqUri)
    isTunnel := usingProxy && isHttps
    if (!isConnected)
    {
      if (isTunnel) socket = openHttpsTunnel
      else
      {
        // make https or http socket
        socket = TcpSocket(socketConfig)
        if (isHttps) socket = socket.upgradeTls

        // connect to proxy or directly to request host
        connUri := usingProxy ? proxy : reqUri
        socket.connect(IpAddr(connUri.host), connUri.port ?: defPort)
      }
    }

    // request uri is absolute if proxy, relative otherwise
    reqPath := (usingProxy ? reqUri : reqUri.relToAuth).encode

    // host authority header
    host := reqUri.host
    if (reqUri.port != null && reqUri.port != defPort) host += ":$reqUri.port"

    // figure out if/how we are streaming out content body
    out := socket.out
    reqOutStream = WebUtil.makeContentOutStream(reqHeaders, out)

    // send request
    out.print(reqMethod).print(" ").print(reqPath)
       .print(" HTTP/").print(reqVersion).print("\r\n")
    out.print("Host: ").print(host).print("\r\n")
    WebUtil.writeHeaders(out, reqHeaders)
    out.print("\r\n")
    out.flush

    return this
  }

  ** Open an https tunnel through our proxy server.
  private TcpSocket openHttpsTunnel()
  {
    socket = TcpSocket(socketConfig)

    // make CONNECT request to proxy server on http port
    socket.connect(IpAddr(proxy.host), proxy.port ?: 80)
    out := socket.out
    out.print("CONNECT ${reqUri.host}:${reqUri.port ?: 443} HTTP/${reqVersion}").print("\r\n")
       .print("Host: ${reqUri.host}:${reqUri.port ?: 443}").print("\r\n")
       .print("\r\n")
    out.flush

    // expect a 200 response code
    readRes
    if (resCode != 200) throw IOErr("Could not open tunnel: bad HTTP response $resCode $resPhrase")

    // upgrade to SSL socket now
    return socket.upgradeTls
  }

  **
  ** Read the response status line and response headers.  This method
  ** may be called after the request has been written via `writeReq`
  ** and `reqOut`.  Once this method completes the response status and
  ** headers are available.  If there is a response body, it is available
  ** for reading via `resIn`.  Throw IOErr if there is a network or
  ** protocol error.  Return this.
  **
  This readRes()
  {
    // read response
    if (!isConnected) throw IOErr("Not connected")
    in := socket.in
    res := ""
    try
    {
      // parse status-line
      res = WebUtil.readLine(in)
      if (res.startsWith("HTTP/1.1")) resVersion = ver11
      else if (res.startsWith("HTTP/1.0")) resVersion = ver10
      else throw Err("Not HTTP")
      resCode = res[9..11].toInt
      resPhrase = res[13..-1]

      // parse response headers
      setCookies := Cookie[,]
      resHeaders = WebUtil.doParseHeaders(in, setCookies)
      if (!setCookies.isEmpty) cookies = setCookies
    }
    catch (Err e) throw IOErr("Invalid HTTP response: $res", e)

    // check for redirect
    if (checkFollowRedirect) return this

    // if there is response content, then create wrap the raw socket
    // input stream with the appropiate chunked input stream
    resInStream = WebUtil.makeContentInStream(resHeaders, socket.in)

    return this
  }

  **
  ** If we have a 3xx statu code with a location header,
  ** then check for an automate redirect.
  **
  private Bool checkFollowRedirect()
  {
    // only redirect on 3xx status code
    if (resCode / 100 != 3) return false

    // must be explicitly configured for redirects
    if (!followRedirects) return false

    // only redirect when there is no request content
    if (reqOutStream != null) return false

    // only redirect if a location header was given
    loc := resHeaders["Location"]
    if (loc == null) return false

    // redirect
    try
    {
      ++numRedirects
      close
      newUri := Uri.decode(loc)
      if (!newUri.isAbs) newUri = reqUri + newUri
      if (reqUri == newUri && numRedirects > 20) throw Err("Cyclical redirect: $newUri")
      reqUri = newUri
      writeReq
      readRes
      return true
    }
    finally
    {
      --numRedirects
    }
  }

  **
  ** Return if this web client is currently connected to the remote host.
  **
  Bool isConnected()
  {
    return socket != null && socket.isConnected
  }

  **
  ** Close the HTTP request and the underlying socket.  Return this.
  **
  This close()
  {
    if (socket != null) socket.close
    socket = null
    return this
  }

//////////////////////////////////////////////////////////////////////////
// Fields
//////////////////////////////////////////////////////////////////////////

  private static const Version ver10 := Version("1.0")
  private static const Version ver11 := Version("1.1")
  private static const [Str:Str] noHeaders := Str:Str[:]

  private InStream? resInStream
  private OutStream? reqOutStream
  internal TcpSocket? socket
  private Int numRedirects := 0

}