/*
 * Copyright 2018 Scytl Secure Electronic Voting SA
 *
 * All rights reserved
 *
 * See our extended copyright notice in *file 'Copyright.txt' which is part of this source code package
 */
/* jshint node:true */
'use strict';

var Tools = require('../tools');
var AjaxCollector = require('./collectors/ajax');
var PrivateKeyCollector = require('./collectors/privatekey');
var JSExecutionCollector = require('./collectors/jsexecution');
var NavigatorInfoCollector = require('./collectors/navigatorinfo');
var MathRandomCollector = require('./collectors/mathrandom');
var WebKitRandomCollector = require('./collectors/webkitrandom');
var DeviceOrientationCollector = require('./events/deviceorientation');
var DeviceMotionCollector = require('./events/devicemotion');
var MouseMoveCollector = require('./events/mousemove');
var MouseWheelCollector = require('./events/mousewheel');
var MouseButtonCollector = require('./events/mousebutton');
var TouchStartCollector = require('./events/touchstart');
var TouchMoveCollector = require('./events/touchmove');
var TouchEndCollector = require('./events/touchend');
var KeyCollector = require('./events/key');
var codec = require('scytl-codec');

module.exports = EntropyManager;

/**
 * Manager in charge of collecting the amount of entropy required to perform
 * secure random operations.
 *
 * @class EntropyManager
 * @private
 * @param {Object}
 *            [options] An object containing optional arguments.
 *
 * <p>
 * This object can be used to override the default values of the entropy
 * manager, by making use of the following fields. For further explanation, see
 * the README.md file for this module.
 * <ul>
 * <li>maxEntropyCounter</li>
 * <li>hashUpdaterInterval</li>
 * <li>windowObject</li>
 * <li>cryptoApi</li>
 * <li>privateKeyData</li>
 * <li>noDefaultCollectors</li>
 * </ul>
 * <p>
 */
