/* @copyright Itential, LLC 2018 */

// Set globals
/* global log g_redis */
/* eslint consistent-return: warn */
/* eslint global-require: warn */
/* eslint import/no-dynamic-require: warn */

/* NodeJS internal utilities */
const fs = require('fs');
const path = require('path');
const AsyncLockCl = require('async-lock');

// The schema validator
const AjvCl = require('ajv');

/* Fetch in the other needed components for the this Class */
const RestHandlerCl = require(path.join(__dirname, '/restHandler.js'));
const PropUtilCl = require(path.join(__dirname, '/propertyUtil.js'));
const ConnectorCl = require(path.join(__dirname, '/connectorRest.js'));
const TransUtilCl = require(path.join(__dirname, '/translatorUtil.js'));
const DBUtilCl = require(path.join(__dirname, '/dbUtil.js'));

let id = null;
const allowFailover = 'AD.300';
const noFailover = 'AD.500';
let dbUtilInst = null;
let propUtilInst = null;
let transUtilInst = null;
const NS_PER_SEC = 1e9;
let username = null;

// used for local cache or a temp if using redis
let cache = {};
const cachelock = 0;
let clock = null;

// INTERNAL FUNCTIONS
/**
 * @summary Validate the properties have been provided for the libraries
 *
 * @function validateProperties
 * @param {String} entityName - the name of the entity (required)
 * @param {String} actionName - the name of the action to take (required)
 *
 * @return {Object} entitySchema - the entity schema object
 */
function validateProperties(properties) {
  const origin = `${id}-requestHandler-validateProperties`;
  log.trace(origin);

  try {
    // get the path for the specific action file
    const propertyFile = path.join(__dirname, '/../schemas/propertiesSchema.json');

    // Read the action from the file system
    const propertySchema = JSON.parse(fs.readFileSync(propertyFile, 'utf-8'));

    // add any defaults to the data
    const combinedProps = propUtilInst.mergeProperties(properties, propUtilInst.setDefaults(propertySchema));

    // validate the entity against the schema
    const ajvInst = new AjvCl();
    const validate = ajvInst.compile(propertySchema);
    const result = validate(combinedProps);

    // if invalid properties throw an error
    if (!result) {
      // create the generic part of an error object
      const errorObj = {
        origin,
        type: 'Schema Validation Failure',
        vars: [validate.errors[0].message]
      };

      // log and throw the error
      log.trace(`${origin}: Schema validation failure ${validate.errors[0].message}`);
      throw new Error(JSON.stringify(errorObj));
    }

    // need to decode/decrypt the static token if it is encoded/encrypted
    if (combinedProps.authentication && combinedProps.authentication.token
        && (combinedProps.authentication.token.indexOf('{code}') === 0
        || combinedProps.authentication.token.indexOf('{crypt}') === 0)) {
      combinedProps.authentication.token = propUtilInst.decryptProperty(combinedProps.authentication.token);
    }

    // need to decode/decrypt the password if it is encoded/encrypted
    if (combinedProps.authentication && combinedProps.authentication.password
        && (combinedProps.authentication.password.indexOf('{code}') === 0
        || combinedProps.authentication.password.indexOf('{crypt}') === 0)) {
      combinedProps.authentication.password = propUtilInst.decryptProperty(combinedProps.authentication.password);
    }

    // return the resulting properties --- add any necessary defaults
    return combinedProps;
  } catch (e) {
    return transUtilInst.checkAndThrow(e, origin, 'Issue validating properties');
  }
}

/**
 * @summary Walk through the entities and make sure they have an action file
 * and that file format is validated against actionSchema.json
 *
 * @function walkThroughActionFiles
 * @param {String} directory - the directory for the adapter (required)
 */
