/* @copyright Itential, LLC 2018-9 */

// Set globals
/* global log */
/* eslint global-require:warn */
/* eslint import/no-dynamic-require:warn */
/* eslint no-use-before-define: warn */
/* eslint prefer-object-spread:warn */
/* eslint prefer-destructuring:warn */

/* NodeJS internal utilities */
const fs = require('fs');
const path = require('path');

// The schema validator
const AjvCl = require('ajv');
const jsonQuery = require('json-query');
const cryptoJS = require('crypto-js');

let id = null;
let propUtilInst = null;
let translator = null;

/**
 * @summary Takes in text and a key, encodes or if key then encrypts and returns the resulting
 * encoded/encrypted string
 *
 * @function encrypt
 * @param {String} value - the text to encrypt (required)
 * @param {Object} eInfo - the encryption information (optional)
 *
 * @return {String} the encrypted/encoded string
 */
function encrypt(value, eInfo) {
  const origin = `${id}-translatorUtil-encrypt`;
  log.trace(origin);

  try {
    // verify the input for the method
    if (!value) {
      return null;
    }

    // if encrypting, return the encrypted string
    if (eInfo && eInfo.key) {
      // this is being added to support different types of encryption
      if (eInfo.type) {
        return `${cryptoJS.AES.encrypt(value, eInfo.key)}`;
      }

      return `${cryptoJS.AES.encrypt(value, eInfo.key)}`;
    }

    // if encoding, return the encoded string
    return `${Buffer.from(value).toString('base64')}`;
  } catch (e) {
    log.error(`${origin}: Encyrpt took exception: ${e}`);
    return null;
  }
}

/**
 * @summary Takes in encrypted or encoded text and decodes/decrypts it to return
 * the actual text
 *
 * @function decrypt
 * @param {String} value - the text to decrypt (required)
 * @param {Object} eInfo - the encryption information (optional)
 *
 * @return {String} the string
 */
function decrypt(value, eInfo) {
  const origin = `${id}-translatorUtil-decrypt`;
  log.trace(origin);

  try {
    // verify the input for the method
    if (!value) {
      return null;
    }

    // if decrypting, return the decrypted string
    if (eInfo && eInfo.key) {
      // this is being added to support different types of encryption
      if (eInfo.type) {
        return cryptoJS.AES.decrypt(value, eInfo.key).toString(cryptoJS.enc.Utf8);
      }

      return cryptoJS.AES.decrypt(value, eInfo.key).toString(cryptoJS.enc.Utf8);
    }

    // if decoding, return the decoded string
    return `${Buffer.from(value, 'base64').toString('ascii')}`;
  } catch (e) {
    log.error(`${origin}: Decrypt took exception: ${e}`);
    return null;
  }
}

/**
 * @summary Takes in an object and returns the value. If it is an Array, it
 * returns the first element in the array. It returns null if it can not get
 * the value from the object
 *
 * @function getValueFromObject
 * @param {Object} object - the object to extract the data from
 * @param {Object} dataSchema - the schema of data to extract
 * @param {boolean} request - whether this is an outbound request
 * @param {Boolean} dynamicFields - do we show fields not in schema
 *
 * @return {Value} the value we want (String, Boolean or Number)
 */
