util = require 'util'
zlib = require 'zlib'
http = require 'http'
https = require 'https'
net = require 'net'
tls = require 'tls'
Q = require 'q'
config = require '../config/config'
config.mongo = config.get 'mongo'
config.router = config.get 'router'
logger = require 'winston'
cookie = require 'cookie'
fs = require 'fs'
utils = require '../utils'
messageStore = require '../middleware/messageStore'
events = require '../middleware/events'
stats = require "../stats"
statsdServer = config.get 'statsd'
application = config.get 'application'
SDC = require 'statsd-client'
os = require 'os'

domain = "#{os.hostname()}.#{application.name}.appMetrics"
sdc = new SDC statsdServer


isRouteEnabled = (route) -> not route.status? or route.status is 'enabled'

exports.numberOfPrimaryRoutes = numberOfPrimaryRoutes = (routes) ->
  numPrimaries = 0
  for route in routes
    numPrimaries++ if isRouteEnabled(route) and route.primary
  return numPrimaries

containsMultiplePrimaries = (routes) -> numberOfPrimaryRoutes(routes) > 1


setKoaResponse = (ctx, response) ->

  # Try and parse the status to an int if it is a string
  if typeof response.status is 'string'
    try
      response.status = parseInt response.status
    catch err
      logger.error err

  ctx.response.status = response.status
  ctx.response.timestamp = response.timestamp
  ctx.response.body = response.body

  if not ctx.response.header
    ctx.response.header = {}

  if ctx.request?.header?["X-OpenHIM-TransactionID"]
    if response?.headers?
      response.headers["X-OpenHIM-TransactionID"] = ctx.request.header["X-OpenHIM-TransactionID"]

  for key, value of response.headers
    switch key.toLowerCase()
      when 'set-cookie' then setCookiesOnContext ctx, value
      when 'location'
        if response.status >= 300 and response.status < 400
          ctx.response.redirect value
        else
          ctx.response.set key, value
      when 'content-type' then ctx.response.type = value
      else
        try
          # Strip the content and transfer encoding headers
          if key != 'content-encoding' and key != 'transfer-encoding'
            ctx.response.set key, value
        catch err
          logger.error err

if process.env.NODE_ENV == "test"
  exports.setKoaResponse = setKoaResponse

setCookiesOnContext = (ctx, value) ->
  logger.info 'Setting cookies on context'
  for c_key,c_value in value
    c_opts = {path:false,httpOnly:false} #clear out default values in cookie module
    c_vals = {}
    for p_key,p_val of cookie.parse c_key
      p_key_l = p_key.toLowerCase()
      switch p_key_l
        when 'max-age' then c_opts['maxage'] = parseInt p_val, 10
        when 'expires' then c_opts['expires'] = new Date p_val
        when 'path','domain','secure','signed','overwrite' then c_opts[p_key_l] = p_val
        when 'httponly' then c_opts['httpOnly'] = p_val
        else c_vals[p_key] = p_val
    for p_key,p_val of c_vals
      ctx.cookies.set p_key,p_val,c_opts

handleServerError = (ctx, err, route) ->
  ctx.autoRetry = true
  if route
    route.error =
      message: err.message
      stack: err.stack if err.stack
  else
    ctx.response.status = 500
    ctx.response.timestamp = new Date()
    ctx.response.body = "An internal server error occurred"
    # primary route error
    ctx.error =
      message: err.message
      stack: err.stack if err.stack

  logger.error "[#{ctx.transactionId?.toString()}] Internal server error occured: #{err}"
  logger.error "#{err.stack}" if err.stack


