/**
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 address = require('ipaddr.js');
const EventEmitter = require('events');

const constants = require('../constants');
const logger = require('../core/logger')('contrast:ip-analyzer');

const { INPUT_TYPES } = constants;

/**
 * Extracts a list of IP address values from a UserInput instance
 * of any type.
 * @param {UserInput} input
 * @returns {null|String[]}
 */
function extractFromInput(input) {
  return input.type === INPUT_TYPES.IP
    ? input.value
    : input.type === INPUT_TYPES.HEADER
    ? extractFromHeaderInput(input)
    : null;
}

/**
 * Extracts a list of IP address values from a UserInput instance
 * of type HEADER.
 * @param {UserInput} input
 * @returns {null|String[]}
 */
function extractFromHeaderInput(input) {
  return input.name === 'x-forwarded-for' ? input.value.split(',') : null;
}

/**
 * Gets a list of IP address from the requests object. The
 * first is from the <code>connection.remoteAddress</code>
 * value, and remaining ones from the x-forwarded-for header.
 * These are appended in reverse order.
 * @param {} req
 */
const getReqAddresses = (req = {}) => {
  const forwardedFor = getForwardedFor(req);
  const ip = req.socket && req.socket.remoteAddress;
  return ip ? [ip].concat(forwardedFor) : forwardedFor;
};

/**
 * Extracts the x-forwarded-for header values from the req.
 * @param   {IncomingMessage} req The request from which to get values
 * @returns {String[]}            Gathered IP values
 */
const getForwardedFor = (req = {}) => {
  const header = req.headers && req.headers['x-forwarded-for'];
  return header ? header.split(',').map((x) => x.trim()) : [];
};

/**
 * Compares two epoch time values to see if the second has "expired", compared to the first.
 * Compares now with the expiresAt unit time to see if expiresAt has passed.
 * If expires is false, it should never expire
 * @param {Object} params
 * @param {Number} params.time      Time to compare
 * @param {Number} params.expiresAt The time of expiry
 * @param {Boolean} params.expires If the ip is supposed to expire
 * @returns {Boolean}
 */
const expired = ({ now, expiresAt, expires }) =>
  expires ? expiresAt - now <= 0 : false;

/**
 * If the string represents a CIDR range, will return its value parsed into
 * an object by ipaddr.js. Otherwise <code>null</code> will return.
 * @param   {String} cidr CIDR formatted IP range
 * @returns {Object}      Value of (ipaddr.js).parseCIDR
 */
const decorateDenylistEntry = (ip, rule) => {
  const slashIdx = ip.indexOf('/');
  const isCIDR = slashIdx >= 0;
  const ipInstance = isCIDR
    ? address.process(ip.substr(0, slashIdx))
    : address.process(ip);

  Object.assign(rule, {
    normalizedValue: ipInstance.toNormalizedString(),
    cidr: null
  });

  if (isCIDR) {
    rule.cidr = { range: address.parseCIDR(ip), kind: ipInstance.kind() };
  }
};

/**
 * Used to build the setting into an object that can be analyzed in an easier
 * and more effifient way. It does some upfront calculations and parsing.
 * @param   {Object}   setting The IpFilter setting
 * @returns {Object}
 */
const build = (setting) => {
  const denylistedIpDetails = {
    ...setting,
    expiresAt: new Date().getTime() + setting.expiresAtMs
  };

  decorateDenylistEntry(setting.ip, denylistedIpDetails);

  return denylistedIpDetails;
};

class IpAnalyzer extends EventEmitter {
  constructor(ipFilterSettings) {
    super();
    this.updateSettings(ipFilterSettings);
  }

  updateSettings(ipFilterSettings = []) {
    this.settings = ipFilterSettings;
    this.entries = ipFilterSettings.map(build);
  }

  /**
   * Returns the first IP allowlist/denylist rule that satisfies
   * for a given request. If none satisfy, or IPs provided are invalid
   * will return null.
   * @param   {IncomingMessage} req
   * @returns {Object}
   */
  match(req) {
    return this.matchFromList(getReqAddresses(req));
  }

  /**
   * Evaluator API Interface
   * @param   {UserInput} userInput UserInput to evaluate
   * @returns {Object} Evaluation details
   */
  evaluate(userInput) {
    return this.matchFromList(IpAnalyzer.getInputAddresses(userInput));
  }

  /**
   * Iterates over the provided set of addresses and returns information for the
   * first denylist entry that satisfies a match.
   * @param {String[]} addresses Set of addresses to check for match
   * @returns {Object}
   */
  matchFromList(addresses) {
    const now = new Date().getTime();
    const result = null;

    if (!addresses.length) {
      return false;
    }

    for (const denylistEntry of this.entries) {
      const { expires, expiresAt } = denylistEntry;
      for (let i = 0; i < addresses.length; i++) {
        const currentIp = addresses[i];

        // Ignore bad IP values.
        if (!address.isValid(currentIp)) {
          logger.warn(`Unable to parse ${currentIp}.`);
          continue;
        }

        // Publish expired denylistEntrys.
        if (expired({ now, expiresAt, expires })) {
          logger.info(`IP expired: ${denylistEntry.name}, ${denylistEntry.ip}`);
          // TODO: CONTRAST-34184
          this.emit('expired', denylistEntry);
          continue;
        }

        const match = IpAnalyzer.applyDenylistMatch(denylistEntry, currentIp);
        if (match) {
          return match;
        }
      }
    }

    return result;
  }

  /**
   * Provided with an IP denylist details entry and an IP address, will check
   * whether the IP satisfies a match against the entry. This could be an exact
   * match atainst the IP, or that the IP falls within a CIDR range if specified
   * by the entry.
   * @param {Object} denylistEntry IP denylist details
   * @param {String} ip IP address
   * @returns {null|Object}
   */
  static applyDenylistMatch(denylistEntry, ip) {
    const parsed = address.process(ip);

    // Check if IP is in CIDR range,
    if (denylistEntry.cidr) {
      if (parsed.kind() !== denylistEntry.cidr.kind) {
        return null;
      }

      if (parsed.match(denylistEntry.cidr.range)) {
        return { ...denylistEntry, match: ip };
      } else {
        return null;
      }
    }

    // or do a direct comparison
    if (parsed.toNormalizedString() === denylistEntry.normalizedValue) {
      return { ...denylistEntry, match: ip };
    }

    return null;
  }

  /**
   * Will return an array of addresses extracted from a <code>UserInput</code>
   * instance.
   * @param {UserInput} userInput Value from which to extract addresses
   * @returns {String[]}
   */
  static getInputAddresses(userInput) {
    const addresses = extractFromInput(userInput);
    if (!addresses) return [];
    if (typeof addresses === 'string') return [addresses];
    return addresses.filter(Boolean);
  }
}

exports.IpAnalyzer = IpAnalyzer;