function getValueFromObject(object, dataSchema, request, dynamicFields) {
  const origin = `${id}-translatorUtil-getValueFromObject`;
  log.trace(origin);

  // if no value found - no object
  if (object === undefined || object === null) {
    log.spam(`${origin}: No object returning null`);
    return null;
  }

  // get the type of data we are working with
  let type = null;

  if (dataSchema && dataSchema.type && !Array.isArray(dataSchema.type)) {
    type = dataSchema.type.toLowerCase();
  } else if (dataSchema && dataSchema.type && Array.isArray(dataSchema.type)) {
    if (dataSchema.type.length === 1) {
      type = dataSchema.type[0].toLowerCase();
    } else if (dataSchema.type.length > 1) {
      if (typeof object === 'string' && dataSchema.type.toString().toLowerCase().indexOf('string') > -1) {
        type = 'string';
      } else if (typeof object === 'number' && dataSchema.type.toString().toLowerCase().indexOf('number') > -1) {
        type = 'number';
      } else if (typeof object === 'number' && dataSchema.type.toString().toLowerCase().indexOf('integer') > -1) {
        type = 'integer';
      } else if (typeof object === 'boolean' && dataSchema.type.toString().toLowerCase().indexOf('boolean') > -1) {
        type = 'boolean';
      } else if (Array.isArray(object) && dataSchema.type.toString().toLowerCase().indexOf('array') > -1) {
        type = 'array';
      } else if (typeof object === 'object' && dataSchema.type.toString().toLowerCase().indexOf('object') > -1) {
        type = 'object';
      }
    }
  }

  // if there is no type on the data, just send it through
  if (!type) {
    return object;
  }

  // if we are supposed to extract a number, boolean or string
  if (type === 'number' || type === 'integer' || type === 'boolean' || type === 'string') {
    // if data is an array, just return the first object
    if (Array.isArray(object)) {
      if (object.length === 0 || object[0] === '') {
        log.spam(`${origin}: No value returning null`);
        return null;
      }

      return object[0];
    }

    if (typeof object === 'string' && object === '') {
      log.spam(`${origin}: Empty value returning null`);
      return null;
    }

    return object;
  }

  // if extracting an array, need recursion to handle elements in the array
  if (type === 'array') {
    // if data is not an array - then can not work an array
    if (!Array.isArray(object)) {
      log.spam(`${origin}: Data is not an array returning null`);
      return null;
    }

    const returnArr = [];

    // loop through all of the elements in the array
    for (let k = 0; k < object.length; k += 1) {
      // if the array is supposed to have items (obect within it)
      if (dataSchema.items) {
        // recursive call to get array elements
        const fieldValue = getValueFromObject(object[k], dataSchema.items, request, dynamicFields);

        // if data to return, add to the array being returned
        if (fieldValue !== null) {
          returnArr.push(fieldValue);
        }
      } else {
        // just add elements to the array to be returned
        returnArr.push(object[k]);
      }
    }

    // if nothing in the array return null
    if (returnArr.length === 0) {
      log.spam(`${origin}: No data to return returning null`);
      return null;
    }

    return returnArr;
  }

  // if extracting an object, need recursion to handle properties in the object
  if (type === 'object') {
    let returnObj = {};

    if (request) {
      returnObj = buildObject(object, dataSchema, dynamicFields);
    } else {
      returnObj = extractObject(object, dataSchema, dynamicFields);
    }

    // if nothing in the array return null
    if (Object.keys(returnObj).length === 0) {
      return {};
    }

    return returnObj;
  }

  // unsupported type - return null
  log.spam(`${origin}: Unsupported data type returning null`);
  return null;
}

/**
 * INTERNAL FUNCTION
 *
 * @summary Takes in a JSON object containing an entity it then extracts the info
 * IAP cares about into a IAP Entity
 *
 * @function extractObject
 * @param {Object} dataObj - the object from the other system
 * @param {String} entitySchema - the entity schema
 * @param {Boolean} dynamicFields - do we show fields not in schema
 *
 * @return {Object} the IAP Entity
 */
