# Copyright (C) 2015 Dominik Picheta
# MIT License - Look at license.txt for details.
import net, strtabs, re, tables, parseutils, os, strutils, uri,
       times, mimetypes, asyncnet, asyncdispatch, macros, md5,
       logging, httpcore, asyncfile, macrocache, json, options,
       strformat

import jester/private/[errorpages, utils]
import jester/[request, patterns]

from cgi import decodeData, decodeUrl, CgiError

export request
export strtabs
export tables
export httpcore
export MultiData
export HttpMethod
export asyncdispatch

export SameSite

when useHttpBeast:
  import httpbeast except Settings, Request
  import options
else:
  import asynchttpserver except Request

type
  MatchProc* = proc (request: Request): Future[ResponseData] {.gcsafe, closure.}
  MatchProcSync* = proc (request: Request): ResponseData{.gcsafe, closure.}

  Matcher = object
    case async: bool
    of false:
      syncProc: MatchProcSync
    of true:
      asyncProc: MatchProc

  ErrorProc* = proc (
    request: Request, error: RouteError
  ): Future[ResponseData] {.gcsafe, closure.}

  Jester* = object
    when not useHttpBeast:
      httpServer*: AsyncHttpServer
    settings: Settings
    matchers: seq[Matcher]
    errorHandlers: seq[ErrorProc]

  MatchType* = enum
    MRegex, MSpecial, MStatic

  RawHeaders* = seq[tuple[key, val: string]]
  ResponseData* = tuple[
    action: CallbackAction,
    code: HttpCode,
    headers: Option[RawHeaders],
    content: string,
    matched: bool
  ]

  CallbackAction* = enum
    TCActionNothing, TCActionSend, TCActionRaw, TCActionPass

  RouteErrorKind* = enum
    RouteException, RouteCode
  RouteError* = object
    case kind*: RouteErrorKind
    of RouteException:
      exc: ref Exception
    of RouteCode:
      data: ResponseData

const jesterVer = "0.4.0"

proc toStr(headers: Option[RawHeaders]): string =
  return $newHttpHeaders(headers.get(@({:})))

proc createHeaders(headers: RawHeaders): string =
  result = ""
  if headers != nil:
    for header in headers:
      let (key, value) = header
      result.add(key & ": " & value & "\c\L")

    result = result[0 .. ^3] # Strip trailing \c\L

proc createResponse(status: HttpCode, headers: RawHeaders): string =
  return "HTTP/1.1 " & $status & "\c\L" & createHeaders(headers) & "\c\L\c\L"

proc unsafeSend(request: Request, content: string) =
  when useHttpBeast:
    request.getNativeReq.unsafeSend(content)
  else:
    # TODO: This may cause issues if we send too fast.
    asyncCheck request.getNativeReq.client.send(content)

proc send(
  request: Request, code: HttpCode, headers: Option[RawHeaders], body: string
) =
  when useHttpBeast:
    let h =
      if headers.isNone: ""
      else: headers.get().createHeaders
    request.getNativeReq.send(code, body, h)
  else:
    # TODO: This may cause issues if we send too fast.
    asyncCheck request.getNativeReq.respond(
      code, body, newHttpHeaders(headers.get(@({:})))
    )

proc statusContent(request: Request, status: HttpCode, content: string,
                   headers: Option[RawHeaders]) =
  try:
    send(request, status, headers, content)
    when not defined(release):
      logging.debug("  $1 $2" % [$status, toStr(headers)])
  except:
    logging.error("Could not send response: $1" % osErrorMsg(osLastError()))

# TODO: Add support for proper Future Streams instead of this weird raw mode.
template enableRawMode* =
  # TODO: Use the effect system to make this implicit?
  result.action = TCActionRaw

proc send*(request: Request, content: string) =
  ## Sends ``content`` immediately to the client socket.
  ##
  ## Routes using this procedure must enable raw mode.
  unsafeSend(request, content)

proc sendHeaders*(request: Request, status: HttpCode,
                  headers: RawHeaders) =
  ## Sends ``status`` and ``headers`` to the client socket immediately.
  ## The user is then able to send the content immediately to the client on
  ## the fly through the use of ``response.client``.
  let headerData = createResponse(status, headers)
  try:
    request.send(headerData)
    logging.debug("  $1 $2" % [$status, $headers])
  except:
    logging.error("Could not send response: $1" % [osErrorMsg(osLastError())])

proc sendHeaders*(request: Request, status: HttpCode) =
  ## Sends ``status`` and ``Content-Type: text/html`` as the headers to the
  ## client socket immediately.
  let headers = @({"Content-Type": "text/html;charset=utf-8"})
  request.sendHeaders(status, headers)

proc sendHeaders*(request: Request) =
  ## Sends ``Http200`` and ``Content-Type: text/html`` as the headers to the
  ## client socket immediately.
  request.sendHeaders(Http200)

proc send*(request: Request, status: HttpCode, headers: RawHeaders,
           content: string) =
  ## Sends out a HTTP response comprising of the ``status``, ``headers`` and
  ## ``content`` specified.
  var headers = headers & @({"Content-Length": $content.len})
  request.sendHeaders(status, headers)
  request.send(content)

