/**
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 { settings, dtm } = require('@contrast/protobuf-api');

const { wrapToObject } = require('../../translations/helpers');
const {
  ApplicationSettings,
  ServerFeatures,
  InputAnalysisResult
} = require('../../translations/to-protobuf/settings');
const { INIT_STATES } = require('../../speedracer/constants');
const metrics = require('../../../core/metrics');
const { SCREENER_HEADER_NAME } = require('../../../constants');
const AsyncStorage = require('../../../core/async-storage');

/**
 * Handles common operations for all speedracer messages. This includes the
 * creation of the "wrapper" transfer model, <code>dtm.Message</code>, and the
 * common pre-sending/post-receiving logic.
 */
class SpeedracerBaseMessage {
  constructor({ agent, reporter, logger, messageType }) {
    this.agent = agent;
    this.reporter = reporter;
    this.speedracer = reporter.speedracer;
    this.clientName = this.speedracer.client && this.speedracer.client.name;
    this.messageType = messageType;
    this.messageCount = this.speedracer.messageCountInc();
    this.logger = logger(
      `contrast:speedracer:${messageType || 'base'}-message`
    );
  }

  /**
   * The successful completion of some messages will transition the agent into
   * various initialization states:
   * - UNKNOWN: Agent may be running, but speedracer has no knowledge of it and
   *   has never seen it before.
   * - STARTING: Speedracer is aware of agent but doesn't have enough info
   *   about the application to perform security processing for it.
   * - READY: Speedracer has all the information is needs to begin security
   *   processing and is ready to accept requests.
   */
  transitionInitState() {
    switch (this.messageType) {
      case 'startup':
        this.speedracer._initState = INIT_STATES.STARTING;
        break;
      case 'appcreate':
        this.speedracer._initState = INIT_STATES.READY;
        break;
    }

    this.logger.debug(
      '%s transitioned agent to state %s',
      this.messageType,
      this.speedracer._initState
    );
  }

  /**
   * Builds and returns the base <code>Message</code> protobuf model.
   * @returns {Message}
   */
  getBaseTransferModel(
    options = {
      clientId: this.speedracer.clientId,
      pid: this.speedracer.pid,
      ppid: this.speedracer.ppid,
      timestampMs: Date.now()
    }
  ) {
    // use an array-like object in place of an array to avoid
    // putting 1 or 2 billion undefineds to reach 27.
    return new dtm.Message({
      0: options.clientId, //            1     client id
      3: this.messageCount, //           4 message_count
      4: options.timestampMs, //         5  timestamp_ms
      5: this.agent.appInfo.name, //     6      app_name
      6: 'Node', //                      7  app_language
      7: options.pid, //                 8           pid
      8: options.ppid, //                9          ppid
      27: this.agent.appInfo.appPath // 28      app_path
    });
  }

  /**
   * Builds the protobuf model and sends message instance through the
   * <code>Speedracer</code> connection state handling mechanisms.
   * @param {Object} data Agent domain model data
   * @returns {Promise}
   */
  send(data) {
    try {
      if (this.messageType === 'observedroute') {
        data = this.trimObservedRoute(data);
      } else if (this.messageType === 'activity') {
        data = this.trimActivity(data);
      }

      this.start = Date.now();
      this.baseMessageModel = this.getBaseTransferModel();
      this.appendMessageModel(data);
      this.logger.debug(
        `sending ${this.messageType} message with count=${this.messageCount}, pid=${this.speedracer.pid}`
      );
    } catch (err) {
      this.logger.error(`failed to send ${this.messageType} msg: ${err}`);
      // if this is rethrown it results in an unhandled promise rejection.
      return Promise.resolve({ error: err });
    }

    // both of the following functions call async functions, any errors
    // should result in the promise being rejected, not thrown, so this
    // code should not be in the above try-catch block.
    const promise = this.shouldEnqueue()
      ? this.sendToMessageQueue()
      : this.sendToSpeedracer();

    return promise.catch((err) => {
      this.logger.error(`send rejected ${this.messageType} msg: ${err}`);
      // if this is rethrown it results in an unhandled promise rejection.
      return Promise.resolve({ error: err });
    });
  }

