/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const flatten = require('@contrast/flat');
const UserInput = require('../app-activity/user-input');
const UserInputFactory = module.exports;
const { get } = require('lodash');

/**
 * Creates a collection of UserInputs from a data object, a set
 * of options, and a depth indicating how far into the tree should
 * the object parser should read.
 * @param   {Object}   data    The user input object, .e.g. HEADER, BODY
 * @param   {Object}   options Specifying UserInput details
 * @returns {UserInput[]}
 */
function makeWith(data, options) {
  const { depth } = options;

  const leaves = parseLeavesUpWith(data, depth);

  // take leaves and make collection
  const flattenedInputs = [leaves].flat(Infinity);

  // filter by inputs that contain value
  const filteredInputs = flattenedInputs.filter((input) => input.value);

  // group by path
  const uniqueInputs = [
    ...new Map(filteredInputs.map((item) => [item.path, item])).values()
  ];

  // group inputs by their relevant depth in tree
  // furthest leaf from root and then inward
  const sortedInputs = uniqueInputs.sort((a, b) => a.depth - b.depth);

  return createUserInputs(sortedInputs, options);
}

/**
 * Parses an object into UserInput-like objects starting at its leaves
 * and working inward to the specified level of depth.
 * @param   {Object}   data     The user input data to parse
 * @param   {Number}   times    The level of depth
 -- used for recursion only -------------------------------------------
 * @param   {String[]} [paths]  Used by recursion to traverse inward
 * @param   {Object[]} [coll]   Used by recursion to collect return val
 ----------------------------------------------------------------------
 * @returns {Object[]}
 */
function parseLeavesUpWith(
  data,
  times = Infinity,
  paths = Object.keys(flatten(data)),
  coll = []
) {
  if (times === 0) {
    return coll;
  }

  const depth = coll.length;

  if (!paths.length) {
    coll.push({ depth, value: valueToString(data) });
    return coll;
  }

  const inputsAtDepth = getNestedValues({ data, paths, depth });
  const shortenedPaths = getShortenedPaths(paths);

  coll.push(inputsAtDepth);
  return parseLeavesUpWith(data, times - 1, shortenedPaths, coll);
}

/**
 * Builds a collection of UserInput-like objects
 */
function getNestedValues({ paths, depth, data }) {
  return paths.reduce((memo, path) => {
    // FIXME: Replace lodash get function with a native one
    const value = valueToString(get(data, path));
    const name = path;
    if (value) {
      memo.push({ depth, name, path, value });
    }
    return memo;
  }, []);
}

/**
 * This is used when we're walking the tree
 * This will cut off the last layer of a path
 * and pass back unique/valid paths to parseLeavesUpWith
 *
 * so path a.b.c.d will now be a.b.c
 *
 * @param {Array} paths array of paths
 */
function getShortenedPaths(paths) {
  const shortPaths = paths.map((path) => path.substr(0, path.lastIndexOf('.')));

  const validPaths = shortPaths.filter(Boolean);
  return [...new Set(validPaths)];
}

/**
 * Returns the value as a string. If it is empty or a Number/Boolean,
 * then null is returned. If the value is not a string, then its
 * stringified representation is returned.
 * @param   {*}      value
 * @returns {string}
 */
function valueToString(value) {
  if (typeof value === 'string') {
    return value.toString();
  }

  if (value && typeof value === 'object' && Object.keys(value).length) {
    return safeStringify(value);
  }

  return null;
}

/**
 * JSON.stringify(data) in try-catch
 * @param {*} data to stringify or call toString
 * @returns {string}
 */
function safeStringify(data) {
  let string;

  try {
    string = JSON.stringify(data);
  } catch (e) {
    string = data.toString();
  }

  return string;
}

/**
 * Iterates over sorted collection of UserInput-like objects
 * merges its data with the passed in options
 *
 * @param {Array} inputs collection of UserInput-like objects
 * @param {Object} options shared options
 * @return {Array} collection of UserInputs
 */
function createUserInputs(inputs, options) {
  return inputs.map((input) => new UserInput({ ...options, ...input }));
}

/**
 * Convenience for only returning the only UserInput
 * as an object not a collection
 */
UserInputFactory.makeOne = function(data) {
  return UserInputFactory.make(data.value, data)[0];
};

/**
 * Creates a collection of UserInputs from a data object, a set
 * of options, and a depth indicating how far into the tree should
 * the object parser should read.
 * @param   {Object} data    The user input object, .e.g. HEADER, BODY
 * @param   {Object} options Specifying UserInput details
 * @returns {UserInput[]}
 */
UserInputFactory.make = function(data, options) {
  // no need to traverse a string to build inputs, only 1
  if (typeof data === 'string') {
    return createUserInputs([{ value: data }], options);
  }

  return makeWith(data, options);
};
