/**
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 { program, options } = require('./core/config/options');
const path = require('path');
const os = require('os');
const semver = require('semver');
const colors = require('./util/colors');
const { AGENT_INFO } = require('./constants');
const { VERSION, SUPPORTED_VERSIONS, NAME } = AGENT_INFO;
const Module = require('module');
const sourceMapUtility = require('./util/source-map');
const loggerFactory = require('./core/logger');
const logger = loggerFactory('contrast:contrast-init');
const { outputAgentConfigFile } = require('./util/config-diagnostics-utils');
const { fetchSystemInfo, outputSystemInfo } = require('../system-diagnostics');

function getAgentSnippet() {
  // getting reference to name every time for testing purposes
  const { NAME } = AGENT_INFO;
  // preload script is not in main when package name is node_contrast
  const preLoadPath = NAME === 'node_contrast' ? `${NAME}/bootstrap` : NAME;
  return `node -r ${preLoadPath} <main>.js`;
}

let AppInfo,
  /** @type {import('./agent')} */
  agent = {},
  TSReporter,
  instrument,
  /** @type {import('./telemetry')} */
  Telemetry,
  tracker;

/**
 * Process flows to bootstrapping user code with the node agent code
 */
const contrastAgent = module.exports;

/**
 * Who doesn't like ASCII art. Displays Matt's cat when CONTRAST_CAT env var is set to MATT
 */
contrastAgent.showBanner = function showBanner() {
  if (process.env.CONTRAST_CAT === 'MATT') {
    const fs = require('fs');
    const file = path.resolve(__dirname, 'cat.txt');
    const cat = fs.readFileSync(file, 'utf8');
    console.log(colors.red(cat));
  }

  const showAgentBanner = (process.env['CONTRAST__SHOW__BANNER'] === 'false') ? false : true;
  if (showAgentBanner) {
    logger.console();
    logger.console('%s %s', NAME, VERSION);
    logger.console(colors.green('--------------------------------------'));
  }
};

/**
 * Defers loading of modules until logging is setup
 */
contrastAgent.doImports = function doImports() {
  AppInfo = require('./app-info');
  agent = require('./agent');
  TSReporter = require('./reporter/ts-reporter');
  instrument = require('./instrumentation');
  Telemetry = require('./telemetry');
  tracker = require('./tracker');
};

/**
 * Checks to see if config.agent.stack_trace_limit is a valid number that is not inifinity
 * @param {Object} config agent config
 */
contrastAgent.setStackTraceLimit = function setStackTraceLimit(config) {
  const limit = config.agent.stack_trace_limit;
  const stackTraceLimit = parseInt(limit, 10);

  if (isNaN(stackTraceLimit) || stackTraceLimit < 10) {
    config.agent.stack_trace_limit = undefined;
    logger.warn('bad value passed in for --stackTraceLimit');
  } else {
    config.agent.stack_trace_limit = stackTraceLimit;
    Error.stackTraceLimit = config.agent.stack_trace_limit;
    logger.debug(
      'setting stack trace limit %d',
      config.agent.stack_trace_limit
    );
  }
};

/**
 * Logs a scary red message if the current version is unspported.
 * @param {String} version    current node version
 * @param {String} minVersion minimum supported version by the Contrast agent
 */
contrastAgent.checkNodeVersion = function(
  version = process.version,
  supportedVersions = SUPPORTED_VERSIONS
) {
  if (
    !semver.satisfies(version, supportedVersions) ||
    semver.major(version) % 2
  ) {
    let validRanges = '';
    supportedVersions.split('||').forEach((range) => {
      const minVersion = semver.minVersion(range).toString();
      const maxVersion = range.split('<').pop()
        .trim();
      validRanges += `${minVersion} and ${maxVersion}, `;
    });
    logger.error(
      'Contrast only officially supports Node LTS versions between %sbut detected %s. Continuing without instrumentation.',
      validRanges,
      version
    );
    return false;
  }
  return true;
};

/**
 * Log information about the customer process
 * @param {Object} systemInfo parameter for testing. In prod is `process'.
 * @param {String} version    the agent version for testing.
 */
contrastAgent.logProcessInfo = function(
  systemInfo = process,
  version = VERSION
) {
  logger.info('agent v%s, node.js %s', version, systemInfo.version);
  try {
    logger.info('process info:');
    logger.info(`arch:  ${os.arch()}`);
    logger.info(`cores: ${os.cpus().length}`);
    logger.info(`os:    ${systemInfo.platform} ${os.release()}`);
    logger.info(`umask: ${systemInfo.umask().toString(8)}`);
    logger.info(
      `gid:   ${
        typeof systemInfo.getgid === 'function' ? systemInfo.getgid() : 'N/A'
      }`
    );
    logger.info(
      `uid:   ${
        typeof systemInfo.getuid === 'function' ? systemInfo.getuid() : 'N/A'
      }`
    );
  } catch (err) {
    logger.warn('Unable to determine process info %o', err, {});
  }
};

