/*
 * Copyright Elasticsearch B.V. and other contributors where applicable.
 * Licensed under the BSD 2-Clause License; you may not use this file except in
 * compliance with the BSD 2-Clause License.
 */

'use strict'

// Handle creating error event objects to be sent to APM server.
// https://github.com/elastic/apm-server/blob/master/docs/spec/v2/error.json

const crypto = require('crypto')
var path = require('path')
const util = require('util')

const { gatherStackTrace } = require('./stacktraces')

const MYSQL_ERROR_MSG_RE = /(ER_[A-Z_]+): /

// ---- internal support functions

// Default `culprit` to the top of the stack or the highest non `library_frame`
// frame if such exists
function culpritFromStacktrace (frames) {
  if (frames.length === 0) return

  var filename = frames[0].filename
  var fnName = frames[0].function
  for (var n = 0; n < frames.length; n++) {
    if (!frames[n].library_frame) {
      filename = frames[n].filename
      fnName = frames[n].function
      break
    }
  }

  return filename ? fnName + ' (' + filename + ')' : fnName
}

// Infer the node.js module name from the top frame filename, if possible.
// Here `frames` is a data structure as returned by `parseStackTrace`.
//
// Examples:
//    node_modules/mymodule/index.js
//                 ^^^^^^^^
//    node_modules/@myorg/mymodule/index.js
//                 ^^^^^^^^^^^^^^^
// or on Windows:
//    node_modules\@myorg\mymodule\lib\subpath\index.js
//                 ^^^^^^^^^^^^^^^
function _moduleNameFromFrames (frames) {
  if (frames.length === 0) {
    return null
  }
  var frame = frames[0]
  if (!frame.library_frame) {
    return null
  }
  var idx = frame.filename.lastIndexOf('node_modules' + path.sep)
  if (idx === -1) {
    return null
  }
  var parts = frame.filename.slice(idx).split(path.sep)
  if (!parts[1]) {
    return null
  } else if (parts[1].startsWith('@')) {
    if (!parts[2]) { // node_modules/@foo
      return null
    } else {
      // Normalize the module name separator to '/', even on Windows.
      return parts[1] + '/' + parts[2]
    }
  } else {
    return parts[1]
  }
}

// Gather properties from `err` to be used for `error.exception.attributes`.
// If there are no properties to include, then it returns undefined.
function attributesFromErr (err) {
  let n = 0
  const attrs = {}
  const keys = Object.keys(err)
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i]
    if (key === 'stack') {
      continue // 'stack' seems to be enumerable in Node 0.11
    }
    if (key === 'code') {
      continue // 'code' is already used for `error.exception.code`
    }

    let val = err[key]
    if (val === null) {
      continue // null is typeof object and well break the switch below
    }
    switch (typeof val) {
      case 'function':
        continue
      case 'object':
        // Ignore all objects except Dates.
        if (typeof val.toISOString !== 'function' || typeof val.getTime !== 'function') {
          continue
        } else if (Number.isNaN(val.getTime())) {
          val = 'Invalid Date' // calling toISOString() on invalid dates throws
        } else {
          val = val.toISOString()
        }
    }
    attrs[key] = val
    n++
  }
  return n ? attrs : undefined
}

// ---- exports

function generateErrorId () {
  return crypto.randomBytes(16).toString('hex')
}

