/**
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';
/**
 * Utility module providing static functions for working with TagRanges
 * @module lib/assess/models/tag-range/util.js
 */

const groupBy = require('lodash/groupBy');
const TagRange = require('.');
const { EXCLUSION_TAG_NAMESPACE } = require('../../../constants');
const logger = require('../../../core/logger')('contrast:rules:TagRange/util');
const TagRangeRelationships = require('./relationships');

/**
 * Add TagRange to list and resolve overlaps.
 *
 * @param {TagRange[]} list the list to add to
 * @param {TagRange} tagRange the TagRange to add
 * @return {TagRange[]} modified list
 */
function add(list, tagRange) {
  const resultTagRanges = list.map((r) => r.clone());
  addInPlace(resultTagRanges, tagRange);
  return resultTagRanges;
}

function addInPlace(list, tagRange) {
  let existingTag;
  for (let i = 0; i < list.length; i++) {
    existingTag = list[i];
    if (existingTag.overlaps(tagRange)) {
      existingTag.merge(tagRange);
      return;
    }
  }
  list.push(tagRange);
}

/**
 * Add all of one list to another list.
 *
 * @param {TagRange[]} list1
 * @param {TagRange[]} list2
 * @return {TagRange[]} merged list
 */
function addAll(list1 = [], list2 = []) {
  if (list2.length === 0) {
    return list1;
  } else if (list1.length === 0) {
    return list2;
  }

  const resultTagRanges = list1.map((r) => r.clone());
  addAllInPlace(resultTagRanges, list2);
  return resultTagRanges;
}

function addAllInPlace(list1, list2) {
  if (!list2) {
    return;
  }
  for (let i = 0; i < list2.length; i++) {
    addInPlace(list1, list2[i]);
  }
}

/**
 * Takes of given names out of a list
 *
 * @param {TagRange[]} list list of tags to remove from
 * @param {string[]} tagNames the names of the tags to remove
 * @return {TagRange[]}filtered list
 */
function remove(list, tagNames) {
  if (tagNames == null || tagNames.length === 0) {
    return list;
  }
  const resultTagRanges = list.map((r) => r.clone());
  removeInPlace(resultTagRanges, tagNames);
  return resultTagRanges;
}

function removeInPlace(list, tagNames) {
  if (tagNames == null) {
    return;
  }
  for (let i = list.length - 1; i >= 0; i--) {
    if (tagNames.indexOf(list[i].tag) >= 0) {
      list.splice(i, 1);
    }
  }
}

/**
 * Check if a list of TagRanges contains a tag by a given name.
 *
 * @param {TagRange[]} list the first list to merge
 * @param {string} tagName the tag name to search for
 * @return {boolean} (true|false) whether or not the list contains a tag with the right name
 */
function contains(list, tagName) {
  for (let i = 0; i < list.length; i++) {
    if (list[i].tag === tagName) {
      return true;
    }
  }
  return false;
}

/**
 * Trim a list based on start and stop. This is used when reducing the size of string,
 * as in `string.trim()` or any other operation that reduces the size of the string or
 * a tag range associated with a string; start and stop represent the indexes that are
 * being kept.
 *
 * list [TagRange(0, 16, "untrusted")]
 * trimmed = trim(list, 2, 14)   // keep indexes 2 through 14
 * string "  i am a string  "
 * index   0123456789.123456
 * trimmed   0123456789.12
 * trimmed === TagRange(0, 12, "untrusted")
 *
 * the tag ranges are adjusted to make them match the new bounds specified by start and
 * stop. tag ranges falling outside the new bounds are removed.
 *
 * @param {TagRange[]} list the list of ranges to trim
 * @param {number} start
 * @param {number} stop
 * @return {TagRange[]} trimmed list
 */
function trim(list, start, stop) {
  if (start < 0) {
    logger.debug(
      new Error('attempt to trim invalid tag range detected (start < 0)')
    );
    return [];
  } else if (stop < start) {
    logger.debug(
      new Error('attempt to trim invalid tag range detected (stop < start)')
    );
    return [];
  }
  const trimmedList = list.map((r) => r.clone());
  trimInPlace(trimmedList, start, stop);
  return trimmedList;
}

// eslint-disable-next-line complexity
function trimInPlace(list, start, stop) {
  if (start < 0) {
    logger.debug(
      new Error('attempt to trim invalid tag range detected (start < 0)')
    );
    list.length = 0;
  } else if (stop < start) {
    logger.debug(
      new Error('attempt to trim invalid tag range detected (stop < start)')
    );
    list.length = 0;
  }

  for (let i = list.length - 1; i >= 0; i--) {
    let newStart, newStop;
    const { start: origStart, stop: origStop, tag: origTag } = list[i];
    switch (list[i].compare({ start, stop })) {
      case TagRangeRelationships.BELOW:
        list.splice(i, 1);
        break;
      case TagRangeRelationships.LOWSPAN:
        newStart = 0;
        newStop = origStop - start;
        break;
      case TagRangeRelationships.WITHIN:
        newStart = origStart - start;
        newStop = origStop - start;
        break;
      case TagRangeRelationships.WITHOUT:
        newStart = 0;
        newStop = stop - start;
        break;
      case TagRangeRelationships.HIGHSPAN:
        newStart = origStart - start;
        newStop = stop - start;
        break;
      case TagRangeRelationships.ABOVE:
        list.splice(i, 1);
        break;
    }

    if (newStart !== undefined && newStop !== undefined) {
      list[i] = new TagRange(newStart, newStop, origTag);
    }
  }
}

