'use strict';
/**
 * @module lib/blockchains/bitcoin/accounts
 * @summary Whiteflag API Bitcoin accounts module
 * @description Module for managing Bitcoin accounts for Whiteflag
 */
module.exports = {
    init: initAccounts,
    get: getAccount,
    check: checkAccount,
    create: createAccount,
    update: updateAccount,
    delete: deleteAccount,
    getAddress,
    getKeypair,
    updateUtxosSpent: updateAccountUtxosSpent,
    updateBalance: updateAccountBalance,
    processBlock: processBlockUtxos,
    test: {
        createAccountEntry,
        createKeypair
    }
};
/* Node.js core and external modules */
const ecc = require('tiny-secp256k1');
const { ECPairFactory } = require('ecpair');
const { payments } = require('bitcoinjs-lib');
/* Common internal functions and classes */
const log = require('../../_common/logger');
const obj = require('../../_common/objects');
const { ignore } = require('../../_common/processing');
const { ProcessingError } = require('../../_common/errors');
/* Whiteflag modules */
const wfState = require('../../protocol/state');
/* Common blockchain functions */
const { getPrivateKeyId } = require('../_common/keys');
/* Bitcoin sub-modules */
const btcRpc = require('./rpc');
/* Module constants */
const MODULELOG = 'bitcoin';
const DEFAULTSCRIPT = 'p2pkh'
const SYNCTIMER = 10000;
const SYNCDELAY = 600000;
const SATOSHI = 100000000;
const INITBLOCKHEIGHT = 900000;
const HEXENCODING = 'hex';
/* Module variables */
let _btcChain;
let _btcState;
let _transactionBatchSize = 128;
/**
 * Initialises Bitcoin accounts management
 * @function initAccounts
 * @alias module:lib/blockchains/bitcoin/accounts.init
 * @param {Object} btcConfig the Bitcoin blockchain configuration
 * @param {Object} btcState the Bitcoin blockchain state
 */
async function initAccounts(btcConfig, btcState) {
    log.trace(MODULELOG, 'Initialising Bitcoin accounts management');
    _btcChain = btcConfig.name;
    _btcState = btcState;
    // Wallet synchronisation parameters
    if (Object.hasOwn(btcConfig, 'transactionBatchSize')) _transactionBatchSize = btcConfig.transactionBatchSize;
    // If configured, create new account if none exists
    if (btcConfig.createAccount && _btcState.accounts.length === 0) {
        createAccount(null)
        .then(account => {
            log.info(MODULELOG, `Automatically created first ${_btcChain} account: ${account.address}`);
        })
        .catch(err => log.warn(MODULELOG, err.message));
    } else {
        // Upgrade data structure of existing accounts
        for (let account of _btcState.accounts) {
            upgradeAccountData(account);
        }
    }
    // Start synchronisation of all accounts
    setTimeout(synchroniseAccounts, SYNCTIMER);
    // All done
    return Promise.resolve();
}
/**
 * Gets account data of a Bitcoin blockchain account by address
 * @function getAccount
 * @alias module:lib/blockchains/bitcoin/accounts.get
 * @param {string} address the Bitcoin account address
 * @returns {Promise} the account data
 */
function getAccount(address) {
    return new Promise((resolve, reject) => {
        let account = _btcState.accounts.find(item => item.address === address);
        if (!account) {
            return reject(new ProcessingError(`The ${_btcChain} account does not exist: ${address}`, null, 'WF_API_NO_RESOURCE'));
        }
        resolve(account);
    });
}
/**
 * Verifies if an account exists and is not syncing
 * @function checkAccount
 * @alias module:lib/blockchains/bitcoin/accounts.check
 * @param {string} address the Bitcoin account address
 * @returns {Promise} the account data
 */
function checkAccount(address) {
    return new Promise((resolve, reject) => {
        getAccount(address)
        .then(account => {
            if (account.syncing === true) {
                return reject(new ProcessingError(`The ${_btcChain} account cannot be used because it is currently syncing at block: ${account.lastBlock}/${_btcState.status.currentBlock}`, null, 'WF_API_NOT_AVAILABLE'));
            }
            resolve(account);
        })
        .catch(err => reject(err));
    });
}
/**
 * Creates a new Bitcoin account from an existing or a new key pair
 * @function createAccount
 * @alias module:lib/blockchains/bitcoin/accounts.create
 * @param {string} [wif] hexadecimal string with with private key in Wallet Import Format (WIF)
 * @returns {Promise} resolves to the account data
 */