function walkThroughActionFiles(directory) {
  const origin = `${id}-requestHandler-walkThroughActionFiles`;
  log.trace(origin);
  const clean = [];

  try {
    // Read the action schema from the file system
    const actionSchemaFile = path.join(__dirname, '/../schemas/actionSchema.json');
    const actionSchema = JSON.parse(fs.readFileSync(actionSchemaFile, 'utf-8'));
    const entitydir = `${directory}/entities`;

    // if there is an entity directory
    if (fs.statSync(directory).isDirectory() && fs.statSync(entitydir).isDirectory()) {
      const entities = fs.readdirSync(entitydir);

      // need to go through each entity in the entities directory
      for (let e = 0; e < entities.length; e += 1) {
        // make sure the entity is a directory - do not care about extra files
        // only entities (dir)
        if (fs.statSync(`${entitydir}/${entities[e]}`).isDirectory()) {
          // see if the action file exists in the entity
          if (fs.existsSync(`${entitydir}/${entities[e]}/action.json`)) {
            // Read the entity actions from the file system
            const actions = JSON.parse(fs.readFileSync(`${entitydir}/${entities[e]}/action.json`, 'utf-8'));

            // add any defaults to the data
            const defActions = propUtilInst.setDefaults(actionSchema);
            const allActions = propUtilInst.mergeProperties(actions, defActions);

            // validate the entity against the schema
            const ajvInst = new AjvCl();
            const validate = ajvInst.compile(actionSchema);
            const result = validate(allActions);

            // if invalid properties throw an error
            if (!result) {
              // Get the action and component from the first ajv error
              let action = 'checkErrorDetails';
              let component = 'checkErrorDetails';
              const temp = validate.errors[0].dataPath;
              const actStInd = temp.indexOf('[');
              const actEndInd = temp.indexOf(']');
              // if we have indexes for the action we can get specifics
              if (actStInd >= 0 && actEndInd > actStInd) {
                const actNum = temp.substring(actStInd + 1, actEndInd);
                // get the action name from the number
                if (actions.actions.length >= actNum) {
                  action = actions.actions[actNum].name;
                }
                // get the component that failed for the action
                if (temp.length > actEndInd + 1) {
                  component = temp.substring(actEndInd + 2);
                }
              }
              let msg = `${origin}: Error on validation of actions for entity `;
              msg += `${entities[e]} - ${action} - ${component}   Details: ${JSON.stringify(validate.errors)}`;
              clean.push(msg);
              log.warn(msg);
            }

            for (let a = 0; a < actions.actions.length; a += 1) {
              const act = actions.actions[a];
              let reqSchema = null;
              let respSchema = null;

              // Check that the request schema file defined for the action exist
              if (act.requestSchema) {
                if (!fs.existsSync(`${entitydir}/${entities[e]}/${act.requestSchema}`)) {
                  let msg = `${origin}: Error on validation of actions for entity `;
                  msg += `${entities[e]}: ${act.name} - missing request schema file - ${act.requestSchema}`;
                  clean.push(msg);
                  log.warn(msg);
                } else {
                  reqSchema = JSON.parse(fs.readFileSync(`${entitydir}/${entities[e]}/${act.requestSchema}`, 'utf-8'));
                }
              } else if (act.schema && fs.existsSync(`${entitydir}/${entities[e]}/${act.schema}`)) {
                reqSchema = JSON.parse(fs.readFileSync(`${entitydir}/${entities[e]}/${act.schema}`, 'utf-8'));
              } else {
                let msg = `${origin}: Error on validation of actions for entity `;
                msg += `${entities[e]}: ${act.name} - missing request schema file - ${act.schema}`;
                clean.push(msg);
                log.warn(msg);
              }

              // Check that the response schema file defined for the action exist
              if (act.responseSchema) {
                if (!fs.existsSync(`${entitydir}/${entities[e]}/${act.responseSchema}`)) {
                  let msg = `${origin}: Error on validation of actions for entity `;
                  msg += `${entities[e]}: ${act.name} - missing response schema file - ${act.responseSchema}`;
                  clean.push(msg);
                  log.warn(msg);
                } else {
                  respSchema = JSON.parse(fs.readFileSync(`${entitydir}/${entities[e]}/${act.responseSchema}`, 'utf-8'));
                }
              } else if (act.schema && fs.existsSync(`${entitydir}/${entities[e]}/${act.schema}`)) {
                respSchema = JSON.parse(fs.readFileSync(`${entitydir}/${entities[e]}/${act.schema}`, 'utf-8'));
              } else {
                let msg = `${origin}: Error on validation of actions for entity `;
                msg += `${entities[e]}: ${act.name} - missing response schema file - ${act.schema}`;
                clean.push(msg);
                log.warn(msg);
              }

              // check that the action is in the schemas
              if (!reqSchema || !reqSchema.properties || !reqSchema.properties.ph_request_type
                  || !reqSchema.properties.ph_request_type.enum || !reqSchema.properties.ph_request_type.enum.includes(act.name)) {
                let msg = `${origin}: Error on validation of actions for entity `;
                msg += `${entities[e]}: ${act.name} - missing from ph_request_type in request schema`;
                clean.push(msg);
                log.warn(msg);
              }
              if (!respSchema || !respSchema.properties || !respSchema.properties.ph_request_type
                  || !respSchema.properties.ph_request_type.enum || !respSchema.properties.ph_request_type.enum.includes(act.name)) {
                let msg = `${origin}: Error on validation of actions for entity `;
                msg += `${entities[e]}: ${act.name} - missing from ph_request_type in response schema`;
                clean.push(msg);
                log.warn(msg);
              }

              // check that the mock data files exist
              if (act.responseObjects) {
                for (let m = 0; m < act.responseObjects.length; m += 1) {
                  if (act.responseObjects[m].mockFile) {
                    if (!fs.existsSync(`${entitydir}/${entities[e]}/${act.responseObjects[m].mockFile}`)) {
                      let msg = `${origin}: Error on validation of actions for entity `;
                      msg += `${entities[e]}: ${act} - missing mock data file - ${act.responseObjects[m].mockFile}`;
                      clean.push(msg);
                      log.warn(msg);
                    }
                  }
                }
              }
            }
          } else {
            log.warn(`${origin}: Entity ${entities[e]} missing action file.`);
            clean.push(`${origin}: Entity ${entities[e]} missing action file.`);
          }
        } else {
          log.warn(`${origin}: Entity ${entities[e]} missing entity directory.`);
          clean.push(`${origin}: Entity ${entities[e]} missing entity directory.`);
        }
      }
    }

    return clean;
  } catch (e) {
    return transUtilInst.checkAndThrow(e, origin, 'Issue validating actions');
  }
}

