/**
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 cp = require('child_process');
const fs = require('fs');
const path = require('path');
// according to https://nodejs.org/api/process.html#process_process the process
// global can be required.  We're only doing so here so we can mock it out in tests
const process = require('process');

const SocketClient = require('../socket-client');
const GRPCClient = require('../grpc-client');
const {
  CONNECTION_STATES,
  INIT_STATES,
  TERM_SIGNALS,
  FAST_FAIL_MS,
  MAX_FAST_FAILURES
} = require('../speedracer/constants');
const UnknownConnectionState = require('./unknown-connection-state');
const DisabledConnectionState = require('./failure-connection-state');
const SuccessConnectionState = require('./success-connection-state');

const RESEND_WAIT_MS = 100;

/**
 * We might have the /bin/* files in /target directory if we're in
 * development. Packaged/installed agents will have the executables in a
 * top-level directory, e.g. /node_modules/@contrast/agent/bin/*
 * @returns {'target' | '.'}
 */
const maybeTargetDir = () => {
  try {
    fs.statSync(path.resolve(__dirname, '..', '..', '..', 'target'));
    return 'target';
  } catch (error) {
    return '.';
  }
};

class Speedracer {
  constructor({ agent, logger }) {
    this.agent = agent;
    this.pid = process.pid;
    this.ppid = process.ppid;
    this.logger = logger('contrast:speedracer');
    this.clientId = `${
      this.agent.cluster && this.agent.cluster.isPrimary ? this.pid : this.ppid
    }`;

    this.serviceProcess = null;
    this.serviceProcessKilled = false;

    // internal state fields
    this._enabled = true;
    this._messageCount = 0;
    this._messageErrorCount = 0;
    this._initState = INIT_STATES.UNKNOWN;
    this.connectionState = new UnknownConnectionState(this);
    this.messageCache = new Map();
    this.client = this.createClient();
    // the following are needed because speedracer startup is driven by sending messages.
    // both message sending and process creation can fail asynchronously. they are completely
    // independent mechanism - the child process exit is handled by the exit event listener while
    // message sending failures are handled via promises. the promises shouldn't wait for an
    // indefinite period of time to see if the speedracer process fails. so this counts sequential
    // failures where speedracer was running less than 1 second and presumes that speedracer
    // cannot successfully be started if that occurs more than 3 times.
    //
    // before this code went in the agent would go into a hard loop trying to start speedracer
    // when speedracer continually exits.
    //
    // a better solution would be have speedracer exit with a code that means "given the config
    // restarting will not help, i cannot run." another aspect that could be improved is to
    // change the startup logic to not be message driven as it is now, but that's a bigger job.
    //
    this.sequentialFailures = 0; // used to determine that speedracer cannot be started
    this.startFailureError = null; // set by the child process exit listener after too many quick exits
    this.signalHandlersSetup = false; // flag to avoid setting up duplicate signal listeners

    // re-defining logger once we know client
    // I was hoping to just update the namespace of logger above but the formatting
    // occurs at time of creation
    this.logger = logger(`contrast:speedracer:${this.client.name}`);
  }

  /**
   * Create a speedracer client as either a GRPC or Socket
   * depending on if the `agent.service.grpc` flag is enabled
   *
   * @param {Object} config agent config
   */
  createClient() {
    const { config } = this.agent;
    const options = {
      port: config.agent.service.port,
      host: config.agent.service.host,
      socket: config.agent.service.socket
    };

    return this.grpcEnabled
      ? new GRPCClient(options)
      : new SocketClient(options);
  }

  /**
   * Increments the internal message count and returns its current value.
   * @returns {Number}
   */
  messageCountInc() {
    return ++this._messageCount;
  }

  /**
   * Sends the message using current connection state. Uses the state pattern
   * to localize and partition message-sending logic.
   * @param {SpeedracerBaseMessage} message
   * @returns {Promise}
   */
  send(message) {
    return this.connectionState.send(message).then((agentSettings) => {
      // As part of the SRA1-3-XXX spec for speedracer communication,
      // if communication fails to start, we should attempt to reconnect
      // and send the initialization messages. As such, we need to save the
      // following messages:
      //   - startup
      //   - appCreate
      this.cacheMessage(message);
      return agentSettings;
    });
  }

  /**
   * As part of the SRA1-3-XXX spec for speedracer communication,
   * if communication fails to start, we should attempt to reconnect
   * and send the initialization messages. As such, we need to save the
   * following messages:
   *   - startup
   *   - appCreate
   * @param {Object} message the message that is being sent (not always cached)
   */
  cacheMessage(message) {
    if (
      message &&
      (message.messageType === 'startup' || message.messageType === 'appcreate')
    ) {
      this.messageCache.set(message.messageType, message);
    }
  }