async function createAccount(wif = null) {
    return new Promise((resolve, reject) => {
        let account;
        try {
            account = createAccountEntry(wif, _btcState.parameters.network);
        } catch(err) {
            wif = null;
            log.error(MODULELOG, `Error while creating new ${_btcChain} account: ${err.message}`);
            return reject(err);
        }
        if (wif) {
            wif = null;
        } else {
            // New accounts should not have transactions before the current highest block
            btcRpc.getBlockCount()
            .then(highestBlock => {
                account.firstBlock = highestBlock || INITBLOCKHEIGHT;
                account.lastBlock = highestBlock || INITBLOCKHEIGHT;
            })
            .catch(err => {
                log.warn(MODULELOG, `Could not set actual blockheight on new account: ${err.message}`);
                account.firstBlock = INITBLOCKHEIGHT;
                account.lastBlock = INITBLOCKHEIGHT;
            });
        }
        // Check for existing account and store account
        getAccount(account.address)
        .then(existingAccount => {
            if (existingAccount.address === account.address) {
                return reject(new ProcessingError(`The ${_btcChain} account already exists: ${account.address}`, null, 'WF_API_RESOURCE_CONFLICT'));
            }
        })
        .catch(err => ignore(err)); // all good if account does not yet exist
        // Save and return result
        upsertAccount(account);
        synchroniseAccount(account);
        return resolve(account);
    });
}
/**
 * Updates a Bitcoin blockchain account attributes
 * @function updateAccount
 * @alias module:lib/blockchains/bitcoin/accounts.update
 * @param {string} account the account information object with updated information
 * @returns {Promise} resolves to the account data
 */
function updateAccount(account) {
    log.trace(MODULELOG, `Updating ${_btcChain} account: ${account.address}`);
    return new Promise((resolve, reject) => {
        // Update only if account exists
        getAccount(account.address)
        .then(existingAccount => {
            // Double check addresses
            if (existingAccount.address !== account.address) {
                return reject(new Error(`Address of ${_btcChain} account in state does not correspond with updated account data address`));
            }
            // Upsert updated account data
            try {
                upsertAccount(account);
            } catch(err) {
                return reject(new Error(`Could not update ${_btcChain} account: ${err.message}`));
            }
            return resolve(account);
        })
        .catch(err => reject(err));
    });
}
/**
 * Deletes a Bitcoin blockchain account
 * @function deleteAccount
 * @alias module:lib/blockchains/bitcoin/accounts.delete
 * @param {string} address the address of the account information object with updated informationto be deleted
 * @param {bcDeleteAccountCb} callback function called on completion
 */
function deleteAccount(address) {
    log.trace(MODULELOG, `Deleting ${_btcChain} account: ${address}`);
    return new Promise((resolve, reject) => {
        // Get index of account in state
        const index = _btcState.accounts.findIndex(item => item.address === address);
        if (index < 0) {
            return reject(new ProcessingError(`Could not find ${_btcChain} account: ${address}`, null, 'WF_API_NO_RESOURCE'));
        }
        // Remove account from state after double check
        const account = _btcState.accounts[index];
        if (account.address === address) {
            _btcState.accounts.splice(index, 1);
            wfState.updateBlockchainData(_btcChain, _btcState);
        } else {
            return reject(new Error(`Could not not delete ${_btcChain} account: ${address}`));
        }
        // Log and return result
        return resolve(account);
    });
}
/**
 * Derives a Bitcoin address from a public key
 * @function getAddress
 * @alias module:lib/blockchains/bitcoin/accounts.getAddress
 * @param {string} btcPublicKey a hexadecimal encoded public key
 * @param {Object} [btcNetwork] the Bitcoin network
 * @param {string} [script] the script to locks an output to a public key hash
 * @returns {Promise} resolves to the the Bitcoin address
 */
function getAddress(btcPublicKey, btcNetwork = _btcState.parameters.network, script = DEFAULTSCRIPT) {
    let btcAddress;
    try {
        btcAddress = deriveAaddress(btcPublicKey, btcNetwork, script);
    } catch(err) {
        return Promise.reject(err);
    }
    return Promise.resolve(btcAddress);
}
/**
 * Creates a Bitcoin keypair from a hexadecimal private key
 * @function getKeypair
 * @alias module:lib/blockchains/bitcoin/accounts.getKeypair
 * @param {string} privateKey the hexadecimal private key
 * @param {Object} [btcNetwork] the Bitcoin network to create the keypair for
 * @returns {Promise} resolves to a keypair
 */