// Create an "error" APM event object to be sent to APM server.
//
// Required args:
// - Exactly one of `args.exception` or `args.logMessage` must be set.
//   `args.exception` is an Error instance. `args.logMessage` is a log message
//   string, or an object of the form `{ message: 'template', params: [ ... ]}`
//   which will be formated with `util.format()`.
// - `args.id` - An ID for the error. It should be created with
//   `errors.generateErrorId()`.
// - `args.log` {Logger}
// - `args.shouldCaptureAttributes` {Boolean}
// - `args.timestampUs` {Integer} - Timestamp of the error in microseconds.
// - `args.handled` {Boolean}
// - `args.sourceLinesAppFrames` {Integer} - Number of lines of source context
//   to include in stack traces.
// - `args.sourceLinesLibraryFrames` {Integer} - Number of lines of source
//   context to include in stack traces. This and the previous arg are typically
//   select from the agent `sourceLines{Error,Span}{App,Library}Frames` config
//   vars.
//
// Optional args:
// - `args.callSiteLoc` - A `Error.captureStackTrace`d object with a stack to
//   include as `error.log.stacktrace`.
// - `args.traceContext` - The current TraceContext, if any.
// - `args.trans` - The current transaction, if any.
// - `args.errorContext` - An object to be included as `error.context`.
// - `args.message` - A message string that will be included as `error.log.message`
//   if `args.exception` is given. Ignored if `args.logMessage` is given.
// - `args.exceptionType` - A string to use for `error.exception.type`. By
//   default `args.exception.name` is used. This argument is only relevant if
//   `args.exception` was provided.
//
// This always calls back with `cb(null, apmError)`, i.e. it doesn't fail.
function createAPMError (args, cb) {
  let numAsyncStepsRemaining = 0 // finish() will call cb() only when this is 0.

  const error = {
    id: args.id,
    timestamp: args.timestampUs
  }
  if (args.traceContext) {
    error.parent_id = args.traceContext.traceparent.id
    error.trace_id = args.traceContext.traceparent.traceId
  }
  if (args.trans) {
    error.transaction_id = args.trans.id
    error.transaction = {
      name: args.trans.name,
      type: args.trans.type,
      sampled: args.trans.sampled
    }
  }
  if (args.errorContext) {
    error.context = args.errorContext
  }

  if (args.exception) {
    // Handle an exception, i.e. `captureError(<an Error instance>, ...)`.
    const err = args.exception
    const errMsg = String(err.message)
    error.exception = {
      message: errMsg,
      type: args.exceptionType || String(err.name),
      handled: args.handled
    }

    if ('code' in err) {
      error.exception.code = String(err.code)
    } else {
      // To provide better grouping of mysql errors that happens after the async
      // boundery, we modify to exception type to include the custom mysql error
      // type (e.g. ER_PARSE_ERROR)
      var match = errMsg.match(MYSQL_ERROR_MSG_RE)
      if (match) {
        error.exception.code = match[1]
      }
    }

    // Optional add an alternative error message as well as the exception message.
    if (args.message && typeof args.message === 'string') {
      error.log = { message: args.message }
    }

    if (args.shouldCaptureAttributes) {
      const attrs = attributesFromErr(err)
      if (attrs) {
        error.exception.attributes = attrs
      }
    }

    numAsyncStepsRemaining++
    gatherStackTrace(
      args.log,
      args.exception,
      args.sourceLinesAppFrames,
      args.sourceLinesLibraryFrames,
      null, // filterCallSite
      function (_err, stacktrace) {
        // _err from gatherStackTrace is always null.

        const culprit = culpritFromStacktrace(stacktrace)
        if (culprit) {
          error.culprit = culprit
        }
        const moduleName = _moduleNameFromFrames(stacktrace)
        if (moduleName) {
          // TODO: consider if we should include this as it's not originally what module was intended for
          error.exception.module = moduleName
        }
        error.exception.stacktrace = stacktrace
        finish()
      }
    )
  } else {
    // Handle a logMessage, i.e. `captureError(<not an Error instance>, ...)`.
    error.log = {}
    const msg = args.logMessage
    if (typeof msg === 'string') {
      error.log.message = msg
    } else if (typeof msg === 'object' && msg !== null) {
      if (msg.message) {
        error.log.message = util.format.apply(this, [msg.message].concat(msg.params))
        error.log.param_message = msg.message
      } else {
        error.log.message = util.inspect(msg)
      }
    } else {
      error.log.message = String(msg)
    }
  }

  if (args.callSiteLoc) {
    numAsyncStepsRemaining++
    gatherStackTrace(
      args.log,
      args.callSiteLoc,
      args.sourceLinesAppFrames,
      args.sourceLinesLibraryFrames,
      null, // filterCallSite
      function (_err, stacktrace) {
        // _err from gatherStackTrace is always null.

        if (stacktrace) {
          // In case there isn't any log object, we'll make a dummy message
          // as the APM Server requires a message to be present if a
          // stacktrace also present
          if (!error.log) {
            error.log = { message: error.exception.message }
          }
          error.log.stacktrace = stacktrace
          finish()
        }
      }
    )
  } else {
    numAsyncStepsRemaining++
    setImmediate(finish)
  }

  function finish () {
    numAsyncStepsRemaining--
    if (numAsyncStepsRemaining === 0) {
      cb(null, error)
    }
  }
}

module.exports = {
  generateErrorId,
  createAPMError,

  // Exported for testing.
  attributesFromErr,
  _moduleNameFromFrames
}
