/**
 * @license
 * https://github.com/bitcoincashjs/cashaddr
 * Copyright (c) 2017-2018 Emilio Almansi
 * Distributed under the MIT software license, see the accompanying
 * file LICENSE or http://www.opensource.org/licenses/mit-license.php.
 */

'use strict';

var base32 = require('./base32');
var bigInt = require('big-integer');
var convertBits = require('./convertBits');
var validation = require('./validation');
var validate = validation.validate;

/**
 * Encoding and decoding of the new Cash Address format for Bitcoin Cash. <br />
 * Compliant with the original cashaddr specification:
 * {@link https://github.com/Bitcoin-UAHF/spec/blob/master/cashaddr.md}
 * @module cashaddr
 */

/**
 * Encodes a hash from a given type into a Bitcoin Cash address with the given prefix.
 * 
 * @static
 * @param {string} prefix Network prefix. E.g.: 'bitcoincash'.
 * @param {string} type Type of address to generate. Either 'P2PKH' or 'P2SH'.
 * @param {Uint8Array} hash Hash to encode represented as an array of 8-bit integers.
 * @returns {string}
 * @throws {ValidationError}
 */
function encode(prefix, type, hash) {
  validate(typeof prefix === 'string' && isValidPrefix(prefix), 'Invalid prefix: ' + prefix + '.');
  validate(typeof type === 'string', 'Invalid type: ' + type + '.');
  validate(hash instanceof Uint8Array, 'Invalid hash: ' + hash + '.');
  var prefixData = concat(prefixToUint5Array(prefix), new Uint8Array(1));
  var versionByte = getTypeBits(type) + getHashSizeBits(hash);
  var payloadData = toUint5Array(concat(Uint8Array.of(versionByte), hash));
  var checksumData = concat(concat(prefixData, payloadData), new Uint8Array(8));
  var payload = concat(payloadData, checksumToUint5Array(polymod(checksumData)));
  return prefix + ':' + base32.encode(payload);
}

/**
 * Decodes the given address into its constituting prefix, type and hash. See [#encode()]{@link encode}.
 * 
 * @static
 * @param {string} address Address to decode. E.g.: 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'.
 * @returns {object}
 * @throws {ValidationError}
 */
function decode(address) {
  validate(typeof address === 'string' && hasSingleCase(address), 'Invalid address: ' + address + '.');
  var pieces = address.toLowerCase().split(':');
  validate(pieces.length === 2, 'Missing prefix: ' + address + '.');
  var prefix = pieces[0];
  var payload = base32.decode(pieces[1]);
  validate(validChecksum(prefix, payload), 'Invalid checksum: ' + address + '.');
  var payloadData = fromUint5Array(payload.slice(0, -8));
  var versionByte = payloadData[0];
  var hash = payloadData.slice(1);
  validate(getHashSize(versionByte) === hash.length * 8, 'Invalid hash size: ' + address + '.');
  var type = getType(versionByte);
  return {
    prefix: prefix,
    type: type,
    hash: hash,
  };
}

/**
 * Error thrown when encoding or decoding fail due to invalid input.
 *
 * @constructor ValidationError
 * @param {string} message Error description.
 */
var ValidationError = validation.ValidationError;

/**
 * Valid address prefixes.
 *
 * @private
 */
var VALID_PREFIXES = ['bitcoincash', 'bchtest', 'bchreg', 'simpleledger', 'slptest', 'slpreg'];

/**
 * Checks whether a string is a valid prefix; ie., it has a single letter case
 * and is one of 'bitcoincash', 'bchtest', or 'bchreg'.
 *
 * @private
 * @param {string} prefix 
 * @returns {boolean}
 */
function isValidPrefix(prefix) {
  return hasSingleCase(prefix) && VALID_PREFIXES.indexOf(prefix.toLowerCase()) !== -1;
}

/**
 * Derives an array from the given prefix to be used in the computation
 * of the address' checksum.
 *
 * @private
 * @param {string} prefix Network prefix. E.g.: 'bitcoincash'. 
 * @returns {Uint8Array}
 */
function prefixToUint5Array(prefix) {
  var result = new Uint8Array(prefix.length);
  for (var i = 0; i < prefix.length; ++i) {
    result[i] = prefix[i].charCodeAt(0) & 31;
  }
  return result;
}

/**
 * Returns an array representation of the given checksum to be encoded
 * within the address' payload.
 *
 * @private
 * @param {BigInteger} checksum Computed checksum.
 * @returns {Uint8Array}
 */
function checksumToUint5Array(checksum) {
  var result = new Uint8Array(8);
  for (var i = 0; i < 8; ++i) {
    result[7 - i] = checksum.and(31).toJSNumber();
    checksum = checksum.shiftRight(5);
  }
  return result;
}

/**
 * Returns the bit representation of the given type within the version
 * byte.
 *
 * @private
 * @param {string} type Address type. Either 'P2PKH' or 'P2SH'.
 * @returns {number}
 * @throws {ValidationError}
 */