/**
 * Creates a global instance of logger. This is used to log in every try/catch.
 * Used for debugging customer issues.  See CONTRAST-23996
 * @param {Object} config contrast config object
 * @param {[]*}    args   arguments passed to init.
 * @param {Object} target object that the global logger is being defined on
 */
contrastAgent.configureGlobalLogger = function(config, args, target = global) {
  if (config.agent.node.enable_catch_log) {
    const globalDebug = loggerFactory('contrast:injected');
    Object.defineProperty(target, 'CONTRAST_LOG', {
      value() {
        globalDebug.warn(...args);
      }
    });
  }
};

function getAgentArgs(options) {
  const agentArgs = {};
  program.options.forEach((opt) => {
    if (opt.name() !== 'application.args' && options[opt.name()]) {
      agentArgs[opt.name()] = options[opt.name()];
    }
  });
  return agentArgs;
}

function parseArgv(argv) {
  const parsed = [];
  const cwd = process.cwd();
  for (let i = 0; i < argv.length; i++) {
    if (argv[i].includes(cwd)) {
      parsed.push(path.relative(cwd, argv[i]).toLowerCase());
    } else {
      parsed.push(path.basename(argv[i]).toLowerCase());
    }
  }
  return parsed;
}
/**
 * Initializes config, logger and other non-instrumentation code.
 *
 * @param {Object} options commander object contains args, rawArgs, agent config, etc
 * @param {Object} commanderArgs args passed to commander
 * @param {Boolean} isCli flag to say if running agent as cli or preloader
 * @return {Boolean} if agent is enabled
 */
contrastAgent.prepare = function(...args) {
  const [options, commanderArgs, isCli = false] = args;
  // mising arg for app main when using cli
  if (isCli && !commanderArgs.length) {
    program.help();
  }

  return Promise.resolve().then(() => {
    const configUtil = require('./core/config/util');
    const config = configUtil.setup(options, logger);

    if (config.agent.node.exclusive_entrypoint) {
      const parsed = parseArgv(process.argv);
      if (!parsed.includes(config.agent.node.exclusive_entrypoint)) {
        return false;
      }
    }

    if (config.agent.node.cmd_ignore_list) {
      const parsed = parseArgv(process.argv);
      const cmdIgnoreList = config.agent.node.cmd_ignore_list.split(',');
      if (parsed.includes('npm')) {
        if (cmdIgnoreList.includes('npm*')) return false;
        parsed.shift();
      }
      const cmd = parsed.join(' ');
      if (cmdIgnoreList.includes(cmd)) {
        return false;
      }
    }

    loggerFactory.init(config);

    contrastAgent.doImports();

    configUtil.logConfig(config, logger);

    contrastAgent.configureGlobalLogger(config, args);

    if (config.agent.stack_trace_limit) {
      contrastAgent.setStackTraceLimit(config);
    }

    // create a heap dump every n seconds.
    require('./util/heap-dump').init(config.agent.heap_dump);

    // optionally enable metric collection
    require('./core/metrics').configure(config);

    if (config.enable && agent.cluster.isPrimary) {
      contrastAgent.showBanner();
    }

    if (isCli) {
      logger.error(
        "DEPRECATED: Agent is started as runner. Please use '%s'",
        getAgentSnippet()
      );
    } else {
      logger.warn(
        'Disabling auto update as it is not supported when using -r to start agent'
      );
      config.set('agent.auto_update.enable', false);
    }

    contrastAgent.logProcessInfo();

    logger.info('Using config file at %s', config.configFile);
    // log the argv before and after modification.
    logger.info(`Original argv: ${program.rawArgs.join(', ')}`);
    logger.info(`Modified argv: ${program.args.join(', ')}`);

    agent.config = config;
    agent.tsFeatureSet.config = config;
    agent.isCli = isCli;

    if (config.agent.node.dev.global_agent) {
      logger.debug('>> config.agent.node.dev.global_agent enabled <<');
      global.contrast_agent = agent;
    }

    if (config.agent.node.dev.global_tracker) {
      logger.debug('>> config.agent.node.dev.global_tracker enabled <<');
      global.contrast_tracker = tracker;
    }

    let app;
    // now that config has been set, keep unknowns around so we can swap them into process.argv
    try {
      app = new AppInfo(options.script, config);
    } catch (e) {
      logger.error('%s. Continuing without instrumentation.', e.message);
      throw e;
    }

    config.version = semver.valid(semver.coerce(VERSION));
    agent.appInfo = app;
    agent.telemetry = new Telemetry(agent, config);

    config.override('application.name', app.name);

    sourceMapUtility.init(agent);

    // return to startup if agent is enabled
    return config.enable;
  });
};

/**
 * Sends startup message to TeamServer then instruments user code
 * @param {Array} args process.argv
 */
