/**
 * @license
 * https://reviews.bitcoinabc.org
 * Copyright (c) 2017-2020 Emilio Almansi
 * Copyright (c) 2023-2024 Bitcoin ABC
 * Distributed under the MIT software license, see the accompanying
 * file LICENSE or http://www.opensource.org/licenses/mit-license.php.
 */

import base32 from './base32';
import convertBits from './convertBits';
import { AddressType, DecodedAddress, TypeAndHash } from './types';
import validation from './validation';
const { validate, ValidationError } = validation;

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

/**
 * Encodes a hash from a given type into an eCash address with the given prefix.
 *
 * @param prefix Cash address prefix. E.g.: 'ecash'.
 * @param type Type of address to generate
 * @param hash Hash to encode represented as an array of 8-bit integers.
 * @throws {ValidationError}
 */
export function encodeCashAddress(
    prefix: string,
    type: AddressType,
    hash: Uint8Array | string,
): string {
    validate(
        typeof prefix === 'string' && isValidPrefix(prefix),
        'Invalid prefix: ' + prefix + '.',
    );
    validate(
        type === 'p2pkh' || type === 'p2sh',
        'Invalid type: ' + type + '.',
    );
    validate(
        hash instanceof Uint8Array || typeof hash === 'string',
        'Invalid hash: ' + hash + '. Must be string or Uint8Array.',
    );
    if (typeof hash === 'string') {
        hash = stringToUint8Array(hash);
    }
    const prefixData = concat(prefixToUint5Array(prefix), new Uint8Array(1));
    const versionByte = getTypeBits(type) + getHashSizeBits(hash);
    const payloadData = toUint5Array(
        concat(new Uint8Array([versionByte]), hash),
    );
    const checksumData = concat(
        concat(prefixData, payloadData),
        new Uint8Array(8),
    );
    const 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}.
 *
 * @param address Address to decode. E.g.: 'ecash:qpm2qsznhks23z7629mms6s4cwef74vcwva87rkuu2'.
 * @throws {ValidationError}
 */
export function decodeCashAddress(address: string): DecodedAddress {
    validate(
        typeof address === 'string' && hasSingleCase(address),
        'Invalid address: ' + address + '.',
    );
    const pieces = address.toLowerCase().split(':');
    // if there is no prefix, it might still be valid
    let prefix, payload;
    if (pieces.length === 1) {
        // Check and see if it has a valid checksum for accepted prefixes
        let hasValidChecksum = false;
        for (let i = 0; i < VALID_PREFIXES.length; i += 1) {
            const testedPrefix = VALID_PREFIXES[i];
            const prefixlessPayload = base32.decode(pieces[0]);
            hasValidChecksum = validChecksum(testedPrefix, prefixlessPayload);
            if (hasValidChecksum) {
                // Here's your prefix
                prefix = testedPrefix;
                payload = prefixlessPayload;
                // Stop testing other prefixes
                break;
            }
        }
        validate(
            hasValidChecksum,
            `Prefixless address ${address} does not have valid checksum for any valid prefix (${VALID_PREFIXES.join(
                ', ',
            )})`,
        );
    } else {
        validate(pieces.length === 2, 'Invalid address: ' + address + '.');
        prefix = pieces[0];
        payload = base32.decode(pieces[1]);
        validate(
            validChecksum(prefix, payload),
            'Invalid checksum: ' + address + '.',
        );
    }

    // We assert that payload will be defined here, as we validate above
    const payloadData = fromUint5Array((payload as Uint8Array).subarray(0, -8));
    const versionByte = payloadData[0];
    const hash = payloadData.subarray(1);
    validate(
        getHashSize(versionByte) === hash.length * 8,
        'Invalid hash size: ' + address + '.',
    );
    const type = getType(versionByte);
    return {
        prefix: prefix as string,
        type,
        hash: uint8arrayToHexString(hash),
    };
}

/**
 * All valid address prefixes
 * Note that as of 2.0.0 we do not validate against these prefixes
 * However we do use them to guess prefix for prefixless addrs
 *
 * @private
 */
export const VALID_PREFIXES = [
    'ecash',
    'bitcoincash',
    'simpleledger',
    'etoken',
    'ectest',
    'ecregtest',
    'bchtest',
    'bchreg',
];