/**
 * @summary Method to check if the entity is in the cache
 *
 * @function isEntityCached
 * @param {String} entityType - the entity type to check for
 * @param {String/Array} entityId - the specific entity we are looking for
 *
 * @return {Array of Enumeration} - whether the entity was
 *                   'found' - entity was found
 *                   'notfound' - entity was not found
 *                   'needupdate' - update cache and try again
 */
function isEntityCached(entityType, entityId) {
  const origin = `${id}-requestHandler-isEntityCached`;
  log.trace(origin);
  let entityIds = entityId;
  const results = [];

  // go through the cache
  if (cache[entityType]) {
    const now = new Date().getTime();

    // see if the cache is valid
    if ((cache[entityType].updated) && (cache[entityType].updated >= now - 300000)) {
      // entityId is not an Array, make it one
      if (!Array.isArray(entityIds)) {
        entityIds = [entityId];
      }

      for (let e = 0; e < entityIds.length; e += 1) {
        // see if the device is in the cache
        if (cache[entityType].list.includes(entityIds[e])) {
          log.trace(`${origin}: Entity ${entityIds[e]} found in cache`);
          results.push('found');
        } else {
          log.trace(`${origin}: Entity ${entityIds[e]} not found in cache`);
          results.push('notfound');
        }
      }

      return results;
    }

    log.warn(`${origin}: Entity Cache out of date`);
    return ['needupdate'];
  }

  // Entity not found in cache
  log.warn(`${origin}: Entity not in cache`);
  return ['needupdate'];
}

/**
 * @summary Method for metric db calls and tests
 *
 * @function dbCalls
 * @param {String} entity - the entity to use. (required)
 * @param {String} action - the action to use. (required)
 * @param {Object} data - anything the user provides goes here. Possible tags:
 * data = {
 *  code : <Number or String>,
 *  numRetries : <Number>,
 *  numRedirects : <Number>,
 *  isThrottling : <Boolean>,
 *  timeouts : <Number>,
 *  queueTime : <Number>,
 *  capabilityTime : <Number>,
 *  tripTime : <Number>,
 *  overallEnd : <Number>
 * }
 *
 */
function dbCalls(collectionName, entity, action, data, callback) {
  try {
    // template_entity.createEntity logs a 201 response code which we don't track so code count doesn't show up in db.
    const filter = { entity, action }; // hypothetically each doc has unique entity+action.
    const edits = {
      $inc: {
        num_called: (data.code && (data.tripTime || data.adapterTime || data.capabilityTime)) ? 1 : 0,
        numRetries: data.retries || 0,
        numRedirects: data.redirects || 0,
        throttleCount: (data.queueTime) ? 1 : 0, // separate thing to keep count of # throttles. may not be necessary.
        timeouts: data.timeouts || 0,
        tot_queue_time: parseFloat(data.queueTime) || 0,
        tot_rnd_trip: parseFloat(data.tripTime) || 0, // = tot_rnd_trip
        tot_library: parseFloat(data.capabilityTime) || 0, // = tot_library, overall things
        tot_overall: parseFloat(data.overallTime) || 0, // = tot_library, overall things
        ['results.'.concat(data.code)]: 1 // Note: this results in the JSON recording differing from DB record: JSON doesn't make a nested document, just "results.xxx".
        // adapterTime: 0 // not accessible in this file, go to adapter.js later.tripTime
      },
      $set: {
        time_units: 'ms',
        entity,
        action,
        isThrottling: data.isThrottling
      },
      metric: {
        entity,
        action
      }
    };
    // were are we writing? fs or db
    return dbUtilInst.findAndModify(collectionName, filter, null, edits, true, null, true, (err, dbres) => {
      if (err && !dbres) {
        return callback(false);
      }
      return callback(true);
    });
  } catch (e) {
    return callback(false);
  }
}