# TODO: Cannot capture 'paths: varargs[string]' here.
proc sendStaticIfExists(
  req: Request, paths: seq[string]
): Future[HttpCode] {.async.} =
  result = Http200
  for p in paths:
    if existsFile(p):

      var fp = getFilePermissions(p)
      if not fp.contains(fpOthersRead):
        return Http403

      let fileSize = getFileSize(p)
      let mimetype = req.settings.mimes.getMimetype(p.splitFile.ext[1 .. ^1])
      if fileSize < 10_000_000: # 10 mb
        var file = readFile(p)

        var hashed = getMD5(file)

        # If the user has a cached version of this file and it matches our
        # version, let them use it
        if req.headers.hasKey("If-None-Match") and req.headers["If-None-Match"] == hashed:
          req.statusContent(Http304, "", none[RawHeaders]())
        else:
          req.statusContent(Http200, file, some(@({
                              "Content-Type": mimetype,
                              "ETag": hashed
                            })))
      else:
        let headers = @({
          "Content-Type": mimetype,
          "Content-Length": $fileSize
        })
        req.statusContent(Http200, "", some(headers))

        var fileStream = newFutureStream[string]("sendStaticIfExists")
        var file = openAsync(p, fmRead)
        # Let `readToStream` write file data into fileStream in the
        # background.
        asyncCheck file.readToStream(fileStream)
        # The `writeFromStream` proc will complete once all the data in the
        # `bodyStream` has been written to the file.
        while true:
          let (hasValue, value) = await fileStream.read()
          if hasValue:
            req.unsafeSend(value)
          else:
            break
        file.close()

      return

  # If we get to here then no match could be found.
  return Http404

proc defaultErrorFilter(error: RouteError): ResponseData =
  case error.kind
  of RouteException:
    let e = error.exc
    let traceback = getStackTrace(e)
    var errorMsg = e.msg
    if errorMsg.isNil: errorMsg = "(nil)"

    let error = traceback & errorMsg
    logging.error(error)
    result.headers = some(@({
      "Content-Type": "text/html;charset=utf-8"
    }))
    result.content = routeException(
      error.replace("\n", "<br/>\n"),
      jesterVer
    )
    result.code = Http502
    result.matched = true
    result.action = TCActionSend
  of RouteCode:
    result.headers = some(@({
      "Content-Type": "text/html;charset=utf-8"
    }))
    result.content = error(
      $error.data.code,
      jesterVer
    )
    result.code = error.data.code
    result.matched = true
    result.action = TCActionSend

proc initRouteError(exc: ref Exception): RouteError =
  RouteError(
    kind: RouteException,
    exc: exc
  )

proc initRouteError(data: ResponseData): RouteError =
  RouteError(
    kind: RouteCode,
    data: data
  )

proc dispatchError(
  jes: Jester,
  request: Request,
  error: RouteError
): Future[ResponseData] {.async.} =
  for errorProc in jes.errorHandlers:
    let data = await errorProc(request, error)
    if data.matched:
      return data

  return defaultErrorFilter(error)

proc dispatch(
  self: Jester,
  req: Request
): Future[ResponseData] {.async.} =
  for matcher in self.matchers:
    if matcher.async:
      let data = await matcher.asyncProc(req)
      if data.matched:
        return data
    else:
      let data = matcher.syncProc(req)
      if data.matched:
        return data

proc handleFileRequest(
  jes: Jester, req: Request
): Future[ResponseData] {.async.} =
  # Find static file.
  # TODO: Caching.
  let path = normalizedPath(
    jes.settings.staticDir / cgi.decodeUrl(req.pathInfo)
  )

  # Verify that this isn't outside our static` dir.
  var status = Http400
  if path.splitFile.dir.startsWith(jes.settings.staticDir):
    if existsDir(path):
      status = await sendStaticIfExists(
        req,
        @[path / "index.html", path / "index.htm"]
      )
    else:
      status = await sendStaticIfExists(req, @[path])

    # Http200 means that the data was sent so there is nothing else to do.
    if status == Http200:
      result[0] = TCActionRaw
      when not defined(release):
        logging.debug("  -> $1" % path)
      return

  return (TCActionSend, status, none[seq[(string, string)]](), "", true)

proc handleRequestSlow(
  jes: Jester,
  req: Request,
  respDataFut: Future[ResponseData] | ResponseData,
  dispatchedError: bool
): Future[void] {.async.} =
  var dispatchedError = dispatchedError
  var respData: ResponseData

  # httpReq.send(Http200, "Hello, World!", "")
  try:
    when respDataFut is Future[ResponseData]:
      respData = await respDataFut
    else:
      respData = respDataFut
  except:
    # Handle any errors by showing them in the browser.
    # TODO: Improve the look of this.
    let exc = getCurrentException()
    respData = await dispatchError(jes, req, initRouteError(exc))
    dispatchedError = true

  # TODO: Put this in a custom matcher?
  if not respData.matched:
    respData = await handleFileRequest(jes, req)

  case respData.action
  of TCActionSend:
    if (respData.code.is4xx or respData.code.is5xx) and
        not dispatchedError and respData.content.len == 0:
      respData = await dispatchError(jes, req, initRouteError(respData))

    statusContent(
      req,
      respData.code,
      respData.content,
      respData.headers
    )
  else:
    when not defined(release):
      logging.debug("  $1" % [$respData.action])

  # Cannot close the client socket. AsyncHttpServer may be keeping it alive.

proc handleRequest(jes: Jester, httpReq: NativeRequest): Future[void] =
  var req = initRequest(httpReq, jes.settings)
  try:
    when not defined(release):
      logging.debug("$1 $2" % [$req.reqMethod, req.pathInfo])

    if likely(jes.matchers.len == 1 and not jes.matchers[0].async):
      let respData = jes.matchers[0].syncProc(req)
      if likely(respData.matched):
        statusContent(
          req,
          respData.code,
          respData.content,
          respData.headers
        )
      else:
        return handleRequestSlow(jes, req, respData, false)
    else:
      return handleRequestSlow(jes, req, dispatch(jes, req), false)
  except:
    let exc = getCurrentException()
    let respDataFut = dispatchError(jes, req, initRouteError(exc))
    return handleRequestSlow(jes, req, respDataFut, true)