  /**
   * This will send message through SR connection/service management.
   * @returns {promise}
   */
  sendToSpeedracer() {
    const promise = this.speedracer.send(this);
    return this.receive(promise);
  }

  /**
   * Sends to message queue via configured exchange when agent is set up to be
   * running in screener mode. We read the queue id from the request header.
   * If there is no request scope then we will use the app-wide queue based on
   * the application name.
   * @returns {promise}
   */
  sendToMessageQueue() {
    const {
      appInfo: { name }
    } = this.agent;

    let queueId;
    const ctx = AsyncStorage.getContext();
    if (
      ctx &&
      ctx.req &&
      ctx.req.headers &&
      ctx.req.headers[SCREENER_HEADER_NAME]
    ) {
      queueId = ctx.req.headers[SCREENER_HEADER_NAME];
    } else {
      queueId = name;
    }

    const message = Buffer.from(
      JSON.stringify(this.baseMessageModel.toObject())
    );
    return this.reporter.messageQueueClient.send({ queueId, message });
  }

  /**
   * Whether the message instance should be sent to a configured message queue
   * rather than to speedracer.
   * @returns {boolean}
   */
  shouldEnqueue() {
    return (
      this.agent.isScreening &&
      this.messageType !== 'poll' &&
      this.messageType !== 'request' &&
      this.messageType !== 'appcreate' &&
      this.messageType !== 'startup'
    );
  }

  /**
   * Generic handling of responses from the Speedracer client.
   * @param {Promise} promise Promise returned by <code>client.send()</code>
   * @returns {AgentSettings}
   */
  receive(promise) {
    return promise.then((clientResponse) => {
      if (this.speedracer.startFailureError) {
        throw this.speedracer.startFailureError;
      }
      const result = this.deserialize(clientResponse);
      metrics.recordMessageTime(this.messageType, Date.now() - this.start);
      this.logger.debug(
        `received response for message with count=${this.messageCount}, pid=${this.speedracer.pid}`
      );
      return result;
    });
  }

  /**
   * Returns the value expected by <code>client.send()</code>. This will get
   * called by the active <code>Speedracer</code> <code>ConnectionState</code>.
   * @returns {Buffer}
   */
  serialize() {
    const start = Date.now();
    // SR socket-client needs to serialize protobuf to binary
    // whereas SR grpc client lib takes care of this serializing
    const messageBuffer =
      this.clientName === 'socket-client'
        ? Buffer.from(this.baseMessageModel.serializeBinary())
        : this.baseMessageModel;

    metrics.recordSerializeTime(this.messageType, Date.now() - start);
    return messageBuffer;
  }

  /**
   * Default behavior is to deserialize a <code>Buffer<code> into the
   * <code>AgentSettings</code> protobuf model and return its object
   * representation.
   * @param {Buffer} clientResponse
   * @returns {Object}
   */
  deserialize(clientResponse) {
    const start = Date.now();
    let model = clientResponse;

    // grpc message are already deserialized
    // so this step can be skipped
    if (this.clientName === 'socket-client') {
      const ui8a = Uint8Array.from(clientResponse);
      model = settings.AgentSettings.deserializeBinary(ui8a);
    }

    const result = this.getModelAsObject(model);
    metrics.recordDeserializeTime(this.messageType, Date.now() - start);
    return result;
  }

  /**
   * Will translate the model returned from speedracer into agent settings
   * object to be used by the agent.
   * @param {AgentSettings} transferModel The protobuf model instance
   * @returns {Object}
   */
  getModelAsObject(transferModel) {
    wrapToObject(
      transferModel,
      ({ applicationSettings, serverFeatures, inputAnalysis }) => {
        if (applicationSettings) {
          ApplicationSettings.toObject(applicationSettings);
        }
        if (serverFeatures) {
          ServerFeatures.toObject(serverFeatures);
        }
        const resultsList = inputAnalysis && inputAnalysis.resultsList;
        if (resultsList) {
          resultsList.forEach((result) => {
            InputAnalysisResult.toObject(result);
          });
        }
      }
    );

    return transferModel.toObject();
  }