class RequestHandler {
  /**
   * Request Handler
   * @constructor
   */
  constructor(prongId, properties, directory) {
    try {
      this.myid = prongId;
      id = prongId;
      this.props = properties;
      this.clean = [];
      this.directory = directory;
      this.suspend = false;
      this.suspendInterval = 60000;

      // need the db utilities before validation
      this.dbUtil = new DBUtilCl(this.myid, properties, directory);
      dbUtilInst = this.dbUtil;

      // need the property utilities before validation
      this.propUtil = new PropUtilCl(this.myid, directory, this.dbUtil);
      propUtilInst = this.propUtil;

      // reference to the needed classes for specific protocol handlers
      this.transUtil = new TransUtilCl(prongId, this.propUtil);
      transUtilInst = this.transUtil;

      // validate the action files for the adapter
      this.clean = walkThroughActionFiles(this.directory);

      // save the adapter base directory
      this.adapterBaseDir = directory;
      this.clockInst = new AsyncLockCl();
      clock = this.clockInst;

      // set up the properties I care about
      this.refreshProperties(properties);

      // instantiate other runtime components
      this.connector = new ConnectorCl(this.myid, this.props, this.transUtil, this.propUtil, this.dbUtil);
      this.restHandler = new RestHandlerCl(this.myid, this.props, this.connector, this.transUtil);
    } catch (e) {
      // handle any exception
      const origin = `${this.myid}-requestHandler-constructor`;
      return this.transUtil.checkAndThrow(e, origin, 'Could not start Adapter Runtime Library');
    }
  }

  /**
   * @callback Callback
   * @param {Object} result - the result of the get request
   * @param {String} error - any error that occured
   */

  /**
   * refreshProperties is used to set up all of the properties for the request handler.
   * It allows properties to be changed later by simply calling refreshProperties rather
   * than having to restart the request handler.
   *
   * @function refreshProperties
   * @param {Object} properties - an object containing all of the properties
   */
  refreshProperties(properties) {
    const origin = `${this.myid}-requestHandler-refreshProperties`;
    log.trace(origin);

    try {
      // validate the properties that came in against library property schema
      this.props = validateProperties(properties);

      // get the list of failover codes
      this.failoverCodes = [];

      if (this.props.request && this.props.request.failover_codes
          && Array.isArray(this.props.request.failover_codes)) {
        this.failoverCodes = this.props.request.failover_codes;
      }

      // get the cache location
      this.cacheLocation = 'local';

      if (this.props.cache_location) {
        this.cacheLocation = this.props.cache_location;
      }

      this.saveMetric = this.props.save_metric || false;

      // set the username (required - default is null)
      if (typeof this.props.authentication.username === 'string') {
        username = this.props.authentication.username;
      }

      // if this is truly a refresh and we have a connector or rest handler, refresh them
      if (this.connector) {
        this.connector.refreshProperties(properties);
      }
      if (this.restHandler) {
        this.restHandler.refreshProperties(properties);
      }
    } catch (e) {
      // handle any exception
      return this.transUtil.checkAndThrow(e, origin, 'Properties may not have been updated properly');
    }
  }

  /**
   * checkActionFiles is used to update the validation of the action files.
   *
   * @function checkActionFiles
   */
  checkActionFiles() {
    const origin = `${this.myid}-requestHandler-checkActionFiles`;
    log.trace(origin);

    try {
      // validate the action files for the adapter
      this.clean = walkThroughActionFiles(this.directory);
      return this.clean;
    } catch (e) {
      return ['Exception increase log level'];
    }
  }

  /**
   * checkProperties is used to validate the adapter properties.
   *
   * @function checkProperties
   * @param {Object} properties - an object containing all of the properties
   */
  checkProperties(properties) {
    const origin = `${this.myid}-requestHandler-checkProperties`;
    log.trace(origin);

    try {
      // validate the action files for the adapter
      this.testPropResult = validateProperties(properties);
      return this.testPropResult;
    } catch (e) {
      return { exception: 'Exception increase log level' };
    }
  }

  /**
   * exposeDB is used to update the adapter metrics with the overall adapter time
   *
   * @function exposeDB
   * @param {String} entity - the name of the entity for this request.
   *                          (required)
   * @param {String} action - the name of the action being executed. (required)
   * @param {String} overallTime - the overall time in milliseconds (required)
   */
  exposeDB(entity, action, overallTime) {
    const origin = `${this.myid}-requestHandler-exposeDB`;
    log.trace(origin);

    try {
      // only allow the adapter.js to update the overallTime
      const allowedData = {
        overallTime
      };
      dbCalls('metrics', entity, action, allowedData, (status) => {
        log.trace(`${origin}: ${status}`);
      });

      return true;
    } catch (e) {
      return false;
    }
  }

