/**
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';

/**
 * @module lib/util/CleanStack
 */

const semver = require('semver');
const logger = require('../core/logger')('contrast:clean-stack');

const EMITTER_TYPE = 'AgentEmitter';
const EVENTS_FILE = semver.gte(process.version, '16.0.0')
  ? 'node:events'
  : 'events.js';
const STACK_TRACE_LIMIT = 25;
const isAgentPath = require('./is-agent-path');
const sourceMap = require('./source-map');

/** @typedef {() => NodeJS.CallSite[]} LazyCallSite */

/**
 * @typedef {Object} Frame
 * @property {string=} eval
 * @property {string} file
 * @property {string | number} lineNumber
 * @property {string} method
 * @property {string} type
 */

/**
 * Can lazily build a stacktrace that contains only user app code.
 */
class CleanStack {
  /**
   * @param {Function} [constructorOpt] If given, all frames above and including constructorOpt will be omitted
   * @param {Frame} topFrame a simulated callsite to inject at top of stack
   */
  constructor(constructorOpt, topFrame) {
    this.error = CleanStack.getError(STACK_TRACE_LIMIT, constructorOpt);
    this.lazyStack = CleanStack.lazyCreate(this.error);
    this.topFrame = topFrame;

    this.callsites = null;
    this.stack = null;
  }

  /**
   * Builds out the callsite object to the instance's stack property.
   * Builds out the frames object to the instance's frames property.
   * @returns {CleanStack} Returns the instance.
   */
  build() {
    if (this.stack) {
      return this;
    }

    this.callsites = this.lazyStack();
    this.stack = CleanStack.getFrames(this.callsites);

    // for rules like reflected-xss we may not be in the right place
    // when creating a stack, this allows us to inject the proper frame
    if (this.topFrame) {
      this.stack.unshift(this.topFrame);
    }

    return this;
  }

  /**
   * Given an error, returns a function that, when called, will return
   * a Callsite Array for the error.
   * @param {Error} error
   * @returns {LazyCallSite} Calling the function will return a callite.
   */
  static lazyCreate(error) {
    /* Create the error to capture the stack we want. */
    error = error || new Error();

    /* Return function that will give us the stack when we want it. */
    return function contrastLazyStack() {
      /* Preserve original */
      const { prepareStackTrace } = Error;
      Error.prepareStackTrace = function(_, stack) {
        return stack;
      };

      /* Property `stack` is a getter, thus lazy. */
      const { stack } = error;
      /* Reset original */
      Error.prepareStackTrace = prepareStackTrace;

      return stack;
    };
  }

  /**
   * Given a collection of callsites or a lazy stack function,
   * Will return an Array of frame objects.
   * @param   {LazyCallSite | NodeJS.CallSite[]} callsites
   * @returns {Frame[]}
   */
  static getFrames(callsites, agent) {
    const sites = resolveCallsite(callsites);
    const frames = sites.map(makeFrame);
    return filterClsHooked(frames)
      .filter(frameFilter)
      .reduceRight(filterContrastEvents, []);
  }

  /**
   * Formats a filename by stripping the current working dir.
   * @param   {string} fileName String to format
   * @returns {string}          The formatted file name
   */
  static formatFileName(fileName = '') {
    const cwd = `${process.cwd()}/`;
    const idx = fileName.indexOf(cwd);

    if (idx > -1) {
      return fileName.replace(cwd, ''); // + 1 to remove /
    }
    return fileName;
  }

  /**
   * Creates an error from the contructorOpt based on the stack trace limit
   * @param {number} limit Max length of the call stack created
   * @param {Function} [constructorOpt] If given, all frames above and including constructorOpt will be omitted
   * @returns {Error}
   */
  static getError(limit, constructorOpt) {
    const { stackTraceLimit } = Error;
    Error.stackTraceLimit = limit;
    const error = {};
    Error.captureStackTrace(error, constructorOpt);
    Error.stackTraceLimit = stackTraceLimit;
    return error;
  }
}