proc newSettings*(
  port = Port(5000), staticDir = getCurrentDir() / "public",
  appName = "", bindAddr = "", reusePort = false,
  futureErrorHandler: proc (fut: Future[void]) {.closure, gcsafe.} = nil
): Settings =
  result = Settings(
    staticDir: staticDir,
    appName: appName,
    port: port,
    bindAddr: bindAddr,
    reusePort: reusePort,
    futureErrorHandler: futureErrorHandler
  )

proc register*(self: var Jester, matcher: MatchProc) =
  ## Adds the specified matcher procedure to the specified Jester instance.
  self.matchers.add(
    Matcher(
      async: true,
      asyncProc: matcher
    )
  )

proc register*(self: var Jester, matcher: MatchProcSync) =
  ## Adds the specified matcher procedure to the specified Jester instance.
  self.matchers.add(
    Matcher(
      async: false,
      syncProc: matcher
    )
  )

proc register*(self: var Jester, errorHandler: ErrorProc) =
  ## Adds the specified error handler procedure to the specified Jester instance.
  self.errorHandlers.add(errorHandler)

proc initJester*(
  settings: Settings = newSettings()
): Jester =
  result.settings = settings
  result.settings.mimes = newMimetypes()
  result.matchers = @[]
  result.errorHandlers = @[]

proc initJester*(
  matcher: MatchProc,
  settings: Settings = newSettings()
): Jester =
  result = initJester(settings)
  result.register(matcher)

proc initJester*(
  matcher: MatchProcSync, # TODO: Annoying nim bug: `MatchProc | MatchProcSync` doesn't work.
  settings: Settings = newSettings()
): Jester =
  result = initJester(settings)
  result.register(matcher)

proc serve*(
  self: var Jester
) =
  ## Creates a new async http server instance and registers
  ## it with the dispatcher.
  ##
  ## The event loop is executed by this function, so it will block forever.

  # Ensure we have at least one logger enabled, defaulting to console.
  if logging.getHandlers().len == 0:
    addHandler(logging.newConsoleLogger())
    setLogFilter(when defined(release): lvlInfo else: lvlDebug)

  if self.settings.bindAddr.len > 0:
    logging.info("Jester is making jokes at http://$1:$2$3" %
      [
        self.settings.bindAddr, $self.settings.port, self.settings.appName
      ]
    )
  else:
    logging.info("Jester is making jokes at http://0.0.0.0:$1$2" %
                 [$self.settings.port, self.settings.appName])

  var jes = self
  when useHttpBeast:
    run(
      proc (req: httpbeast.Request): Future[void] =
        result = handleRequest(jes, req),
      httpbeast.initSettings(self.settings.port, self.settings.bindAddr)
    )
  else:
    self.httpServer = newAsyncHttpServer(reusePort=self.settings.reusePort)
    let serveFut = self.httpServer.serve(
      self.settings.port,
      proc (req: asynchttpserver.Request): Future[void] {.gcsafe, closure.} =
        result = handleRequest(jes, req),
      self.settings.bindAddr)
    if not self.settings.futureErrorHandler.isNil:
      serveFut.callback = self.settings.futureErrorHandler
    else:
      asyncCheck serveFut
    runForever()

template resp*(code: HttpCode,
               headers: openarray[tuple[key, value: string]],
               content: string): typed =
  ## Sets ``(code, headers, content)`` as the response.
  bind TCActionSend, newHttpHeaders
  result = (TCActionSend, code, headers.newHttpHeaders.some(), content, true)
  break route

template setHeader(headers: var Option[RawHeaders], key, value: string): typed =
  bind isNone
  if isNone(headers):
    headers = some(@({key: value}))
  else:
    block outer:
      # Overwrite key if it exists.
      var h = headers.get()
      for i in 0 ..< h.len:
        if h[i][0] == key:
          h[i][1] = value
          headers = some(h)
          break outer

      # Add key if it doesn't exist.
      headers = some(h & @({key: value}))

template resp*(content: string, contentType = "text/html;charset=utf-8"): typed =
  ## Sets ``content`` as the response; ``Http200`` as the status code
  ## and ``contentType`` as the Content-Type.
  bind TCActionSend, newHttpHeaders, strtabs.`[]=`
  result[0] = TCActionSend
  result[1] = Http200
  setHeader(result[2], "Content-Type", contentType)
  result[3] = content
  # This will be set by our macro, so this is here for those not using it.
  result.matched = true
  break route

template resp*(content: JsonNode): typed =
  ## Serializes ``content`` as the response, sets ``Http200`` as status code
  ## and "application/json" Content-Type.
  resp($content, contentType="application/json")

template resp*(code: HttpCode, content: string,
               contentType = "text/html;charset=utf-8"): typed =
  ## Sets ``content`` as the response; ``code`` as the status code
  ## and ``contentType`` as the Content-Type.
  bind TCActionSend, newHttpHeaders
  result[0] = TCActionSend
  result[1] = code
  setHeader(result[2], "Content-Type", contentType)
  result[3] = content
  result.matched = true
  break route

template resp*(code: HttpCode): typed =
  ## Responds with the specified ``HttpCode``. This ensures that error handlers
  ## are called.
  bind TCActionSend, newHttpHeaders
  result[0] = TCActionSend
  result[1] = code
  result.matched = true
  break route

