/**
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';

/**
 * Contains helper functions used to manage the policy
 * Management of hooked functions that are defined in assess policies(e.g. deadzone.json, propagators.json, rules.json)
 * @module
 */
const _ = require('lodash');
const logger = require('../../core/logger')('contrast:rules:utils');
const patcher = require('../../hooks/patcher');
const resolveObj = require('../../util/resolve-obj');
const requireHook = require('../../hooks/require');
const utils = module.exports;
const agentEmitter = require('../../agent-emitter');
const path = require('path');
const policy = require('./');

// mapping of event name -> resolved sink targets
// exported for testing
utils.resolvedTargets = {};

/**
 * Builds method name from signature
 * @param {Object} signature
 * @return {string}
 */
function createMethodId(signature) {
  const moduleName = { signature };
  return signature.methodName
    ? [moduleName, '.', signature.methodName].join('')
    : moduleName;
}

/**
 * Returns the module and name from resolve module
 * @param {Object} mod resolved module
 * @param {string} methodName name of method to hook
 * @return {Object}
 */
function resolveHookTarget(mod, methodName) {
  if (!methodName && typeof mod === 'function') {
    return {
      module: mod
    };
  } else {
    const obj = resolveObj.resolveInitObj(mod, methodName),
      name = resolveObj.resolveMethod(methodName);

    return {
      module: obj,
      name
    };
  }
}

/**
 * Uses 'enabled' field as well as the 'versions' semver
 * to determine if a node in the policy is enabled
 * @param {Object} policyDefinition - A node in the policy file
 * @returns {boolean}
 */
utils.isEnabled = function(policyDefinition) {
  return !!policyDefinition.enabled;
};

/**
 * Get whether a rule is enabled by rule ID
 * @param {String} ruleId
 * @returns {boolean}
 */
utils.isRuleEnabled = function(ruleId) {
  return _.get(policy, `rules.${ruleId}.enabled`, true);
};

/**
 * @param {Object} node		The node to turn on or off
 * @param {boolean} enabled	What to set the enabled property to
 */
utils.setEnabled = function(node, enabled) {
  /*eslint no-prototype-builtins: "warn"*/
  if (node.hasOwnProperty('enabled')) {
    node.enabled = enabled;
    return true;
  }
  return false;
};

/**
 * Describes what should be enabled or disabled following
 * a comparison of two lists of features
 * @typedef Difference
 * @property {Array} enable List of things which should be enabled
 * @property {Array} disable List of things which should be disabled
 */
/**
 * @param {Array} old - The cached list of features
 * @param {Array} update - The list of changes received in the update
 * @param {Function} modifier - Optional third argument to _.differenceBy
 * @returns {Difference}
 */
utils.findDifference = function(old, update, modifier) {
  const difference = Object.create(null);
  difference.enable = _.differenceBy(old, update, modifier);
  difference.disable = _.differenceBy(update, old, modifier);

  return difference;
};

/**
 * Checks if module exists in global namespace
 * @param {string} modName module name
 * @return {Boolean}
 */
utils.isGlobal = function(modName) {
  const globals = Object.getOwnPropertyNames(global);
  return globals.indexOf(modName) !== -1;
};

/**
 * Calls utils.patch with resolved module from global scope
 * @param {string} modName module name
 * @param {string} path path to method
 * @param {Array} methods methods to hook
 * @param {Object} options options for patcher
 */
utils.patchGlobal = function(modName, path, methods, options) {
  const mod = global[modName];
  utils.patch(mod, path, methods, options);
};

/**
 * Calls utils.patch with resolved module from require hook
 * @param {string} modName module name
 * @param {string} path path to method
 * @param {Array} methods methods to hook
 * @param {Object} options options for patcher
 */
utils.patchModule = function(modName, path, methods, options) {
  requireHook.resolve({ name: modName }, function(mod) {
    return utils.patch(mod, path, methods, options);
  });
};

/**
 * Calls patcher.patch on resolved module with props and options
 * @param {string} obj resolved module
 * @param {string} path path to method
 * @param {Array} props props for patcher
 * @param {Object} options options for patcher
 */
utils.patch = function(obj, path, props, hookOptions) {
  if (Array.isArray(path)) {
    if (path.length > 0) {
      path = path.join('.');
      if (!_.has(obj, path)) {
        logger.error(`unable to resolve path ${path}`);
      } else {
        obj = _.get(obj, path);
      }
    }
  }

  if (!props) {
    return utils.patchRecursive(obj, hookOptions, 0);
  } else {
    return patcher.patch(obj, props, hookOptions);
  }
};

// global weaksets normally can be a pain point, but because
// this has side effects and is applying a patch, in the event that
// this triggers, it /should/ be patched anyways.
const refs = new WeakSet();

/**
 * Recursively patch all exports on objects and classes
 * note cyclomatic check disabled because there's no place to break
 * this that would make it even remotely more readable. This function
 * is more simple this way.
 * @param   {Object|Function} obj         object, class, or function being patched
 * @param   {Object}          hookOptions options for patched
 * @param   {number}          depth       current recursion depth
 * @returns {Object|function}             returns original reference but patched
 */
