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

/**
 * All source events that are emitted by the source provider are subscribed
 * to here and are forwarded to the Protect Service.
 * @module lib/rules/protect
 */

const _ = require('lodash');

const Stream = require('stream');
const parseurl = require('parseurl');
const agentEmitter = require('../agent-emitter');
const ApplicationContext = require('./models/application-context');
const Request = require('../reporter/models/request');
const {
  AsyncStorage,
  KEYS: { SAMPLES, RULES, INPUT_EXCLUSIONS, RES, REQ, REQUEST },
} = require('../core/async-storage');
const Samples = require('./samples.js');
const ProtectService = require('./service');
const { INPUT_TYPES } = require('../constants');
const handlerAsyncErrors = require('./errors/handler-async-errors');

module.exports = function protectEventListener(agent, reporter) {
  const service = new ProtectService(agent, reporter);

  handlerAsyncErrors.install();

  agentEmitter.on('http.requestStart', (req, res, ipEvent) => {
    // apply exclusions based only on the url path name, not on the full
    // path (which may include a query string).
    const { pathname: urlPath } = parseurl(req);

    const rules = service.getEnabledRules(urlPath, ipEvent);
    AsyncStorage.set(RULES, rules);
    AsyncStorage.set(
      INPUT_EXCLUSIONS,
      service.getEnabledInputExclusions(urlPath),
    );
    AsyncStorage.set(SAMPLES, new Samples());
  });

  agentEmitter.on('http.responseFinished', () => {
    const samples = AsyncStorage.get(SAMPLES);
    const rules = AsyncStorage.get(RULES);

    service.submitFindings(rules, samples);
  });

  const sourceRuleFilter = getSourceRuleFilter(agent.config);
  agentEmitter.on('protect.source', (event) => {
    if (isStream(event.data) || !isValidEvent(event)) {
      return;
    }

    const ctxt = AsyncStorage.getContext();
    if (ctxt.defend) {
      const { exclusions: inputExclusions, samples } = ctxt.defend;
      const rules = sourceRuleFilter(ctxt.defend.rules);
      enrichEvent(event, ctxt);
      enrichSamples(event, samples);

      if (!rules || rules.length === 0) {
        return;
      }
      // it's ugly because this probably should have been here all along as opposed
      // to "enriching" the event with data from ctxt.
      event._ctxt = ctxt;
      service.handleSourceEvent(event, rules, inputExclusions, samples);
    }
  });

  agentEmitter.on('protect.sink', (event) => {
    if (!isValidEvent(event)) {
      return;
    }

    const ctxt = AsyncStorage.getContext();
    const rules = _.get(ctxt, RULES);
    const samples = _.get(ctxt, SAMPLES);

    enrichEvent(event, ctxt);

    service.handleSinkEvent({ event, rules, samples });
  });

  return service;
};

/**
 * Enriches a property on the event with data from the async storage.
 *
 * @param {Object} event the SourceEvent instance
 * @param {Set} storage async protect storage
 */
function enrichEvent(event, storageCtxt) {
  if (storageCtxt) {
    [
      { key: '_incomingMessage', ctxtKey: REQ },
      { key: '_serverResponse', ctxtKey: RES },
      { key: 'request', ctxtKey: REQUEST },
      { key: 'response', ctxtKey: RES },
    ].forEach(({ key, ctxtKey }) => {
      if (!event[key]) {
        setKey({ storageCtxt, ctxtKey, event, key });
      }
    });

    // add to storage context if not already there as such
    if (!(event.request instanceof Request)) {
      event.request = storageCtxt[REQUEST];
    }
  }
}

/**
 * This is required when using SR for input analysis. It is not possible to know
 * the name (as registered in route) of URL_PARAMETER input types when they are
 * parsed out of the URL path. Since we still emit source events when using SR,
 * we can perform a simple hueristic to try to update the names of any collected
 * samples' inputs to match the key name of event data when the values match the
 * decoded name.
 * @param {SourceEvent} event
 * @param {StorageContext} ctxt
 */

function enrichSamples(event, samples) {
  for (const key of Object.keys(event.data)) {
    for (const sample of samples.getAllByType(INPUT_TYPES.URL_PARAMETER)) {
      const decoded = decodeURIComponent(sample.input.name);
      if (decoded === event.data[key]) {
        // Set value to decoded version to better input tracing capabilities
        sample.input.name = key;
        sample.input.value = decoded;
      }
    }
  }
}

/**
 *
 * Gets a value from async storage and sets it on the event
 *
 * @param {Object} param
 * @param {Object} param.event Application context data
 * @param {Object} param.key Property to set on event
 * @param {Object} param.storageCtxt AsyncAsyncStorage context map
 * @param {Object} param.ctxtKey key to access from storageCtxt
 */
function setKey({ storageCtxt, ctxtKey, event, key }) {
  // TODO: I think we should use AsyncStorage.set here
  const value = _.get(storageCtxt, ctxtKey);
  // only set on event if it exists in async storage
  if (value) {
    _.set(event, key, value);
  }
}

/**
 * If event is an instance of ApplicationContext
 * it will return true
 *
 * @param {Object} event ApplicationContext model
 * @return {Boolean}
 */
function isValidEvent(event) {
  return event instanceof ApplicationContext;
}

/**
 * Checks if data is an instance of Stream
 * and will return true if it is
 *
 * @param {*} data data emitted as source event
 * @return {Boolean}
 */
function isStream(data) {
  return data instanceof Stream;
}

/**
 * return a function that filters out rules that speedracer or agent-lib
 * handles.
 * @param {Object} config The agent configuration
 * @returns {Function} function that returns either filtered or unfiltered rules
 */
function getSourceRuleFilter(config) {
  return config.agent.node.speedracer_input_analysis && !config.agent.node.native_input_analysis
    ? (rules) => rules.filter((rule) => rule.inputClassification === false)
    : (rules) => rules;
}

module.exports.enrichEvent = enrichEvent;
module.exports.enrichSamples = enrichSamples;
module.exports.setKey = setKey;
module.exports.isValidEvent = isValidEvent;
module.exports.isStream = isStream;
module.exports.getSourceRuleFilter = getSourceRuleFilter;
