/*
 * 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 ElGamalPublicKey = require('./public-key');
var ElGamalEncryptedElements = require('./encrypted-elements');
var validator = require('./input-validator');
var forge = require('node-forge');

var BigInteger = forge.jsbn.BigInteger;

module.exports = ElGamalEncrypter;

/**
 * @class ElGamalEncrypter
 * @classdesc The ElGamal encrypter API. To instantiate this object, use the
 *            method {@link ElGamalCryptographyService.newEncrypter}.
 * @hideconstructor
 * @param {MathematicalService}
 *            mathService The mathematical service to use.
 */
function ElGamalEncrypter(mathService) {
  var mathService_ = mathService;
  var mathArrayCompressor_ = mathService_.newArrayCompressor();
  var mathRandomGenerator_ = mathService_.newRandomGenerator();

  var publicKey_;
  var secureRandomGenerator_;

  /**
   * Initializes the ElGamal encrypter with the provided ElGamal public key.
   *
   * @function init
   * @memberof ElGamalEncrypter
   * @param {ElGamalPublicKey}
   *            publicKey The ElGamal public key with which to initialize the
   *            ElGamal encrypter.
   * @param {Object}
   *            [options] An object containing optional arguments.
   * @param {SecureRandomGenerator}
   *            [options.secureRandomGenerator=Created internally] The secure
   *            random generator to use.
   * @returns {ElGamalEncrypter} A reference to this object, to facilitate
   *          method chaining.
   * @throws {Error}
   *             If the input data validation fails.
   */
  this.init = function(publicKey, options) {
    checkElGamalPublicKey(
        publicKey,
        'ElGamal public key with which to initialize ElGamal encrypter');

    publicKey_ = publicKey;

    options = options || {};
    secureRandomGenerator_ = options.secureRandomGenerator;

    return this;
  };

  /**
   * ElGamal encrypts some Zp group elements. Before using this method, the
   * encrypter must have been initialized with an ElGamal public key, via the
   * method {@link ElGamalEncrypter.init}.
   * <p>
   * The number of elements to encrypt must be less than or equal to the
   * number of elements in the ElGamal public key that was used to initialize
   * the ElGamal encrypter.
   *
   * @function encrypt
   * @memberof ElGamalEncrypter
   * @param {ZpGroupElement[]|string[]}
   *            elements The Zp group elements to encrypt. The Zp group
   *            elements may also be provided in the form of BigInteger string
   *            values.
   * @param {Object}
   *            [options] An object containing optional arguments.
   * @param {ElGamalEncryptedElements}
   *            [options.preComputation=Generated internally] A
   *            pre-computation of the ElGamal encryption.
   * @param {boolean}
   *            [options.useShortExponent=false] If <code>true</code>, then
   *            a <code>short</code> secret exponent will be generated for
   *            the pre-computation process.
   * @param {boolean}
   *            [options.saveSecret=false] If <code>true</code>, the secret
   *            exponent will be saved and returned, along with the encrypted
   *            elements. <b>CAUTION:</b> Use only when the secret is really
   *            needed later (e.g. for zero-knowledge proof generation).
   * @returns {ElGamalEncryptedElements} The encrypted Zp group elements.
   * @throws {Error}
   *             If the input data validation fails, the encrypter was not
   *             initialized or an attempt is made to ElGamal encrypt a
   *             non-quadratic residue Zp subgroup, using a short exponent.
   */
  this.encrypt = function(elements, options) {
    if (typeof publicKey_ === 'undefined') {
      throw new Error(
          'Could not ElGamal encrypt; Encrypter has not been initialized with any ElGamal public key');
    }

    options = options || {};

    checkEncryptionData(publicKey_, elements, options);

    if (typeof elements[0] === 'string') {
      elements = getGroupElementsFromStrings(publicKey_.group, elements);
    }

    var preComputation;
    if (typeof options.preComputation !== 'undefined') {
      preComputation = options.preComputation;
    } else {
      preComputation = this.preCompute({
        useShortExponent: options.useShortExponent,
        saveSecret: options.saveSecret
      });
    }

    var compressedPreComputation =
        compressPreComputation(preComputation, elements.length);

    var gamma = compressedPreComputation.gamma;
    var preComputedPhis = compressedPreComputation.phis;
    var phis = [];
    for (var i = 0; i < preComputedPhis.length; i++) {
      phis.push(elements[i].multiply(preComputedPhis[i]));
    }

    var saveSecret = options.saveSecret || false;
    if (!saveSecret) {
      return new ElGamalEncryptedElements(gamma, phis);
    } else {
      return new ElGamalEncryptedElements(gamma, phis, preComputation.secret);
    }
  };

  /**
   * Computes the part of the ElGamal encryption that can be performed before
   * knowing the Zp group elements that are to be encrypted.
   *
   * @function preCompute
   * @memberof ElGamalEncrypter
   * @param {Object}
   *            [options] An object containing optional arguments.
   * @param {boolean}
   *            [options.useShortExponent=false] If <code>true</code>, then
   *            a <code>short</code> secret exponent will be generated for
   *            the pre-computation process.
   * @param {boolean}
   *            [options.saveSecret=false] If <code>true</code>, the secret
   *            exponent will be saved and returned, along with the encrypted
   *            elements. <b>CAUTION:</b> Use only when the secret is really
   *            needed later (e.g. for zero-knowledge proof generation).
   * @returns {ElGamalEncryptedElements} The encryption pre-computation.
   * @throws {Error}
   *             If the input data validation fails or an attempt is made to
   *             ElGamal pre-compute a non-quadratic residue Zp subgroup,
   *             using a short exponent.
   */
  this.preCompute = function(options) {
    if (typeof publicKey_ === 'undefined') {
      throw new Error(
          'Could not ElGamal pre-compute; Encrypter has not been initialized with any ElGamal public key');
    }

    options = options || {};

    checkPreComputationData(publicKey_, options);

    var group = publicKey_.group;
    var publicKeyElements = publicKey_.elements;

    var useShortExponent = options.useShortExponent || false;
    var secret = mathRandomGenerator_.nextExponent(group, {
      secureRandomGenerator: secureRandomGenerator_,
      useShortExponent: useShortExponent
    });

    var gamma = group.generator.exponentiate(secret);
    var phis = [];
    for (var i = 0; i < publicKeyElements.length; i++) {
      phis.push(publicKeyElements[i].exponentiate(secret));
    }

    var saveSecret = options.saveSecret || false;
    if (!saveSecret) {
      return new ElGamalEncryptedElements(gamma, phis);
    } else {
      return new ElGamalEncryptedElements(gamma, phis, secret);
    }
  };

  function checkElGamalPublicKey(publicKey, label) {
    validator.checkElGamalPublicKey(publicKey, label);
    validator.checkIsInstanceOf(
        publicKey, ElGamalPublicKey, 'ElGamalPublicKey', label);
  }

  function checkElGamalEncryptedElements(elements, label) {
    validator.checkElGamalEncryptedElements(elements, label);
    validator.checkIsInstanceOf(
        elements, ElGamalEncryptedElements, 'ElGamalEncryptedElements', label);
  }

  function checkEncryptionData(publicKey, elements, options) {
    validator.checkIsArray(elements, 'Zp group elements to encrypt');
    if (typeof elements[0] !== 'string') {
      validator.checkZpGroupElements(
          elements, 'Zp group elements to encrypt', publicKey.group);
    } else {
      validator.checkIsStringArray(
          elements, 'Zp group element value strings to encrypt');
    }

    var numElements = elements.length;
    var numPublicKeyElements = publicKey.elements.length;
    if (numElements > numPublicKeyElements) {
      throw new Error(
          'Expected number of Zp group elements to encrypt to be less than or equal to number of public key elements: ' +
          numPublicKeyElements + ' ; Found: ' + numElements);
    }

    var preComputation = options.preComputation;
    if (typeof preComputation !== 'undefined') {
      checkElGamalEncryptedElements(
          preComputation, 'ElGamal encryption pre-computation');

      var numPreComputedPhis = preComputation.phis.length;
      if (numElements > numPreComputedPhis) {
        throw new Error(
            'Expected number of elements to encrypt to be less than or equal to number of pre-computed phi elements: ' +
            numPreComputedPhis + ' ; Found: ' + numElements);
      }
    }
  }

  function checkPreComputationData(publicKey, options) {
    var useShortExponent = options.useShortExponent;
    if (typeof useShortExponent !== 'undefined') {
      validator.checkIsType(
          useShortExponent, 'boolean',
          '\"Use short exponent\" flag for pre-computation');

      if (useShortExponent && !publicKey.group.isQuadraticResidueGroup()) {
        throw new Error(
            'Attempt to ElGamal pre-compute using short exponent for Zp subgroup that is not of type quadratic residue.');
      }
    }

    var saveSecret = options.saveSecret;
    if (typeof saveSecret !== 'undefined') {
      validator.checkIsType(
          saveSecret, 'boolean', '\"Save secret\" flag for pre-computation');
    }
  }

  function getGroupElementsFromStrings(group, elementValueStrings) {
    var p = group.p;
    var q = group.q;

    var groupElements = [];
    var groupElement;
    for (var i = 0; i < elementValueStrings.length; i++) {
      groupElement = mathService_.newZpGroupElement(
          p, q, new BigInteger(elementValueStrings[i]));
      groupElements.push(groupElement);
    }

    return groupElements;
  }

  function compressPreComputation(preComputation, numElements) {
    var preComputedPhis = preComputation.phis;

    if (preComputedPhis.length === numElements) {
      return preComputation;
    } else {
      var compressedPreComputedPhis =
          mathArrayCompressor_.compressTrailingZpGroupElements(
              preComputedPhis, numElements);

      return new ElGamalEncryptedElements(
          preComputation.gamma, compressedPreComputedPhis);
    }
  }
}