template redirect*(url: string): typed =
  ## Redirects to ``url``. Returns from this request handler immediately.
  ## Any set response headers are preserved for this request.
  bind TCActionSend, newHttpHeaders
  result[0] = TCActionSend
  result[1] = Http303
  setHeader(result[2], "Location", url)
  result[3] = ""
  result.matched = true
  break route

template pass*(): typed =
  ## Skips this request handler.
  ##
  ## If you want to stop this request from going further use ``halt``.
  result.action = TCActionPass
  break outerRoute

template cond*(condition: bool): typed =
  ## If ``condition`` is ``False`` then ``pass`` will be called,
  ## i.e. this request handler will be skipped.
  if not condition: break outerRoute

template halt*(code: HttpCode,
               headers: openarray[tuple[key, val: string]],
               content: string): typed =
  ## Immediately replies with the specified request. This means any further
  ## code will not be executed after calling this template in the current
  ## route.
  bind TCActionSend, newHttpHeaders
  result[0] = TCActionSend
  result[1] = code
  result[2] = some(@headers)
  result[3] = content
  result.matched = true
  break allRoutes

template halt*(): typed =
  ## Halts the execution of this request immediately. Returns a 404.
  ## All previously set values are **discarded**.
  halt(Http404, {"Content-Type": "text/html;charset=utf-8"}, error($Http404, jesterVer))

template halt*(code: HttpCode): typed =
  halt(code, {"Content-Type": "text/html;charset=utf-8"}, error($code, jesterVer))

template halt*(content: string): typed =
  halt(Http404, {"Content-Type": "text/html;charset=utf-8"}, content)

template halt*(code: HttpCode, content: string): typed =
  halt(code, {"Content-Type": "text/html;charset=utf-8"}, content)

template attachment*(filename = ""): typed =
  ## Instructs the browser that the response should be stored on disk
  ## rather than displayed in the browser.
  var disposition = "attachment"
  if filename != "":
    disposition.add("; filename=\"" & extractFilename(filename) & "\"")
    let ext = splitFile(filename).ext
    let contentTypeSet =
      isSome(result[2]) and result[2].get().toTable.hasKey("Content-Type")
    if not contentTypeSet and ext != "":
      setHeader(result[2], "Content-Type", getMimetype(request.settings.mimes, ext))
  setHeader(result[2], "Content-Disposition", disposition)

template sendFile*(filename: string): typed =
  ## Sends the file at the specified filename as the response.
  result[0] = TCActionRaw
  let sendFut = sendStaticIfExists(request, @[filename])
  yield sendFut
  let status = sendFut.read()
  if status != Http200:
    raise newException(JesterError, "Couldn't send requested file: " & filename)
  # This will be set by our macro, so this is here for those not using it.
  result.matched = true
  break route

template `@`*(s: string): untyped =
  ## Retrieves the parameter ``s`` from ``request.params``. ``""`` will be
  ## returned if parameter doesn't exist.
  if s in params(request):
    # TODO: Why does request.params not work? :(
    # TODO: This is some weird bug with macros/templates, I couldn't
    # TODO: reproduce it easily.
    params(request)[s]
  else:
    ""

proc setStaticDir*(request: Request, dir: string) =
  ## Sets the directory in which Jester will look for static files. It is
  ## ``./public`` by default.
  ##
  ## The files will be served like so:
  ##
  ## ./public/css/style.css ``->`` http://example.com/css/style.css
  ##
  ## (``./public`` is not included in the final URL)
  request.settings.staticDir = dir

proc getStaticDir*(request: Request): string =
  ## Gets the directory in which Jester will look for static files.
  ##
  ## ``./public`` by default.
  return request.settings.staticDir

proc makeUri*(request: Request, address = "", absolute = true,
              addScriptName = true): string =
  ## Creates a URI based on the current request. If ``absolute`` is true it will
  ## add the scheme (Usually 'http://'), `request.host` and `request.port`.
  ## If ``addScriptName`` is true `request.appName` will be prepended before
  ## ``address``.

  # Check if address already starts with scheme://
  var uri = parseUri(address)

  if uri.scheme != "": return address
  uri.path = "/"
  uri.query = ""
  uri.anchor = ""
  if absolute:
    uri.hostname = request.host
    uri.scheme = (if request.secure: "https" else: "http")
    if request.port != (if request.secure: 443 else: 80):
      uri.port = $request.port

  if addScriptName: uri = uri / request.appName
  if address != "":
    uri = uri / address
  else:
    uri = uri / request.pathInfo
  return $uri

template uri*(address = "", absolute = true, addScriptName = true): untyped =
  ## Convenience template which can be used in a route.
  request.makeUri(address, absolute, addScriptName)

proc daysForward*(days: int): DateTime =
  ## Returns a DateTime object referring to the current time plus ``days``.
  return getTime().utc + initInterval(days = days)

template setCookie*(name, value: string, expires="",
                    sameSite: SameSite=Lax): typed =
  ## Creates a cookie which stores ``value`` under ``name``.
  ##
  ## The SameSite argument determines the level of CSRF protection that
  ## you wish to adopt for this cookie. It's set to Lax by default which
  ## should protect you from most vulnerabilities. Note that this is only
  ## supported by some browsers:
  ## https://caniuse.com/#feat=same-site-cookie-attribute
  let newCookie = makeCookie(name, value, expires)
  if isSome(result[2]) and
     (let headers = result[2].get(); headers.toTable.hasKey("Set-Cookie")):
    result[2] = some(headers & @({"Set-Cookie": newCookie}))
  else:
    setHeader(result[2], "Set-Cookie", newCookie)