  /**
   * @summary Method that identifies the actual request to be made and then
   * makes the call through the appropriate Handler
   *
   * @function identifyRequest
   * @param {String} entity - the name of the entity for this request.
   *                          (required)
   * @param {String} action - the name of the action being executed. (required)
   * @param {Object} requestObj - an object that contains all of the possible
   *                              parts of the request (payload, uriPathVars,
   *                              uriQuery, uriOptions and addlHeaders
   *                              (optional). Can be a stringified Object.
   * @param {Boolean} translate - whether to translate the response. Defaults
   *                              to true. If no translation will just return
   *                              'success' or an error message
   * @param {Callback} callback - a callback function to return the result
   *                              Data/Status or the Error
   */
  identifyRequest(entity, action, requestObj, translate, callback) {
    const meth = 'requestHandler-identifyRequest';
    const origin = `${this.myid}-${meth}`;
    log.trace(`${origin}: ${entity}-${action}`);
    const overallTime = process.hrtime();

    try {
      // verify parameters passed are valid
      if (entity === null || entity === '') {
        const errorObj = this.formatErrorObject(this.myid, meth, 'Missing Data', ['entity'], null, null, null);
        log.error(`${origin}: ${errorObj.IAPerror.displayString}`);
        return callback(null, errorObj);
      }
      if (action === null || action === '') {
        const errorObj = this.formatErrorObject(this.myid, meth, 'Missing Data', ['action'], null, null, null);
        log.error(`${origin}: ${errorObj.IAPerror.displayString}`);
        return callback(null, errorObj);
      }

      // Get the entity schema from the file system
      return this.propUtil.getEntitySchema(entity, action, this.dbUtil, (entitySchema, entityError) => {
        // verify protocol for call
        if (entityError) {
          const errorObj = this.transUtil.checkAndReturn(entityError, origin, 'Issue identifiying request');
          return callback(null, errorObj);
        }

        // verify protocol for call
        if (!Object.hasOwnProperty.call(entitySchema, 'protocol')) {
          const errorObj = this.formatErrorObject(this.myid, meth, 'Missing Data', ['action protocol'], null, null, null);
          log.error(`${origin}: ${errorObj.IAPerror.displayString}`);
          return callback(null, errorObj);
        }

        // Determine the Protocol so the appropriate handler can be called
        if (entitySchema.protocol.toUpperCase() === 'REST') {
          return this.restHandler.genericRestRequest(entity, action, entitySchema, requestObj, translate, (result, error) => {
            const overallDiff = process.hrtime(overallTime);
            const overallEnd = `${Math.round(((overallDiff[0] * NS_PER_SEC) + overallDiff[1]) / 1000000)}ms`;
            if (error) {
              const newError = error;
              if (!newError.metrics) {
                newError.metrics = {};
              }

              newError.metrics.capabilityTime = overallEnd;
              // console.log(newError); //can't test b/c no errors built into tests rn.
              // will call from adapterFunction.ejs only eventually since it will have all metrics here + the 2 missing ones.
              if (this.saveMetric) {
                dbCalls('metrics', entity, action, newError.metrics, (saved) => {
                  if (saved) {
                    log.info(`${origin}: Metrics Saved`);
                  }
                });
              }
              return callback(null, newError);
            }

            let newRes = result;
            if (!newRes) {
              newRes = {
                metrics: {
                }
              };
            } else if (!newRes.metrics) {
              newRes.metrics = {};
            }

            newRes.metrics.capabilityTime = overallEnd;
            // overallEnd is from start of identifyRequest to right before dbCalls is called (error or good).
            // will call from adapterFunction.ejs only eventually since it will have all metrics here + the 2 missing ones.
            if (this.saveMetric) {
              dbCalls('metrics', entity, action, newRes.metrics, (saved) => {
                if (saved) {
                  log.info(`${origin}: Metrics Saved`);
                }
              });
            }
            return callback(newRes);
          });
        }

        // Unsupported protocols
        const errorObj = this.formatErrorObject(this.myid, meth, 'Unsupported Protocol', [entitySchema.protocol], null, null, null);
        log.error(`${origin}: ${errorObj.IAPerror.displayString}`);
        return callback(null, errorObj);
      });
    } catch (e) {
      // handle any exception
      const errorObj = this.transUtil.checkAndReturn(e, origin, 'Issue identifiying request');
      return callback(null, errorObj);
    }
  }

