/**
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 { readFileSync, existsSync } = require('fs');
const { transfer } = require('multi-stage-sourcemap');
const { SourceMapConsumer } = require('@contrast/synchronous-source-maps');
const path = require('path');
const isAgentPath = require('./is-agent-path');

/**
 * Utility for working with sourcemaps. This utility was built
 * because source map building happens inside of CleanStack which never has
 * any /real/ context for
 */
class SourceMapUtility {
  /**
   * @param {Object} agent  reference to the global agent object
   */
  constructor(agent) {
    this.agent = agent;
    this.logger = require('../core/logger')('contrast:source-map');
    this.retrieveSourceMap = require('../hooks/module/helpers').retrieveSourceMap;
    this.consumerCache = {};
  }

  /**
   * If the customer's source tree has source maps present, use them to
   * tie our traces back to the original code.
   * This function will read the original map.
   *
   * @param   {string}          filename    the original filename
   * @param   {Babel.sourceMap} contrastMap the source map generated by the agent
   * @returns {Babel.sourceMap}             the chained source map.
   */
  chain(filename, contrastMap) {
    // is there an existing map file that we want to point back to?
    // this is the case if we want to reference back into typescript etc. code
    if (existsSync(`${filename}.map`)) {
      try {
        const existingMap = JSON.parse(readFileSync(`${filename}.map`, 'utf8'));
        contrastMap = transfer({
          fromSourceMap: contrastMap,
          toSourceMap: existingMap
        });
        this.logger.trace(`Merged sourcemap from ${filename}.map`);
      } catch (e) {
        this.logger.debug(`Unable to read ${filename}.map.js`);
        this.logger.debug(`${e}`);
      }
    }

    return contrastMap;
  }

  /**
   * Generate a source map consumer and cache it for later application
   * @param {string}          filename the full path to the file
   * @param {Babel.sourceMap} map      the parsed map to generate the consumer
   */
  cacheMap(filename, map) {
    // path guaranteed to exist due to lib/core/config/util.js
    if (!this.agent.config.agent.node.enable_source_maps) return;
    const consumer = new SourceMapConsumer(map);
    this.consumerCache[filename] = consumer;
  }

  /**
   * Get the consumer if it is cached or create a new one if not
   * @param {string} file full path to file
   */
  getConsumer(file) {
    let consumer = null;
    if (this.consumerCache[file]) {
      consumer = this.consumerCache[file];
    } else {
      const mapFile = this.retrieveSourceMap(this.agent, file);
      if (mapFile) {
        consumer = new SourceMapConsumer(mapFile);
        this.consumerCache[file] = consumer;
      }
    }

    return consumer;
  }

  /**
   * Filter out cases when sourcemaps aren't applicable.
   * If source maps are disabled, it is agent code, or it is
   * node internal file, we don't want to apply a source map.
   * @param {string} file full path to file.
   */
  needsSourceMap(file) {
    const applicable =
      file &&
      this.agent.config.agent.node.enable_source_maps &&
      !isAgentPath(file) &&
      !file.startsWith('internal/');

    return applicable;
  }

  /**
   * Apply a source map to a filename and source line.
   * Will return original filename and number if it can't use
   * a sourcemap
   *
   * @param   {string} file       filename the agent is using
   * @param   {number} lineNumber original line number from error
   * @param   {number} column     original column number from error
   * @returns {{ lineNumber: number, file: string }} { new line number, new filename }
   */
  applyMap(file, lineNumber, column) {
    if (!this.needsSourceMap(file)) return { lineNumber, file };

    const consumer = this.getConsumer(file);

    if (!column) column = 0; // this is a required property

    if (consumer) {
      const { line, source } = consumer.originalPositionFor({
        line: lineNumber,
        column
      }) || { line: undefined, source: undefined };

      if (line) lineNumber = line;
      if (source) file = this.replaceSource(file, source);
    }

    return { lineNumber, file };
  }

  /**
   * Replace the original filename in the stacktrace
   * ex: original source file we're reading from the map
   *     comes from source.ts, we want to direct there.
   * Worth noting that this is something of a hack because if the user's source
   * just corresponds to the /map/ and the original source is elsewhere
   * then these paths are gonna be wrong. That said, it is gonna point
   * to the right file, so I'm pretty confident users can figure this out.
   *
   * @param   {string} original full filepath of source the agent is using
   * @param   {string} source   filename (without path) coming from source-map.
   * @returns {string}          fixed filename with full path
   */
  replaceSource(original, source) {
    return original === source
      ? original
      : original.replace(path.basename(original), source);
  }
}

module.exports.util = null;

module.exports.init = (agent) => {
  module.exports.util = new SourceMapUtility(agent);
};

module.exports.SourceMapUtility = SourceMapUtility;
