/**
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 { trackResult } = require('../common');
const TagRange = require('../../models/tag-range');
const tagRangeUtil = require('../../models/tag-range/util');
const tracker = require('../../../tracker');
const path = require('path');

/**
 * Splits string by separator
 * If win32 it will first replace `/` with `\`
 *
 * @param {string[]} str string to split
 * @param {boolean} win32 flag to indicate win32
 *
 */
function splitString(str, win32) {
  // windows treats both (forward and backward) slashes as separators
  const posixRegEx = /(?=\/)/g;
  const win32RegEx = /(?=[/\\])/g;

  if (win32) {
    str = str.replace(/\//g, '\\').split(win32RegEx);
  } else {
    str = str.split(posixRegEx);
  }
  if (str.length) {
    // if there is an extension split it out.
    const ix = str[str.length - 1].lastIndexOf('.');
    if (ix > 0) {
      const last = str[str.length - 1];
      str[str.length - 1] = last.substr(0, ix);
      str.push(last.substr(ix + 1));
    }
  }
  return str;
}

/**
 * Gets the offset from a string(result or arg) of path.resolve for a given path segment
 *
 * @param {Object} meta object of metadata from result/arg
 * @param {(segmentOffset: number) => boolean} meta.evaluator expression to determine the proper position of segment in string
 * @param {string} meta.str result from path.resolve or arg
 * @param {number} offset offset of full arg from result of path.resolve
 * @param {string} segment path segment from one of the args to path.resolve
 */
// eslint-disable-next-line complexity
function getSegmentOffset({ meta: { str, evaluator }, offset, segment, win32, validateAgainstResult }) {
  // as the segments in each arg and in the result get traversed,
  // winnow away the string to ensure we are finding the proper
  // segment within the path
  const substr = str.substring(offset);
  const { sep } = path[win32 ? 'win32' : 'posix'];
  let segmentOffset;
  // Sometimes the segment starts with a separator, but the path method removes the initial separator if the path is not absolute
  if (validateAgainstResult && segment.startsWith(sep) && offset == 0 && !['\\', '/', '.'].includes(substr[0]) && !['\\', '/'].includes(segment)) {
    segment = segment.slice(1);
  }
  segmentOffset = substr.indexOf(segment);

  // If tracking separators, evaluator will fail on extensions
  if (substr === `.${segment}`) {
    return { offset: segmentOffset + offset, value: segment };
  }

  // Adjust offset if the segment does not start with a separator
  if (substr.startsWith(sep) && !segment.startsWith(sep)) {
    segmentOffset--;
  }

  return { offset: evaluator(segmentOffset) ? segmentOffset + offset : -1, value: segment };
}

/**
 * Checks to see if a string intersects a tag
 *
 * @param {string} str result from path.resolve
 * @param {number} start offset of full arg from result of path.resolve
 * @param {TagRange} tag tagRange to check against
 */
function isWithinTag(str, start, tag) {
  const stop = str.length - 1 + start;

  return (
    (stop <= tag.stop && stop >= tag.start) ||
    (start >= tag.start && start <= tag.stop) ||
    (tag.start >= start && tag.start <= stop)
  );
}

/**
 * Moves the start based on offset within argument and offset of arg within result
 *
 * @param {Object} param
 * @param {TagRange} params.tag instance of TagRange class
 * @param {number} params.argOffset offset of segment within an arg to path.resolve
 * @param {number} params.offset offset of segment within result of path.resolve
 */
function adjustStart({ tag, argOffset, offset }) {
  const start = tag.start - argOffset;

  return start > 0 ? start + offset : offset;
}

/**
 * Moves the stop based on offset within argument and offset of arg within result
 *
 * @param {Object} param
 * @param {TagRange} params.tag instance of TagRange class
 * @param {number} params.argOffset offset of segment within an arg to path.resolve
 * @param {number} params.offset offset of segment within result of path.resolve
 * @param {string} params.segement path segment from one of the args to path.resolve
 */
function adjustStop({ tag, argOffset, offset, segment }) {
  if (tag.stop === argOffset) {
    return tag.stop;
  }

  let stop = tag.stop - argOffset + offset;

  if (stop >= segment.length + offset) {
    stop = segment.length + offset - 1;
  }

  return stop;
}

/**
 * path.resolve will ignore previously resolved path segments
 * if a proceeding arg starts with a separator
 *
 * i.e. - path.resolve('/root', '/path', '/new-path', 'test/me');
 * results in /new-path/test/me
 *
 * We need to reset the result offset to 0 in this case
 *
 * @param {Object} params
 * @param {number} params.index index of argument
 * @param {string} params.arg argument
 * @param {Object} params.resultMeta
 */
function maybeUpdateResultMeta({ index, arg, resultMeta }) {
  if (
    index !== 0 &&
    resultMeta.method === 'path.resolve' &&
    path.win32.isAbsolute(arg)
  ) {
    resultMeta.offset = 0;
  }
}

/**
 * Calculates a new offset based on a segment
 *
 * @param {Object} offset offset of the object
 * @param {string} segment path segment from one of the args to path.resolve
 */
function calculateNewOffset(offset, segment) {
  return offset + segment.length;
}

/**
 * `..` and `.` get removed from path.resolve results
 * Also since we're splitting on `/` we can safely ignore empty string
 *
 * @param {string} segment path segment to check
 * @return {boolean}
 */
function isApplicableSegment(segment) {
  return segment !== '.' && segment !== '..' && segment !== '';
}

/**
 * Filters invalid segments like those that are not part of the end result.
 * It also calculates additional offset.
 *
 * @param {*} segments splitted path into segments
 * @param {Object} resultMeta metadata from result of path.resolve
 * @param {Object} data the data object of the propagate function
 * @param {number} index the iteration index
 * @param {boolean} win32 indicating win32 to replace `/` with `\'
 */
function filterSegmentsAndCalculateOffset(
  segments,
  resultMeta,
  data,
  index,
  win32
) {
  const { sep } = path[win32 ? 'win32' : 'posix'];
  const isFirstIteration = index === 0;
  const validSegments = [];
  let additionalOffset = 0;

  segments.reduce(
    // eslint-disable-next-line complexity
    (accumulator, segment) => {
      let hasChange = false;
      if (!isApplicableSegment(segment)) return accumulator;

      if (!accumulator.isModified && !isFirstIteration) {
        const previousArg = data.args[index - 1];

        const sepAdded = !segment.startsWith(sep) && !previousArg.endsWith(sep);
        const sepTaken = segment.startsWith(sep) && previousArg.endsWith(sep);
        accumulator.isModified = sepAdded || sepTaken;
        hasChange = sepAdded || sepTaken;
        additionalOffset = hasChange ? (sepAdded ? 1 : -1) : 0;
      }


      const validatedSegment = getSegmentOffset({
        meta: resultMeta,
        offset: accumulator.offset + additionalOffset + accumulator.fileDotSeparator,
        segment,
        win32,
        validateAgainstResult: true
      });

      const { offset: segmentOffset, value: segmentValue } = validatedSegment;

      // no reason to proceed if segment is not in final result
      if (segmentOffset === -1) {
        if (hasChange) {
          additionalOffset = 0;
          accumulator.isModified = false;
        }

        return accumulator;
      }

      // in case we have a file we need to increment the offset
      if (resultMeta.str[segmentOffset + segmentValue.length] === '.') {
        accumulator.fileDotSeparator = 1;
      }
      validSegments.push(validatedSegment);
      accumulator.offset = calculateNewOffset(accumulator.offset, segmentValue);
      accumulator.isModified = true;

      return accumulator;
    },
    {
      offset: resultMeta.offset,
      fileDotSeparator: 0,
      isModified: false
    }
  );

  return {
    additionalOffset,
    segments: validSegments
  };
}

/**
 * Moves the tag ranges properly based on if a path segment exists in the
 * result
 *
 * @param {Object} resultMeta metadata for the result
 * @param {Object} argMeta meta for a given argument
 * @param {boolean} win32 indicating win32 to replace `/` with `\'
 * @param {Object} data the data object of the propagate function
 * @param {number} index the iteration index
 */
function adjustTagsToPart(resultMeta, argMeta, win32, data, index) {
  const { str, tagRanges } = argMeta;
  const { segments, additionalOffset } = filterSegmentsAndCalculateOffset(
    splitString(str, win32),
    resultMeta,
    data,
    index,
    win32
  );

  // updating the offset
  resultMeta.offset += additionalOffset;

  return segments.reduce((newTags, segment) => {
    const { offset, value: segmentValue } = segment;
    const { offset: argOffset } = getSegmentOffset({ meta: argMeta, offset: argMeta.offset, segment: segmentValue, win32, validateAgainstResult: false });

    // updating the offset
    resultMeta.offset = calculateNewOffset(resultMeta.offset, segmentValue);
    argMeta.offset = calculateNewOffset(argMeta.offset, segmentValue);

    // in case we have a file we need to increment the offset
    if (resultMeta.str[offset + segment.value.length] === '.') {
      resultMeta.offset += 1;
    }

    tagRanges.forEach((tag) => {
      if (!isWithinTag(segmentValue, argOffset, tag)) return;

      const start = adjustStart({
        tag,
        argOffset,
        offset
      });
      const stop = adjustStop({
        tag,
        argOffset,
        offset,
        segment: segmentValue
      });

      newTags.push(new TagRange(start, stop, tag.tag));
    });

    return newTags;
  }, []);
}

function propagate({ resultMeta, data, win32 }) {
  if (!resultMeta.evaluator) {
    resultMeta.evaluator = (segmentOffset) => segmentOffset === 0;
  }

  data.args.forEach((arg, index) => {
    const argData = tracker.getData(arg);

    maybeUpdateResultMeta({ resultMeta, index, arg });

    const argMeta = {
      evaluator: (segmentOffset) => segmentOffset > -1,
      offset: 0,
      str: arg,
      tagRanges: argData ? argData.tagRanges : []
    };

    const targetTagRanges = adjustTagsToPart(
      resultMeta,
      argMeta,
      win32,
      data,
      index
    );

    if (targetTagRanges.length > 0) {
      resultMeta.parents.push(argData.event);
      tagRangeUtil.addAllInPlace(resultMeta.tagRanges, targetTagRanges);
    }
  });

  trackResult(resultMeta, data);
}

function getResultMeta(data, method) {
  let offset = 0;
  if (method === 'resolve' && !path.win32.isAbsolute(data.args[0])) {
    offset = process.cwd().length;
  }

  return {
    method: `path.${method}`,
    offset,
    str: data.result,
    tagRanges: [],
    parents: []
  };
}

module.exports = {
  absolutePath: path.win32.isAbsolute,
  getResultMeta,
  adjustStart,
  adjustStop,
  adjustTagsToPart,
  getSegmentOffset,
  isWithinTag,
  propagate,
  splitString
};