// eslint-disable-next-line complexity
utils.patchRecursive = function(obj, hookOptions, depth) {
  let protoNeedsPatch = false;
  let propertyNames; // scoped value so that this doesn't need to be called twice.
  // max depth
  if (depth > 15 || refs.has(obj)) {
    return obj;
  }

  try {
    if (typeof obj === 'function') {
      if (obj.prototype) {
        propertyNames = Object.getOwnPropertyNames(obj.prototype);
        protoNeedsPatch = propertyNames.indexOf('constructor') !== -1;
      }
      if (!protoNeedsPatch) {
        return patcher.patch(obj, hookOptions);
      }
    }

    let objRef = obj;
    let keys = Object.keys(obj);

    // when given a class, we need to handle the prototype,
    // not the original object
    if (protoNeedsPatch) {
      keys = propertyNames;
      objRef = obj.prototype;
    }

    for (const key of keys) {
      if (
        objRef[key] &&
        typeof objRef[key] === 'object' &&
        !Array.isArray(objRef[key])
      ) {
        refs.add(objRef);
        utils.patchRecursive(objRef[key], hookOptions, depth + 1);
      } else if (typeof objRef[key] === 'function' && key !== 'constructor') {
        patcher.patch(objRef, key, hookOptions);
      }
    }
  } catch (e) {
    logger.info(`unable to recursively patch ${e}`);
  }

  return obj;
};

/**
 * Patches either global module or a resolved
 * @param {string} name name of module
 * @param {Object} props props for a policy node(deadzone props)
 * @param {Objet} options options for patcher
 */
utils.createPatch = function(name, props, options) {
  const path = name.split('.');
  const modName = path.shift();

  if (utils.isGlobal(modName)) {
    utils.patchGlobal(modName, path, props.methods, options);
  } else {
    utils.patchModule(modName, path, props.methods, options);
  }
};

/**
 * Checks if signature exists with a moduleName and isModule is a boolean
 * @param {Object} signature
 * @return {Boolean}
 */
utils.isValidSignature = function(signature) {
  return (
    !!signature &&
    !!signature.moduleName &&
    typeof signature.isModule === 'boolean'
  );
};

/**
 * Build patchNames in a consistent way (CONTRAST-13595)
 *
 * @param {signature} signature the signature (from policy) of the method
 * @returns {string}
 */
utils.buildBasePatchName = function(signature) {
  if (signature.name) {
    return signature.name;
  }

  if (signature.moduleName && !signature.methodName) {
    return `${signature.moduleName} export`;
  }

  // patcher appends the methodName, so we strip it here
  const full = `${signature.moduleName}.${signature.methodName}`,
    patchName = full.substring(0, full.lastIndexOf('.'));
  return patchName;
};

/**
 * @param {string} key The key used to identify the handler being removed from
 * an event
 */
utils.removeHookBySignature = function(key, signature, patchType) {
  let patchName = utils.buildBasePatchName(signature);
  const target = getResolvedTarget(patchName, patchType);
  if (target) {
    // patcher appended the method name to the patch name
    // so use that as the unpatch target
    if (signature.methodName) {
      patchName += `.${signature.methodName.substring(
        signature.methodName.lastIndexOf('.') + 1
      )}`;
    }
    patcher.unpatch(target, patchName, patchType);
  }
};

/**
 * Unhooks events for a http rule(response scanning rule)
 * @param {string} id name of rule
 * @param {Array} eventNames events to unregister
 */
utils.removeHttpHook = function(id, eventListeners) {
  for (const event in eventListeners) {
    const listeners = eventListeners[event];
    for (const listener of listeners) {
      agentEmitter.removeListener(event, listener);
    }
  }
};

/**
 * Creates a hook(s) from signature
 * @param {Object} signature
 * @param {Object} options options for patcher
 */
utils.createHookFromSignature = function(signature, options, patchType) {
  const methodId = createMethodId(signature);
  const patchName = utils.buildBasePatchName(signature);
  const { methodName } = signature;

  logger.trace(`adding hook to ${methodId}`);

  options.patchType = patchType;
  options.name = patchName;

  // this tells patcher to use options.name as
  // event name instead of dynamically building from
  // methods on the obj
  options.staticName = signature.staticName;

  /**
   * callback that is executed when a module resolves
   * @param {Object} mod resolved module
   */
  function requireCallback(mod) {
    const hookTarget = resolveHookTarget(mod, methodName);
    const { module: obj, name: prop } = hookTarget;

    const hook = patcher.patch(obj, prop, options);

    // catch for function exports which rely on require
    // to be hooked---these otherwise won't be set by reference
    if (typeof hook === 'function') {
      setResolvedTarget(patchName, patchType, hook);
      return hook;
    } else {
      setResolvedTarget(patchName, patchType, obj[prop]);
    }
  }

  if (signature.isModule) {
    if (signature.fileName) {
      requireHook.resolve(
        {
          name: signature.moduleName,
          file: signature.fileName,
          version: signature.version
        },
        requireCallback
      );
    } else {
      requireHook.resolve(
        {
          name: signature.moduleName,
          version: signature.version
        },
        requireCallback
      );
    }
  } else {
    const mod = global[signature.moduleName],
      hookTarget = resolveHookTarget(mod, methodName);

    const { module: obj, name: prop } = hookTarget;

    if (!obj) {
      return;
    }

    patcher.patch(obj, prop, options);
    setResolvedTarget(patchName, patchType, obj[prop]);
  }
};

function setResolvedTarget(patchName, patchType, target) {
  utils.resolvedTargets[`${patchName}:${patchType}`] = target;
}

function getResolvedTarget(patchName, patchType) {
  return utils.resolvedTargets[`${patchName}:${patchType}`];
}

/**
 * Loads provider/override but first adds `..` to the path for a provider so all custom
 * instrumentation can be loaded from `lib/assess`
 *
 * @param {string} relativePath to prepend `..` to for a given custom instrumentation file
 * @return {Module}
 */
utils.loadProvider = function(relativePath) {
  return require(path.join('..', relativePath));
};