  /**
   * reduces the number of sources reported to SR. this is done so it's less
   * likely that we run into grpc-js's default receive limit of 4 MB.
   *
   * sources is an array detailing where each tracked item comes from. this code
   * was originally put into place to deal with very large objects (150K keys)
   * that caused grpc-js sends to fail. the 150K-key objects resulted in a grpc
   * message of 70 MB, but it turns out that the receiving server, SR, will
   * return an error to the agent when the message is larger than the default
   * receive size limit. the agent is not able to recover from this error once
   * it has occurred.
   *
   * @param {object} data agent domain model data
   */
  trimObservedRoute(data) {
    // should these limits be configuration settings?
    // sources is an Array.
    this.logger.trace(
      'observedroute data.sources.length %d',
      data.sources.length
    );

    const maxSources = 200;
    if (data.sources.length < maxSources) {
      return data;
    }

    const removeCount = data.sources.length - maxSources;
    this.logger.trace('observedroute data.sources removing %d', removeCount);
    // don't fiddle with original data - is this required? i.e., does any existing
    // code care whether this data is here or not?
    data.sources.length = data.sources.length - removeCount;
    this.logger.trace(
      'observedroute data.sources final %d',
      data.sources.length
    );

    return data;
  }

  /**
   * reduces the number findings reported to SR. this is done so it's less
   * likely to run into grpc-js's default receive limit of 4 MB.
   *
   * it's important to keep "key findings", i.e., non-creation findings because
   * SR will not report a vulnerability if there is not a propagation and a
   * trigger event. (it's possible that the creation event for a propagation and
   * trigger must be present. tbd.)
   */
  trimActivity(data) {
    const maxEvents = 200;
    // findings is a Map. loop through each finding's events.
    // verify findings exist as some tests don't create.
    const findings = data.findings ? [...data.findings.entries()] : [];

    let modified = false;
    for (const findingEntry of findings) {
      const finding = findingEntry[1];
      // the creation/propagation/trigger/? mix is not known, but creations are expendable (i think).
      // if they are expendable then not putting them into this data model will save cpu, memory, gc,
      // etc.
      if (finding.events.length > maxEvents) {
        modified = true;
        this.logger.trace(
          'activity finding %s events %d',
          finding.ruleId,
          finding.events.length
        );
        // might have to go through and match triggers with creation events but not sure.
        // keyfindings are ! CREATION (e.g., TRIGGERS, P2R PROPAGATION). (key as in "important".)
        const keyFindings = [];
        let creations = [];

        for (const event of finding.events) {
          if (event.action === 'CREATION') {
            creations.push(event);
          } else {
            keyFindings.push(event);
          }
        }

        // delete all keyfindings[n].trackedRanges. it doesn't appear that these are used
        // by TS.
        for (const kf of keyFindings) {
          // i've observed that the trackedRanges array for "non-creation" events contain arrays
          // of trackedRanges that include every item from the originating JSON string. so they
          // need to be cleaned up.
          //
          // it doesn't appear that they need to be deleted in sync with creations. deleting
          // all elements in the array doesn't semm to cause any problems in the
          // tests i've been executing.
          kf.trackedRanges.length = 0;
        }

        // remove creation events leaving at least 1 and at most maxEvents.
        if (creations.length > maxEvents) {
          creations = creations.slice(0, maxEvents);
        }

        finding.events = creations.concat(keyFindings);
        this.logger.trace(
          'activity finding %s events %d',
          finding.ruleId,
          finding.events.length
        );
      }
    }
    if (modified) {
      data.findings = new Map(findings);
    }

    return data;
  }
}

module.exports = {
  BaseMessage: SpeedracerBaseMessage
};
