/**
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';
/* eslint-disable no-process-exit */

const Module = require('module');
const process = require('process');
const fs = require('fs');
const path = require('path');
const util = require('util');
const builtins = require('builtin-modules');

const loggerFactory = require('../core/logger');
const configOptions = require('../core/config/options');
const configUtil = require('../core/config/util');
const { ContrastAgent } = require('../agent');
const agentEmitter = require('../agent-emitter');
const moduleHelper = require('../hooks/module/helpers');
const injections = require('../core/rewrite/injections');

const readFile = util.promisify(fs.readFile);
const LOGGER_NS = 'contrast:cli-rewriter';
const getType = require('../util/get-file-type');

class CLIRewriter {
  /**
   * Receives entrypoint used to initialize agent.
   * Sets up config, logging, agent, and rewriter state.
   * @param {string} entrypoint application's entrypoint script
   */
  constructor(entrypoint) {
    // keep track of files rewritten - don't rewrite if file is required more than once
    this.visited = new Set();
    this.entrypoint = entrypoint;
    this.filename = path.resolve(process.cwd(), this.entrypoint);
    this.initConfig();

    try {
      fs.statSync(this.filename);
    } catch (err) {
      this.logger.error('entrypoint file not found: %s', entrypoint);
      process.exit(1);
    }

    this.initAgent();
    this.initStaticRulesVisitors();
    this.initRewriter();
  }

  /**
   * Loads configuration from contrast_security.yaml, env vars, and cli options.
   * Configuration is validated to ensure that rewrite caching is
   * enabled. If invalid config is detected, will log fatal error and exit.
   * Sets cliOptions.script value of entrypoint, which is done in typical agent
   * usage in lib/contrast.init().
   */
  initConfig() {
    const cliOptions = { script: this.entrypoint };
    configOptions.program
      .action((options) => Object.assign(cliOptions, options))
      .parse(process.argv);
    cliOptions.script = this.entrypoint;

    this.logger = loggerFactory(LOGGER_NS);
    this.config = configUtil.setup(cliOptions, this.logger);
    loggerFactory.init(this.config);

    const cacheEnabled = this.config.get('agent.node.rewrite_cache.enable');
    const assessEnabled = this.config.get('assess.enable');
    if (!cacheEnabled || !assessEnabled) {
      this.logger.error(
        'fatal configuration error: contrast-transpile requires \'agent.node.rewrite_cache.enable=true\' AND \'assess.enable=true'
      );
      process.exit(1);
    }
  }

  /**
   * Sets required fields on the agent such as configuration and application
   * info used to construct rewrite paths. Also sets the require detector
   * visitor to run during rewriting.
   */
  initAgent() {
    // we have to require after we call loggerFactory.init()
    const AppInfo = require('../app-info');
    this.agent = new ContrastAgent();
    this.agent.config = this.config;
    this.agent.staticVisitors.push(CLIRewriter.requireDetector);
    this.agent.staticVisitors.push(CLIRewriter.importDetector);
    this.agent.appInfo = new AppInfo(this.entrypoint, this.config);
  }

  initStaticRulesVisitors() {
    const { logger } = this;
    const cacheDir = this.agent.getCacheDir();
    const findingsFilePath = `${cacheDir}/contrast-static-findings.json`;
    const rule = require('../assess/static/hardcoded')({ agent: this.agent });
    const policy = require('../assess/policy/non-dataflow-rules.json');

    agentEmitter.on('finding', function writeFindingToAFile (finding) {
      try {
        if (!fs.existsSync(findingsFilePath)) {
          if (!fs.existsSync(cacheDir)) {
            fs.mkdirSync(cacheDir, { recursive: true });
          }
          fs.writeFileSync(findingsFilePath, '{\n"findings":\n[\n');
        }
        fs.appendFileSync(`${cacheDir}/contrast-static-findings.json`, `${JSON.stringify(finding)},\n`);
      } catch (error) {
        logger.error('Error writing to static findings file. Static finding won\'t be reported. Error: %s', error);
      }
    });
    rule.handle(policy.rules['hardcoded-password'], 'hardcoded-password');
    rule.handle(policy.rules['hardcoded-key'], 'hardcoded-key');
  }

  /**
   * Loads babel rewriter and utils an initializes them.
   */
  initRewriter() {
    ['JSON', 'Function', 'Object', 'String'].forEach((inj) => {
      injections.get(inj).enable();
    });
    require('../util/source-map').init(this.agent);
    // the above line must happen before the following require, otherwise
    // the util required by the rewriter will be `null` and result in fatal errors.
    // we also defer this require until loggerFactory.init() is called
    this.rewriter = require('../core/rewrite')(this.agent);
  }

