/**
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 { stat } = require('fs').promises;
const stringify = require('json-stable-stringify');
const Module = require('module');
const { AGENT_INFO } = require('./constants');
const { runInNoInstrumentationScope } = require('./core/async-storage/scopes');
const logger = require('./core/logger')('contrast:libraries');
const listInstalled = require('./list-installed');
const AppUpdate = require('./reporter/models/app-update');

const DEADZONE_NAME = 'agent: libraries';

/**
 * @typedef {import('./list-installed').Result} Result
 */

/**
 * Searches a list of paths and returns the first valid path found.
 * @param {string[]} paths
 * @returns {Promise<string | undefined>}
 */
const findNodeModsPath = async (paths) =>
  // this functions similarly to `Bluebird.map() with concurrency: 1
  paths.reduce(async (prev, path) => {
    if (await prev) return prev;

    try {
      await stat(path);
      return path;
    } catch (err) {
      return undefined;
    }
  }, Promise.resolve());

/**
 * Filters out the agent as a dependency, formats libraries, and keeps track of
 * nested modules.
 *
 * @param {{ [key: string]: Result | string }} deps collection of dependencies from app root
 * @param {Map<string, Set<string>} requiredBy
 * @param {string} parent the name of the module requiring the current when nested
 * @return {{ [key: string]: Result }} formatted object
 */
const formatDependencies = (deps, requiredBy, parent) =>
  Object.entries(deps || {}).reduce((result, [key, val]) => {
    if (key === AGENT_INFO.NAME) {
      return result;
    }

    if (parent) {
      const set = requiredBy.has(key) ? requiredBy.get(key) : new Set();

      set.add(parent);
      requiredBy.set(key, set);
    }

    if (typeof val === 'string') {
      result[key] = {
        name: key,
        version: val,
        dependencies: {}
      };
      return result;
    }

    if (!val.name) val.name = key;

    val.dependencies = formatDependencies(val.dependencies, requiredBy, key);
    result[key] = val;

    return result;
  }, {});

/**
 * Recursively adds each dependency as a library which is used later
 * for reporting inventory and class usage
 *
 * @param {{ [key: string]: Result }} deps the formatted object from formatDependencies
 * @param {Map<string, Set<string>} requiredBy map containing data regarding dependents
 * @param {import('./agent')} agent agent instance
 */
const processDependencies = (deps, requiredBy, agent) => {
  Object.values(deps).forEach((dep) => {
    dep._requiredBy = requiredBy.has(dep.name)
      ? Array.from(requiredBy.get(dep.name))
      : [];

    const newLib = AppUpdate.addLib(dep, agent);
    // If this is the first time we've checked this dep, check its deps as well.
    if (newLib) {
      processDependencies(dep.dependencies, requiredBy, agent);
    }
  });
};

/**
 * Traverses entire dependency tree to report library inventory
 *
 * @param {import('./agent')} agent agent instance
 * @param {boolean} eluEnabled flag to get composition for each module
 * @returns {AppUpdate.libraries}
 */
const getLibInfo = async (agent, eluEnabled) =>
  runInNoInstrumentationScope(async () => {
    try {
      const boundRequire = Module.createRequire(agent.appInfo.path);
      const paths = boundRequire.resolve.paths(agent.appInfo.path);
      const nodeModsPath = await findNodeModsPath(paths);

      if (!nodeModsPath) {
        logger.error(
          'unable to read installed dependencies because a node_modules directory could not be detected given a package.json located at %s - use the agent.node.app_root configuration variable if installed in non-standard location',
          agent.appInfo.path
        );
        return AppUpdate.libraries;
      }

      logger.debug('detected node_modules at %s', nodeModsPath);
      const data = await listInstalled(
        agent.config.agent.node.npm_path,
        nodeModsPath,
        logger
      );

      logger.debug(
        'package.json dependencies: %s',
        // _dependencies contains { name: version } info
        stringify(data._dependencies, { space: 2 })
      );
      logger.debug(
        'package.json devDependencies: %s',
        stringify(data.devDependencies, { space: 2 })
      );

      const requiredBy = new Map();
      const dependencies = formatDependencies(data.dependencies, requiredBy);
      processDependencies(dependencies, requiredBy, agent);

      const libs = AppUpdate.libraries;
      if (eluEnabled) {
        logger.info('recursively scanning dependencies');
        // this functions similarly to `Bluebird.map()` with concurrency: 1
        await Object.values(libs).reduce(async (prev, lib) => {
          await prev;
          return lib.getComposition();
        }, Promise.resolve());
      }

      logger.info(
        'finished library inventory for %s dependencies',
        Object.keys(libs).length
      );

      return libs;
    } catch (err) {
      logger.error('unable to read installed dependencies: %o', err);
      return AppUpdate.libraries;
    }
  }, DEADZONE_NAME);

module.exports.getLibInfo = getLibInfo;