function EntropyManager(options) {
  ////////////////////////////////// PRIVATE //////////////////////////////////

  var that = this;

  var tools_;

  var maxEntropyCounter_;
  var hashUpdaterInterval_;
  var windowObject_;
  var cryptoApi_;
  var privateKeyData_;

  var collectors_ = [];
  var events_ = [];
  var collectorHashUpdater_ = null;
  var entropyHashHex_ = '';
  var entropyCounter_ = 0;
  var isCollecting_ = false;
  var fullEntropyReached_ = false;
  var fullEntropyCallback_ = null;

  /**
   * Initializes the entropy manager.
   *
   * @function init
   * @memberof EntropyManager
   * @param {Object}
   *            [options] The optional arguments passed by the constructor to
   *            override the default values of the entropy manager
   */
  function init(options) {
    tools_ = new Tools();

    // Optional arguments.
    options = options || {};

    // Amount of entropy to accumulate before stopping collectors. Default is
    // 256.
    maxEntropyCounter_ = options.maxEntropyCounter || 256;

    // Interval (in milliseconds) at which entropy data is to be hashed. Default
    // is 1 second.
    hashUpdaterInterval_ = options.hashUpdaterInterval || 1000;

    // "window" object provided to collectors. Default is built-in "window"
    // object.
    windowObject_ =
        options.windowObject || (typeof window !== 'undefined') ? window : null;

    // Cryptographic library supplied to WebKit collector (if being used).
    // Default is user's built-in cryptographic library.
    cryptoApi_ = options.cryptoApi ||
        (windowObject_ ? (windowObject_.crypto || windowObject_.msCrypto) :
                         null);

    // Private key collector data. By default, this collector is not used.
    privateKeyData_ = options.privateKeyData || null;

    // Set up standard set of collectors, unless specified not to.
    if (!options.noDefaultCollectors) {
      // Set up the default collectors.
      setupCollectors();
    }
  }

  /**
   * Sets up all of the entropy collectors to be used. If WebKit is available,
   * its entropy collector will be used exclusively. Otherwise, a combination
   * of other types of collectors will used, including the ajax calls
   * collector, JS calls execution collector, navigator information collector,
   * math random collector, private key collector, mouse events collectors,
   * key events collectors, load event collector and scroll collector.
   *
   * @function setupCollectors
   * @memberof EntropyManager
   */
  function setupCollectors() {
    if (!windowObject_) {
      addNonWindowCollectors();
      return;
    }

    if (cryptoApi_ && (cryptoApi_.getRandomValues)) {
      that.addCollector(new WebKitRandomCollector(
          collectEntropy, cryptoApi_, maxEntropyCounter_));
    } else {
      addNonWindowCollectors();

      // The API detects and delivers accelerometer data 50 times per
      // second
      if (window.DeviceOrientationEvent) {
        // Listen for the device orientation event and handle
        // DeviceOrientationEvent object
        that.addEvent(
            'deviceorientation',
            new DeviceOrientationCollector(collectEntropy).handleEvent);
      } else if (window.OrientationEvent) {
        // Listen for the MozOrientation event and handle
        // OrientationData object
        that.addEvent(
            'MozOrientation',
            new DeviceOrientationCollector(collectEntropy).handleEvent);
      }
      if (window.DeviceMotionEvent) {
        that.addEvent(
            'devicemotion',
            new DeviceMotionCollector(collectEntropy).handleEvent);
      }
    }
  }

  /**
   * Registers the entropy collectors that do not need a window object to
   * work.
   *
   * @function addNonWindowCollectors
   * @memberof EntropyManager
   */
  function addNonWindowCollectors() {
    addCollectors([
      AjaxCollector, JSExecutionCollector, NavigatorInfoCollector,
      MathRandomCollector
    ]);

    if (privateKeyData_) {
      collectors_.push(new PrivateKeyCollector(
          collectEntropy, privateKeyData_.privateKeyPem,
          privateKeyData_.pinEntropy));
    }

    addEvents({
      'mousemove': MouseMoveCollector,
      'mousewheel': MouseWheelCollector,
      'mouseup': MouseButtonCollector,
      'mousedown': MouseButtonCollector,
      'touchstart': TouchStartCollector,
      'touchmove': TouchMoveCollector,
      'touchend': TouchEndCollector,
      'gesturestart': TouchStartCollector,
      'gestureend': TouchEndCollector,
      'keyup': KeyCollector,
      'keydown': KeyCollector,
      'load': KeyCollector,
      'scroll': KeyCollector,
      'beforeload': KeyCollector,
    });
  }

  /**
   * Registers a set of entropy collectors from an array of their
   * constructors.
   *
   * @function addCollectors
   * @memberof EntropyManager
   * @param {Array}
   *            collectors The array of entropy collector constructors.
   */
  function addCollectors(collectors) {
    for (var i = 0; i < collectors.length; ++i) {
      collectors_.push(new collectors[i](collectEntropy));
    }
  }

  /**
   * Registers a set of entropy events from a map of their constructors, using
   * the event names as keys.
   *
   * @function addEvents
   * @memberof EntropyManager
   * @param {Object}
   *            events The map of entropy event constructors, using the event
   *            names as keys.
   */
  function addEvents(events) {
    for (var i = 0, eventNames = Object.keys(events), size = eventNames.length;
         i < size; i++) {
      that.addEvent(
          eventNames[i],
          (new events[eventNames[i]](collectEntropy)).handleEvent);
    }
  }

  /**
   * Registers an entropy collector. The "collector" argument can be an
   * object, in which case it will be added directly to the collector array,
   * or it can be a constructor, in which case it will be instantiated and the
   * resulting object will be added to the collector array.
   *
   * @function addCollector
   * @memberof EntropyManager
   * @param {Object}
   *            collector An object or constructor that implements the
   *            "startCollector" and "stopCollector" methods.
   * @returns {Object} The instantiation of the entropy collector that was
   *          registered.
   */
  this.addCollector = function(Collector) {
    if (typeof Collector === 'function') {
      // If a constructor is passed, create a new object and link it to the
      // entropy collector.
      Collector = new Collector(collectEntropy);
    }

    collectors_.push(Collector);

    return Collector;
  };

  /**
   * Registers a new event.
   *
   * @function addEvent
   * @memberof EntropyManager
   * @param {string}
   *            type The event type.
   * @param {function}
   *            listener The event listener.
   */
  this.addEvent = function(type, listener) {
    events_.push({'type': type, 'listener': listener});
  };

  /**
   * Adds entropy to the entropy collected so far, and increments the entropy
   * counter. This method is usually called internally by the entropy collectors
   * and the events but is made public here for use as a callback function when
   * adding individual collectors via the secure random service.
   *
   * @function collectEntropy
   * @memberof EntropyManager
   * @param {string}
   *            data The entropy hash data to be added.
   * @param {number}
   *            increment The entropy counter increment to be added.
   */
  this.collectEntropy = function(data, increment) {
    return collectEntropy(data, increment);
  };

  /**
   * Adds entropy to the entropy collected so far, and increments the entropy
   * counter. This method is usually called by the entropy collectors and the
   * events.
   *
   * @function collectEntropy
   * @memberof EntropyManager
   * @param {string}
   *            data The entropy hash data to be added.
   * @param {number}
   *            increment The entropy counter increment to be added.
   */
  function collectEntropy(data, increment) {
    entropyHashHex_ += data;
    entropyCounter_ += (increment ? increment : 0);

    // if the entropy counter has reached the limit, update the hash and stop
    // the collectors
    if (isCollecting_ && (entropyCounter_ >= maxEntropyCounter_)) {
      // Full entropy reached.
      fullEntropyReached_ = true;

      // Stop collecting entropy.
      that.stopCollectors();

      // Make the full entropy callback, if present.
      if (typeof fullEntropyCallback_ === 'function') {
        fullEntropyCallback_();
      }
    }
  }

  /**
   * Updates the entropy hash, using the entropy collected so far.
   *
   * @function updateEntropyHash
   * @memberof EntropyManager
   */
  function updateEntropyHash() {
    var digester = tools_.getUpdateDigester();
    digester.update(entropyHashHex_);
    entropyHashHex_ =
        codec.hexEncode(codec.binaryDecode(digester.digest().getBytes()));
  }

  /////////////////////////////////// PUBLIC ///////////////////////////////////

  /**
   * Starts the entropy collectors.
   *
   * @function startCollectors
   * @memberof EntropyManager
   * @param {function}
   *            fullEntropyCallback The function that is called when the
   *            required amount of entropy has been collected.
   */
  this.startCollectors = function(fullEntropyCallback) {
    if (isCollecting_) {
      // Entropy collection is in progress -- nothing to do.
      return;
    } else {
      isCollecting_ = true;
    }

    if (fullEntropyCallback) {
      // Assign the full entropy callback.
      fullEntropyCallback_ = fullEntropyCallback;
    }

    var i = 0;
    // start all the collectors
    while (i < collectors_.length) {
      try {
        collectors_[i].startCollector();
      } catch (e) {
        // do not do anything about any exception that is thrown
        // by the execution of the collectors
      }
      i++;
    }

    i = 0;
    // start all the events
    while (i < events_.length) {
      try {
        if (window.addEventListener) {
          window.addEventListener(events_[i].type, events_[i].listener, false);
        } else if (document.attachEvent) {
          document.attachEvent('on' + events_[i].type, events_[i].listener);
        }
      } catch (e) {
        // do not do anything about any exception that is thrown
        // by the execution of the events
      }
      i++;
    }

    tools_.getUpdateDigester().start();
    collectorHashUpdater_ = setInterval(function() {
      updateEntropyHash();
    }, hashUpdaterInterval_);
  };

  /**
   * Stops the entropy collectors. This method will have no effect if the
   * required amount of entropy has already been collected.
   *
   * @function stopCollectors
   * @memberof EntropyManager
   * @returns {boolean} True if the required amount of entropy has been
   *          collected, false otherwise.
   */
  this.stopCollectors = function() {
    if (!isCollecting_) {
      // Entropy collection is not in progress -- nothing to do.
      if (entropyCounter_ >= maxEntropyCounter_) {
        return true;
      } else {
        return false;
      }
    } else {
      isCollecting_ = false;
    }

    var i = 0;
    while (i < collectors_.length) {
      try {
        collectors_[i].stopCollector();
      } catch (e) {
        // do not do anything about any exception that is thrown
        // by the execution of the collectors
      }
      i++;
    }

    i = 0;
    while (i < events_.length) {
      try {
        if (window.removeEventListener) {
          window.removeEventListener(
              events_[i].type, events_[i].listener, false);
        } else if (document.detachEvent) {
          document.detachEvent('on' + events_[i].type, events_[i].listener);
        }
      } catch (e) {
        // do not do anything about any exception that is thrown
        // by the removal of the events
      }
      i++;
    }

    if (collectorHashUpdater_) {
      clearInterval(collectorHashUpdater_);
    }

    updateEntropyHash();

    return fullEntropyReached_;
  };

  /**
   * @function getCollectors.
   * @memberof EntropyManager
   * @returns {Array} The array of collectors currently in use.
   */
  this.getCollectors = function() {
    return collectors_;
  };

  /**
   * @function getEntropyHash
   * @memberof EntropyManager
   * @returns {Uint8Array} The hash of entropy that has been collected so far.
   */
  this.getEntropyHash = function() {
    return codec.hexDecode(entropyHashHex_);
  };

  /**
   * @function getEntropyPercentage returns {number} The percentage of the
   *           required entropy that has been collected so far.
   * @memberof EntropyManager
   */
  this.getEntropyPercentage = function() {
    if (entropyCounter_ >= maxEntropyCounter_) {
      return 100;
    }

    return entropyCounter_ * 100 / maxEntropyCounter_;
  };

  /**
   * @function isCollecting
   * @memberof EntropyManager
   * @returns {boolean} True if entropy is still being collected, false
   *          otherwise.
   */
  this.isCollecting = function() {
    return isCollecting_;
  };

  //////////////////////////////// CONSTRUCTOR ////////////////////////////////

  // Initialize the object.
  init(options);
}
