/**
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 EventEmitter = require('events');

const scanStates = require('./scan-states');
const logger = require('../../../core/logger')('contrast:protect:scan-state');

const FLAGS = {
  SKIP: 'SKIP',
  INCLUSIVE_CAPTURE: 'INCLUSIVE_CAPTURE',
  INCLUDE_WHITESPACE: 'INCLUDE_WHITESPACE',
  FORCE_TOKEN: 'FORCE_TOKEN',
  NESTED_STATE: 'NESTED_STATE'
};

const FLAG_DEFAULTS = {
  [FLAGS.SKIP]: 0,
  [FLAGS.INCLUSIVE_CAPTURE]: false,
  [FLAGS.INCLUDE_WHITESPACE]: false,
  [FLAGS.FORCE_TOKEN]: false,
  [FLAGS.NESTED_STATE]: false
};

/**
 * Holds state information for the scanner as it makes a pass through
 * the characters of string as it is tokenized. As the value of the
 * state changes, the tokens are captured via events.
 */
class ScanState extends EventEmitter {
  constructor(codeString) {
    super();

    this.codeString = codeString;
    this.state = scanStates.EXPECTING_TOKEN;
    this.index = -1;
    this.stateHistory = [];
    this.nestedStates = [];
    this.tokenStart = 0;
    this.targetIndex = codeString.length - 1;
    this.resetFlags();
  }

  /**
   * Set flag value by name. Chainable.
   */
  flag(name, value) {
    if (!(name in FLAGS)) {
      logger.warn('Unknown flag: %s', name);
    } else {
      this.flags[name] = value;
    }
    return this;
  }

  /**
   * Update the state value. Chainable.
   * @param {String} nextState The state value to which to transition.
   */
  is(nextState) {
    // Advance our position in the string and check if we're at end
    const done = ++this.index === this.targetIndex;

    this.decrementSkipFlag();

    // There are different code paths each sharing similar checks.
    // Let's first handle cases where the state is changing.
    if (this.state !== nextState) {
      // Update the value.
      this.state = nextState;

      // When the last character is a state change, there are cases:
      if (done) {
        // If the flag already tells us to capture the final
        // character with the current token being scanned we're done.
        if (this.value('INCLUSIVE_CAPTURE')) {
          this.emit('state-change', this.advanceAndGetToken());
        } else {
          // Otherwise, we need to capture the previous token first.
          this.emit('state-change', this.advanceAndGetToken());
          // And then the final token.
          this.flag('INCLUSIVE_CAPTURE', true);
          this.emit('state-change', this.advanceAndGetToken());
        }

        return this;
      }

      // This check is just so we don't emit at the very first
      // state change at index 0.
      if (0 < this.index) {
        this.emit('state-change', this.advanceAndGetToken());
        return this;
      } else {
        this.updateHistory();
      }

      return this;
    } else {
      // Now let's handle cases where state did not change.
      // Skipping? Just capture state first.
      if (this.flags.SKIP) {
        this.updateHistory();
        return this;
      }

      // No more chars? Then capture the remaining text.
      if (done) {
        this.flag('INCLUSIVE_CAPTURE', true);
        this.emit('state-change', this.advanceAndGetToken());
        return this;
      }

      if (this.flags.FORCE_TOKEN) {
        this.emit('state-change', this.advanceAndGetToken());
        return this;
      }

      return this;
    }
  }

  /**
   * Captures a provides state value, and adds it to nested states.
   * @param {String} value State value
   * @returns {ScanState} The instance
   */
  enter(value) {
    this.nestedStates.push(this.value());
    return this.flag(FLAGS.NESTED_STATE, true).is(value);
  }

  /**
   * Exits any nested state and captures it at current index.
   * @returns {ScanState} The instance
   */
  exit() {
    const stillNested = !!this.nestedStates.length;
    const state = this.nestedStates.pop();
    return this.is(state).flag(FLAGS.NESTED_STATE, stillNested);
  }

  /**
   * Get nested state at the level of depth provided.
   * @param {Number} level The level of depth
   * @returns {String} The value of the nested state
   */
  nestedState(level = 1) {
    const idx = this.nestedStates.length - level;
    return this.nestedStates[idx];
  }

  /**
   * Will check if we have a positive skip value. If so, will decrement the
   * value and return true. Otherwise, returns false. Only called during
   * the processing step to ensure proper countdown.
   */
  decrementSkipFlag() {
    if (this.flags.SKIP === 0) {
      return false;
    }
    --this.flags.SKIP;
    return true;
  }

  /**
   * Get the state value state, or a flag if you pass its key.
   * @param   {String} key The key used to lookup the flag value.
   * @returns {*}          The state or flags value.
   */
  value(key) {
    return key ? this.flags[key] : this.state;
  }

  /**
   * Used to reset the flag values upon instantiation or
   * when a state change is processed.
   */
  resetFlags() {
    this.flags = Object.assign({}, FLAG_DEFAULTS);
  }

  /**
   * Moves the next-token-start based on whether we are performing
   * an inclusive capture.
   */
  advanceTokenStart() {
    return this.flags.INCLUSIVE_CAPTURE
      ? (this.tokenStart = this.index + 1)
      : (this.tokenStart = this.index);
  }

  /**
   * Called when we want to get the value of the token, and advance the
   * next-token-start, update history, and reset flags. Called
   * when we emit the 'state-change' event.
   */
  advanceAndGetToken() {
    const start = this.tokenStart;
    const inclusive = this.flags.INCLUSIVE_CAPTURE;
    const stop = inclusive ? this.index + 1 : this.index;

    this.advanceTokenStart();
    this.resetFlags();
    this.updateHistory();

    return {
      token: this.codeString.substring(start, stop),
      start,
      stop
    };
  }

  /**
   * Update our state log with the current value. Keeps the current
   * and last 2 state values;
   */
  updateHistory() {
    this.stateHistory = [
      this.state,
      this.stateHistory[0],
      this.stateHistory[1]
    ];
  }
}

module.exports = ScanState;
module.exports.FLAGS = FLAGS;
module.exports.FLAG_DEFAULTS = FLAG_DEFAULTS;
module.exports.ScanState = ScanState;