/**
 * Checks whether a string is a valid prefix
 * ie., it has a single letter case and no spaces
 * Could be extended to validate for accepted prefixes
 *
 * @private
 * @param prefix
 */
function isValidPrefix(prefix: string): boolean {
    return hasSingleCase(prefix) && !prefix.includes(' ');
}

/**
 * Derives an array from the given prefix to be used in the computation
 * of the address' checksum.
 *
 * @private
 * @param prefix Cash address prefix. E.g.: 'ecash'.
 */
function prefixToUint5Array(prefix: string): Uint8Array {
    const result = new Uint8Array(prefix.length);
    for (let 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 checksum Computed checksum.
 * TODO update big-integer so we can use correct types
 */
function checksumToUint5Array(checksum: bigint): Uint8Array {
    const result = new Uint8Array(8);
    for (let i = 0; i < 8; ++i) {
        // Extract the least significant 5 bits (31 is 11111 in binary)
        result[7 - i] = Number(checksum & 31n);
        // Shift right by 5 bits
        checksum >>= 5n;
    }
    return result;
}

/**
 * Returns the bit representation of the given type within the version
 * byte.
 *
 * @private
 * @param type Address type. Either 'P2PKH' or 'P2SH'.
 * @throws {ValidationError}
 */
function getTypeBits(type: AddressType): number {
    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 versionByte
 */
function getType(versionByte: number): AddressType {
    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 hash Hash to encode represented as an array of 8-bit integers.
 * @throws {ValidationError}
 */
function getHashSizeBits(hash: Uint8Array): number {
    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 versionByte
 */
function getHashSize(versionByte: number): number {
    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;
        default:
            throw new Error('Invalid input');
    }
}

/**
 * Converts an array of 8-bit integers into an array of 5-bit integers,
 * right-padding with zeroes if necessary.
 *
 * @private
 * @param {Uint8Array} data
 */
function toUint5Array(data: Uint8Array): Uint8Array {
    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 data
 * @throws {ValidationError}
 */
function fromUint5Array(data: Uint8Array): Uint8Array {
    return convertBits(data, 5, 8, true);
}

/**
 * Returns the concatenation a and b.
 *
 * @private
 * @param a
 * @param b
 * @throws {ValidationError}
 */
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
    const 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 data Array of 5-bit integers over which the checksum is to be computed.
 */
function polymod(data: Uint8Array): bigint {
    const GENERATOR = [
        BigInt('0x98f2bc8e61'),
        BigInt('0x79b76d99e2'),
        BigInt('0xf33e5fb3c4'),
        BigInt('0xae2eabe2a8'),
        BigInt('0x1e4f43e470'),
    ];
    let checksum = 1n; // BigInt for 1

    for (let i = 0; i < data.length; i += 1) {
        const value = BigInt(data[i]);
        const topBits = checksum >> 35n;
        checksum = ((checksum & 0x07ffffffffn) << 5n) ^ value;

        for (let j = 0; j < GENERATOR.length; ++j) {
            if ((topBits >> BigInt(j)) & 1n) {
                checksum ^= GENERATOR[j];
            }
        }
    }
    return checksum ^ 1n;
}

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

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

/**
 * Returns a uint8array for a given string input
 *
 * @private
 * @param string Input string.
 */
function stringToUint8Array(string: string): Uint8Array {
    const array = new Uint8Array(string.length / 2);
    for (let i = 0; i < string.length; i += 2) {
        // Convert each pair of characters to an integer
        array[i / 2] = parseInt(string.slice(i, i + 2), 16);
    }
    return array;
}

/**
 * Returns a uint8array for a given string input
 *
 * @private
 * @param uint8Array Input string.
 */
export function uint8arrayToHexString(uint8Array: Uint8Array): string {
    let hexString = '';
    for (let i = 0; i < uint8Array.length; i++) {
        let hex = uint8Array[i].toString(16);
        // Ensure we have 2 digits for each byte
        hex = hex.length === 1 ? '0' + hex : hex;
        hexString += hex;
    }
    return hexString;
}

/**
 * Get type and hash from an outputScript
 *
 * Supported outputScripts:
 *
 * P2PKH: 76a914<hash>88ac
 * P2SH:  a914<hash>87
 *
 * Validates for supported outputScript and hash length *
 *
 * @param outputScript an ecash tx outputScript
 * @throws {ValidationError}
 */
export function getTypeAndHashFromOutputScript(
    outputScript: string,
): TypeAndHash {
    const p2pkhPrefix = '76a914';
    const p2pkhSuffix = '88ac';

    const p2shPrefix = 'a914';
    const p2shSuffix = '87';

    let hash, type: AddressType;

    // If outputScript begins with '76a914' and ends with '88ac'
    if (
        outputScript.slice(0, p2pkhPrefix.length) === p2pkhPrefix &&
        outputScript.slice(-1 * p2pkhSuffix.length) === p2pkhSuffix
    ) {
        // We have type p2pkh
        type = 'p2pkh';

        // hash is the string in between '76a194' and '88ac'
        hash = outputScript.substring(
            outputScript.indexOf(p2pkhPrefix) + p2pkhPrefix.length,
            outputScript.lastIndexOf(p2pkhSuffix),
        );
        // If outputScript begins with 'a914' and ends with '87'
    } else if (
        outputScript.slice(0, p2shPrefix.length) === p2shPrefix &&
        outputScript.slice(-1 * p2shSuffix.length) === p2shSuffix
    ) {
        // We have type p2sh
        type = 'p2sh';
        // hash is the string in between 'a914' and '87'
        hash = outputScript.substring(
            outputScript.indexOf(p2shPrefix) + p2shPrefix.length,
            outputScript.lastIndexOf(p2shSuffix),
        );
    } else {
        // Throw validation error if outputScript not of these two types
        throw new ValidationError('Unsupported outputScript: ' + outputScript);
    }

    // Throw validation error if hash is of invalid size
    // Per spec, valid hash sizes in bytes
    const VALID_SIZES = [20, 24, 28, 32, 40, 48, 56, 64];

    if (!VALID_SIZES.includes(hash.length / 2)) {
        throw new ValidationError(
            'Invalid hash size in outputScript: ' + outputScript,
        );
    }
    return { type, hash };
}

export const getOutputScriptFromTypeAndHash = (
    type: AddressType,
    hash: string,
): string => {
    validate(
        type === 'p2pkh' || type === 'p2sh',
        'Invalid type: ' + type + '.',
    );
    let outputScript;
    if (type === 'p2pkh') {
        outputScript = `76a914${hash}88ac`;
    } else {
        outputScript = `a914${hash}87`;
    }
    return outputScript;
};

/**
 * Encodes a given outputScript into an eCash address using the optionally specified prefix.
 *
 * @static
 * @param outputScript an ecash tx outputScript
 * @param prefix Cash address prefix. E.g.: 'ecash'.
 * @throws {ValidationError}
 */
export function encodeOutputScript(
    outputScript: string,
    prefix = 'ecash',
): string {
    // Get type and hash from outputScript
    const { type, hash } = getTypeAndHashFromOutputScript(outputScript);

    // The encode function validates hash for correct length
    return encodeCashAddress(prefix, type, hash);
}

/**
 * Return true for a valid cashaddress
 * Prefixless addresses with valid checksum are also valid
 *
 * @static
 * @param testedAddress a string tested for cashaddress validity
 * @param optionalPrefix cashaddr prefix
 * @throws {ValidationError}
 */
export function isValidCashAddress(
    cashaddress: string,
    optionalPrefix: boolean | string = false,
): boolean {
    try {
        const { prefix } = decodeCashAddress(cashaddress);
        if (optionalPrefix) {
            return prefix === optionalPrefix;
        }
        return true;
    } catch {
        return false;
    }
}

/**
 * Return true for a valid cashaddress
 * Prefixless addresses with valid checksum are also valid
 *
 * @static
 * @param address a valid p2pkh or p2sh cash address
 * @returns the outputScript associated with this address and type
 * @throws {ValidationError} if decode fails
 */
export function getOutputScriptFromAddress(address: string): string {
    const { type, hash } = decodeCashAddress(address);
    let registrationOutputScript;
    if (type === 'p2pkh') {
        registrationOutputScript = `76a914${hash}88ac`;
    } else {
        registrationOutputScript = `a914${hash}87`;
    }
    return registrationOutputScript;
}