function getKeypair(privateKey, btcNetwork = _btcState.parameters.network) {
    let keypair;
    try {
        keypair = createKeypair(privateKey, btcNetwork);
    } catch(err) {
        return Promise.reject(err);
    }
    return Promise.resolve(keypair);
}
/**
 * Updates the status of the UTXOs for an account
 * @function updateAccountUtxosSpent
 * @alias module:lib/blockchains/bitcoin/accounts.updateUtxos
 * @param {wfAccount} account the Bitcoin account
 * @param {Array} spentUtxos the UTXOs spent by a transaction
 */
 function updateAccountUtxosSpent(account, spentUtxos) {
    // Check account UTXOs against the provided UTXOs
    spentUtxos.forEach(spentUtxo => {
        account.utxos.forEach(utxo => {
            if (utxo.txid === spentUtxo.txid) {
                utxo.spent = true;
            }
        });
    });
    updateAccountBalance(account);
}
/**
 * Updates the account balance based on unspent UTXOs
 * @function updateAccountBalance
 * @alias module:lib/blockchains/bitcoin/accounts.updateBalance
 * @param {wfAccount} account the account to be updated
 */
 function updateAccountBalance(account) {
    // Get unspent UTXOs
    let unspentUtxos = [];
    for (let utxo of account.utxos) {
        if (!utxo.spent) unspentUtxos.push(utxo);
    }
    // Sum unspent UTXOs
    account.balance = (unspentUtxos.reduce((accumulator, utxo) => {
        return accumulator + utxo.value;
    }, 0));
    upsertAccount(account);
}
/**
 * Processes block to check for incoming UTXOs for each account
 * @function processBlockUtxos
 * @alias module:lib/blockchains/bitcoin/accounts.processBlock
 * @param {number} blockNumber the blocknumer
 * @param {Object} block the full block including transactions
 */
function processBlockUtxos(blockNumber, block) {
    for (let account of _btcState.accounts) {
        // Only process the next block and if not already syncing
        if (account.syncing === true) break;
        if (account.lastBlock === (blockNumber - 1)) {
            processAccountTransactions(0, block.tx, account)
            .then(() => {
                account.lastBlock = blockNumber;
            })
            .catch(err => {
                log.warn(MODULELOG, `Error processing block ${blockNumber} to check UTXOs for account ${account.address}: ${err.message}`);
                return scheduleSynchroniseAccount(account.address, SYNCDELAY);
            });
        } else {
            // This block is not the next block, so strart syncing
            synchroniseAccount(account);
        }
    }
}
/* PRIVATE MODULE FUNCTIONS */
/**
 * Create Bitcoin account entry from Bitcoin key pair
 * @private
 * @param {string} [wif] hexadecimal string with with private key in Wallet Import Format (WIF)
 * @param {Object} [btcNetwork] the Bitcoin network to create the entry for
 * @returns {Object} the account data
 */
function createAccountEntry(wif = null, btcNetwork = _btcState.parameters.network) {
    const ECPair = ECPairFactory(ecc);
    let btcKeypair;
    if (wif) {
        log.trace(MODULELOG, `Creating ${_btcChain} account from WIF`);
        btcKeypair = ECPair.fromWIF(wif, btcNetwork);
        wif = null;
    } else {
        log.trace(MODULELOG, `Creating new ${_btcChain} account with generated keys`);
        btcKeypair = ECPair.makeRandom({ network: btcNetwork });
    }
    return {
        address: deriveAaddress(btcKeypair.publicKey, btcNetwork),
        publicKey: Buffer.from(btcKeypair.publicKey).toString(HEXENCODING),
        privateKey: Buffer.from(btcKeypair.privateKey).toString(HEXENCODING),
        balance: 0,
        firstBlock: 1,
        lastBlock: 0,
        syncing: false,
        utxos: []
    };
}
/**
 * Creates a Bitcoin keypair from a private key
 * @private
 * @param {string} privateKey the hexadecimal private key
 * @param {Object} btcNetwork the Bitcoin network to create the keypair for
 * @returns {Object} a keypair
 */