function extractObject(dataObj, entitySchema, dynamicFields) {
  const origin = `${id}-translatorUtil-extractObject`;
  log.trace(origin);
  const returnObj = {};
  let addFields = dynamicFields;

  // if no translation needed - just return the object
  if (Object.hasOwnProperty.call(entitySchema, 'translate')
      && typeof entitySchema.translate === 'boolean' && entitySchema.translate === false) {
    return dataObj;
  }
  // Should allow dymanic fields on this object? - change to inherited
  if (Object.hasOwnProperty.call(entitySchema, 'dynamicfields')
      && typeof entitySchema.dynamicfields === 'boolean') {
    addFields = entitySchema.dynamicfields;
  }
  // if there are no properties - if addFields is true return object
  if (!entitySchema.properties && (addFields)) {
    return dataObj;
  }
  // if there are no properties and no dynamic fields, return null
  if (!entitySchema.properties) {
    return returnObj;
  }

  const schemaKeys = Object.keys(entitySchema.properties);

  // loop through all of the properties in the schema
  for (let k = 0; k < schemaKeys.length; k += 1) {
    const field = entitySchema.properties[schemaKeys[k]];

    // if this property has an external name, need to see if in the data object
    if (Object.hasOwnProperty.call(field, 'external_name')) {
      // if the external name is something get that field
      if (field.external_name) {
        // need to determine the field in the incoming object where the data is
        const externalPath = field.external_name.split('.');
        let location = dataObj;
        let inField = null;

        // get to the field in the object
        for (let a = 0; a < externalPath.length; a += 1) {
          // if we are at the point to get the data, get the data
          if (a === externalPath.length - 1) {
            inField = location[externalPath[a]];
          } else if (location[externalPath[a]]) {
            // walk down the object path, if it exists
            location = location[externalPath[a]];
          } else {
            // if the path does not exist, break the loop and value is null
            break;
          }
        }

        // get the field value from the data so it can be put in the return
        let fieldValue = getValueFromObject(inField, field, false, addFields);

        // if we are decoding/decrypting the value
        if (field.encode || (field.encrypt && field.encrypt.key)) {
          fieldValue = decrypt(fieldValue, field.encrypt);
        }

        // if in the data object, add to the IAP entity
        if (fieldValue !== null) {
          if (field.respFilter) {
            returnObj[schemaKeys[k]] = jsonQuery(field.respFilter, { data: fieldValue }).value;
          } else {
            returnObj[schemaKeys[k]] = fieldValue;
          }
        }
      }
      // if the external field is null or empty then we ignore the field!
    } else {
      // if the field does not have an external name then use field key
      let fieldValue = getValueFromObject(dataObj[schemaKeys[k]], field, false, addFields);

      // if we are decoding/decrypting the value
      if (field.encode || (field.encrypt && field.encrypt.key)) {
        fieldValue = decrypt(fieldValue, field.encrypt);
      }

      // if in the data object, add to the IAP entity
      if (fieldValue !== null) {
        if (field.respFilter) {
          returnObj[schemaKeys[k]] = jsonQuery(field.respFilter, { data: fieldValue }).value;
        } else {
          returnObj[schemaKeys[k]] = fieldValue;
        }
      }
    }
  }

  // if we should allow dymanic fields on this object, add the fields
  if (addFields) {
    const objectKeys = Object.keys(dataObj);

    // loop through all of the fields in the object
    for (let o = 0; o < objectKeys.length; o += 1) {
      let found = false;

      // loop through all of the properties in the schema
      for (let en = 0; en < schemaKeys.length; en += 1) {
        const field = entitySchema.properties[schemaKeys[en]];

        // if the field is not in the schema - we need to add it
        // using the field name that came in since no translation
        if (field.external_name) {
          const externalPath = field.external_name.split('.');

          if (externalPath[externalPath.length - 1] === objectKeys[o]) {
            found = true;
          }
        } else if (schemaKeys[en] === objectKeys[o]) {
          found = true;
        }
      }

      // if not found, add it
      if (!found) {
        returnObj[objectKeys[o]] = dataObj[objectKeys[o]];
      }
    }
  }

  // return the resulting IAP entity
  return returnObj;
}

/**
 * @summary Checks to see if any fields in the schema need to be parsed
 *
 * @function parseFields
 * @param {Object} retObject - the object to extract the data from
 * @param {Array} dataSchema - the schema of data to extract
 *
 * @return {Object} the return object with parsed data
 */
function parseFields(retObject, dataSchema) {
  const origin = `${id}-translatorUtil-parseFields`;
  log.trace(origin);
  const myReturn = retObject;
  const schemaKeys = Object.keys(dataSchema);

  // loop through all of the properties in the schema
  for (let k = 0; k < schemaKeys.length; k += 1) {
    const field = dataSchema[schemaKeys[k]];

    // if object or array need rercursion
    if (field.type === 'object' && field.properties && myReturn && myReturn[schemaKeys[k]]) {
      myReturn[schemaKeys[k]] = parseFields(myReturn[schemaKeys[k]], field.properties);
    }
    if (field.type === 'array' && field.items && myReturn && myReturn[schemaKeys[k]]) {
      myReturn[schemaKeys[k]] = parseFields(myReturn[schemaKeys[k]], field.items);
    }

    // if string and we need to parse this field
    if (field.type === 'string' && field.parse && myReturn && myReturn[schemaKeys[k]]) {
      try {
        myReturn[schemaKeys[k]] = JSON.parse(myReturn[schemaKeys[k]]);
      } catch (ex) {
        log.warn(`${origin}: Could not parse data in field`);
      }
    }
  }
  return myReturn;
}