  /**
   * Starting at the provided entrypoint will rewrite it and any dependencies
   * detected. Recursively continues until all files are rewritten.
   * @param {string} entrypoint provided application entrypoint script
   */
  async rewrite() {
    const parent = CLIRewriter.getModuleData(this.filename);
    const start = Date.now();
    const cacheDir = this.agent.getCacheDir();
    const findingsFilePath = `${cacheDir}/contrast-static-findings.json`;

    await this.traverse(this.filename, parent);

    if (fs.existsSync(findingsFilePath)) {
      try {
        const findingsFileSize = fs.statSync(findingsFilePath).size;
        if (findingsFileSize > 20) {
          fs.truncateSync(findingsFilePath, findingsFileSize - 2);
        }
        fs.appendFileSync(findingsFilePath, '\n]\n}');
      } catch (error) {
        this.logger.error('Error formatting static findings file. Static findings won\'t be reported. Error: %s', error);
      }
    }

    this.logger.info('rewriting complete [%ss]', (Date.now() - start) / 1000);
  }

  /**
   * Visits the filename in order to rewrite and cache. Visitor returns found
   * dependencies in the file. Recursively traverses the absolute file paths of
   * dependencies found by the require detector.
   * @param {string} filename file to rewrite
   * @param {object} parent parent module data
   */
  async traverse(filename, parent) {
    const type = getType(filename);
    const fileDependencies = await this.visitDependency(filename, type);

    return Promise.all(
      fileDependencies.map((request) => {
        try {
          const _filename = Module._resolveFilename(request, parent);
          if (_filename.endsWith('.js') || _filename.endsWith('.mjs')) {
            const _parent = CLIRewriter.getModuleData(_filename);
            return this.traverse(_filename, _parent);
          }
        } catch (err) {
          if (err.code === 'MODULE_NOT_FOUND') {
            this.logger.debug(
              `module not found. skipping ${request} required by ${filename}`
            );
          } else {
            this.logger.error('error resolving filename: %o', err);
          }
        }
      })
    );
  }

  /**
   * For each file dependency rewrite and cache it. Returns array of found
   * dependencies.
   * @param {string} filename name of file to rewrite / cache
   * @returns {array[string]} list of the file's dependencies
   */
  async visitDependency(filename, type) {
    if (this.visited.has(filename)) {
      return [];
    }

    this.visited.add(filename);

    const content = await readFile(filename, 'utf8');
    const rewriteData = this.rewriter.rewriteFile(content, filename, {
      sourceType: type === 'commonjs' ? 'script' : 'module'
    });

    if (rewriteData.code) {
      await moduleHelper.cacheWithSourceMap(this.agent, filename, rewriteData);
    }

    if (!rewriteData.deps) {
      return [];
    }

    return rewriteData.deps.filter((dep) => !builtins.includes(dep));
  }

  /**
   * Gets module data in order to resolve abs paths of deps.
   * @param {string} filename absolute path of file being rewritten
   * @returns {object}
   */
  static getModuleData(filename) {
    return {
      id: filename,
      filename,
      paths: Module._nodeModulePaths(path.dirname(filename))
    };
  }

  /**
   * Added to agent's static visitors. Runs during rewriting to detect require
   * calls to discover dependencies.
   * @param {object} node AST node being visited during rewrite
   * @param {string} filename file being rewritten
   * @param {object} state rewriter state
   */
  static requireDetector(node, filename, state) {
    if (isRequire(node)) {
      if (isLiteralRequire(node) || isStaticTemplateRequire(node)) {
        const request = getRequireArg(node);
        if (request) {
          state.deps.push(request);
        }
      }
    }
  }

  /**
   * Added to agent's static visitors. Runs during rewriting to detect import
   * calls to discover dependencies.
   * @param {object} node AST node being visited during rewrite
   * @param {string} filename file being rewritten
   * @param {object} state rewriter state
   */
  static importDetector(node, filename, state) {
    if (node.type === 'ImportDeclaration') {
      state.deps.push(node.source.extra.rawValue);
    }
  }
}

/**
 * Whether AST node looks like a `require([request])` call.
 * @param {object} node AST node
 * @returns {boolean}
 */
function isRequire(node) {
  return (
    node.type === 'CallExpression' &&
    node.callee &&
    node.callee.type === 'Identifier' &&
    node.callee.name === 'require' &&
    node.arguments &&
    node.arguments.length === 1
  );
}

/**
 * Whether node is static template require
 * @param {object} node AST node
 * @returns {boolean}
 */
function isStaticTemplateRequire(node) {
  return (
    node.arguments[0].type === 'TemplateLiteral' &&
    node.arguments[0].expressions.length === 0
  );
}

/**
 * Whether node is literal require
 * @param {object} node AST node
 * @returns {boolean}
 */
function isLiteralRequire(node) {
  return (
    node.arguments[0].type === 'Literal' ||
    node.arguments[0].type === 'StringLiteral'
  );
}

/**
 * Gets value of argument to `require([request])`
 * @param {object} node AST node
 * @returns {string}
 */
function getRequireArg(node) {
  return node.arguments[0].type === 'TemplateLiteral'
    ? node.arguments[0].quasis[0].value.raw
    : node.arguments[0].value;
}

module.exports.CLIRewriter = CLIRewriter;