template setCookie*(name, value: string, expires: DateTime,
                    sameSite: SameSite=Lax): typed =
  ## Creates a cookie which stores ``value`` under ``name``.
  setCookie(name, value, format(expires, "ddd',' dd MMM yyyy HH:mm:ss 'GMT'"))

proc normalizeUri*(uri: string): string =
  ## Remove any trailing ``/``.
  if uri[uri.len-1] == '/': result = uri[0 .. uri.len-2]
  else: result = uri

# -- Macro

proc checkAction*(respData: var ResponseData): bool =
  case respData.action
  of TCActionSend, TCActionRaw:
    result = true
  of TCActionPass:
    result = false
  of TCActionNothing:
    assert(false)

proc skipDo(node: NimNode): NimNode {.compiletime.} =
  if node.kind == nnkDo:
    result = node[6]
  else:
    result = node

proc ctParsePattern(pattern, pathPrefix: string): NimNode {.compiletime.} =
  result = newNimNode(nnkPrefix)
  result.add newIdentNode("@")
  result.add newNimNode(nnkBracket)

  proc addPattNode(res: var NimNode, typ, text,
                   optional: NimNode) {.compiletime.} =
    var objConstr = newNimNode(nnkObjConstr)

    objConstr.add bindSym("Node")
    objConstr.add newNimNode(nnkExprColonExpr).add(
        newIdentNode("typ"), typ)
    objConstr.add newNimNode(nnkExprColonExpr).add(
        newIdentNode("text"), text)
    objConstr.add newNimNode(nnkExprColonExpr).add(
        newIdentNode("optional"), optional)

    res[1].add objConstr

  var patt = parsePattern(pattern)
  if pathPrefix.len > 0:
    result.addPattNode(
      bindSym("NodeText"), # Node kind
      newStrLitNode(pathPrefix), # Text
      newIdentNode("false") # Optional?
    )

  for node in patt:
    result.addPattNode(
      case node.typ
      of NodeText: bindSym("NodeText")
      of NodeField: bindSym("NodeField"),
      newStrLitNode(node.text),
      newIdentNode(if node.optional: "true" else: "false"))

template setDefaultResp*(): typed =
  # TODO: bindSym this in the 'routes' macro and put it in each route
  bind TCActionNothing, newHttpHeaders
  result.action = TCActionNothing
  result.code = Http200
  result.content = ""

template declareSettings(): typed {.dirty.} =
  when not declaredInScope(settings):
    var settings = newSettings()

proc createJesterPattern(
  routeNode, patternMatchSym: NimNode,
  pathPrefix: string
): NimNode {.compileTime.} =
  var ctPattern = ctParsePattern(routeNode[1].strVal, pathPrefix)
  # -> let <patternMatchSym> = <ctPattern>.match(request.path)
  return newLetStmt(patternMatchSym,
      newCall(bindSym"match", ctPattern, parseExpr("request.pathInfo")))

proc escapeRegex(s: string): string =
  result = ""
  for i in s:
    case i
    # https://stackoverflow.com/a/400316/492186
    of '.', '^', '$', '*', '+', '?', '(', ')', '[', '{', '\\', '|':
      result.add('\\')
      result.add(i)
    else:
      result.add(i)

proc createRegexPattern(
  routeNode, reMatchesSym, patternMatchSym: NimNode,
  pathPrefix: string
): NimNode {.compileTime.} =
  # -> let <patternMatchSym> = find(request.pathInfo, <pattern>, <reMatches>)
  var strNode = routeNode[1].copyNimTree()
  strNode[1].strVal = escapeRegex(pathPrefix) & strNode[1].strVal
  return newLetStmt(
    patternMatchSym,
    newCall(
      bindSym"find",
      parseExpr("request.pathInfo"),
      strNode,
      reMatchesSym
    )
  )

proc determinePatternType(pattern: NimNode): MatchType {.compileTime.} =
  case pattern.kind
  of nnkStrLit:
    var patt = parsePattern(pattern.strVal)
    if patt.len == 1 and patt[0].typ == NodeText:
      return MStatic
    else:
      return MSpecial
  of nnkCallStrLit:
    expectKind(pattern[0], nnkIdent)
    case ($pattern[0]).normalize
    of "re": return MRegex
    else:
      macros.error("Invalid pattern type: " & $pattern[0])
  else:
    macros.error("Unexpected node kind: " & $pattern.kind)

proc createCheckActionIf(): NimNode =
  var checkActionIf = parseExpr(
    "if checkAction(result): result.matched = true; break routesList"
  )
  checkActionIf[0][0][0] = bindSym"checkAction"
  return checkActionIf

proc createGlobalMetaRoute(routeNode, dest: NimNode) {.compileTime.} =
  ## Creates a ``before`` or ``after`` route with no pattern, i.e. one which
  ## will be always executed.

  # -> block route: <ifStmtBody>
  var innerBlockStmt = newStmtList(
    newNimNode(nnkBlockStmt).add(newIdentNode("route"), routeNode[1].skipDo())
  )

  # -> block outerRoute: <innerBlockStmt>
  var blockStmt = newNimNode(nnkBlockStmt).add(
    newIdentNode("outerRoute"), innerBlockStmt)
  dest.add blockStmt