/**
 * INTERNAL FUNCTION
 *
 * @summary Takes in a JSON object containing an entity it then extracts the info
 * IAP cares about into a IAP Entity. This object is then merged with
 * default data and validated against the provided entity schema.
 *
 * @function extractJSONEntity
 * @param {Object} dataObj - the object from the other system
 * @param {String} entitySchema - the entity schema
 *
 * @return {Object} the IAP Entity
 */
function extractJSONEntity(dataObj, entitySchema) {
  const origin = `${id}-translatorUtil-extractJSONEntity`;
  log.trace(origin);
  const returnObj = extractObject(dataObj, entitySchema, false);

  try {
    // add any defaults to the data
    let combinedEntity = propUtilInst.mergeProperties(returnObj, propUtilInst.setDefaults(entitySchema));

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

    // 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.error(`${origin}: Schema validation failure ${validate.errors[0].message}`);
      throw new Error(JSON.stringify(errorObj));
    }

    // if IAP request type exists, remove it (internal use only)
    if (combinedEntity.ph_request_type) {
      delete combinedEntity.ph_request_type;
    }

    // see if we need to parse fields
    // only if translating
    if (entitySchema.translate) {
      // must have properties
      if (entitySchema.properties) {
        // get the schema keys
        combinedEntity = parseFields(combinedEntity, entitySchema.properties);
      }
    }

    // return the resulting IAP entity
    return combinedEntity;
  } catch (e) {
    return translator.checkAndThrow(e, origin, 'Issue extracting JSON');
  }
}

/**
 * INTERNAL FUNCTION
 *
 * @summary Takes in a IAP object containing an entity it then extracts the info
 * the external system cares about into a System Entity
 *
 * @function buildJSONEntity
 * @param {Object} dataObj - the object from IAP
 * @param {String} entitySchema - the entity schema
 * @param {Boolean} dynamicFields - do we show fields not in schema
 *
 * @return {Object} the Entity for the other system
 */