sendRequestToRoutes = (ctx, routes, next) ->
  promises = []
  promise = {}
  ctx.timer = new Date

  if containsMultiplePrimaries routes
    return next new Error "Cannot route transaction: Channel contains multiple primary routes and only one primary is allowed"

  utils.getKeystore (err, keystore) ->

    for route in routes
      do (route) ->
        if not isRouteEnabled route then return #continue

        path = getDestinationPath route, ctx.path
        options =
          hostname: route.host
          port: route.port
          path: path
          method: ctx.request.method
          headers: ctx.request.header
          agent: false
          rejectUnauthorized: true
          key: keystore.key
          cert: keystore.cert.data
          secureProtocol: 'TLSv1_method'

        if route.cert?
          options.ca = keystore.ca.id(route.cert).data

        if ctx.request.querystring
          options.path += '?' + ctx.request.querystring

        if options.headers and options.headers.authorization and not route.forwardAuthHeader
          delete options.headers.authorization

        if route.username and route.password
          options.auth = route.username + ":" + route.password

        if options.headers && options.headers.host
          delete options.headers.host

        if route.primary
          ctx.primaryRoute = route
          promise = sendRequest(ctx, route, options)
          .then (response) ->
            logger.info "executing primary route : #{route.name}"
            if response.headers?['content-type']?.indexOf('application/json+openhim') > -1
              # handle mediator reponse
              responseObj = JSON.parse response.body
              ctx.mediatorResponse = responseObj

              if responseObj.error?
                ctx.autoRetry = true
                ctx.error = responseObj.error

              # then set koa response from responseObj.response
              setKoaResponse ctx, responseObj.response
            else
              setKoaResponse ctx, response
          .then ->
            logger.info "primary route completed"
            next()

          .fail (reason) ->
            # on failure
            handleServerError ctx, reason
            next()
        else
          logger.info "executing non primary: #{route.name}"
          promise = buildNonPrimarySendRequestPromise(ctx, route, options, path)
          .then (routeObj) ->
            logger.info "Storing non primary route responses #{route.name}"

            try
              if not routeObj?.name?
                routeObj =
                  name: route.name

              if not routeObj?.response?
                routeObj.response =
                  status: 500
                  timestamp: ctx.requestTimestamp

              if not routeObj?.request?
                routeObj.request =
                  host: options.hostname
                  port: options.port
                  path: path
                  headers: ctx.request.header
                  querystring: ctx.request.querystring
                  method: ctx.request.method
                  timestamp: ctx.requestTimestamp

              messageStore.storeNonPrimaryResponse ctx, routeObj, ->
                stats.nonPrimaryRouteRequestCount ctx, routeObj, ->
                  stats.nonPrimaryRouteDurations ctx, routeObj, ->

            catch err
              logger.error err


        promises.push promise

    (Q.all promises).then ->
      messageStore.setFinalStatus ctx, ->
        logger.info "All routes completed for transaction: #{ctx.transactionId.toString()}"
        if ctx.routes
          logger.debug "Storing route events for transaction: #{ctx.transactionId}"

          done = (err) -> logger.error err if err
          trxEvents = []

          events.createSecondaryRouteEvents trxEvents, ctx.transactionId, ctx.requestTimestamp, ctx.authorisedChannel, ctx.routes, ctx.currentAttempt
          events.saveEvents trxEvents, done


# function to build fresh promise for transactions routes
buildNonPrimarySendRequestPromise = (ctx, route, options, path) ->
  sendRequest ctx, route, options
  .then (response) ->
    routeObj = {}
    routeObj.name = route.name
    routeObj.request =
      host: options.hostname
      port: options.port
      path: path
      headers: ctx.request.header
      querystring: ctx.request.querystring
      method: ctx.request.method
      timestamp: ctx.requestTimestamp

    if response.headers?['content-type']?.indexOf('application/json+openhim') > -1
      # handle mediator reponse
      responseObj = JSON.parse response.body
      routeObj.mediatorURN = responseObj['x-mediator-urn']
      routeObj.orchestrations = responseObj.orchestrations
      routeObj.properties = responseObj.properties
      routeObj.metrics = responseObj.metrics if responseObj.metrics
      routeObj.response = responseObj.response
    else
      routeObj.response = response

    ctx.routes = [] if not ctx.routes
    ctx.routes.push routeObj
    return routeObj
  .fail (reason) ->
    # on failure
    routeObj = {}
    routeObj.name = route.name
    handleServerError ctx, reason, routeObj
    return routeObj

sendRequest = (ctx, route, options) ->
  if route.type is 'tcp' or route.type is 'mllp'
    logger.info 'Routing socket request'
    return sendSocketRequest ctx, route, options
  else
    logger.info 'Routing http(s) request'
    return sendHttpRequest ctx, route, options

obtainCharset = (headers) ->
  contentType = headers['content-type'] || ''
  matches =  contentType.match(/charset=([^;,\r\n]+)/i)
  if (matches && matches[1])
    return matches[1]
  return  'utf-8'