proc createRoute(
  routeNode, dest: NimNode, pathPrefix: string, isMetaRoute: bool = false
) {.compileTime.} =
  ## Creates code which checks whether the current request path
  ## matches a route.
  ##
  ## The `isMetaRoute` parameter determines whether the route to be created is
  ## one of either a ``before`` or an ``after`` route.

  var patternMatchSym = genSym(nskLet, "patternMatchRet")

  # Only used for Regex patterns.
  var reMatchesSym = genSym(nskVar, "reMatches")
  var reMatches = parseExpr("var reMatches: array[20, string]")
  reMatches[0][0] = reMatchesSym
  reMatches[0][1][1] = bindSym("MaxSubpatterns")

  let patternType = determinePatternType(routeNode[1])
  case patternType
  of MStatic:
    discard
  of MSpecial:
    dest.add createJesterPattern(routeNode, patternMatchSym, pathPrefix)
  of MRegex:
    dest.add reMatches
    dest.add createRegexPattern(
      routeNode, reMatchesSym, patternMatchSym, pathPrefix
    )

  var ifStmtBody = newStmtList()
  case patternType
  of MStatic: discard
  of MSpecial:
    # -> setPatternParams(request, ret.params)
    ifStmtBody.add newCall(bindSym"setPatternParams", newIdentNode"request",
                           newDotExpr(patternMatchSym, newIdentNode"params"))
  of MRegex:
    # -> setReMatches(request, <reMatchesSym>)
    ifStmtBody.add newCall(bindSym"setReMatches", newIdentNode"request",
                           reMatchesSym)

  ifStmtBody.add routeNode[2].skipDo()

  let checkActionIf =
    if isMetaRoute:
      parseExpr("break routesList")
    else:
      createCheckActionIf()
  # -> block route: <ifStmtBody>; <checkActionIf>
  var innerBlockStmt = newStmtList(
    newNimNode(nnkBlockStmt).add(newIdentNode("route"), ifStmtBody),
    checkActionIf
  )

  let ifCond =
    case patternType
    of MStatic:
      infix(
        parseExpr("request.pathInfo"),
        "==",
        newStrLitNode(pathPrefix & routeNode[1].strVal)
      )
    of MSpecial:
      newDotExpr(patternMatchSym, newIdentNode("matched"))
    of MRegex:
      infix(patternMatchSym, "!=", newIntLitNode(-1))

  # -> if <patternMatchSym>.matched: <innerBlockStmt>
  var ifStmt = newIfStmt((ifCond, innerBlockStmt))

  # -> block outerRoute: <ifStmt>
  var blockStmt = newNimNode(nnkBlockStmt).add(
    newIdentNode("outerRoute"), ifStmt)
  dest.add blockStmt

proc createError(
  errorNode: NimNode,
  httpCodeBranches,
  exceptionBranches: var seq[tuple[cond, body: NimNode]]
) =
  if errorNode.len != 3:
    error("Missing error condition or body.", errorNode)

  let routeIdent = newIdentNode("route")
  let outerRouteIdent = newIdentNode("outerRoute")
  let checkActionIf = createCheckActionIf()
  let exceptionIdent = newIdentNode("exception")
  let errorIdent = newIdentNode("error") # TODO: Ugh. I shouldn't need these...
  let errorCond = errorNode[1]
  let errorBody = errorNode[2]
  let body = quote do:
    block `outerRouteIdent`:
      block `routeIdent`:
        `errorBody`
      `checkActionIf`

  case errorCond.kind
  of nnkIdent:
    let name = errorCond.strVal
    if name.len == 7 and name.startsWith("Http"):
      # HttpCode.
      httpCodeBranches.add(
        (
          infix(parseExpr("error.data.code"), "==", errorCond),
          body
        )
      )
    else:
      # Exception
      exceptionBranches.add(
        (
          infix(parseExpr("error.exc"), "of", errorCond),
          quote do:
            let `exceptionIdent` = (ref `errorCond`)(`errorIdent`.exc)
            `body`
        )
      )
  of nnkCurly:
    expectKind(errorCond[0], nnkInfix)
    httpCodeBranches.add(
      (
        infix(parseExpr("error.data.code"), "in", errorCond),
        body
      )
    )
  else:
    error("Expected exception type or set[HttpCode].", errorCond)

const definedRoutes = CacheTable"jester.routes"

proc processRoutesBody(
  body: NimNode,
  # For HTTP methods.
  caseStmtGetBody,
  caseStmtPostBody,
  caseStmtPutBody,
  caseStmtDeleteBody,
  caseStmtHeadBody,
  caseStmtOptionsBody,
  caseStmtTraceBody,
  caseStmtConnectBody,
  caseStmtPatchBody: var NimNode,
  # For `error`.
  httpCodeBranches,
  exceptionBranches: var seq[tuple[cond, body: NimNode]],
  # For before/after stmts.
  beforeStmts,
  afterStmts: var NimNode,
  # For other statements.
  outsideStmts: var NimNode,
  pathPrefix: string
) =
  for i in 0..<body.len:
    case body[i].kind
    of nnkCall:
      let cmdName = body[i][0].`$`.normalize
      case cmdName
      of "before":
        createGlobalMetaRoute(body[i], beforeStmts)
      of "after":
        createGlobalMetaRoute(body[i], afterStmts)
      else:
        outsideStmts.add(body[i])
    of nnkCommand:
      let cmdName = body[i][0].`$`.normalize
      case cmdName
      # HTTP Methods
      of "get":
        createRoute(body[i], caseStmtGetBody, pathPrefix)
      of "post":
        createRoute(body[i], caseStmtPostBody, pathPrefix)
      of "put":
        createRoute(body[i], caseStmtPutBody, pathPrefix)
      of "delete":
        createRoute(body[i], caseStmtDeleteBody, pathPrefix)
      of "head":
        createRoute(body[i], caseStmtHeadBody, pathPrefix)
      of "options":
        createRoute(body[i], caseStmtOptionsBody, pathPrefix)
      of "trace":
        createRoute(body[i], caseStmtTraceBody, pathPrefix)
      of "connect":
        createRoute(body[i], caseStmtConnectBody, pathPrefix)
      of "patch":
        createRoute(body[i], caseStmtPatchBody, pathPrefix)
      # Other
      of "error":
        createError(body[i], httpCodeBranches, exceptionBranches)
      of "before":
        createRoute(body[i], beforeStmts, pathPrefix, isMetaRoute=true)
      of "after":
        createRoute(body[i], afterStmts, pathPrefix, isMetaRoute=true)
      of "extend":
        # Extend another router.
        let extend = body[i]
        if extend[1].kind != nnkIdent:
          error("Expected identifier.", extend[1])

        let prefix =
          if extend.len > 1:
            extend[2].strVal
          else:
            ""
        if prefix.len != 0 and prefix[0] != '/':
          error("Path prefix for extended route must start with '/'", extend[2])

        processRoutesBody(
          definedRoutes[extend[1].strVal],
          caseStmtGetBody,
          caseStmtPostBody,
          caseStmtPutBody,
          caseStmtDeleteBody,
          caseStmtHeadBody,
          caseStmtOptionsBody,
          caseStmtTraceBody,
          caseStmtConnectBody,
          caseStmtPatchBody,
          httpCodeBranches,
          exceptionBranches,
          beforeStmts,
          afterStmts,
          outsideStmts,
          pathPrefix & prefix
        )
      else:
        outsideStmts.add(body[i])
    of nnkCommentStmt:
      discard
    of nnkPragma:
      if body[i][0].strVal.normalize notin ["async", "sync"]:
        outsideStmts.add(body[i])
    else:
      outsideStmts.add(body[i])