  /**
   * @summary Method that identifies the protocol for the healthcheck and then
   * takes the appropriate action.
   *
   * @function identifyHealthcheck
   * @param {Object} requestObj - an object that contains all of the possible
   *                              parts of the request (payload, uriPathVars,
   *                              uriQuery, uriOptions and addlHeaders
   *                              (optional). Can be a stringified Object.
   * @param {Callback} callback - a callback function to return the result of
   *                              the Healthcheck
   */
  identifyHealthcheck(requestObj, callback) {
    const meth = 'requestHandler-identifyHealthcheck';
    const origin = `${this.myid}-${meth}`;
    log.trace(origin);
    const overallTime = process.hrtime();

    try {
      let prot = this.props.healthcheck.protocol;

      // Get the entity schema from the file system
      return this.propUtil.getEntitySchema('.system', 'healthcheck', this.dbUtil, (healthSchema, healthError) => {
        if (healthError || !healthSchema || Object.keys(healthSchema).length === 0) {
          log.debug(`${origin}: Using adapter properties for healthcheck information`);
        } else {
          log.debug(`${origin}: Using action and schema for healthcheck information`);
        }

        if (healthSchema && healthSchema.protocol) {
          prot = healthSchema.protocol;
        }

        // Determine the Protocol so the appropriate handler can be called
        if (prot.toUpperCase() === 'REST') {
          return this.restHandler.healthcheckRest(healthSchema, requestObj, (result, error) => {
            const overallDiff = process.hrtime(overallTime);
            const overallEnd = `${Math.round(((overallDiff[0] * NS_PER_SEC) + overallDiff[1]) / 1000000)}ms`;
            if (error) {
              const newError = error;
              if (!newError.metrics) {
                newError.metrics = {};
              }

              newError.metrics.capabilityTime = overallEnd;
              // console.log(newError); //can't test b/c no errors built into tests rn.
              // will call from adapterFunction.ejs only eventually since it will have all metrics here + the 2 missing ones.
              if (this.saveMetric) {
                dbCalls('metrics', '.system', 'healthcheck', newError.metrics, (saved) => {
                  if (saved) {
                    log.info(`${origin}: Metrics Saved`);
                  }
                });
              }
              return callback(null, newError);
            }

            let newRes = result;
            if (!newRes) {
              newRes = {
                metrics: {
                }
              };
            } else if (!newRes.metrics) {
              newRes.metrics = {};
            }

            newRes.metrics.capabilityTime = overallEnd;
            // overallEnd is from start of identifyRequest to right before dbCalls is called (error or good).
            // will call from adapterFunction.ejs only eventually since it will have all metrics here + the 2 missing ones.
            if (this.saveMetric) {
              dbCalls('metrics', '.system', 'healthcheck', newRes.metrics, (saved) => {
                if (saved) {
                  log.info(`${origin}: Metrics Saved`);
                }
              });
            }
            return callback(newRes);
          });
        }

        // Unsupported protocols
        const errorObj = this.formatErrorObject(this.myid, meth, 'Unsupported Protocol', [prot], null, null, null);
        log.error(`${origin}: ${errorObj.IAPerror.displayString}`);
        return callback(null, errorObj);
      });
    } catch (e) {
      // handle any exception
      const errorObj = this.transUtil.checkAndReturn(e, origin, 'Issue identifiying healthcheck');
      return callback(null, errorObj);
    }
  }

  /**
   * getQueue is used to get information for all of the requests currently in the queue.
   *
   * @function getQueue
   * @param {queueCallback} callback - a callback function to return the result (Queue)
   *                                   or the error
   */
  getQueue(callback) {
    const meth = 'requestHandler-getQueue';
    const origin = `${this.myid}-${meth}`;
    log.trace(origin);

    try {
      // Determine the Protocol so the appropriate handler can be called
      if (this.props.healthcheck.protocol.toUpperCase() === 'REST') {
        return this.restHandler.getQueue(callback);
      }

      // Unsupported protocols
      const errorObj = this.formatErrorObject(this.myid, meth, 'Unsupported Protocol', [this.props.healthcheck.protocol], null, null, null);
      log.error(`${origin}: ${errorObj.IAPerror.displayString}`);
      return callback(null, errorObj);
    } catch (e) {
      // handle any exception
      const errorObj = this.transUtil.checkAndReturn(e, origin, 'Issue getting queue');
      return callback(null, errorObj);
    }
  }

  /**
   * @summary Takes in property text and an encoding/encryption and returns
   * the resulting encoded/encrypted string
   *
   * @function encryptProperty
   * @param {String} property - the property to encrypt
   * @param {String} technique - the technique to use to encrypt
   *
   * @param {Callback} callback - a callback function to return the result
   *                              Encrypted String or the Error
   */
  encryptProperty(property, technique, callback) {
    const meth = 'requestHandler-encryptProperty';
    const origin = `${this.myid}-${meth}`;
    log.trace(origin);

    try {
      const returnObj = {
        icode: 'AD.200'
      };

      returnObj.response = this.propUtil.encryptProperty(property, technique);
      return callback(returnObj);
    } catch (e) {
      // handle any exception
      const errorObj = this.transUtil.checkAndReturn(e, origin, 'Issue encrypting property');
      return callback(null, errorObj);
    }
  }

  /**
   * @summary Build a standard error object from the data provided
   *
   * @function formatErrorObject
   * @param {String} adaptId - the id of the adapter (required).
   * @param {String} origin - the originator of the error (required).
   * @param {String} failCode - the internal IAP error code or error type (required).
   * @param {Integer} sysCode - the error code from the other system (optional).
   * @param {Object} sysRes - the raw response from the other system (optional).
   * @param {Exception} stack - tany available stack trace from the issue (optional).
   *
   * @return {Object} the error object, null if missing pertinent information
   */
  formatErrorObject(adaptId, origin, failCode, variables, sysCode, sysRes, stack) {
    const morigin = `${this.myid}-requestHandler-formatErrorObject`;
    log.trace(morigin);

    // this is just a pass through for clients to use - rather then expose translator
    return this.transUtil.formatErrorObject(`${adaptId}-${origin}`, failCode, variables, sysCode, sysRes, stack);
  }