###
# A promise returning function that send a request to the given route and resolves
# the returned promise with a response object of the following form:
#   response =
#    status: <http_status code>
#    body: <http body>
#    headers: <http_headers_object>
#    timestamp: <the time the response was recieved>
###
sendHttpRequest = (ctx, route, options) ->
  defered = Q.defer()
  response = {}

  gunzip = zlib.createGunzip()
  inflate = zlib.createInflate()

  method = http

  if route.secured
    method = https

  routeReq = method.request options, (routeRes) ->
    response.status = routeRes.statusCode
    response.headers = routeRes.headers

    uncompressedBodyBufs = []
    if routeRes.headers['content-encoding'] == 'gzip' #attempt to gunzip
      routeRes.pipe gunzip

      gunzip.on "data", (data) ->
        uncompressedBodyBufs.push data
        return

    if routeRes.headers['content-encoding'] == 'deflate' #attempt to inflate
      routeRes.pipe inflate

      inflate.on "data", (data) ->
        uncompressedBodyBufs.push data
        return

    bufs = []
    routeRes.on "data", (chunk) ->
      bufs.push chunk

    # See https://www.exratione.com/2014/07/nodejs-handling-uncertain-http-response-compression/
    routeRes.on "end", ->
      response.timestamp = new Date()
      charset = obtainCharset(routeRes.headers)
      if routeRes.headers['content-encoding'] == 'gzip'
        gunzip.on "end", ->
          uncompressedBody =  Buffer.concat uncompressedBodyBufs
          response.body = uncompressedBody.toString charset
          if not defered.promise.isRejected()
            defered.resolve response
          return

      else if routeRes.headers['content-encoding'] == 'deflate'
        inflate.on "end", ->
          uncompressedBody =  Buffer.concat uncompressedBodyBufs
          response.body = uncompressedBody.toString charset
          if not defered.promise.isRejected()
            defered.resolve response
          return

      else
        response.body = Buffer.concat bufs
        if not defered.promise.isRejected()
          defered.resolve response

  routeReq.on "error", (err) -> defered.reject err

  routeReq.on "clientError", (err) -> defered.reject err

  routeReq.setTimeout +config.router.timeout, -> defered.reject "Request Timed Out"

  if ctx.request.method == "POST" || ctx.request.method == "PUT"
    routeReq.write ctx.body

  routeReq.end()

  return defered.promise

###
# A promise returning function that send a request to the given route using sockets and resolves
# the returned promise with a response object of the following form: ()
#   response =
#    status: <200 if all work, else 500>
#    body: <the received data from the socket>
#    timestamp: <the time the response was recieved>
#
# Supports both normal and MLLP sockets
###
sendSocketRequest = (ctx, route, options) ->
  mllpEndChar = String.fromCharCode(0o034)

  defered = Q.defer()
  requestBody = ctx.body
  response = {}

  method = net
  if route.secured
    method = tls

  options =
    host: options.hostname
    port: options.port
    rejectUnauthorized: options.rejectUnauthorized
    key: options.key
    cert: options.cert
    secureProtocol: options.secureProtocol
    ca: options.ca

  client = method.connect options, ->
    logger.info "Opened #{route.type} connection to #{options.host}:#{options.port}"
    if route.type is 'tcp'
      client.end requestBody
    else if route.type is 'mllp'
      client.write requestBody
    else
      logger.error "Unkown route type #{route.type}"

  bufs = []
  client.on 'data', (chunk) ->
    bufs.push chunk
    if route.type is 'mllp' and chunk.toString().indexOf(mllpEndChar) > -1
      logger.debug 'Received MLLP response end character'
      client.end()

  client.on 'error', (err) -> defered.reject err

  client.on 'clientError', (err) -> defered.reject err

  client.on 'end', ->
    logger.info "Closed #{route.type} connection to #{options.host}:#{options.port}"

    if route.secured and not client.authorized
      return defered.reject new Error 'Client authorization failed'
    response.body = Buffer.concat bufs
    response.status = 200
    response.timestamp = new Date()
    if not defered.promise.isRejected()
      defered.resolve response

  return defered.promise

getDestinationPath = (route, requestPath) ->
  if route.path
    route.path
  else if route.pathTransform
    transformPath requestPath, route.pathTransform
  else
    requestPath

###
# Applies a sed-like expression to the path string
#
# An expression takes the form s/from/to
# Only the first 'from' match will be substituted
# unless the global modifier as appended: s/from/to/g
#
# Slashes can be escaped as \/
###
exports.transformPath = transformPath = (path, expression) ->
  # replace all \/'s with a temporary ':' char so that we don't split on those
  # (':' is safe for substitution since it cannot be part of the path)
  sExpression = expression.replace /\\\//g, ':'
  sub = sExpression.split '/'

  from = sub[1].replace /:/g, '\/'
  to = if sub.length > 2 then sub[2] else ""
  to = to.replace /:/g, '\/'

  if sub.length > 3 and sub[3] is 'g'
    fromRegex = new RegExp from, 'g'
  else
    fromRegex = new RegExp from

  path.replace fromRegex, to


###
# Gets the authorised channel and routes
# the request to all routes within that channel. It updates the
# response of the context object to reflect the response recieved from the
# route that is marked as 'primary'.
#
# Accepts (ctx, next) where ctx is a [Koa](http://koajs.com/) context
# object and next is a callback that is called once the route marked as
# primary has returned an the ctx.response object has been updated to
# reflect the response from that route.
###
exports.route = (ctx, next) ->
  channel = ctx.authorisedChannel
  sendRequestToRoutes ctx, channel.routes, next

###
# The [Koa](http://koajs.com/) middleware function that enables the
# router to work with the Koa framework.
#
# Use with: app.use(router.koaMiddleware)
###
exports.koaMiddleware = (next) ->
  startTime = new Date() if statsdServer.enabled
  route = Q.denodeify exports.route
  yield route this
  sdc.timing "#{domain}.routerMiddleware", startTime if statsdServer.enabled
  yield next
