/**
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.
*/
/**
 * @module lib/socket-client
 */

'use strict';

const net = require('net');
const { jspack } = require('jspack');

const metrics = require('../core/metrics');

const CONNECT_FAILURE_ERRORS = ['ENOENT', 'ECONNREFUSED'];

class SocketClient {
  constructor({ port, socket, host }) {
    this.port = port;
    this.socket = socket;
    this.host = host;
    this.connectArgs = this.socket ? [this.socket] : [this.port, this.host];
    this.name = 'socket-client';
  }

  /**
   * The default data to return in the promise returned by <code>send</code>.
   * This will ultimately result in an empty <code>AgentSettings</code> object
   * after being send through the deserialization process.
   * @returns {Buffer}
   */
  get defaultResponse() {
    return Buffer.from('');
  }

  /**
   * Gets a socket connection based on the client's configuration.
   * @returns {Socket} TCP or IPC socket
   */
  getConnection() {
    this.start = Date.now();
    // We return a _new_ connection for each message. This will likely need to
    // change in the future (for performance reasons), but is currently a
    // consequence of how the S-R service handles requests.
    return net.connect(...this.connectArgs);
  }

  /**
   * Sends a message to the server. The returned promise will resolve
   * to the message returned, or will be rejected upon error.
   * @param {Buffer} message The message to send to the server
   * @returns {Promise}
   */
  async send(messageBuffer) {
    if (!messageBuffer || messageBuffer.length === 0) {
      throw new Error('socket-client.send() called with empty buffer');
    }

    const connection = this.getConnection();

    return new Promise((resolve, reject) => {
      const state = { chunks: [], length: 0 };

      connection.once('connect', () => {
        metrics.recordConnectTime(Date.now() - this.start);
        SocketClient.writeToSocket(connection, messageBuffer);
      });

      connection.on('data', (data) => {
        SocketClient.bufferData(state, data);
      });

      const t = setTimeout(() => onError(new Error('socket timeout')), 5000);

      connection.on('end', () => {
        SocketClient.handleEnd(state, connection, resolve);
        if (t) {
          clearTimeout(t);
        }
      });

      function onError(error) {
        SocketClient.handleError(connection, error, reject);
        if (t) {
          clearTimeout(t);
        }
      }
      connection.on('error', onError);
    });
  }

  /**
   * Whether the error represents a connection failure signifying that the
   * service is not running on the expected host:port or socket.
   * @param {Error} err
   * @returns {Boolean}
   */
  isConnectFailureError(err) {
    return CONNECT_FAILURE_ERRORS.indexOf(err.code) !== -1;
  }

  /**
   * Writes the message to the connected socket instance. To be called when a
   * socket emits a 'connect' event.
   * @param {Socket} connection
   * @param {Buffer} outgoingMessageBuffer
   */
  static writeToSocket(connection, outgoingMessageBuffer) {
    // This is part of the S-R protocol where we write, as a "header", the
    // length of the message.
    const start = Date.now();
    const length = jspack.Pack('>I', [outgoingMessageBuffer.length]);
    connection.write(Buffer.from(length));
    connection.write(outgoingMessageBuffer, () => {
      metrics.recordWriteTime(Date.now() - start);
    });
  }

  /**
   * Collects the data chunk into the state object. To be called when a socket
   * emits a 'data' event.
   * @param {Object} state
   * @param {Buffer} dataBuffer
   */
  static bufferData(state, dataBuffer) {
    state.length = state.length + dataBuffer.length;
    state.chunks.push(dataBuffer);
  }

  /**
   * Handles the Socket 'end' event. This will aggregate the accumulated buffers
   * for the connection's state and pass the resulting Buffer instance to the
   * provided promise-resolution callback.
   * @param {Object} state
   * @param {Socket} connection
   * @param {Function} resolve
   */
  static handleEnd(state, connection, resolve) {
    const responseBuffer = Buffer.concat(state.chunks, state.length).slice(4);
    connection.destroy();
    resolve(responseBuffer);
  }

  /**
   * Handles error events. This will close the connection and pass the error
   * to the providd promise-rejection callback.
   * @param {Socket} connection
   * @param {Error} error
   * @param {Function} reject
   */
  static handleError(connection, error, reject) {
    connection.destroy();
    reject(error);
  }

  static create(...args) {
    return Reflect.construct(SocketClient, args);
  }
}

module.exports = SocketClient;