type
  NeedsAsync = enum
    ImplicitTrue, ImplicitFalse, ExplicitTrue, ExplicitFalse
proc needsAsync(node: NimNode): NeedsAsync =
  result = ImplicitFalse
  case node.kind
  of nnkCommand, nnkCall:
    if node[0].kind == nnkIdent:
      case node[0].strVal.normalize
      of "await", "sendfile":
        return ImplicitTrue
      of "resp", "halt", "attachment", "pass", "redirect", "cond", "get",
         "post", "patch", "delete":
        # This is just a simple heuristic. It's by no means meant to be
        # exhaustive.
        discard
      else:
        return ImplicitTrue
  of nnkYieldStmt:
    return ImplicitTrue
  of nnkPragma:
    if node[0].kind == nnkIdent:
      case node[0].strVal.normalize
      of "sync":
        return ExplicitFalse
      of "async":
        return ExplicitTrue
      else: discard
  else: discard

  for c in node:
    let r = needsAsync(c)
    if r in {ImplicitTrue, ExplicitTrue, ExplicitFalse}: return r

proc routesEx(name: string, body: NimNode): NimNode =
  # echo(treeRepr(body))
  # echo(treeRepr(name))

  # Save this route's body so that it can be incorporated into another route.
  definedRoutes[name] = body.copyNimTree

  result = newStmtList()

  # -> declareSettings()
  result.add newCall(bindSym"declareSettings")

  var outsideStmts = newStmtList()

  var matchBody = newNimNode(nnkStmtList)
  let setDefaultRespIdent = bindSym"setDefaultResp"
  matchBody.add newCall(setDefaultRespIdent)
  # TODO: This diminishes the performance. Would be nice to only include it
  # TODO: when setPatternParams or setReMatches is used.
  matchBody.add parseExpr("var request = request")

  # HTTP router case statement nodes:
  var caseStmt = newNimNode(nnkCaseStmt)
  caseStmt.add parseExpr("request.reqMethod")

  var caseStmtGetBody = newNimNode(nnkStmtList)
  var caseStmtPostBody = newNimNode(nnkStmtList)
  var caseStmtPutBody = newNimNode(nnkStmtList)
  var caseStmtDeleteBody = newNimNode(nnkStmtList)
  var caseStmtHeadBody = newNimNode(nnkStmtList)
  var caseStmtOptionsBody = newNimNode(nnkStmtList)
  var caseStmtTraceBody = newNimNode(nnkStmtList)
  var caseStmtConnectBody = newNimNode(nnkStmtList)
  var caseStmtPatchBody = newNimNode(nnkStmtList)

  # Error handler nodes:
  var httpCodeBranches: seq[tuple[cond, body: NimNode]] = @[]
  var exceptionBranches: seq[tuple[cond, body: NimNode]] = @[]

  # Before/After nodes:
  var beforeRoutes = newStmtList()
  var afterRoutes = newStmtList()

  processRoutesBody(
    body,
    caseStmtGetBody,
    caseStmtPostBody,
    caseStmtPutBody,
    caseStmtDeleteBody,
    caseStmtHeadBody,
    caseStmtOptionsBody,
    caseStmtTraceBody,
    caseStmtConnectBody,
    caseStmtPatchBody,
    httpCodeBranches,
    exceptionBranches,
    beforeRoutes,
    afterRoutes,
    outsideStmts,
    ""
  )

  var ofBranchGet = newNimNode(nnkOfBranch)
  ofBranchGet.add newIdentNode("HttpGet")
  ofBranchGet.add caseStmtGetBody
  caseStmt.add ofBranchGet

  var ofBranchPost = newNimNode(nnkOfBranch)
  ofBranchPost.add newIdentNode("HttpPost")
  ofBranchPost.add caseStmtPostBody
  caseStmt.add ofBranchPost

  var ofBranchPut = newNimNode(nnkOfBranch)
  ofBranchPut.add newIdentNode("HttpPut")
  ofBranchPut.add caseStmtPutBody
  caseStmt.add ofBranchPut

  var ofBranchDelete = newNimNode(nnkOfBranch)
  ofBranchDelete.add newIdentNode("HttpDelete")
  ofBranchDelete.add caseStmtDeleteBody
  caseStmt.add ofBranchDelete

  var ofBranchHead = newNimNode(nnkOfBranch)
  ofBranchHead.add newIdentNode("HttpHead")
  ofBranchHead.add caseStmtHeadBody
  caseStmt.add ofBranchHead

  var ofBranchOptions = newNimNode(nnkOfBranch)
  ofBranchOptions.add newIdentNode("HttpOptions")
  ofBranchOptions.add caseStmtOptionsBody
  caseStmt.add ofBranchOptions

  var ofBranchTrace = newNimNode(nnkOfBranch)
  ofBranchTrace.add newIdentNode("HttpTrace")
  ofBranchTrace.add caseStmtTraceBody
  caseStmt.add ofBranchTrace

  var ofBranchConnect = newNimNode(nnkOfBranch)
  ofBranchConnect.add newIdentNode("HttpConnect")
  ofBranchConnect.add caseStmtConnectBody
  caseStmt.add ofBranchConnect

  var ofBranchPatch = newNimNode(nnkOfBranch)
  ofBranchPatch.add newIdentNode("HttpPatch")
  ofBranchPatch.add caseStmtPatchBody
  caseStmt.add ofBranchPatch

  # Wrap the routes inside ``routesList`` blocks accordingly, and add them to
  # the `match` procedure body.
  let routesListIdent = newIdentNode("routesList")
  matchBody.add(
    quote do:
      block `routesListIdent`:
        `beforeRoutes`
  )

  matchBody.add(
    quote do:
      block `routesListIdent`:
        `caseStmt`
  )

  matchBody.add(
    quote do:
      block `routesListIdent`:
        `afterRoutes`
  )

  let matchIdent = newIdentNode(name)
  let reqIdent = newIdentNode("request")
  let needsAsync = needsAsync(body)
  case needsAsync
  of ImplicitFalse, ExplicitFalse:
    hint(fmt"Synchronous route `{name}` has been optimised. Use `{{.async.}}` to change.")
  of ImplicitTrue, ExplicitTrue:
    hint(fmt"Asynchronous route: {name}.")
  var matchProc =
    if needsAsync in {ImplicitTrue, ExplicitTrue}:
      quote do:
        proc `matchIdent`(
          `reqIdent`: Request
        ): Future[ResponseData] {.async, gcsafe.} =
          discard
    else:
      quote do:
        proc `matchIdent`(
          `reqIdent`: Request
        ): ResponseData {.gcsafe.} =
          discard

  # The following `block` is for `halt`. (`return` didn't work :/)
  let allRoutesBlock = newTree(
    nnkBlockStmt,
    newIdentNode("allRoutes"),
    matchBody
  )
  matchProc[6] = newTree(nnkStmtList, allRoutesBlock)
  result.add(outsideStmts)
  result.add(matchProc)

  # Error handler proc
  let errorHandlerIdent = newIdentNode(name & "ErrorHandler")
  let errorIdent = newIdentNode("error")
  let exceptionIdent = newIdentNode("exception")
  let resultIdent = newIdentNode("result")
  var errorHandlerProc = quote do:
    proc `errorHandlerIdent`(
      `reqIdent`: Request, `errorIdent`: RouteError
    ): Future[ResponseData] {.gcsafe, async.} =
      block `routesListIdent`:
        `setDefaultRespIdent`()
        case `errorIdent`.kind
        of RouteException:
          discard
        of RouteCode:
          discard
  if exceptionBranches.len != 0:
    var stmts = newStmtList()
    for branch in exceptionBranches:
      stmts.add(newIfStmt(branch))
    errorHandlerProc[6][0][1][^1][1][1][0] = stmts
  if httpCodeBranches.len > 1:
    var stmts = newStmtList()
    for branch in httpCodeBranches:
      stmts.add(newIfStmt(branch))
    errorHandlerProc[6][0][1][^1][2][1][0] = stmts
  result.add(errorHandlerProc)

  # TODO: Replace `body`, `headers`, `code` in routes with `result[i]` to
  # get these shortcuts back without sacrificing usability.
  # TODO2: Make sure you replace what `guessAction` used to do for this.

  # echo toStrLit(result)
  # echo treeRepr(result)

macro routes*(body: untyped): typed =
  result = routesEx("match", body)
  let jesIdent = genSym(nskVar, "jes")
  let matchIdent = newIdentNode("match")
  let errorHandlerIdent = newIdentNode("matchErrorHandler")
  let settingsIdent = newIdentNode("settings")
  result.add(
    quote do:
      var `jesIdent` = initJester(`matchIdent`, `settingsIdent`)
      `jesIdent`.register(`errorHandlerIdent`)
  )
  result.add(
    quote do:
      serve(`jesIdent`)
  )

macro router*(name: untyped, body: untyped): typed =
  if name.kind != nnkIdent:
    error("Need an ident.", name)

  routesEx($name.ident, body)

macro settings*(body: untyped): typed =
  #echo(treeRepr(body))
  expectKind(body, nnkStmtList)

  result = newStmtList()

  # var settings = newSettings()
  let settingsIdent = newIdentNode("settings")
  result.add newVarStmt(settingsIdent, newCall("newSettings"))

  for asgn in body.children:
    expectKind(asgn, nnkAsgn)
    result.add newAssignment(newDotExpr(settingsIdent, asgn[0]), asgn[1])