function buildObject(dataObj, entitySchema, dynamicFields) {
  const origin = `${id}-translatorUtil-buildObject`;
  log.trace(origin);

  const returnObj = {};
  let addFields = dynamicFields;

  // if no translation needed - just return the object
  if (Object.hasOwnProperty.call(entitySchema, 'translate')
      && typeof entitySchema.translate === 'boolean' && entitySchema.translate === false) {
    return dataObj;
  }
  // Should allow dymanic fields on this object? - change to inherited
  if (Object.hasOwnProperty.call(entitySchema, 'dynamicfields')
      && typeof entitySchema.dynamicfields === 'boolean') {
    addFields = entitySchema.dynamicfields;
  }
  // if there are no properties - if addFields is true return object
  if (!entitySchema.properties && addFields) {
    return dataObj;
  }
  // if there are no properties and no dynamic fields, return null
  if (!entitySchema.properties) {
    return returnObj;
  }

  const schemaKeys = Object.keys(entitySchema.properties);

  // loop through all of the properties in the schema
  for (let k = 0; k < schemaKeys.length; k += 1) {
    const field = entitySchema.properties[schemaKeys[k]];

    // if this property has an external name, need to see if in the data object
    if (Object.hasOwnProperty.call(field, 'external_name')) {
      // if the external name is something get that field
      if (field.external_name) {
        let fieldValue = getValueFromObject(dataObj[schemaKeys[k]], field, true, addFields);

        // if we are encoding/encrypting the value
        if (field.encode || (field.encrypt && field.encrypt.key)) {
          fieldValue = encrypt(fieldValue, field.encrypt);
        }

        // if in the data object, add to the system entity
        if (fieldValue !== null) {
          // need to determine the field in the object where the data should go
          const externalPath = field.external_name.split('.');
          let location = returnObj;

          // get to the field in the object
          for (let a = 0; a < externalPath.length; a += 1) {
            let isArray = false;
            let tind = 0;
            let epath = externalPath[a];
            if (epath.indexOf('[') >= 0) {
              isArray = true;
              const epathparts = epath.split('[');
              epath = epathparts[0];
              const endI = epathparts[1].indexOf(']');
              tind = Number(epathparts[1].substring(0, endI));
            }

            // if we are at the point to put the data - set it
            if (a === externalPath.length - 1) {
              // if we already have this key in the object, need to merge
              if (location[epath] && typeof location[epath] === 'object') {
                if (isArray) {
                  log.warn(`${epath} is already in the data as an object - check data for errors`);
                }
                const baseObj = {};
                baseObj[epath] = fieldValue;

                // this will merge the two objects - first object is default
                this.mergeObjects(baseObj, location[epath]);
              } else if (location[epath] && Array.isArray(location[epath])) {
                if (!isArray) {
                  log.warn(`${epath} is already in the data as an array - check data for errors`);
                }
                // just push the data into the array
                location[epath].push(fieldValue);
              } else if (location[epath]) {
                if (isArray) {
                  log.warn(`${epath} is already in the data as a field - check data for errors`);
                }
                location[epath] = fieldValue;
              } else {
                // just put the value in the return object
                location[epath] = fieldValue;
              }
            } else if (location[epath]) {
              // if type does not match then it is an issue - Array v Object
              if ((isArray && typeof location[epath] === 'object') || (!isArray && Array.isArray(location[epath]))) {
                log.warn(`${epath} is already in the data and the type does not match the desired item - check data for errors`);
              }
              // if an array need to make sure the index exists and walk down the array
              if (isArray && Array.isArray(location[epath])) {
                // if the index has not been created yet - create it
                for (let i = location[epath].length; i <= tind; i += 1) {
                  location[epath].push({});
                }
                location = location[epath][tind];
              } else {
                // walk down the object path, if it exists
                location = location[epath];
              }
            } else if (isArray) {
              // if the array does not exist yet, create it
              location[epath] = [];
              for (let i = 0; i <= tind; i += 1) {
                location[epath].push({});
              }
              location = location[epath][tind];
            } else {
              // if the sub object does not exist yet, create it
              location[epath] = {};
              location = location[epath];
            }
          }
        }
      }
      // if the external field is null or empty then we ignore the field!
    } else {
      // if the field does not have an external name then use field key
      let fieldValue = getValueFromObject(dataObj[schemaKeys[k]], field, true, addFields);

      // if we are encoding/encrypting the value
      if (field.encode || (field.encrypt && field.encrypt.key)) {
        fieldValue = encrypt(fieldValue, field.encrypt);
      }

      // if in the data object, add to the IAP entity
      if (fieldValue !== null) {
        // if we already have this key in the object, need to merge
        if (returnObj[schemaKeys[k]] && typeof returnObj[schemaKeys[k]] === 'object') {
          const baseObj = {};
          baseObj[schemaKeys[k]] = fieldValue;

          // this will merge the two objects - first object is default
          this.mergeObjects(baseObj, returnObj[schemaKeys[k]]);
        } else {
          // just put the value in the return object
          returnObj[schemaKeys[k]] = fieldValue;
        }
      }
    }
  }

  // if we should allow dymanic fields on this object, add the fields
  if (addFields) {
    const objectKeys = Object.keys(dataObj);

    // loop through all of the fields in the object
    for (let o = 0; o < objectKeys.length; o += 1) {
      // if the field is not in the schema - we need to add it
      // using the field name that came in since no translation
      if (!schemaKeys.includes(objectKeys[o])) {
        returnObj[objectKeys[o]] = dataObj[objectKeys[o]];
      }
    }
  }

  // return the resulting system entity
  return returnObj;
}

/**
 * INTERNAL FUNCTION
 *
 * @summary Takes in a IAP object containing an entity. This object is then merged with
 * default data and validated against the provided entity schema. It then extracts the info
 * the external system cares about into a System Entity
 *
 * @function buildJSONEntity
 * @param {Object} dataObj - the object from IAP
 * @param {String} entitySchema - the entity schema
 *
 * @return {Object} the Entity for the other system
 */
function buildJSONEntity(dataObj, entitySchema) {
  const origin = `${id}-translatorUtil-buildJSONEntity`;
  log.trace(origin);

  try {
    // add any defaults to the data
    const combinedEntity = propUtilInst.mergeProperties(dataObj, propUtilInst.setDefaults(entitySchema));

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

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

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

    // if IAP request type exists, remove it (internal use only)
    if (combinedEntity.ph_request_type) {
      delete combinedEntity.ph_request_type;
    }

    const returnObj = buildObject(combinedEntity, entitySchema, false);

    // return the resulting system entity
    return returnObj;
  } catch (e) {
    return translator.checkAndThrow(e, origin, 'Issue extracting JSON');
  }
}