  /**
   * @summary Determines whether the error is one that can be failed over to
   * the next adapter to handle.
   *
   * @function setFailover
   * @param {Object} errorObj - the error object
   *
   * @return {Enumeration} failoverCode - a string containing the failover code
   *                                      'AD.300' - failover OK
   *                                      'AD.500' - no failover
   *                                      code from response if can not decide
   */
  setFailover(errorObj) {
    const origin = `${this.myid}-requestHandler-setFailover`;
    log.trace(origin);

    try {
      // if the errorMsg exists and has a code, check it
      if (errorObj && errorObj.code) {
        if (this.failoverCodes) {
          // is it a code that we allow failover?
          for (let f = 0; f < this.failoverCodes.length; f += 1) {
            if (errorObj.code === this.failoverCodes[f]) {
              return allowFailover;
            }
          }
        }
      }

      // if not resolved based on code, return what was in the error if provided
      if (errorObj && errorObj.icode) {
        return errorObj.icode;
      }

      // return the default result
      return noFailover;
    } catch (e) {
      log.error(`${origin}: Caught Exception ${e}`);
      return noFailover;
    }
  }

  /**
   * @summary Check the current cache to see if we know about a specific entity
   *
   * @function isEntityCached
   * @param {String} entityType - the entity type to check for
   * @param {String/Array} entityId - the specific entity we are looking for
   *
   * @return {Array of Enumeration} - whether the entity was
   *                   'found' - entity was found
   *                   'notfound' - entity was not found
   *                   'error' - there was an error - check logs
   *                   'needupdate' - update cache and try again
   */
  checkEntityCached(entityType, entityId, callback) {
    const origin = `${this.myid}-requestHandler-checkEntityCached`;
    log.trace(origin);

    try {
      if (this.cacheLocation === 'redis') {
        const ckey = `${this.myid}__%%__cache`;

        // get the cache from redis
        return g_redis.get(ckey, (err, res) => {
          if (err) {
            log.error(`${origin}: Error on retrieve cache for ${ckey}`);
            return callback(['error']);
          }

          // if there was no cache returned
          if (!res) {
            log.error(`${origin}: No cache for ${ckey}`);
            return callback(['needupdate']);
          }

          // set the local cache to what we got from redis (temp storage)
          cache = res;
          return callback(isEntityCached(entityType, entityId));
        });
      }

      return callback(isEntityCached(entityType, entityId));
    } catch (e) {
      log.error(`${origin}: Caught Exception ${e}`);
      return callback(['error']);
    }
  }

  /**
   * @summary Adds the provided entity list to the cache
   *
   * @function addEntityCache
   * @param {String} entityType - the entity type for the list
   * @param {Array} entities - the list of entities to be added
   *
   * @param {Callback} callback - whether the cache was updated
   */
  addEntityCache(entityType, entities, callback) {
    const meth = 'requestHandler-addEntityCache';
    const origin = `${this.myid}-${meth}`;
    log.trace(origin);
    let storeEnt = entities;

    if (!Array.isArray(entities)) {
      storeEnt = [entities];
    }

    const entityEntry = {
      list: storeEnt,
      updated: new Date().getTime()
    };

    try {
      // Lock the cache while adding items to it
      return clock.acquire(cachelock, (done) => {
        // if only storing locally, done
        if (this.cacheLocation === 'local') {
          // add the entities to the cache
          cache[entityType] = entityEntry;
          done(true, null);
        } else {
          // set the redis key
          const ckey = `${this.myid}__%%__cache`;

          // get the cache from redis
          g_redis.get(ckey, (err, res) => {
            if (err) {
              log.error(`${origin}: Error on retrieve cache for ${ckey}`);
              done(false, null);
            } else {
              cache = res;

              // if no cache was found
              if (!cache) {
                cache = {};
              }

              // add the entities to the cache
              cache[entityType] = entityEntry;

              // store the cache in redis
              g_redis.set(ckey, JSON.stringify(cache), (error) => {
                if (error) {
                  log.error(`${origin}: Cache: ${ckey} not stored in redis`);
                  done(false, null);
                } else {
                  done(true, null);
                }
              });
            }
          });
        }
      }, (ret, error) => {
        if (error) {
          log.error(`${origin}: Error from retrieving entity cache: ${error}`);
        }

        return callback(ret, error);
      });
    } catch (e) {
      // handle any exception
      const errorObj = this.transUtil.checkAndReturn(e, origin, 'Issue adding entity to cache');
      return callback(null, errorObj);
    }
  }