function createKeypair(privateKey, btcNetwork) {
    const ECPair = ECPairFactory(ecc);
    return ECPair.fromPrivateKey(
        hexToBuffer(privateKey), 
        { network: btcNetwork }
    );
}
/**
 * Derives the P2WPKH (Pay To Witness Public Key Hash) address
 * @private
 * @param {string} btcPubkey the hexadecimal encoded public key
 * @param {Object} btcNetwork the Bitcoin network to derive the address for
 * @param {string} [script] the script to locks an output to a public key hash
 */
function deriveAaddress(btcPubkey, btcNetwork, script = DEFAULTSCRIPT) {
    switch (script) {
        case 'p2wpkh': {
            return payments.p2wpkh({
                pubkey: Buffer.from(btcPubkey),
                network: btcNetwork
            }).address
        }
        default:
        case 'p2pkh': {
            return payments.p2pkh({
                pubkey: Buffer.from(btcPubkey),
                network: btcNetwork
            }).address
        }
    }
}
/**
 * Upgrades Bitcoin account data structure to current version
 * @private
 * @param {wfAccount} account the account data
 */
function upgradeAccountData(account) {
    // Upgrade UTXO spent values
    for (let utxo of account.utxos) {
        if (typeof utxo.spent === 'string' || utxo.spent instanceof String) {
            if (utxo.spent === 'UNSPENT') utxo.spent = false;
            if (utxo.spent === 'SPENT') utxo.spent = true;
            if (utxo.spent === 'SPENTVERIFIED') utxo.spent = true;
            if (utxo.spent === 'NEEDSVERIFICATION') utxo.spent = true;
        }
        utxo.index = +utxo.index;
    }
    return account;
}
/**
 * Updates or inserts an Bitcoin account in the blockchain state without private key
 * @private
 * @param {wfAccount} account Bitcoin account to be upserted
 */
function upsertAccount(account) {
    // Securely store the private key in state
    if (Object.hasOwn(account, 'privateKey')) {
        const privateKeyId = getPrivateKeyId(_btcChain, account.address);
        wfState.upsertKey('blockchainKeys', privateKeyId, account.privateKey);
        delete account.privateKey;
    }
    // Inserting or updating
    let existingAccount = _btcState.accounts.find(item => item.address === account.address);
    if (!existingAccount) {
        // Insert new account
        _btcState.accounts.push(account);
    } else {
        // Update account
        obj.update(account, existingAccount);
    }
    wfState.updateBlockchainData(_btcChain, _btcState);
}
/**
 * Starts synchronisation of all accounts
 * @private
 */
function synchroniseAccounts() {
    for (let account of _btcState.accounts) {
        scheduleSynchroniseAccount(account.address);
    }
}
/**
 * Schedules synchronisation of the specified account
 * @private
 * @param {string} address the account address
 * @param {number} delay how long to wait in milisecond
 */
function scheduleSynchroniseAccount(address, delay = SYNCTIMER) {
    setTimeout(function timeoutSynchroniseAccountCb() {
        let account = _btcState.accounts.find(item => item.address === address);
        if (!account) {
            return log.warn(MODULELOG, `Scheduled account to synchronize does not exist: ${address}`)
        }
        return synchroniseAccount(account);
    }, delay);
}
/**
 * Syncronises an account with the blockchain by looking for utxos
 * @private
 * @param {wfAccount} account the account to synchronise
 */