class AdapterTranslatorUtil {
  /**
   * Adapter Translator Utility
   * @constructor
   */
  constructor(prongId, propUtilCl) {
    id = prongId;
    this.myid = prongId;
    this.propUtil = propUtilCl;

    // set globals (available to private functions)
    propUtilInst = this.propUtil;
    translator = this;

    // get the path for the specific error file
    const errorFile = path.join(this.propUtil.baseDir, '/error.json');

    // if the file does not exist - error
    if (!fs.existsSync(errorFile)) {
      const origin = `${this.myid}-translatorUtil-constructor`;
      log.warn(`${origin}: Could not locate ${errorFile} - errors will be missing details`);
    }

    // Read the action from the file system
    this.errors = JSON.parse(fs.readFileSync(errorFile, 'utf-8'));
    this.errors = this.errors.errors;
  }

  // GENERIC UTILITY CALLS USED BY VARIOUS TRANSLATORS
  /**
   * @summary Takes in a JSON object containing an entity it then extracts the info
   * IAP cares about into a IAP Entity
   *
   * @function mapFromOutboundEntity
   * @param {Object} inEntity - the entity from the other system
   * @param {String} entitySchema - the entity schema
   *
   * @return {Object} the Entity for use in IAP
   */
  mapFromOutboundEntity(inEntity, entitySchema) {
    const origin = `${this.myid}-translatorUtil-mapFromOutboundEntity`;
    log.trace(origin);

    // create the generic part of an error object
    const errorObj = {
      origin
    };

    try {
      // if nothing passed in, nothing to do
      if (inEntity === null) {
        // add the specific pieces of the error object
        errorObj.type = 'Missing Data';
        errorObj.vars = ['Entity'];

        // log and throw the error
        log.error(`${origin}: No Entity for mapFromOutboundEntity`);
        throw new Error(JSON.stringify(errorObj));
      }
      if (entitySchema === null) {
        // add the specific pieces of the error object
        errorObj.type = 'Missing Data';
        errorObj.vars = ['Entity Schema'];

        // log and throw the error
        log.error(`${origin}: No Entity Schema for mapFromOutboundEntity`);
        throw new Error(JSON.stringify(errorObj));
      }

      // if no translation needed on top object - just return the object
      if (Object.hasOwnProperty.call(entitySchema, 'translate')
          && typeof entitySchema.translate === 'boolean' && entitySchema.translate === false) {
        const cleanEntity = inEntity;

        // if IAP request type exists, remove it (internal use only)
        if (cleanEntity.ph_request_type) {
          delete cleanEntity.ph_request_type;
        }

        return cleanEntity;
      }

      // make sure we are working with a JSON object instead of strigified object
      const transObj = this.formatInputData(inEntity);

      // if an array of Entities, just translate the data (no objects)
      if (Array.isArray(transObj)) {
        const outEntities = [];

        for (let i = 0; i < transObj.length; i += 1) {
          // is this just an array of data or something that needs to be translated?
          if (typeof transObj[i] === 'object') {
            // move the fields we care about into a IAP Entity Object
            outEntities.push(extractJSONEntity(transObj[i], entitySchema));
          } else {
            outEntities.push(transObj[i]);
          }
        }

        return outEntities;
      }

      // if a single System Entity, should translate data and get value for objects
      // move the fields we care about into a IAP Entity Object
      return extractJSONEntity(transObj, entitySchema);
    } catch (e) {
      return this.checkAndThrow(e, origin, 'Issue mapping from outbound entity');
    }
  }