/**
 * If the callsite is a function it will return its results as a collection.
 *
 * @param   {LazyCallSite | NodeJS.CallSite[]} callsites
 * @throws  {AssertionError}      If it cannot resolve argument to Array.
 * @returns {Callsite[]}
 */
const resolveCallsite = (callsites) => {
  const resolved = typeof callsites == 'function' ? callsites() : callsites;

  if (!Array.isArray(resolved)) {
    logger.warn('Cannot resolve argument to Callsite array');
    return [];
  }

  return resolved;
};

/**
 * wraps calling getTypeName in a try catch
 * Why? I have no f'n idea but scared to change it
 * @param {NodeJS.CallSite} callsite
 * @return {string} callsite type
 */
const getCallsiteType = (callsite) => {
  try {
    return callsite.getTypeName();
  } catch (e) {
    /* swallow error */
  }
};

/**
 * Creates a frame object from a Callsite.
 *
 * @param   {NodeJS.CallSite} callsite
 * @returns {Frame}
 */
const makeFrame = (callsite) => {
  try {
    const method = callsite.getFunctionName();
    const type = getCallsiteType(callsite);
    let evalOrigin;
    let file;
    let lineNumber;
    let columnNumber;

    if (callsite.isEval()) {
      evalOrigin = CleanStack.formatFileName(callsite.getEvalOrigin());
      [, file, lineNumber, columnNumber] = callsite
        .getEvalOrigin()
        .match(/\((.{3,4095}?):(\d+):\d+\)/);
    }

    file = file || callsite.getFileName();
    lineNumber = lineNumber || callsite.getLineNumber();
    columnNumber = columnNumber || callsite.getColumnNumber();

    try {
      ({ lineNumber, file } = sourceMap.util.applyMap(
        file,
        lineNumber,
        columnNumber
      ));
    } catch (e) {
      logger.debug(`Unable to apply sourcemap ${file}:${lineNumber}`);
    }

    return {
      eval: evalOrigin,
      file,
      lineNumber,
      method,
      type
    };
  } catch (e) {
    // NODE-828: I expect some customers are adding frames that are not valid CallSites?
    logger.debug('frame is not a valid CallSite %o', callsite);
    return {};
  }
};

/**
 * filter cls-hooked call frames that result in agent code being run
 */
const filterClsHooked = (frames) => {
  let rv = [];
  let clsHookedSegment = [];
  let isClsPath = false;
  let frame, file;

  for (frame of frames) {
    ({ file } = frame);
    isClsPath = file ? file.indexOf('cls-hooked') !== -1 : false;
    if (isClsPath) {
      clsHookedSegment.push(frame);
    } else {
      // drop or keep the cls frames we've been building up
      // depending on whether or not they're running agent code
      if (clsHookedSegment.length > 0) {
        if (!isAgentPath(file)) {
          rv = rv.concat(clsHookedSegment);
        }
        clsHookedSegment = [];
      }
      rv.push(frame);
    }
  }

  return rv;
};

/**
 * Remove empty frames, these are usually because stack
 * contained a frame that was not a CallSite
 *
 * Remove frames with invalid filenames
 *
 * Removes all of contrast method function calls
 * @param {Frame} frame
 * @returns {boolean}
 */
const frameFilter = (frame) => {
  const { file, type, method } = frame;
  return (
    !!file &&
    !isAgentPath(file) &&
    `${type}${method}`.indexOf('ContrastMethods') === -1
  );
};

/**
 * Given a collection of frames, will filter out all frames
 * that are in existence because of an event that was emitted
 * by the AgentEmitter.
 *
 * @param {Frame[]} acc
 * @param {Frame} frame
 * @param {number} idx
 * @param {Frame[]} frames
 * @returns {Frame[]}
 */
const filterContrastEvents = (acc, frame, idx, frames) => {
  if (
    frame.type === EMITTER_TYPE ||
    (frame.file === EVENTS_FILE &&
      idx > 0 &&
      frames[idx - 1].type === EMITTER_TYPE)
  )
    return acc;

  return [frame, ...acc];
};

module.exports = CleanStack;