function synchroniseAccount(account) {
    // To sync or not to sync
    if (account.lastBlock >= _btcState.status.currentBlock) {
        if (account.syncing) {
            log.info(MODULELOG, `Completed synchronisation of account ${account.address} at block: ${account.lastBlock}/${_btcState.status.highestBlock}`);
            account.syncing = false;
        }
        return Promise.resolve();
    }
    if (!account.syncing) {
        log.info(MODULELOG, `Starting synchronisation of account ${account.address} from block: ${account.lastBlock}/${_btcState.status.highestBlock}`);
        account.syncing = true;
    }
    // Get next block
    let thisBlock = account.lastBlock + 1;
    log.trace(MODULELOG, `Synchronising account ${account.address} with block: ${thisBlock}/${_btcState.status.highestBlock}`);
    btcRpc.getBlockByNumber(thisBlock, true)
    .then(block => {
        return processAccountTransactions(0, block.tx, account);
    })
    .then(() => {
        account.lastBlock = thisBlock;
        if (thisBlock % 100 === 0) upsertAccount(account);
        return synchroniseAccount(account);
    })
    .catch(err => {
        log.warn(MODULELOG, `Rescheduling synchronisation of account ${account.address} after ${SYNCDELAY} ms: Could not process block ${thisBlock}: ${err.message}`);
        upsertAccount(account);
        return scheduleSynchroniseAccount(account.address, SYNCDELAY);
    });
}
/**
 * Processes the transactions of a Bitcoin block
 * @private
 * @param {number} index the transaction in the array to process
 * @param {Array} transactions the transactions to process
 * @param {wfAccount} account the account to check transaction for
 * @returns {Promise} resolves if all transactions are successfully processed
 */
 function processAccountTransactions(index, transactions, account) {
    // Get transaction batch of Promises in an array to check transactions for UTXOs
    let transactionBatch = createTransactionBatch(index, transactions, account);
    if (transactionBatch.length < 1) return Promise.resolve();
    // Resolve all transaction promises in the batch
    return Promise.all(transactionBatch)
    .then(data => {
        ignore(data);
        // Next batch
        let nextIndex = index + _transactionBatchSize;
        if (nextIndex >= transactions.length) return Promise.resolve();
        return processAccountTransactions(nextIndex, transactions, account);
    })
    .catch(err => {
        return Promise.reject(err);
    });
}
/**
 * Combines multiple transactions from a Bitcoin block as promises in an array for batch processing
 * @private
 * @param {number} index the transaction in the array to process
 * @param {Array} transactions the transactions to process
 * @param {wfAccount} account the account to check transaction for
 * @returns {Array} Array with transaction Promises
 */
function createTransactionBatch(index, transactions, account) {
    let transactionBatch = [];
    for (
        let i = index;
        i < Math.min(index + _transactionBatchSize, transactions.length);
        i++
    ) {
        // Get a promise for the next transaction
        transactionBatch.push(
            new Promise(resolve => {
                processTransaction(transactions[i], account);
                return resolve();
            })
        );
    }
    return transactionBatch;
}
/**
 * Checks a list of transactions for utxos related to an account
 * @private
 * @param {Object} transaction the transaction to check for UTXOs
 * @param {wfAccount} account the account to check the transaction for
 */
 function processTransaction(transaction, account) {
    // Check transaction for received UTXOs
    for (let index in transaction.vout) {
        if (typeof transaction.vout[index].scriptPubKey.addresses !== 'undefined') {
            checkAccountUtxosReceived(transaction, index, account);
        }
    }
    // Check transaction for spent UTXOs
    for (let index in transaction.vin) {
        if (typeof transaction.vin[index].txid !== 'undefined') {
            checkAccountUtxosSpent(transaction, index, account);
        }
    }
}
/**
 * Checks a transaction and processes a received utxo for an account
 * @private
 * @param {Object} transaction the transaction to check
 * @param {Object} index the UTXO index within the transaction
 * @param {wfAccount} account the account to check against
 */
function checkAccountUtxosReceived(transaction, index, account) {
    if (account.address === transaction.vout[index].scriptPubKey.addresses[0]) {
        if (!account.utxos.some(utxo => utxo.txid === transaction.txid)) {
            let utxo = {
                txid: transaction.txid,
                index: +index,
                value: parseInt((transaction.vout[index].value * SATOSHI).toFixed(0)),
                spent: false
            };
            log.info(MODULELOG, `Account ${account.address} received ${utxo.value} satoshis`);
            account.utxos.push(utxo);
            updateAccountBalance(account);
        }
    }
}
/**
 * Checks a transaction and processes a spent utxo for an account
 * @private
 * @param {Object} transaction the transaction to check
 * @param {Object} index the UTXO index within the transaction
 * @param {wfAccount} account the account to check against
 */
function checkAccountUtxosSpent(transaction, index, account) {
    for (let utxo of account.utxos) {
        if (utxo.txid === transaction.vin[index].txid
            && +transaction.vin[index].vout === +utxo.index
        ) {
            log.info(MODULELOG, `Account ${account.address} spent ${utxo.value} satoshis`);
            utxo.spent = transaction.txid;
            updateAccountBalance(account);
        }
    }
}