  /**
   * @summary Takes in a JSON object containing a IAP entity it then extracts the info
   * the other system cares about into an Entity
   *
   * @function MapToJSONEntity
   * @param {Object} outEntity - the entity to the other system
   * @param {String} entitySchema - the entity schema
   *
   * @return {Object} the Entity for use in the other system
   */
  mapToOutboundEntity(outEntity, entitySchema) {
    const origin = `${this.myid}-translatorUtil-mapToOutboundEntity`;
    log.trace(origin);

    // create the generic part of an error object
    const errorObj = {
      origin
    };

    try {
      // if nothing passed in, nothing to do
      if (outEntity === null) {
        // add the specific pieces of the error object
        errorObj.type = 'Missing Data';
        errorObj.vars = ['Entity'];

        // log and throw the error
        log.error(`${origin}: No Entity for mapToOutboundEntity`);
        throw new Error(JSON.stringify(errorObj));
      }
      if (!entitySchema) {
        // add the specific pieces of the error object
        errorObj.type = 'Missing Data';
        errorObj.vars = ['Entity Schema'];

        // log and throw the error
        log.error(`${origin}: No Entity Schema for mapToOutboundEntity`);
        throw new Error(JSON.stringify(errorObj));
      }

      // if no translation needed on top object - just return the object
      if (Object.hasOwnProperty.call(entitySchema, 'translate')
          && typeof entitySchema.translate === 'boolean' && entitySchema.translate === false) {
        const cleanEntity = outEntity;

        // if IAP request type exists, remove it (internal use only)
        if (cleanEntity.ph_request_type) {
          delete cleanEntity.ph_request_type;
        }

        return cleanEntity;
      }

      // make sure we are working with a JSON object instead of strigified object
      const transObj = this.formatInputData(outEntity);

      // if this is an array of Objects, translate each of the objects
      if (Array.isArray(transObj)) {
        const retObjects = [];

        for (let i = 0; i < transObj.length; i += 1) {
          // move the fields the other system cares about into the Object
          retObjects.push(buildJSONEntity(transObj[i], entitySchema));
        }

        return retObjects;
      }

      // if a single Object, should translate data and get value for objects
      // move the fields the other system cares about into the Object
      return buildJSONEntity(transObj, entitySchema);
    } catch (e) {
      return this.checkAndThrow(e, origin, 'Issue mapping to outbound entity');
    }
  }

  /**
   * @summary Takes in two objects and merges them so the returned
   * object has secondary only where no primary values were provided.
   *
   * @function mergeObjects
   * @param {Object} object - the primary object
   * @param {Object} secondary - the secondary object
   *
   * @return {Object} the properties with the merged in secondaries
   */
  mergeObjects(object, secondary) {
    const origin = `${this.myid}-translatorUtil-mergeObjects`;
    log.trace(origin);

    // do not waste time merging if there is nothing to merge
    if (!object || typeof object !== 'object') {
      return Object.assign({}, secondary);
    }
    if (!secondary || typeof secondary !== 'object') {
      return Object.assign({}, object);
    }

    const combinedObj = Object.assign({}, secondary);
    const keys = Object.keys(object);

    // loop through all of the primary object to insert them into the conbined data
    for (let k = 0; k < keys.length; k += 1) {
      const thisField = object[keys[k]];

      // if this key is to an object
      if (thisField && typeof thisField === 'object' && combinedObj[keys[k]]) {
        // recursive call with primary and secondary object
        combinedObj[keys[k]] = this.mergeObjects(thisField, combinedObj[keys[k]]);
      } else if (thisField || !combinedObj[keys[k]]) {
        // if no secondary or primary has value merge it - overriding the secondary
        combinedObj[keys[k]] = thisField;
      }
    }

    // return the merged object
    return combinedObj;
  }

  /**
   * @summary If the input is a stringified JSON object, return the JSON object. Otherwise,
   * return the object that was provided.
   *
   * @function formatInputData
   * @param {Object} input - the input that was recieved (optional).
   *                         Can be a stringified Object.
   *
   * @return {Object} formatOutput - the query params as an object
   */
  formatInputData(input) {
    const origin = `${this.myid}-translatorUtil-formatInputData`;
    log.trace(origin);

    // prepare the input we received
    let formatOutput = input;

    // if the input was passed in as a string parse the json into an object
    if (input && typeof input === 'string') {
      try {
        // parse the query parameters object that was passed in
        formatOutput = JSON.parse(input);
      } catch (err) {
        log.trace(`${origin}: Could not JSON parse the input: ${err}`);
        return input;
      }
    }

    // return the object
    return formatOutput;
  }

