// GPG4Browsers - An OpenPGP implementation in javascript
// Copyright (C) 2011 Recurity Labs GmbH
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 3.0 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA

/* eslint-disable no-console */

/**
 * This object contains utility functions
 * @requires address-rfc2822
 * @requires web-stream-tools
 * @requires config
 * @requires encoding/base64
 * @module util
 */

import rfc2822 from 'address-rfc2822';
import stream from 'web-stream-tools';
import config from './config';
import util from './util'; // re-import module to access util functions
import b64 from './encoding/base64';

export default {
  isString: function(data) {
    return typeof data === 'string' || String.prototype.isPrototypeOf(data);
  },

  isArray: function(data) {
    return Array.prototype.isPrototypeOf(data);
  },

  isUint8Array: stream.isUint8Array,

  isStream: stream.isStream,

  /**
   * Get transferable objects to pass buffers with zero copy (similar to "pass by reference" in C++)
   *   See: https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage
   * Also, convert ReadableStreams to MessagePorts
   * @param  {Object} obj           the options object to be passed to the web worker
   * @returns {Array<ArrayBuffer>}   an array of binary data to be passed
   */
  getTransferables: function(obj) {
    const transferables = [];
    util.collectTransferables(obj, transferables);
    return transferables.length ? transferables : undefined;
  },

  collectTransferables: function(obj, collection) {
    if (!obj) {
      return;
    }

    if (util.isUint8Array(obj) && collection.indexOf(obj.buffer) === -1) {
      if (config.zero_copy) {
        collection.push(obj.buffer);
      }
      return;
    }
    if (Object.prototype.isPrototypeOf(obj)) {
      Object.entries(obj).forEach(([key, value]) => { // recursively search all children
        if (util.isStream(value)) {
          if (value.locked) {
            obj[key] = null;
          } else {
            const transformed = stream.transformPair(value, async readable => {
              const reader = stream.getReader(readable);
              const { port1, port2 } = new MessageChannel();
              port1.onmessage = async function({ data: { action } }) {
                if (action === 'read') port1.postMessage(await reader.read());
                else if (action === 'cancel') port1.postMessage(await transformed.cancel());
              };
              obj[key] = port2;
              collection.push(port2);
            });
          }
          return;
        }
        if (typeof MessagePort !== 'undefined' && MessagePort.prototype.isPrototypeOf(value)) {
          throw new Error("Can't transfer the same stream twice.");
        }
        util.collectTransferables(value, collection);
      });
    }
  },

  /**
   * Convert MessagePorts back to ReadableStreams
   * @param  {Object} obj
   * @returns {Object}
   */
  restoreStreams: function(obj) {
    if (Object.prototype.isPrototypeOf(obj) && !Uint8Array.prototype.isPrototypeOf(obj)) {
      Object.entries(obj).forEach(([key, value]) => { // recursively search all children
        if (MessagePort.prototype.isPrototypeOf(value)) {
          obj[key] = new ReadableStream({
            pull(controller) {
              return new Promise(resolve => {
                value.onmessage = evt => {
                  const { done, value } = evt.data;
                  if (!done) {
                    controller.enqueue(value);
                  } else {
                    controller.close();
                  }
                  resolve();
                };
                value.postMessage({ action: 'read' });
              });
            },
            cancel() {
              return new Promise(resolve => {
                value.onmessage = resolve;
                value.postMessage({ action: 'cancel' });
              });
            }
          }, {highWaterMark: 0});
          return;
        }
        util.restoreStreams(value);
      });
    }
    return obj;
  },

  readNumber: function (bytes) {
    let n = 0;
    for (let i = 0; i < bytes.length; i++) {
      n += (256 ** i) * bytes[bytes.length - 1 - i];
    }
    return n;
  },

  writeNumber: function (n, bytes) {
    const b = new Uint8Array(bytes);
    for (let i = 0; i < bytes; i++) {
      b[i] = (n >> (8 * (bytes - i - 1))) & 0xFF;
    }

    return b;
  },

  readDate: function (bytes) {
    const n = util.readNumber(bytes);
    const d = new Date(n * 1000);
    return d;
  },

  writeDate: function (time) {
    const numeric = Math.floor(time.getTime() / 1000);

    return util.writeNumber(numeric, 4);
  },

  normalizeDate: function (time = Date.now()) {
      return time === null ? time : new Date(Math.floor(+time / 1000) * 1000);
  },

  /**
   * Create hex string from a binary
   * @param {String} str String to convert
   * @returns {String} String containing the hexadecimal values
   */
  str_to_hex: function (str) {
    if (str === null) {
      return "";
    }
    const r = [];
    const e = str.length;
    let c = 0;
    let h;
    while (c < e) {
      h = str.charCodeAt(c++).toString(16);
      while (h.length < 2) {
        h = "0" + h;
      }
      r.push("" + h);
    }
    return r.join('');
  },

  /**
   * Create binary string from a hex encoded string
   * @param {String} str Hex string to convert
   * @returns {String}
   */
  hex_to_str: function (hex) {
    let str = '';
    for (let i = 0; i < hex.length; i += 2) {
      str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
    }
    return str;
  },

  /**
   * Convert a Uint8Array to an MPI-formatted Uint8Array.
   * Note: the output is **not** an MPI object.
   * @see {@link module:type/mpi/MPI.fromUint8Array}
   * @see {@link module:type/mpi/MPI.toUint8Array}
   * @param {Uint8Array} bin An array of 8-bit integers to convert
   * @returns {Uint8Array} MPI-formatted Uint8Array
   */
  Uint8Array_to_MPI: function (bin) {
    const size = (bin.length - 1) * 8 + util.nbits(bin[0]);
    const prefix = Uint8Array.from([(size & 0xFF00) >> 8, size & 0xFF]);
    return util.concatUint8Array([prefix, bin]);
  },

  /**
   * Convert a Base-64 encoded string an array of 8-bit integer
   *
   * Note: accepts both Radix-64 and URL-safe strings
   * @param {String} base64 Base-64 encoded string to convert
   * @returns {Uint8Array} An array of 8-bit integers
   */
  b64_to_Uint8Array: function (base64) {
    return b64.decode(base64.replace(/-/g, '+').replace(/_/g, '/'));
  },

  /**
   * Convert an array of 8-bit integer to a Base-64 encoded string
   * @param {Uint8Array} bytes An array of 8-bit integers to convert
   * @param {bool}       url   If true, output is URL-safe
   * @returns {String}          Base-64 encoded string
   */
  Uint8Array_to_b64: function (bytes, url) {
    return b64.encode(bytes, url).replace(/(\n)/g, '');
  },

  /**
   * Convert a hex string to an array of 8-bit integers
   * @param {String} hex  A hex string to convert
   * @returns {Uint8Array} An array of 8-bit integers
   */
  hex_to_Uint8Array: function (hex) {
    const result = new Uint8Array(hex.length >> 1);
    for (let k = 0; k < hex.length >> 1; k++) {
      result[k] = parseInt(hex.substr(k << 1, 2), 16);
    }
    return result;
  },

  /**
   * Convert an array of 8-bit integers to a hex string
   * @param {Uint8Array} bytes Array of 8-bit integers to convert
   * @returns {String} Hexadecimal representation of the array
   */
  Uint8Array_to_hex: function (bytes) {
    const r = [];
    const e = bytes.length;
    let c = 0;
    let h;
    while (c < e) {
      h = bytes[c++].toString(16);
      while (h.length < 2) {
        h = "0" + h;
      }
      r.push("" + h);
    }
    return r.join('');
  },

  /**
   * Convert a string to an array of 8-bit integers
   * @param {String} str String to convert
   * @returns {Uint8Array} An array of 8-bit integers
   */
  str_to_Uint8Array: function (str) {
    return stream.transform(str, str => {
      if (!util.isString(str)) {
        throw new Error('str_to_Uint8Array: Data must be in the form of a string');
      }

      const result = new Uint8Array(str.length);
      for (let i = 0; i < str.length; i++) {
        result[i] = str.charCodeAt(i);
      }
      return result;
    });
  },

  /**
   * Convert an array of 8-bit integers to a string
   * @param {Uint8Array} bytes An array of 8-bit integers to convert
   * @returns {String} String representation of the array
   */
  Uint8Array_to_str: function (bytes) {
    bytes = new Uint8Array(bytes);
    const result = [];
    const bs = 1 << 14;
    const j = bytes.length;

    for (let i = 0; i < j; i += bs) {
      result.push(String.fromCharCode.apply(String, bytes.subarray(i, i+bs < j ? i+bs : j)));
    }
    return result.join('');
  },

  /**
   * Convert a native javascript string to a Uint8Array of utf8 bytes
   * @param {String|ReadableStream} str The string to convert
   * @returns {Uint8Array|ReadableStream} A valid squence of utf8 bytes
   */
  encode_utf8: function (str) {
    const encoder = new TextEncoder('utf-8');
    // eslint-disable-next-line no-inner-declarations
    function process(value, lastChunk=false) {
      return encoder.encode(value, { stream: !lastChunk });
    }
    return stream.transform(str, process, () => process('', true));
  },

  /**
   * Convert a Uint8Array of utf8 bytes to a native javascript string
   * @param {Uint8Array|ReadableStream} utf8 A valid squence of utf8 bytes
   * @returns {String|ReadableStream} A native javascript string
   */
  decode_utf8: function (utf8) {
    const decoder = new TextDecoder('utf-8');
    // eslint-disable-next-line no-inner-declarations
    function process(value, lastChunk=false) {
      return decoder.decode(value, { stream: !lastChunk });
    }
    return stream.transform(utf8, process, () => process(new Uint8Array(), true));
  },

  /**
   * Concat a list of Uint8Arrays, Strings or Streams
   * The caller must not mix Uint8Arrays with Strings, but may mix Streams with non-Streams.
   * @param {Array<Uint8array|String|ReadableStream>} Array of Uint8Arrays/Strings/Streams to concatenate
   * @returns {Uint8array|String|ReadableStream} Concatenated array
   */
  concat: stream.concat,

  /**
   * Concat Uint8Arrays
   * @param {Array<Uint8array>} Array of Uint8Arrays to concatenate
   * @returns {Uint8array} Concatenated array
   */
  concatUint8Array: stream.concatUint8Array,

  /**
   * Check Uint8Array equality
   * @param {Uint8Array} first array
   * @param {Uint8Array} second array
   * @returns {Boolean} equality
   */
  equalsUint8Array: function (array1, array2) {
    if (!util.isUint8Array(array1) || !util.isUint8Array(array2)) {
      throw new Error('Data must be in the form of a Uint8Array');
    }

    if (array1.length !== array2.length) {
      return false;
    }

    for (let i = 0; i < array1.length; i++) {
      if (array1[i] !== array2[i]) {
        return false;
      }
    }
    return true;
  },

  /**
   * Calculates a 16bit sum of a Uint8Array by adding each character
   * codes modulus 65535
   * @param {Uint8Array} Uint8Array to create a sum of
   * @returns {Uint8Array} 2 bytes containing the sum of all charcodes % 65535
   */
  write_checksum: function (text) {
    let s = 0;
    for (let i = 0; i < text.length; i++) {
      s = (s + text[i]) & 0xFFFF;
    }
    return util.writeNumber(s, 2);
  },

  /**
   * Helper function to print a debug message. Debug
   * messages are only printed if
   * @link module:config/config.debug is set to true.
   * @param {String} str String of the debug message
   */
  print_debug: function (str) {
    if (config.debug) {
      console.log(str);
    }
  },

  /**
   * Helper function to print a debug message. Debug
   * messages are only printed if
   * @link module:config/config.debug is set to true.
   * Different than print_debug because will call Uint8Array_to_hex iff necessary.
   * @param {String} str String of the debug message
   */
  print_debug_hexarray_dump: function (str, arrToHex) {
    if (config.debug) {
      str += ': ' + util.Uint8Array_to_hex(arrToHex);
      console.log(str);
    }
  },

  /**
   * Helper function to print a debug message. Debug
   * messages are only printed if
   * @link module:config/config.debug is set to true.
   * Different than print_debug because will call str_to_hex iff necessary.
   * @param {String} str String of the debug message
   */
  print_debug_hexstr_dump: function (str, strToHex) {
    if (config.debug) {
      str += util.str_to_hex(strToHex);
      console.log(str);
    }
  },

  /**
   * Helper function to print a debug error. Debug
   * messages are only printed if
   * @link module:config/config.debug is set to true.
   * @param {String} str String of the debug message
   */
  print_debug_error: function (error) {
    if (config.debug) {
      console.error(error);
    }
  },

  /**
   * Read a stream to the end and print it to the console when it's closed.
   * @param {String} str String of the debug message
   * @param {ReadableStream|Uint8array|String} input Stream to print
   * @param {Function} concat Function to concatenate chunks of the stream (defaults to util.concat).
   */
  print_entire_stream: function (str, input, concat) {
    stream.readToEnd(stream.clone(input), concat).then(result => {
      console.log(str + ': ', result);
    });
  },

  getLeftNBits: function (array, bitcount) {
    const rest = bitcount % 8;
    if (rest === 0) {
      return array.subarray(0, bitcount / 8);
    }
    const bytes = (bitcount - rest) / 8 + 1;
    const result = array.subarray(0, bytes);
    return util.shiftRight(result, 8 - rest); // +String.fromCharCode(string.charCodeAt(bytes -1) << (8-rest) & 0xFF);
  },

  // returns bit length of the integer x
  nbits: function (x) {
    let r = 1;
    let t = x >>> 16;
    if (t !== 0) {
      x = t;
      r += 16;
    }
    t = x >> 8;
    if (t !== 0) {
      x = t;
      r += 8;
    }
    t = x >> 4;
    if (t !== 0) {
      x = t;
      r += 4;
    }
    t = x >> 2;
    if (t !== 0) {
      x = t;
      r += 2;
    }
    t = x >> 1;
    if (t !== 0) {
      x = t;
      r += 1;
    }
    return r;
  },

  /**
   * If S[1] == 0, then double(S) == (S[2..128] || 0);
   * otherwise, double(S) == (S[2..128] || 0) xor
   * (zeros(120) || 10000111).
   *
   * Both OCB and EAX (through CMAC) require this function to be constant-time.
   *
   * @param {Uint8Array} data
   */
  double: function(data) {
    const double = new Uint8Array(data.length);
    const last = data.length - 1;
    for (let i = 0; i < last; i++) {
      double[i] = (data[i] << 1) ^ (data[i + 1] >> 7);
    }
    double[last] = (data[last] << 1) ^ ((data[0] >> 7) * 0x87);
    return double;
  },

  /**
   * Shift a Uint8Array to the right by n bits
   * @param {Uint8Array} array The array to shift
   * @param {Integer} bits Amount of bits to shift (MUST be smaller
   * than 8)
   * @returns {String} Resulting array.
   */
  shiftRight: function (array, bits) {
    if (bits) {
      for (let i = array.length - 1; i >= 0; i--) {
        array[i] >>= bits;
        if (i > 0) {
          array[i] |= (array[i - 1] << (8 - bits));
        }
      }
    }
    return array;
  },

  /**
   * Get native Web Cryptography api, only the current version of the spec.
   * The default configuration is to use the api when available. But it can
   * be deactivated with config.use_native
   * @returns {Object}   The SubtleCrypto api or 'undefined'
   */
  getWebCrypto: function() {
    if (!config.use_native) {
      return;
    }

    return typeof window !== 'undefined' && window.crypto && window.crypto.subtle;
  },

  /**
   * Get native Web Cryptography api for all browsers, including legacy
   * implementations of the spec e.g IE11 and Safari 8/9. The default
   * configuration is to use the api when available. But it can be deactivated
   * with config.use_native
   * @returns {Object}   The SubtleCrypto api or 'undefined'
   */
  getWebCryptoAll: function() {
    if (!config.use_native) {
      return;
    }

    if (typeof window !== 'undefined') {
      if (window.crypto) {
        return window.crypto.subtle || window.crypto.webkitSubtle;
      }
      if (window.msCrypto) {
        return window.msCrypto.subtle;
      }
    }
  },

  /**
   * Detect Node.js runtime.
   */
  detectNode: function() {
    return typeof window === 'undefined';
  },

  /**
   * Get native Node.js module
   * @param {String}     The module to require
   * @returns {Object}   The required module or 'undefined'
   */
  nodeRequire: function(module) {
    if (!util.detectNode()) {
      return;
    }

    // Requiring the module dynamically allows us to access the native node module.
    // otherwise, it gets replaced with the browserified version
    // eslint-disable-next-line import/no-dynamic-require
    return require(module);
  },

  /**
   * Get native Node.js crypto api. The default configuration is to use
   * the api when available. But it can also be deactivated with config.use_native
   * @returns {Object}   The crypto module or 'undefined'
   */
  getNodeCrypto: function() {
    if (!config.use_native) {
      return;
    }

    return util.nodeRequire('crypto');
  },

  getNodeZlib: function() {
    if (!config.use_native) {
      return;
    }

    return util.nodeRequire('zlib');
  },

  /**
   * Get native Node.js Buffer constructor. This should be used since
   * Buffer is not available under browserify.
   * @returns {Function}   The Buffer constructor or 'undefined'
   */
  getNodeBuffer: function() {
    return (util.nodeRequire('buffer') || {}).Buffer;
  },

  getNodeStream: function() {
    return (util.nodeRequire('stream') || {}).Readable;
  },

  getHardwareConcurrency: function() {
    if (util.detectNode()) {
      const os = util.nodeRequire('os');
      return os.cpus().length;
    }

    return navigator.hardwareConcurrency || 1;
  },

  isEmailAddress: function(data) {
    if (!util.isString(data)) {
      return false;
    }
    const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+([a-zA-Z]{2,}|xn--[a-zA-Z\-0-9]+)))$/;
    return re.test(data);
  },

  /**
   * Format user id for internal use.
   */
  formatUserId: function(id) {
    // name and email address can be empty but must be of the correct type
    if ((id.name && !util.isString(id.name)) || (id.email && !util.isEmailAddress(id.email))) {
      throw new Error('Invalid user id format');
    }
    return new rfc2822.Address(id.name, id.email, id.comment).format();
  },

  /**
   * Parse user id.
   */
  parseUserId: function(userid) {
    if (userid.length > config.max_userid_length) {
      throw new Error('User id string is too long');
    }
    try {
      const [{ phrase: name, address: email, comment }] = rfc2822.parse(userid);
      return { name, email, comment: comment.replace(/^\(|\)$/g, '') };
    } catch(e) {
      throw new Error('Invalid user id format');
    }
  },

  /**
   * Normalize line endings to \r\n
   */
  canonicalizeEOL: function(text) {
    return stream.transform(util.nativeEOL(text), value => value.replace(/\r/g, "\n").replace(/\n/g, "\r\n"));
  },

  /**
   * Convert line endings from canonicalized \r\n to native \n
   */
  nativeEOL: function(text) {
    let lastChar = '';
    return stream.transform(text, value => {
      value = lastChar + value;
      if (value[value.length - 1] === '\r') {
        lastChar = '\r';
        value = value.slice(0, -1);
      } else {
        lastChar = '';
      }
      return value.replace(/\r\n/g, '\n');
    }, () => lastChar);
  },

  /**
   * Remove trailing spaces and tabs from each line
   */
  removeTrailingSpaces: function(text) {
    return text.replace(/[ \t]+$/mg, "");
  },

  /**
   * Encode input buffer using Z-Base32 encoding.
   * See: https://tools.ietf.org/html/rfc6189#section-5.1.6
   *
   * @param {Uint8Array} data The binary data to encode
   * @returns {String} Binary data encoded using Z-Base32
   */
  encodeZBase32: function(data) {
    if (data.length === 0) {
      return "";
    }
    const ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769";
    const SHIFT = 5;
    const MASK = 31;
    let buffer = data[0];
    let index = 1;
    let bitsLeft = 8;
    let result = '';
    while (bitsLeft > 0 || index < data.length) {
      if (bitsLeft < SHIFT) {
        if (index < data.length) {
          buffer <<= 8;
          buffer |= data[index++] & 0xff;
          bitsLeft += 8;
        } else {
          const pad = SHIFT - bitsLeft;
          buffer <<= pad;
          bitsLeft += pad;
        }
      }
      bitsLeft -= SHIFT;
      result += ALPHABET[MASK & (buffer >> bitsLeft)];
    }
    return result;
  }
};
