/**
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 fs = require('fs');
const os = require('os');
const makeDir = require('make-dir');
const path = require('path');
const util = require('util');
const logger = require('../../core/logger')('contrast:hooks:module:cache');

/** @typedef {typeof import('../../agent')} Agent */

const writeFile = util.promisify(fs.writeFile);

/** @type {RegExp} */
let appDirRegex;

/**
 * Returns the filename of a cached rewrite result.
 * @example
 *   /path/to/app/node_modules/mod/index.js => .cache/node_modules/mod/index.js
 *   /somewhere/else/index.js => .cache/somewhere/else/index.js
 * @param {Agent} agent
 * @param {string} filename
 * @returns {string}
 */
const getCachedFilename = (agent, filename) => {
  appDirRegex = appDirRegex || new RegExp(`^(${agent.appInfo.appDir})?`);
  if (os.platform() === 'win32') {
    filename = filename.replace(/C:/i, '');
  }
  return filename.replace(appDirRegex, agent.getCacheDir());
};

/**
 * Returns the modification time of a file as a number.
 * @param {string} filename
 * @returns {number}
 */
const mtime = (filename) => +fs.statSync(filename).mtime;

/**
 * Writes a rewritten file to the cache directory. Runs asynchronously so that
 * disk I/O doesn't impact startup times.
 * @param {Agent} agent
 * @param {string} filename
 * @param {string} result
 * @returns {Promise<void>}
 */
module.exports.cache = async function cache(agent, filename, result) {
  if (!agent.config.agent.node.rewrite_cache.enable) return;

  const cachedFilename = getCachedFilename(agent, filename);

  try {
    await makeDir(path.dirname(cachedFilename), { recursive: true });
    await writeFile(cachedFilename, result, 'utf8');

    logger.trace('Cache entry created: %o', {
      filename,
      cachedFilename,
    });
  } catch (err) {
    logger.warn('Unable to cache rewrite results for %s: %o', filename, err);
  }
};

/**
 * Writes a rewritten file and its source map to the cache directory. Runs
 * asynchronously so that disk I/O doesn't impact startup times.
 * @param {Agent} agent
 * @param {string} filename
 * @param {import('@babel/generator').GeneratorResult} result
 * @returns {Promise<void>}
 */
module.exports.cacheWithSourceMap = async function cacheWithSourceMap(
  agent,
  filename,
  result,
) {
  if (!agent.config.agent.node.rewrite_cache.enable) return;
  // if this is the result of the estools rewriter, we want to use
  // the normal caching functionality
  if (!result.code) {
    return module.exports.cache(agent, filename, result);
  }

  const cachedFilename = getCachedFilename(agent, filename);

  try {
    await makeDir(path.dirname(cachedFilename), {
      recursive: true,
    });
    await Promise.all([
      writeFile(cachedFilename, result.code, 'utf8'),
      writeFile(`${cachedFilename}.map`, JSON.stringify(result.map), 'utf8'),
    ]);

    logger.trace('Cache entry created: %o', {
      filename,
      cachedFilename,
      map: true,
    });
  } catch (err) {
    logger.warn('Unable to cache rewrite results for %s: %o', filename, err);
  }
};

/**
 * Looks up and returns the string content of a previously rewritten file. This
 * operation must be blocking since `require` is a synchronous call.
 * @param {Agent} agent
 * @param {string} filename
 * @returns {string | undefined}
 */
module.exports.find = function find(agent, filename) {
  if (!agent.config.agent.node.rewrite_cache.enable) return undefined;

  const cachedFilename = getCachedFilename(agent, filename);

  try {
    const time = mtime(filename);
    const cachedTime = mtime(cachedFilename);

    // if the file being loaded is newer than our cache entry, recompile
    if (time > cachedTime) {
      logger.trace('Cache invalid, falling back to compiling: %o', {
        sourceFilename: filename,
        cachedFilename,
        sourceTime: time,
        cachedTime,
      });
      return;
    }

    // otherwise we can simply return the content of the cached file
    logger.trace('Cache valid: %o', {
      sourceFilename: filename,
      cachedFilename,
      sourceTime: time,
      cachedTime,
    });
    return fs.readFileSync(cachedFilename, 'utf8');
  } catch (err) {
    // statSync throws when file is not found, any error not ENOENT is bad
    if (err.code !== 'ENOENT') {
      logger.error(
        'An unexpected error occurred, falling back to compiling %s: %o',
        filename,
        err,
      );
    }

    logger.trace('Cache miss, falling back to compiling: %o', {
      filename,
      cachedFilename,
    });
    return undefined;
  }
};

/**
 * Looks up and returns the expected source map object for source-map-support.
 * @param {Agent} agent
 * @param {string} filename
 * @returns {import('source-map-support').UrlAndMap | null}
 */
module.exports.retrieveSourceMap = function retrieveSourceMap(agent, filename) {
  if (!agent.config.agent.node.rewrite_cache.enable) return null;

  const cachedFilename = getCachedFilename(agent, filename);

  try {
    return fs.readFileSync(`${cachedFilename}.map`, 'utf8');
  } catch (err) {
    return null;
  }
};
