#
#
#            Nim's Runtime Library
#        (c) Copyright 2013 Andreas Rumpf, Dominik Picheta
#
#    See the file "copying.txt", included in this
#    distribution, for details about the copyright.
#

## This module implements helper procs for SCGI applications. Example:
##
## .. code-block:: Nim
##
##    import strtabs, sockets, scgi
##
##    var counter = 0
##    proc handleRequest(client: Socket, input: string,
##                       headers: StringTableRef): bool {.procvar.} =
##      inc(counter)
##      client.writeStatusOkTextContent()
##      client.send("Hello for the $#th time." % $counter & "\c\L")
##      return false # do not stop processing
##
##    run(handleRequest)
##
## **Warning:** The API of this module is unstable, and therefore is subject
## to change.
##
## **Warning:** This module only supports the old asynchronous interface.
## You may wish to use the `asynchttpserver <asynchttpserver.html>`_
## instead for web applications.

include "system/inclrtl"

import sockets, strutils, os, strtabs, asyncio

type
  ScgiError* = object of IOError ## the exception that is raised, if a SCGI error occurs

proc raiseScgiError*(msg: string) {.noreturn.} =
  ## raises an ScgiError exception with message `msg`.
  var e: ref ScgiError
  new(e)
  e.msg = msg
  raise e

proc parseWord(inp: string, outp: var string, start: int): int =
  result = start
  while inp[result] != '\0': inc(result)
  outp = substr(inp, start, result-1)

proc parseHeaders(s: string, L: int): StringTableRef =
  result = newStringTable()
  var i = 0
  while i < L:
    var key, val: string
    i = parseWord(s, key, i)+1
    i = parseWord(s, val, i)+1
    result[key] = val
  if s[i] == ',': inc(i)
  else: raiseScgiError("',' after netstring expected")

proc recvChar(s: Socket): char =
  var c: char
  if recv(s, addr(c), sizeof(c)) == sizeof(c):
    result = c

type
  ScgiState* = object of RootObj ## SCGI state object
    server: Socket
    bufLen: int
    client*: Socket ## the client socket to send data to
    headers*: StringTableRef ## the parsed headers
    input*: string  ## the input buffer


  # Async

  ClientMode = enum
    ClientReadChar, ClientReadHeaders, ClientReadContent

  AsyncClient = ref object
    c: AsyncSocket
    mode: ClientMode
    dataLen: int
    headers: StringTableRef ## the parsed headers
    input: string  ## the input buffer

  AsyncScgiStateObj = object
    handleRequest: proc (client: AsyncSocket,
                         input: string,
                         headers: StringTableRef) {.closure, gcsafe.}
    asyncServer: AsyncSocket
    disp: Dispatcher
  AsyncScgiState* = ref AsyncScgiStateObj

{.deprecated: [EScgi: ScgiError, TScgiState: ScgiState,
   PAsyncScgiState: AsyncScgiState, scgiError: raiseScgiError].}

proc recvBuffer(s: var ScgiState, L: int) =
  if L > s.bufLen:
    s.bufLen = L
    s.input = newString(L)
  if L > 0 and recv(s.client, cstring(s.input), L) != L:
    raiseScgiError("could not read all data")
  setLen(s.input, L)

proc open*(s: var ScgiState, port = Port(4000), address = "127.0.0.1",
           reuseAddr = false) =
  ## opens a connection.
  s.bufLen = 4000
  s.input = newString(s.bufLen) # will be reused

  s.server = socket()
  if s.server == invalidSocket: raiseOSError(osLastError())
  new(s.client) # Initialise s.client for `next`
  if s.server == invalidSocket: raiseScgiError("could not open socket")
  #s.server.connect(connectionName, port)
  if reuseAddr:
    s.server.setSockOpt(OptReuseAddr, true)
  bindAddr(s.server, port, address)
  listen(s.server)

proc close*(s: var ScgiState) =
  ## closes the connection.
  s.server.close()

proc next*(s: var ScgiState, timeout: int = -1): bool =
  ## proceed to the first/next request. Waits ``timeout`` milliseconds for a
  ## request, if ``timeout`` is `-1` then this function will never time out.
  ## Returns `true` if a new request has been processed.
  var rsocks = @[s.server]
  if select(rsocks, timeout) == 1 and rsocks.len == 1:
    new(s.client)
    accept(s.server, s.client)
    var L = 0
    while true:
      var d = s.client.recvChar()
      if d == '\0':
        s.client.close()
        return false
      if d notin strutils.Digits:
        if d != ':': raiseScgiError("':' after length expected")
        break
      L = L * 10 + ord(d) - ord('0')
    recvBuffer(s, L+1)
    s.headers = parseHeaders(s.input, L)
    if s.headers.getOrDefault("SCGI") != "1": raiseScgiError("SCGI Version 1 expected")
    L = parseInt(s.headers.getOrDefault("CONTENT_LENGTH"))
    recvBuffer(s, L)
    return true

proc writeStatusOkTextContent*(c: Socket, contentType = "text/html") =
  ## sends the following string to the socket `c`::
  ##
  ##   Status: 200 OK\r\LContent-Type: text/html\r\L\r\L
  ##
  ## You should send this before sending your HTML page, for example.
  c.send("Status: 200 OK\r\L" &
         "Content-Type: $1\r\L\r\L" % contentType)