  /**
   * @summary Provides a way for the adapter to tell north bound integrations
   * whether the adapter supports type and specific entity
   *
   * @function verifyCapability
   * @param {String} entityType - the entity type to check for
   * @param {String} actionType - the action type to check for
   * @param {String/Array} entityId - the specific entity we are looking for
   *
   * @return {Array of Enumeration} - whether the entity was
   *                   'found' - entity was found
   *                   'notfound' - entity was not found
   *                   'error' - there was an error - check logs
   *                   'needupdate' - update cache and try again
   */
  verifyCapability(entityType, actionType, entityId, callback) {
    const origin = `${this.myid}-requestHandler-verifyCapability`;
    log.trace(origin);
    const entitiesD = `${this.adapterBaseDir}/entities`;

    try {
      // verify the entities directory exists
      if (!fs.existsSync(entitiesD)) {
        log.error(`${origin}: Missing ${entitiesD} directory - Aborting!`);
        return callback(['error']);
      }

      // get the entities this adapter supports
      const entities = fs.readdirSync(entitiesD);

      // go through the entities
      for (let e = 0; e < entities.length; e += 1) {
        // did we find the entity type?
        if (entities[e] === entityType) {
          // if we are only interested in the entity
          if (!actionType && !entityId) {
            return callback(['found']);
          }
          // if we do not have an action, check for the specific entity
          if (!actionType) {
            return this.checkEntityCached(entityType, entityId, callback);
          }

          // get the entity actions from the action file
          const actionFile = `${entitiesD}/${entities[e]}/action.json`;
          if (fs.existsSync(actionFile)) {
            const actionJson = require(actionFile);
            const { actions } = actionJson;

            // go through the actions for a match
            for (let a = 0; a < actions.length; a += 1) {
              if (actions[a].name === actionType) {
                // if we are not interested in a specific entity
                if (!entityId) {
                  return callback(['found']);
                }
                return this.checkEntityCached(entityType, entityId, callback);
              }
            }

            log.warn(`${origin}: Action ${actionType} not found on entity ${entityType}`);

            // return an array for the entityids since can not act on any
            const result = ['notfound'];
            if (entityId && Array.isArray(entityId)) {
              // add not found for each entity, (already added the first)
              for (let r = 1; r < entityId.length; r += 1) {
                result.push('notfound');
              }
            }
            return callback(result);
          }

          log.error(`${origin}: Action ${actionType} on entity ${entityType} missing action file`);
          return callback(['error']);
        }
      }

      log.error(`${origin}: Entity ${entityType} not found in adapter`);

      // return an array for the entityids since can not act on any
      const result = ['notfound'];
      if (entityId && Array.isArray(entityId)) {
        // add not found for each entity, (already added the first)
        for (let r = 1; r < entityId.length; r += 1) {
          result.push('notfound');
        }
      }

      return callback(result);
    } catch (e) {
      log.error(`${origin}: Caught Exception ${e}`);
      return callback(['error']);
    }
  }

  /**
   * @summary Provides a way for the adapter to tell north bound integrations
   * all of the capabilities for the current adapter
   *
   * @function getAllCapabilities
   *
   * @return {Array} - containing the entities and the actions available on each entity
   */
  getAllCapabilities() {
    const origin = `${this.myid}-requestHandler-getAllCapabilities`;
    log.trace(origin);
    const entitiesD = `${this.adapterBaseDir}/entities`;
    const capabilities = [];

    try {
      // verify the entities directory exists
      if (!fs.existsSync(entitiesD)) {
        log.error(`${origin}: Missing ${entitiesD} directory`);
        return capabilities;
      }

      // get the entities this adapter supports
      const entities = fs.readdirSync(entitiesD);

      // go through the entities
      for (let e = 0; e < entities.length; e += 1) {
        // get the entity actions from the action file
        const actionFile = `${entitiesD}/${entities[e]}/action.json`;
        const entityActions = [];

        if (fs.existsSync(actionFile)) {
          const actionJson = require(actionFile);
          const { actions } = actionJson;

          // go through the actions for a match
          for (let a = 0; a < actions.length; a += 1) {
            entityActions.push(actions[a].name);
          }
        }

        const newEntity = {
          entity: entities[e],
          actions: entityActions
        };

        capabilities.push(newEntity);
      }

      return capabilities;
    } catch (e) {
      log.error(`${origin}: Caught Exception ${e}`);
      return capabilities;
    }
  }

  /**
   * @summary get a token with the provide parameters
   *
   * @function makeTokenRequest
   * @param {Object} reqBody - Any data to add to the body of the token request
   * @param {Object} callProperties - Properties that override the default properties
   *
   * @return {Object} - containing the token(s)
   */
  makeTokenRequest(reqBody, callProperties, callback) {
    const origin = `${this.myid}-requestHandler-makeTokenRequest`;
    log.trace(origin);

    try {
      // set up the right credentials - passed in overrides default
      let useUser = username;
      if (callProperties && callProperties.authentication && callProperties.authentication.username) {
        useUser = callProperties.authentication.username;
      }

      // validate the action files for the adapter
      return this.connector.makeTokenRequest('/none/token/path', useUser, reqBody, null, callProperties, callback);
    } catch (e) {
      // handle any exception
      const errorObj = this.transUtil.checkAndReturn(e, origin, 'Issue getting token');
      return callback(null, errorObj);
    }
  }
}

module.exports = RequestHandler;