  /**
   * Starts the contrast-service executable shipped with the agent.
   * @returns {Promise}
   */
  start() {
    this.logger.info('starting contrast-service');
    this.startTime = Date.now();

    let speedracerPath = path.resolve(
      __dirname,
      '..',
      '..',
      '..',
      maybeTargetDir(),
      'bin',
      `contrast-service-${process.platform}-${process.arch}`
    );

    if (process.platform === 'win32') {
      speedracerPath = `${speedracerPath}.exe`;
    }

    try {
      fs.statSync(speedracerPath);
    } catch (error) {
      this.logger.error(
        'unable to locate a speedracer binary for the current platform (%s) and architecture (%s)',
        process.platform,
        process.arch
      );
      return Promise.reject();
    }

    this.serviceProcess = cp.spawn(speedracerPath, {
      env: {
        ...process.env,
        CONTRAST_CONFIG_PATH: this.agent.config.configFile
      },
      stdio: 'ignore',
      windowsHide: true
    });

    this.serviceProcess.on('error', (err) => {
      this.serviceProcess = null;
      this.transitionConnectionState(CONNECTION_STATES.FAILURE);
      this.logger.error('speedracer disabled due to process error: %o', err);
    });

    this.serviceProcess.on('exit', (code) => {
      const uptime = Date.now() - this.startTime;
      if (uptime < FAST_FAIL_MS) {
        if (++this.sequentialFailures > MAX_FAST_FAILURES) {
          // make sure it doesn't try to exit non-existent process when
          // on('exit', ...)
          this.serviceProcess = null;
          this.transitionConnectionState(CONNECTION_STATES.FAILURE);
          this.startFailureError = new Error(
            `cannot start contrast-service: ${code}`
          );
        }
      } else {
        this.sequentialFailures = 0;
      }
      this.logger.warn(
        'the contrast-service child process exited and a restart may be attempted'
      );
      this.serviceProcess = null;
    });

    this.serviceProcess.unref();

    // if retrying start() could be called multiple times, each setting up handlers.
    // the handling logic doesn't stop the contrast-service process if more than one
    // listener is present, so SIGINT is a no-op in that case.
    if (!this.signalHandlersSetup) {
      this.setupSignalHandlers();
      this.signalHandlersSetup = true;
    }

    return new Promise((resolve) => setTimeout(resolve, RESEND_WAIT_MS));
  }

  /**
   * Proper checks to kill processes
   * Prevents non-existent processes from being killed when speedracer is not found
   */
  killSpeedracer() {
    if (
      this.serviceProcess &&
      this.serviceProcess.pid &&
      this.serviceProcessKilled === false
    ) {
      this.serviceProcessKilled = true;
      process.kill(this.serviceProcess.pid, 'SIGTERM');
    }
  }

  /**
   * Adds handlers for exit and all SIG* events to properly kill the
   * running speedracer subprocess
   */
  setupSignalHandlers() {
    process.on('exit', () => {
      this.killSpeedracer();
    });

    for (const signal of TERM_SIGNALS) {
      process.on(signal, () => {
        // if the user code has installed another handler do nothing
        // if their handler causes a process exit we'll catch it via
        // the 'exit' handler above.  If they haven't kill the service
        // proc and re-raise the signal causing the behavior if we
        // hadn't had a handler at all
        if (process.listenerCount(signal) === 1) {
          this.killSpeedracer();
          process.removeAllListeners(signal);
          process.kill(process.pid, signal);
        }
      });
    }
  }

  /**
   * Will transition the connection state based on the state constant provided.
   * This will alter the behavior of the instance's <code>send</code> method.
   * @param {String} connectionState The state into which to transition
   */
  transitionConnectionState(connectionState) {
    const from = this.connectionState.state;
    const to = connectionState;
    this.logger.info(`transitioning SR connection from ${from} to ${to}`);
    switch (connectionState) {
      case CONNECTION_STATES.UNKNOWN: {
        this.connectionState = new UnknownConnectionState(this);
        break;
      }
      case CONNECTION_STATES.FAILURE: {
        this.connectionState = new DisabledConnectionState(this);
        break;
      }
      case CONNECTION_STATES.SUCCESS: {
        this.connectionState = new SuccessConnectionState(this);
        break;
      }
    }
  }

  /**
   * GRPC is enabled when `agent.service.grpc` is not false, e.g., undefined
   * defaults to enabled, and `agent.service.socket` is not configured
   */
  get grpcEnabled() {
    const grpc = this.agent.config.agent.service.grpc !== false;
    const socketConfigured = !!this.agent.config.agent.service.socket;

    if (grpc && socketConfigured) {
      this.logger.warn(
        'GRPC is enabled but is configured to run on a socket instead of port/host, service config: %o',
        this.agent.config.agent.service
      );
    }

    return grpc && !socketConfigured;
  }
}

module.exports = Speedracer;