function getTypeBits(type) {
  switch (type) {
  case 'P2PKH':
    return 0;
  case 'P2SH':
    return 8;
  default:
    throw new ValidationError('Invalid type: ' + type + '.');
  }
}

/**
 * Retrieves the address type from its bit representation within the
 * version byte.
 *
 * @private
 * @param {number} versionByte
 * @returns {string}
 * @throws {ValidationError}
 */
function getType(versionByte) {
  switch (versionByte & 120) {
  case 0:
    return 'P2PKH';
  case 8:
    return 'P2SH';
  default:
    throw new ValidationError('Invalid address type in version byte: ' + versionByte + '.');
  }
}

/**
 * Returns the bit representation of the length in bits of the given
 * hash within the version byte.
 *
 * @private
 * @param {Uint8Array} hash Hash to encode represented as an array of 8-bit integers.
 * @returns {number}
 * @throws {ValidationError}
 */
function getHashSizeBits(hash) {
  switch (hash.length * 8) {
  case 160:
    return 0;
  case 192:
    return 1;
  case 224:
    return 2;
  case 256:
    return 3;
  case 320:
    return 4;
  case 384:
    return 5;
  case 448:
    return 6;
  case 512:
    return 7;
  default:
    throw new ValidationError('Invalid hash size: ' + hash.length + '.');
  }
}

/**
 * Retrieves the the length in bits of the encoded hash from its bit
 * representation within the version byte.
 *
 * @private
 * @param {number} versionByte
 * @returns {number}
 */
function getHashSize(versionByte) {
  switch (versionByte & 7) {
  case 0:
    return 160;
  case 1:
    return 192;
  case 2:
    return 224;
  case 3:
    return 256;
  case 4:
    return 320;
  case 5:
    return 384;
  case 6:
    return 448;
  case 7:
    return 512;
  }
}

/**
 * Converts an array of 8-bit integers into an array of 5-bit integers,
 * right-padding with zeroes if necessary.
 *
 * @private
 * @param {Uint8Array} data
 * @returns {Uint8Array}
 */
function toUint5Array(data) {
  return convertBits(data, 8, 5);
}

/**
 * Converts an array of 5-bit integers back into an array of 8-bit integers,
 * removing extra zeroes left from padding if necessary.
 * Throws a {@link ValidationError} if input is not a zero-padded array of 8-bit integers.
 *
 * @private
 * @param {Uint8Array} data
 * @returns {Uint8Array}
 * @throws {ValidationError}
 */
function fromUint5Array(data) {
  return convertBits(data, 5, 8, true);
}

/**
 * Returns the concatenation a and b.
 *
 * @private
 * @param {Uint8Array} a 
 * @param {Uint8Array} b 
 * @returns {Uint8Array}
 * @throws {ValidationError}
 */
function concat(a, b) {
  var ab = new Uint8Array(a.length + b.length);
  ab.set(a);
  ab.set(b, a.length);
  return ab;
}

/**
 * Computes a checksum from the given input data as specified for the CashAddr
 * format: https://github.com/Bitcoin-UAHF/spec/blob/master/cashaddr.md.
 *
 * @private
 * @param {Uint8Array} data Array of 5-bit integers over which the checksum is to be computed.
 * @returns {BigInteger}
 */
function polymod(data) {
  var GENERATOR = [0x98f2bc8e61, 0x79b76d99e2, 0xf33e5fb3c4, 0xae2eabe2a8, 0x1e4f43e470];
  var checksum = bigInt(1);
  for (var i = 0; i < data.length; ++i) {
    var value = data[i];
    var topBits = checksum.shiftRight(35);
    checksum = checksum.and(0x07ffffffff).shiftLeft(5).xor(value);
    for (var j = 0; j < GENERATOR.length; ++j) {
      if (topBits.shiftRight(j).and(1).equals(1)) {
        checksum = checksum.xor(GENERATOR[j]);
      }
    }
  }
  return checksum.xor(1);
}

/**
 * Verify that the payload has not been corrupted by checking that the
 * checksum is valid.
 * 
 * @private
 * @param {string} prefix Network prefix. E.g.: 'bitcoincash'.
 * @param {Uint8Array} payload Array of 5-bit integers containing the address' payload.
 * @returns {boolean}
 */
function validChecksum(prefix, payload) {
  var prefixData = concat(prefixToUint5Array(prefix), new Uint8Array(1));
  var checksumData = concat(prefixData, payload);
  return polymod(checksumData).equals(0);
}

/**
 * Returns true if, and only if, the given string contains either uppercase
 * or lowercase letters, but not both.
 *
 * @private
 * @param {string} string Input string.
 * @returns {boolean}
 */
function hasSingleCase(string) {
  return string === string.toLowerCase() || string === string.toUpperCase();
}

module.exports = {
  encode: encode,
  decode: decode,
  ValidationError: ValidationError,
};