contrastAgent.bootstrap = function(args) {
  const reporter = new TSReporter(agent);

  // returning promise for testing purposes only
  return reporter
    .onboard(args)
    .catch((err) => {
      logger.error('Reporter onboarding failed. Continuing without instrumentation.');
      logger.error(err);
      agent.clearIntervals();
      return;
    })
    .then((success) => {
      if (!success) {
        agent.clearIntervals();
        return;
      } else {
        contrastAgent.logRewriteCacheInfo(agent);
        return instrument(agent, reporter);
      }
    })
    .catch((err) => {
      logger.error(
        'Unexpected error while trying to start contrast. Continuing without instrumentation. Error: %o',
        err
      );
    })
    .finally(async () => {
      if (agent.config._flat['agent.diagnostics.enable'] !== false) {
        let destination = agent.config._flat['agent.diagnostics.report_path'];
        if (destination == null) {
          destination = agent.config._flat['agent.logger.path'].split('/');
          destination.pop() && destination.push('contrast_effective_config.json');
          destination = destination.join('/');
        }

        const args = {
          quiet: agent.config._flat['agent.diagnostics.quiet'],
          output: destination,
          skip: false,
        };

        try {
          outputAgentConfigFile(agent, options, args, null);
        } catch (err) {
          outputAgentConfigFile(agent, options, args, err);
        }

        args.output = path.join(args.output, '../contrast_system_info.json');
        const info = fetchSystemInfo();
        outputSystemInfo(args, info);
      }
    });
};

/**
 * Calls contrastAgent.prepare with commander parsing the cli args
 *
 * @param {Array} args process.argv
 * @param {Boolean} isCli flag to tell code if this is in cli mode
 *
 */
contrastAgent.init = async function(args, isCli = false) {
  let enabled, script;

  if (!contrastAgent.checkNodeVersion()) {
    return { enabled: false };
  }

  program
    // source: args passed to cli, destination: args after cli parsed it
    .action(async function callPrepare(options, commanderArgs = []) {
      // the user app main differs if a runner vs preload
      script = isCli ? program.args[0] : program.rawArgs[1];
      options.script = script;
      // need to slice off app main in runner mode
      options['application.args'] = isCli
        ? program.args.slice(1)
        : program.args;

      try {
        enabled = await contrastAgent.prepare(options, commanderArgs, isCli);
        agent.appArgs = options['application.args'];
        agent.agentArgs = getAgentArgs(options);
      } catch (err) {
        // something failed in prepare, setting enabled to false
        // so agent doesn't try to instrument
        logger.error(
          'Unexpected error while trying to start contrast. Continuing without instrumentation. %o',
          err
        );

        enabled = false;
      }
    })
    .on('--help', function() {
      console.log('Example:');
      console.log(
        colors.cyan('\t$ %s -c ../contrast_security.yaml -- --appArg1 --appArg2 -e -t -c'),
        getAgentSnippet(),
      );
    });

  await program.parseAsync(args);

  // need to return the app main when in cli mode
  // because process.argv[1] was our cli.js not their main
  return { script, enabled };
};

/**
 * Starts the application script via <code>Module.runMain()</code> method
 *
 * @param  {String} nodePath Path to node (process.argv[0])
 * @param  {String} script The path to the application's entry point.
 * @throws {Error}
 */
contrastAgent.run = function(nodePath, script) {
  contrastAgent.resetArgs(nodePath, script);

  try {
    const msg = colors.green(`${NAME}: Starting ${script}...`);
    logger.console(msg);
    Module.runMain();
  } catch (err) {
    // we crashed trying to rewrite something,
    // stop the appactivity timer and let the script end
    agent.clearIntervals();
    throw err;
  }
};

/**
 * removes the contrast args from process.argv as well
 * as resolves the path to the user's startup script
 *
 * @param  {String} nodePath Path to node (process.argv[0])
 * @param  {String} script The path to the application's entry point.
 */
contrastAgent.resetArgs = function(nodePath, script) {
  script = path.resolve(script);
  process.argv = agent.config
    ? [nodePath, script].concat(agent.config.application.args)
    : process.argv;
  const isPrimary =
    !Object.prototype.hasOwnProperty.call(agent, 'cluster') ||
    agent.cluster.isPrimary;
  const location = isPrimary ? 'Entering main' : 'Entering fork';

  logger.debug('%s at %s', location, script);
};

/**
 * Logs elapsed time results from `process.hrtime` in a human-readable format.
 * @param {[number, number]} startTime process.hrtime() before an operation occurs
 */
contrastAgent.logTime = function(startTime, step = 'application') {
  const [seconds, nanos] = process.hrtime(startTime);
  const prettyTime = (seconds + nanos / 1e9).toFixed(3);
  logger.info('%s initialized in %ss', step, prettyTime);
};

contrastAgent.logRewriteCacheInfo = function(agent) {
  const cacheEnabled = agent.config.get('agent.node.rewrite_cache.enable');
  if (!cacheEnabled) {
    return;
  }

  const cacheDir = agent.getCacheDir();

  logger.info('Rewrite caching enabled, caching to %s', cacheDir);
};