proc run*(handleRequest: proc (client: Socket, input: string,
                               headers: StringTableRef): bool {.nimcall,gcsafe.},
          port = Port(4000)) =
  ## encapsulates the SCGI object and main loop.
  var s: ScgiState
  s.open(port)
  var stop = false
  while not stop:
    if next(s):
      stop = handleRequest(s.client, s.input, s.headers)
      s.client.close()
  s.close()

# -- AsyncIO start

proc recvBufferAsync(client: AsyncClient, L: int): ReadLineResult =
  result = ReadPartialLine
  var data = ""
  if L < 1:
    raiseScgiError("Cannot read negative or zero length: " & $L)
  let ret = recvAsync(client.c, data, L)
  if ret == 0 and data == "":
    client.c.close()
    return ReadDisconnected
  if ret == -1:
    return ReadNone # No more data available
  client.input.add(data)
  if ret == L:
    return ReadFullLine

proc checkCloseSocket(client: AsyncClient) =
  if not client.c.isClosed:
    if client.c.isSendDataBuffered:
      client.c.setHandleWrite do (s: AsyncSocket):
        if not s.isClosed and not s.isSendDataBuffered:
          s.close()
          s.delHandleWrite()
    else: client.c.close()

proc handleClientRead(client: AsyncClient, s: AsyncScgiState) =
  case client.mode
  of ClientReadChar:
    while true:
      var d = ""
      let ret = client.c.recvAsync(d, 1)
      if d == "" and ret == 0:
        # Disconnected
        client.c.close()
        return
      if ret == -1:
        return # No more data available
      if d[0] notin strutils.Digits:
        if d[0] != ':': raiseScgiError("':' after length expected")
        break
      client.dataLen = client.dataLen * 10 + ord(d[0]) - ord('0')
    client.mode = ClientReadHeaders
    handleClientRead(client, s) # Allow progression
  of ClientReadHeaders:
    let ret = recvBufferAsync(client, (client.dataLen+1)-client.input.len)
    case ret
    of ReadFullLine:
      client.headers = parseHeaders(client.input, client.input.len-1)
      if client.headers.getOrDefault("SCGI") != "1": raiseScgiError("SCGI Version 1 expected")
      client.input = "" # For next part

      let contentLen = parseInt(client.headers.getOrDefault("CONTENT_LENGTH"))
      if contentLen > 0:
        client.mode = ClientReadContent
      else:
        s.handleRequest(client.c, client.input, client.headers)
        checkCloseSocket(client)
    of ReadPartialLine, ReadDisconnected, ReadNone: return
  of ClientReadContent:
    let L = parseInt(client.headers.getOrDefault("CONTENT_LENGTH")) -
               client.input.len
    if L > 0:
      let ret = recvBufferAsync(client, L)
      case ret
      of ReadFullLine:
        s.handleRequest(client.c, client.input, client.headers)
        checkCloseSocket(client)
      of ReadPartialLine, ReadDisconnected, ReadNone: return
    else:
      s.handleRequest(client.c, client.input, client.headers)
      checkCloseSocket(client)

proc handleAccept(sock: AsyncSocket, s: AsyncScgiState) =
  var client: AsyncSocket
  new(client)
  accept(s.asyncServer, client)
  var asyncClient = AsyncClient(c: client, mode: ClientReadChar, dataLen: 0,
                                 headers: newStringTable(), input: "")
  client.handleRead =
    proc (sock: AsyncSocket) =
      handleClientRead(asyncClient, s)
  s.disp.register(client)

proc open*(handleRequest: proc (client: AsyncSocket,
                                input: string, headers: StringTableRef) {.
                                closure, gcsafe.},
           port = Port(4000), address = "127.0.0.1",
           reuseAddr = false): AsyncScgiState =
  ## Creates an ``AsyncScgiState`` object which serves as a SCGI server.
  ##
  ## After the execution of ``handleRequest`` the client socket will be closed
  ## automatically unless it has already been closed.
  var cres: AsyncScgiState
  new(cres)
  cres.asyncServer = asyncSocket()
  cres.asyncServer.handleAccept = proc (s: AsyncSocket) = handleAccept(s, cres)
  if reuseAddr:
    cres.asyncServer.setSockOpt(OptReuseAddr, true)
  bindAddr(cres.asyncServer, port, address)
  listen(cres.asyncServer)
  cres.handleRequest = handleRequest
  result = cres

proc register*(d: Dispatcher, s: AsyncScgiState): Delegate {.discardable.} =
  ## Registers ``s`` with dispatcher ``d``.
  result = d.register(s.asyncServer)
  s.disp = d

proc close*(s: AsyncScgiState) =
  ## Closes the ``AsyncScgiState``.
  s.asyncServer.close()

when false:
  var counter = 0
  proc handleRequest(client: Socket, input: string,
                     headers: StringTableRef): bool {.procvar.} =
    inc(counter)
    client.writeStatusOkTextContent()
    client.send("Hello for the $#th time." % $counter & "\c\L")
    return false # do not stop processing

  run(handleRequest)

