/**
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 patcher = require('./patcher.js');
const { PATCH_TYPES } = require('../constants');
const logger = require('../core/logger')('contrast:hooks:cluster');
const path = require('path');
const _ = require('lodash');

let workerId = 0;

/**
 * Converts a map of agentArg:val entries to an argv array
 *
 * @param args map of agent args ({ arg1: val1, arg2: val2 })
 * @return array of agent args ([--arg1, val1, --arg2, val2])
 */
function formatAgentArgs(args) {
  const formatted = [];
  for (const arg in args) {
    formatted.push(`--${arg}`);
    formatted.push(args[arg]);
  }
  return formatted;
}

/**
 * Initialize default cluster settings for the agent.
 *   Prepends agent arguments in the argv array
 *   Prepends the entry point script if in CLI mode
 *
 * @param agent
 */
function initClusterSettings(agent) {
  agent.cluster.settings = {
    exec: agent.argv[1],
    args: [...formatAgentArgs(agent.agentArgs), '--', ...agent.appArgs]
  };
  if (agent.isCli) {
    agent.cluster.settings.args = [
      process.argv[1],
      ...agent.cluster.settings.args
    ];
  }
}

/**
 * Modify cluster settings for CLI mode
 *   If caller is setting exec preserve original exec and set new exec to args[0]
 *   If caller is setting args set args to ...agentArgs -- ...newArgs
 *
 * @param agent
 * @param settings the settings to modify
 */
function updateSettingsCli(agent, settings) {
  if (settings.exec && settings.args) {
    // exec = current exec (cli.js)
    // args = <new script>, <agent args>, --, <new app args>
    settings.args = [
      settings.exec,
      ...formatAgentArgs(agent.agentArgs),
      '--',
      ...settings.args
    ];
    delete settings.exec;
  } else if (settings.exec) {
    // exec = current exec (cli.js)
    // args = <new script>, <agent args>, --, <current app args>
    settings.args = [settings.exec, ...agent.cluster.settings.args.slice(1)];
    delete settings.exec;
  } else if (settings.args) {
    // exec = current exec (cli.js)
    // args = <current script>, <agent args>, --, <new app args>
    settings.args = [
      agent.cluster.settings.args[0],
      ...formatAgentArgs(agent.agentArgs),
      '--',
      ...settings.args
    ];
  }
}

/**
 * Modify cluster settings for preload mode
 *   If caller is setting execArgv prepend the preload args to new execArgv
 *   If caller is setting args set args to ...agentArgs -- ...newArgs
 *
 * @param agent
 * @param settings the settings to modify
 */
function updateSettingsPreload(agent, settings) {
  if (settings.args) {
    //args = <agent args>, --, <new app args>
    settings.args = [
      ...formatAgentArgs(agent.agentArgs),
      '--',
      ...settings.args
    ];
  }

  if (settings.execArgv) {
    //prepend -r path to preload
    settings.execArgv = [
      '-r',
      path.resolve(__dirname, '../../bootstrap.js'),
      ...settings.execArgv
    ];
  }
}

function init(agent) {
  logger.debug('applying non-policy hook: cluster');
  patcher.patch(agent.cluster, 'setupMaster', {
    alwaysRun: true,
    name: 'agent.cluster',
    patchType: PATCH_TYPES.MISC,
    pre(data) {
      /*
       * initialize with the original argv
       */
      if (_.isEmpty(agent.cluster.settings)) {
        initClusterSettings(agent);
      }

      const settings = data.args[0] ? data.args[0] : {};

      /*
       * manipulate args and exec as appropriate for the configuration
       */
      if (agent.isCli) {
        updateSettingsCli(agent, settings);
      } else {
        updateSettingsPreload(agent, settings);
      }

      logger.info(
        'setupMaster called with settings %s',
        JSON.stringify(settings)
      );
      data.args[0] = settings;
    }
  });

  // We need to block the "online" emit for clusters until we are ready to actually ready to send messages.
  patcher.patch(agent.cluster, 'emit', {
    name: 'agent.cluster',
    patchType: PATCH_TYPES.MISC,
    alwaysRun: true,
    pre(data) {
      const [msg, obj] = data.args;
      if (msg === 'online') {
        if (!agent.clusterEmitPids.has(obj.process.pid)) {
          // first cluster online event for this worker
          logger.debug(
            `swallowing cluster online event for pid ${obj.process.pid}`
          );
          agent.clusterEmitPids.add(obj.process.pid);
          data.result = patcher.preempt();
          return;
        }
      }
    }
  });

  patcher.patch(agent.cluster, 'fork', {
    name: 'agent.cluster',
    patchType: PATCH_TYPES.MISC,
    alwaysRun: true,
    post(data) {
      const worker = data.result;
      // incrementing ID to make sure events are unique
      // otherwise you get erroneous workers emitting online
      // events.  could use pid, but since they can be re-used
      // we'd have to clean these up
      patcher.patch(worker, `emit`, {
        name: `worker.${workerId++}.emit`,
        patchType: PATCH_TYPES.MISC,
        alwaysRun: true,
        pre(data) {
          const [msg] = data.args;

          if (msg === 'online') {
            if (!agent.workerEmitPids.has(worker.process.pid)) {
              // first online event from the proc
              logger.debug(
                `swallowing worker online event for pid ${worker.process.pid}`
              );
              agent.workerEmitPids.add(worker.process.pid);
              data.result = patcher.preempt();
              return;
            }
          }
        }
      });

      // stop tracking the pids on exit in case that get re-used
      // we want to re-swallow
      worker.on('exit', function() {
        agent.workerEmitPids.delete(worker.process.pid);
        agent.clusterEmitPids.delete(worker.process.pid);
      });
    }
  });
}

module.exports = function(agent) {
  init(agent);
};

/*
 * for testing only
 */
module.exports.unhook = function(xport) {
  patcher.unpatch(
    xport.setupMaster,
    'agent.cluster.setupMaster',
    PATCH_TYPES.MISC
  );
};
