/**
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 { get } = require('lodash');
const { PROXY_TARGET } = require('../../constants');
const querystring = require('querystring');
const cookie = require('cookie');
const { IncomingMessage } = require('http');

/**
 * When certain source events are emitted for a given
 * framework, the IncomingMessage is either req
 * or some lower level path.
 *
 * @param {Object} req IncomingMessage or Framework req/ctx
 * @param {string} altPath alternative path for IncomingMessage (e.g - 'raw.req')
 * @return {import('http').IncomingMessage}
 */
function findIncomingMessage(req, altPath) {
  // FIXME: Replace lodash get function with a native one
  return req instanceof IncomingMessage ? req : get(req, altPath);
}

/**
 * Model of request objects sent to TeamServer
 */
class Request {
  /**
   * @param {Object} req the raw request object
   * @param {string} rawLocations
   */
  constructor(req, rawLocations) {
    const raw = findIncomingMessage(req, rawLocations);

    if (raw) {
      /** @type {import('http').IncomingMessage} */
      this._raw = raw;

      /**
       * express overrides IncomingMessage.url, boo them
       * @type {string}
       */
      const url = raw.originalUrl || raw.url;

      const [uri, queryString] = Request.splitUrl(url);
      this.uri = uri;

      /** @type {string} */
      this.queryString = queryString;

      /**
       * http version
       * @type {string}
       */
      this.version = raw.httpVersion;

      /** @type {string} */
      this.method = raw.method;

      /** @type {Object<string, string>} */
      this.headers = raw.headers;

      if (raw.headers) {
        /** @type {string} */
        this.content_type = raw.headers['content-type'];

        /** @type {string} */
        this.user_agent = raw.headers['user-agent'];

        /** @type {Object<string, string>} */
        this.cookies = cookie.parse(raw.headers.cookie || '');
      }

      /** @type {string} */
      this.protocol = raw.socket && raw.socket.encrypted ? 'https' : 'http';

      /** @type {string} */
      this.ip = this.resolveIp();

      /** @type {string} */
      this.port = this.resolvePort();

      // START OF UNKNOWN VARS
      /** @type {any} */
      this.serverVersionInfo = null;
      // END OF UNKNOWN VARS

      // ---- these are added by framework specific implementations ----

      /** @type {string} */
      this.body = undefined;

      /** @type {Object} */
      this.parameters = undefined;

      /** @type {string} */
      this.normalizedUri = undefined;
    }
  }

  set parameters(params) {
    this._params = params || {};
  }

  get parameters() {
    return this._params;
  }

  get body() {
    return this._body;
  }

  set body(body) {
    this._body = !body || !Object.keys(body).length ? null : body;
  }

  get queryParams() {
    return querystring.parse(this.queryString);
  }

  serializeBody() {
    if (!this.body) {
      return null;
    }
    let { body } = this;
    const proxied = this.body[PROXY_TARGET];
    if (proxied) {
      body = proxied.target;
    }
    /*
     * request body serialization
     *
     * If the request is x-www-form-urlencoded url encode it so SR
     * can perform sensitive data masking (need to re-encode b/c body parsing
     * does decoding).  TODO - should this only be done if SR is enabled?
     *
     * Otherwise just JSON.stringify it so it can be sent over the wire.  Note
     * this isn't actually the raw body but is what we've done historically.
     */
    if (
      this.content_type &&
      this.content_type === 'application/x-www-form-urlencoded'
    ) {
      return querystring.stringify(body);
    } else if (
      !this.content_type &&
      this.headers['content-type'] === 'application/x-www-form-urlencoded'
    ) {
      return querystring.stringify(body);
    } else {
      // if the body isn't an object just stringify it as it is
      if (typeof body !== 'object') {
        return JSON.stringify(body);
      }

      // body is an array or an object; it doesn't really matter which,
      const keys = Object.keys(body);
      const nominalMaxKeys = 200; // maximum number of keys to display, but
      const minimumToRemove = 20; // if this many can't be removed don't remove any

      // the point of nominalMaxKeys and minimumToRemove is to avoid removing an
      // inconsequential number resulting in something like:
      // {100 kv pairs} (1 removed) {100 kv pairs}
      // the 10% minimum to remove is arbitrary but does avoid the situation above.
      let s;
      if (keys.length > nominalMaxKeys + minimumToRemove) {
        s = this.stringifyWithOmissions(keys, body, nominalMaxKeys);
      } else {
        s = JSON.stringify(body);
      }

      return s;
    }
  }

  stringifyWithOmissions(keys, body, max) {
    // get the pieces
    const partSize = Math.trunc(max / 2);
    const p1 = keys.slice(0, partSize);
    const p2 = keys.slice(-partSize);

    // get the head part
    let part = {};
    for (let i = 0; i < partSize; i++) {
      part[p1[i]] = body[p1[i]];
    }
    const head = JSON.stringify(part);

    // now the tail part
    part = {};
    for (let i = 0; i < partSize; i++) {
      part[p2[i]] = body[p2[i]];
    }
    return `${head}...(${keys.length - max} removed)...${JSON.stringify(part)}`;
  }

  /**
   * Render the object so that it can be stringified and consumed by TeamServer
   */
  toSerializable() {
    return {
      body: this.serializeBody(),
      content_type: this.content_type,
      cookies: this.cookies,
      headers: formatMap(this.headers),
      ip: this.ip,
      method: this.method,
      parameters: formatMap(this.parameters),
      port: this.port,
      protocol: this.protocol,
      queryString: this.queryString,
      serverVersionInfo: this.serverVersionInfo,
      uri: this.normalizedUri || this.uri,
      user_agent: this.user_agent,
      version: this.version
    };
  }

  /**
   * Splits url by ?
   * @param {string} url
   * @returns {string[]}
   */
  static splitUrl(url = '') {
    return url.split('?');
  }

  /** @returns {string} */
  resolveIp() {
    let ip;

    ip = this._raw.headers && this._raw.headers['x-forwarded-for'];
    if (ip) {
      ip = ip.split(',')[0];
    } else {
      ip = (this._raw.socket && this._raw.socket.remoteAddress) || '';
    }

    return ip.trim();
  }

  /** @returns {string} */
  resolvePort() {
    const port = (this._raw.socket && this._raw.socket.localPort) || 0;

    return port.toString();
  }

  /**
   * Only returns the path of the uri, strips query params
   * @returns {string}
   */
  static normalizeURI(url) {
    return Request.splitUrl(url)[0];
  }
}

/**
 * Takes a map of key --> value and changes it to key --> [value]
 * @param {Object<string, string>} obj
 * @returns {Object<string, string[]>}
 */
function formatMap(obj) {
  const map = {};
  for (const key in obj) {
    if (!(key in map)) {
      map[key] = [];
    }
    map[key].push(obj[key]);
  }

  return map;
}

module.exports = Request;