/**
 * Returns a filtered lists of TagRanges by type
 * @param {string} type
 * @param {Array} tags collection of TagRanges
 * @return {Array} collection of TagRanges by type
 */
function filterMatching(type, tagRanges = []) {
  return tagRanges.filter((tagRange) => tagRange.tag === type);
}

/**
 * Returns a collection of TagRanges grouped by type
 * that do not match a type that is passed in
 *
 * @param {string} type
 * @param {Array} tags collection of TagRanges
 * @return {Array} collection of TagRanges by type
 *
 */
function filterAndGroupNonMatching(type, tagRanges = []) {
  const filteredTags = tagRanges.filter((tagRange) => tagRange.tag !== type);
  return groupBy(filteredTags, 'tag');
}

/**
 * Checks if every TagRange of one type is within another type of TagRanges
 *
 * @param {Array} tagRangeGroup collection of TagRanges to compare ranges
 * @param {Array} tagsOfType collection of TagRanges to check if they are within other TagRange
 * @return {Boolean}
 */
function tagRangesEncompass(tagRangeGroup, tagsOfType) {
  return tagsOfType.every((toEncompass) =>
    anyEncompass(toEncompass, tagRangeGroup)
  );
}

/**
 * Given a tag name and a collection of TagRanges, will return a new
 * collection containing only the TagRanges from the given collection whose
 * collective groups' ranges form a superset of the ranges in the collection
 * whose name matches the parameter.
 *
 * @param   {String}     type  -  The tag type: untrusted, url-encode, ...
 * @param   {TagRange[]} tags  -  Collection of tags to filter.
 * @returns {TagRange[]}
 */
function filterNonSupersetGroups(type, tags = []) {
  const tagsOfType = filterMatching(type, tags);
  const otherTagsGrouped = filterAndGroupNonMatching(type, tags);
  const output = [];

  for (const tagRangeGroup of Object.values(otherTagsGrouped)) {
    if (tagRangesEncompass(tagRangeGroup, tagsOfType)) {
      output.push(...tagRangeGroup);
    }
  }

  return [...output, ...tagsOfType];
}

/**
 * Given a tag range and a collection of TagRanges, return true if any
 * of the items in the collection encompass the single TagRange's range.
 *
 * @param  {TagRange}   toEncompass - The TagRange to which to compare.
 * @param  {TagRange[]} tagRanges   - The collection to test whether any
 *                                     elements encompass @toEncompass.
 * @returns {Boolean}
 */
function anyEncompass(toEncompass, tagRanges) {
  return tagRanges.some(
    (tagRange) => toEncompass.compare(tagRange) === TagRangeRelationships.WITHIN
  );
}

/**
 * Add all of one list to another list with a range offset.
 *
 * @param {TagRange[]} dest
 * @param {TagRange[]} src
 * @param {Number}     offset offset of the tag
 * @return {TagRange[]} result merged list
 */
function addAllWithOffset(left = [], right = [], offset = 0) {
  const resultTagRanges = left.map((r) => r.clone());
  addAllWithOffsetInPlace(resultTagRanges, right, offset);
  return resultTagRanges;
}

function addAllWithOffsetInPlace(left, right, offset) {
  return addAllInPlace(
    left,
    right.map((tr) => new TagRange(tr.start + offset, tr.stop + offset, tr.tag))
  );
}

/**
 * Given a collection of new TagRanges and an old collection of TagRanges, will
 * return a non-overlapping collection of TagRanges.
 *
 * @param {TagRange[]} newTagRanges
 * @param {TagRanges[]} oldTagRanges
 * @returns {TagRange[]} mergedTagRanges
 */
function merge(newTagRanges, oldTagRanges) {
  let mergedTagRanges = [];

  oldTagRanges.forEach((oldTag) => {
    const sameStart = newTagRanges.filter((tag) => tag.start === oldTag.start);

    if (sameStart.length > 0 && sameStart.stop !== oldTag.stop) {
      // if oldTag and new Tag have the same start point, oldTag is obsolete since something occurred to change range
      mergedTagRanges = add(mergedTagRanges, sameStart[0]);
    } else {
      mergedTagRanges = add(mergedTagRanges, oldTag);
    }
  });

  mergedTagRanges = addAll(mergedTagRanges, newTagRanges);
  return mergedTagRanges;
}

/**
 * Checks whether the TagRange collection has exclusion tags for the ruleId.
 * @param {string} ruleId
 * @param {TagRange[]} tagRanges
 * @returns {boolean}
 */
function tagRangesExcludeRuleId(ruleId, tagRanges) {
  for (const tagRange of tagRanges) {
    // rule-specific named input exclusions will have these tags
    if (tagRange.tag === `${EXCLUSION_TAG_NAMESPACE}:${ruleId}`) {
      logger.debug('input exclusion tag found for %s', ruleId);
      return true;
    }
  }
  return false;
}

module.exports.addAllWithOffset = addAllWithOffset;
module.exports.addAllWithOffsetInPlace = addAllWithOffsetInPlace;
module.exports.trim = trim;
module.exports.trimInPlace = trimInPlace;
module.exports.add = add;
module.exports.addInPlace = addInPlace;
module.exports.remove = remove;
module.exports.removeInPlace = removeInPlace;
module.exports.addAll = addAll;
module.exports.addAllInPlace = addAllInPlace;
module.exports.merge = merge;
module.exports.contains = contains;
module.exports.anyEncompass = anyEncompass;
module.exports.filterNonSupersetGroups = filterNonSupersetGroups;
module.exports.tagRangesExcludeRuleId = tagRangesExcludeRuleId;