  /**
   * @summary Build a standard error object from the data provided
   *
   * @function formatErrorObject
   * @param {String} origin - the originator of the error (optional).
   * @param {String} type - the internal error type (optional).
   * @param {Array} variables - the variables to put into the error message (optional).
   * @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 - any available stack trace from the issue (optional).
   *
   * @return {Object} - the error object, null if missing pertinent information
   */
  formatErrorObject(origin, type, variables, sysCode, sysRes, stack) {
    log.trace(`${this.myid}-translatorUtil-formatErrorObject`);

    // add the required fields
    const errorObject = {
      icode: 'AD.999',
      IAPerror: {
        origin: `${this.myid}-unidentified`,
        displayString: 'error not provided',
        recommendation: 'report this issue to the adapter team!'
      }
    };

    if (origin) {
      errorObject.IAPerror.origin = origin;
    }
    if (type) {
      errorObject.IAPerror.displayString = type;
    }

    // add the messages from the error.json
    for (let e = 0; e < this.errors.length; e += 1) {
      if (this.errors[e].key === type) {
        errorObject.icode = this.errors[e].icode;
        errorObject.IAPerror.displayString = this.errors[e].displayString;
        errorObject.IAPerror.recommendation = this.errors[e].recommendation;
      } else if (this.errors[e].icode === type) {
        errorObject.icode = this.errors[e].icode;
        errorObject.IAPerror.displayString = this.errors[e].displayString;
        errorObject.IAPerror.recommendation = this.errors[e].recommendation;
      }
    }

    // replace the variables
    let varCnt = 0;
    while (errorObject.IAPerror.displayString.indexOf('$VARIABLE$') >= 0) {
      let curVar = '';

      // get the current variable
      if (variables && Array.isArray(variables) && variables.length >= varCnt + 1) {
        curVar = variables[varCnt];
      }
      varCnt += 1;
      errorObject.IAPerror.displayString = errorObject.IAPerror.displayString.replace('$VARIABLE$', curVar);
    }

    // add all of the optional fields
    if (sysCode) {
      errorObject.IAPerror.code = sysCode;
    }
    if (sysRes) {
      errorObject.IAPerror.raw_response = sysRes;
    }
    if (stack) {
      errorObject.IAPerror.stack = stack;
    }

    // return the object
    return errorObject;
  }

  /**
   * @summary Checks the type of error (internal v external). Then it either returns an
   * Exception object and returns the error object
   *
   * @function checkAndReturn
   * @param {Object} e - the exception to check.
   * @param {String} caller - the method that is calling this.
   * @param {String} logMsg - the message to be logged.
   *
   * @return - the error object
   */
  checkAndReturn(e, caller, logMsg) {
    log.trace(`${this.myid}-translatorUtil-checkAndReturn`);
    let internal = null;
    let origin = caller;

    if (!caller) {
      origin = `${this.myid}-unidentified`;
    }

    // create the generic part of an error object
    const errorObj = {
      origin,
      type: 'Caught Exception',
      vars: [],
      syscode: null,
      sysresp: null,
      exception: e
    };

    // determine if we already had an internal message
    try {
      internal = JSON.parse(e.message);
    } catch (ex) {
      // message was not internal
      log.error(`${origin}: ${logMsg}: ${e}`);
      internal = null;
    }

    // return the appropriate error message
    if (internal && internal.origin && internal.type) {
      return this.formatErrorObject(internal.origin, internal.type, internal.vars, internal.syscode, internal.sysresp, internal.exception);
    }

    return this.formatErrorObject(errorObj.origin, errorObj.type, errorObj.vars, errorObj.syscode, errorObj.sysresp, errorObj.exception);
  }

  /**
   * @summary Checks the type of error (internal v external). Then it either rethrows it or
   * creates an Exception object and throws that
   *
   * @function checkAndThrow
   * @param {Object} e - the exception to check.
   * @param {String} caller - the method that is calling this.
   * @param {String} logMsg - the message to be logged.
   *
   * @return - throws a new Error
   */
  checkAndThrow(e, caller, logMsg) {
    log.trace(`${this.myid}-translatorUtil-checkAndThrow`);
    let internal = null;
    let origin = caller;

    if (!caller) {
      origin = `${this.myid}-unidentified`;
    }

    // create the generic part of an error object
    const errorObj = {
      origin,
      type: 'Caught Exception',
      vars: [],
      syscode: null,
      sysresp: null,
      exception: e
    };

    // determine if we already had an internal message
    try {
      internal = JSON.parse(e.message);
    } catch (ex) {
      // message was not internal
      log.error(`${origin}: ${logMsg}: ${e}`);
      internal = null;
    }

    // return the appropriate error message
    if (internal && internal.origin && internal.type) {
      throw e;
    } else {
      throw new Error(JSON.stringify(errorObj));
    }
  }
}

module.exports = AdapterTranslatorUtil;
