import {rostrumProvider} from "../network/RostrumProvider";
import {AccountIndexes, AccountKeys, AddressKey, Balance} from "../models/wallet.entities";
import * as Bip39 from 'bip39';
import bigDecimal from "js-big-decimal";
import {ITXHistory, ITXInput, ITXOutput} from "../models/rostrum.entities";
import {Address, AddressType, HDPrivateKey, Networkish} from "libnexa-ts";

import {isNil} from "lodash-es";
import {BaseAccount} from "../wallet/accounts/interfaces/BaseAccountInterface";
import DAppAccount from "../wallet/accounts/models/DappAccount";
import DefaultAccount from "../wallet/accounts/models/DefaultAccount";
import VaultAccount from "../wallet/accounts/models/VaultAccount";
import {TransactionEntity, TxEntityState} from "../models/transaction.entities";
import {currentTimestamp, isNullOrEmpty} from "./CommonUtils";

export enum TxTokenType {
    NO_GROUP,
    CREATE,
    MINT,
    MELT,
    RENEW,
    TRANSFER
}

export enum AccountType {
    NEXA_ACCOUNT,
    VAULT_ACCOUNT,
    DAPP_ACCOUNT,
}

export function isValidNexaAddress(address: string, network: Networkish, type = AddressType.PayToScriptTemplate) {
    return Address.isValid(address, network, type);
}

export function generateMasterKey(mnemonic: string, passphrase?: string | undefined) {
    const seed = Bip39.mnemonicToSeedSync(mnemonic, passphrase);
    const masterKey = HDPrivateKey.fromSeed(seed);
    return masterKey.deriveChild(44, true).deriveChild(29223, true);
}

export function generateAccountKey(masterKey: HDPrivateKey, account: number) {
    return masterKey.deriveChild(account, true);
}

export function generateKeysAndAddresses(accountKey: HDPrivateKey, fromRIndex: number, rIndex: number, fromCIndex: number, cIndex: number): AccountKeys {
    if (fromRIndex < 0) {
        throw new Error(`Can not generate keys with fromRIndex ${fromRIndex}. must be >= 0.`);
    }
    if (fromCIndex < 0) {
        throw new Error(`Can not generate keys with fromCIndex ${fromCIndex}. must be >= 0.`);
    }
    let receive = accountKey.deriveChild(0, false);
    let change = accountKey.deriveChild(1, false);
    let rKeys: AddressKey[] = [], cKeys: AddressKey[] = [];
    for (let index = fromRIndex; index < rIndex; index++) {
        let k = receive.deriveChild(index, false);
        let addr = k.privateKey.toAddress().toString();
        rKeys.push({key: k, address: addr, balance: "0", tokensBalance: {}});
    }
    for (let index = fromCIndex; index < cIndex; index++) {
        let k = change.deriveChild(index, false);
        let addr = k.privateKey.toAddress().toString();
        cKeys.push({key: k, address: addr, balance: "0", tokensBalance: {}});
    }
    return {receiveKeys: rKeys, changeKeys: cKeys};
}

export function generateKeyAndAddress(accountKey: HDPrivateKey, rIndex: number): AddressKey {
    let receive = accountKey.deriveChild(0, false);
    let k = receive.deriveChild(rIndex, false);
    let addr = k.privateKey.toAddress().toString();
    return ({key: k, address: addr, balance: "0", tokensBalance: {}});
}

async function discoverUsedAccountIndexes(deriveKey: HDPrivateKey) {
    let lastUsed = -1, index = 0, toScan = 20;

    while (toScan > 0) {
        toScan--;
        let rAddr = deriveKey.deriveChild(index, false).privateKey.toAddress().toString();
        let isUsed = await isAddressUsed(rAddr);
        if (isUsed) {
            lastUsed = index;
            toScan = 20;
        }
        index++;
    }
    // return the last used index, returns -1 if no indexes are used
    return lastUsed;
}

export async function discoverNexaAccount(accountKey: HDPrivateKey) {

    let receiveKey = accountKey.deriveChild(0, false);
    let changeKey = accountKey.deriveChild(1, false);

    let rIndexPromise = discoverUsedAccountIndexes(receiveKey);
    let cIndexPromise = discoverUsedAccountIndexes(changeKey);

    let [rIndex, cIndex] = await Promise.all([rIndexPromise, cIndexPromise]);

    // get the index that is the last used nexa addr
    let indexes: AccountIndexes = { rIndex: rIndex, cIndex: cIndex };

    return indexes;
}

export async function discoverNexaAccounts(masterKey: HDPrivateKey) {
    let accounts: DefaultAccount[] = [];

    let index = 0;
    while (true) {
        const nexaAccountKey = generateAccountKey(masterKey, index);
        const indexes = await discoverNexaAccount(nexaAccountKey);
        if (indexes.rIndex < 0 && indexes.cIndex < 0)
        {
            break;
        }
        if (indexes.rIndex < 0) {
            indexes.rIndex = 0;
        }
        if (indexes.cIndex < 0) {
            indexes.cIndex = 0;
        }
        // make account after break check, otherwise we might push an empty account
        const nexaAccount = new DefaultAccount(index, indexes, generateKeysAndAddresses(nexaAccountKey, indexes.rIndex + 1, indexes.rIndex + 20, indexes.cIndex + 1, indexes.cIndex + 20))
        await nexaAccount.loadBalances()
        accounts.push(nexaAccount);
        if (index == 0) {
            index = 100;
        }
        else {
            index++;
        }
    }
    if (accounts.length == 0) {
        // default account was unused but we need to populate at least one account,
        // make the default account here
        let defaultNexaAccountKey = generateAccountKey(masterKey, 0);
        // get the last used indexes, -1 means unused
        let defaultIndexes: AccountIndexes = { rIndex: 0, cIndex: 0 };
        const defaultAccount = new DefaultAccount(0, defaultIndexes, generateKeysAndAddresses(defaultNexaAccountKey, defaultIndexes.rIndex, defaultIndexes.rIndex + 20, defaultIndexes.cIndex, defaultIndexes.cIndex + 20))
        await defaultAccount.loadBalances()
        accounts.push(defaultAccount)
    }
    return accounts;
}

async function findUsedVaultAccounts(masterKey: HDPrivateKey) {
    // all vaults are in bip44 account 1
    let vaultAccountKey = generateAccountKey(masterKey, 1);
    // all vaults are in the external chain
    let vaultChain = vaultAccountKey.deriveChild(0, false);
    // get the index that is the last used vault
    return await discoverUsedAccountIndexes(vaultChain);
}

export async function discoverVaults(masterKey: HDPrivateKey) {
    let accounts: VaultAccount[] = [];
    // all vaults are in bip44 account 1
    let vaultAccountKey = generateAccountKey(masterKey, 1);
    // find the next unused vault
    let lastUsedVaultIndex: number = await findUsedVaultAccounts(masterKey);
    // if all vaults unused, generate at least the first vault account
    if (lastUsedVaultIndex < 0) lastUsedVaultIndex = 0;
    // for each vault found, make the DefaultAccount for that vault
    for (let index = 0; index <= lastUsedVaultIndex; index++)
    {
        const vaultAccount = new VaultAccount(1, index, generateKeyAndAddress(vaultAccountKey, index))
        await vaultAccount.loadBalances();
        accounts.push(vaultAccount);
    }
    return accounts;
}

async function findUsedDappAccounts(masterKey: HDPrivateKey) {
    // all dApp accounts are in bip44 account 2
    let dappAccountKey = generateAccountKey(masterKey, 2);
    // all dApp accounts use the external chain
    let dappChain = dappAccountKey.deriveChild(0, false);
    // get the index that is the next unused dApp account
    return await discoverUsedAccountIndexes(dappChain);
}

export async function discoverDappAccounts(masterKey: HDPrivateKey) {
    let accounts: DAppAccount[] = [];
    // all dApp accounts are in bip44 account 2
    let dappAccountKey = generateAccountKey(masterKey, 2);
    // find the next unused dapp account
    let lastUsedDappIndex: number = await findUsedDappAccounts(masterKey);
    // if all dapp accounts unused, generate at least the first dapp account
    if (lastUsedDappIndex < 0) lastUsedDappIndex = 0;
    // for each dApp account found, make the DefaultAccount for that dApp account
    // for each vault found, make the DefaultAccount for that vault
    for (let index = 0; index <= lastUsedDappIndex; index++)
    {
        const dappAccount = new DAppAccount(2, index, generateKeyAndAddress(dappAccountKey, index))
        await dappAccount.loadBalances();
        accounts.push(dappAccount);
    }
    return accounts;
}

export async function discoverWallet(masterKey: HDPrivateKey) {
    let accounts: BaseAccount[] = [];
    // accounts 0, 100+
    const nexaAccounts = await discoverNexaAccounts(masterKey);
    // vaults in bip44 account 1
    const vaultAccounts = await discoverVaults(masterKey);
    // dApp accounts in bip44 account 2
    const dappAccounts = await discoverDappAccounts(masterKey);
    // 3 - 99 are reserved and will go here when added
    accounts = accounts.concat(nexaAccounts);
    accounts = accounts.concat(vaultAccounts);
    accounts = accounts.concat(dappAccounts);
    return accounts;
}

async function isAddressUsed(address: string) {
    try {
        let firstUse = await rostrumProvider.getFirstUse(address);
        return firstUse.tx_hash && firstUse.tx_hash !== "";
    } catch (e) {
        if (e instanceof Error && e.message.includes("not found")) {
            return false;
        }
        throw e;
    }
}

async function getKeyTokenBalance(key: AddressKey) {
    let tokensBalance = await rostrumProvider.getTokensBalance(key.address);
    let balance: Record<string, Balance> = {};

    for (const cToken in tokensBalance.confirmed) {
        if (tokensBalance.confirmed[cToken] != 0) {
            balance[cToken] = { confirmed: BigInt(tokensBalance.confirmed[cToken]).toString(), unconfirmed: "0" }
        }
    }

    for (const uToken in tokensBalance.unconfirmed) {
        if (tokensBalance.unconfirmed[uToken] != 0) {
            if (balance[uToken]) {
                balance[uToken].unconfirmed = BigInt(tokensBalance.unconfirmed[uToken]).toString();
            } else {
                balance[uToken] = { confirmed: "0", unconfirmed: BigInt(tokensBalance.unconfirmed[uToken]).toString() }
            }
        }
    }

    return balance;
}

async function getAndUpdateAddressKeyBalance(key: AddressKey) {
    let balance = await rostrumProvider.getBalance(key.address);
    key.balance = (BigInt(balance.confirmed) + BigInt(balance.unconfirmed)).toString();
    key.tokensBalance = await getKeyTokenBalance(key);

    return balance;
}

export async function fetchTotalBalance(keys: AddressKey[]) {
    let promises: Promise<Balance>[] = [];
    keys.forEach(key => {
        let b = getAndUpdateAddressKeyBalance(key);
        promises.push(b);
    });

    return await Promise.all(promises);
}

export function sumBalance(balances: Balance[]): Balance {
    let confirmed = new bigDecimal(0), unconfirmed = new bigDecimal(0);
    balances.forEach(b => {
        confirmed = confirmed.add(new bigDecimal(b.confirmed));
        unconfirmed = unconfirmed.add(new bigDecimal(b.unconfirmed));
    });
    return {confirmed: confirmed.getValue(), unconfirmed: unconfirmed.getValue()};
}

export function sumTokensBalance(balances: Record<string, Balance>[]) {
    let tokensBalance: Record<string, Balance> = {};
    balances.forEach(b => {
        for (const key in b) {
            if (tokensBalance[key]) {
                tokensBalance[key].confirmed = (BigInt(tokensBalance[key].confirmed) + BigInt(b[key].confirmed)).toString();
                tokensBalance[key].unconfirmed = (BigInt(tokensBalance[key].unconfirmed) + BigInt(b[key].unconfirmed)).toString();
            } else {
                tokensBalance[key] = { confirmed: b[key].confirmed, unconfirmed: b[key].unconfirmed };
            }
        }
    });

    return tokensBalance;
}

export async function fetchTransactionsHistory(addresses: string[], fromHeight: number) {
    let index = 0, i = 0, data = new Map<string, ITXHistory>(), maxHeight = fromHeight;

    for (let address of addresses) {
        i++;
        let txHistory = await rostrumProvider.getTransactionsHistory(address);
        if (txHistory && txHistory.length > 0) {
            index = i;
            for (let tx of txHistory) {
                if (tx.height === 0 || tx.height > fromHeight) {
                    maxHeight = Math.max(maxHeight, tx.height);
                    data.set(tx.tx_hash, tx);
                }
            }
        }
    }

    return {index: index, txs: data, lastHeight: maxHeight};
}

async function rescanAddressesHistory(addresses: string[]) {
    let index = 0, i = 0, minHeight = Number.MAX_SAFE_INTEGER;

    for (let address of addresses) {
        i++;
        let txHistory = await rostrumProvider.getTransactionsHistory(address);
        if (!isNil(txHistory)) {
            index = i;
            let heights = txHistory.filter(tx => tx.height > 0).map(h => h.height);
            if (!isNil(heights)) {
                minHeight = Math.min(minHeight, ...heights);
            }
        }
    }

    return {index: (index > 0 ? index + 1 : 0), height: (minHeight == Number.MAX_SAFE_INTEGER ? 0 : minHeight)};
}

export async function getNextAccountIndex(accountType: AccountType, masterKey: HDPrivateKey) {
    if (accountType == AccountType.NEXA_ACCOUNT) {
        let defaultNexaAccountKey = generateAccountKey(masterKey, 0);
        const defaultIndexes = await discoverNexaAccount(defaultNexaAccountKey);
        if (defaultIndexes.rIndex < 0 && defaultIndexes.cIndex < 0){
            return 0;
        }
        else {
            // account 0 was not empty
            for (let index = 100; ; index++) {
                const nexaAccountKey = generateAccountKey(masterKey, index);
                const indexes = await discoverNexaAccount(nexaAccountKey);
                if (indexes.rIndex < 0 && indexes.cIndex < 0)
                {
                    return index;
                }
            }
        }
    }
    else if (accountType == AccountType.VAULT_ACCOUNT) {
        // find the next unused vault
        const lastUsedVault: number = await findUsedVaultAccounts(masterKey);
        return lastUsedVault + 1;
    }
    else if (accountType == AccountType.DAPP_ACCOUNT) {
        // find the next unused dapp account
        const lastUsedDappAccount: number = await findUsedDappAccounts(masterKey);
        return lastUsedDappAccount + 1;
    }
    else {
        throw new Error("Can not get next account index. Invalid accountType.");
    }
}

export async function classifyTransaction(txHistory: ITXHistory, myAddresses: string[]) {
    let t = await rostrumProvider.getTransaction(txHistory.tx_hash);

    let outputs = t.vout.filter(utxo => !isNil(utxo.scriptPubKey.addresses));

    let isOutgoing = t.vin.length > 0 && myAddresses.includes(t.vin[0].addresses[0]);
    let isIncoming = !isOutgoing || outputs.every(utxo => myAddresses.includes(utxo.scriptPubKey.addresses[0]));
    let isConfirmed = t.height > 0;

    let txEntry = {} as TransactionEntity;
    txEntry.txId = t.txid;
    txEntry.txIdem = t.txidem;
    txEntry.height = isConfirmed ? t.height : 0;
    txEntry.time = isConfirmed ? t.time : currentTimestamp();
    txEntry.fee = t.fee_satoshi;

    if (isOutgoing && isIncoming) {
        txEntry.state = 'both';
        txEntry.value = "0";
        txEntry.payTo = "Payment to yourself";
    } else if (isIncoming) {
        txEntry.state = 'incoming';
        let utxos = outputs.filter(utxo => myAddresses.includes(utxo.scriptPubKey.addresses[0]));
        let amount = new bigDecimal(0);
        utxos.forEach(utxo => {
            amount = amount.add(new bigDecimal(utxo.value_satoshi));
        });
        txEntry.value = amount.getValue();
        txEntry.payTo = utxos[0].scriptPubKey.addresses[0];
    } else if(isOutgoing) {
        txEntry.state = 'outgoing';
        let utxos = outputs.filter(utxo => !myAddresses.includes(utxo.scriptPubKey.addresses[0]));
        let amount = new bigDecimal(0);
        utxos.forEach(utxo => {
            amount = amount.add(new bigDecimal(utxo.value_satoshi));
        });
        txEntry.value = amount.getValue();
        txEntry.payTo = utxos[0].scriptPubKey.addresses[0];
    }

    let [txType, txGroup, tokenAmount, extraGroup] = classifyTokenTransaction(t.vin, outputs, txEntry.state, myAddresses);
    txEntry.txGroupType = txType;
    txEntry.token = txGroup;
    txEntry.tokenAmount = tokenAmount;
    txEntry.extraGroup = extraGroup;

    return txEntry;
}

function classifyTokenTransaction(vin: ITXInput[], vout: ITXOutput[], txState: TxEntityState, myAddresses: string[]): [TxTokenType, string, string, string] {
    let groupInputs = vin.filter(input => !isNullOrEmpty(input.group));
    let groupOutputs = vout.filter(output => !isNullOrEmpty(output.scriptPubKey.group));

    if (isNullOrEmpty(groupInputs) && isNullOrEmpty(groupOutputs)) {
        return [TxTokenType.NO_GROUP, "none", "0", "none"];
    }

    let myGroupInputs = groupInputs.filter(input => myAddresses.includes(input.addresses[0]));
    let myGroupOutputs = groupOutputs.filter(output => myAddresses.includes(output.scriptPubKey.addresses[0]));

    if (isNullOrEmpty(myGroupInputs) && isNullOrEmpty(myGroupOutputs)) {
        return [TxTokenType.NO_GROUP, "none", "0", "none"];
    }

    if (isNullOrEmpty(groupInputs)) {
        let group = myGroupOutputs.find(output => BigInt(output.scriptPubKey.groupQuantity) < 0n)?.scriptPubKey.group ?? "none";
        return [TxTokenType.CREATE, group, "0", "none"];
    }

    if (isNullOrEmpty(groupOutputs)) {
        if (txState === 'incoming') {
            return [TxTokenType.NO_GROUP, "none", "0", "none"];
        }

        let inputs = myGroupInputs.filter(input => BigInt(input.groupQuantity) > 0n);
        if (!isNullOrEmpty(inputs)) {
            let amount = new bigDecimal(0);
            inputs.forEach(utxo => {
                amount = amount.add(new bigDecimal(utxo.groupQuantity));
            });
            let group = inputs[0].group;
            let extraGroup = myGroupInputs.find(input => BigInt(input.groupQuantity) < 0n && inputs[0].group != input.group)?.group ?? "none";
            return [TxTokenType.MELT, group, amount.getValue(), extraGroup];
        }

        let group = myGroupInputs.find(input => BigInt(input.groupQuantity) < 0n)?.group ?? "none";
        let extraGroup = myGroupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none";
        return [TxTokenType.MELT, group, "0", extraGroup];
    }

    let tokenInputs = groupInputs.filter(input => BigInt(input.groupQuantity) > 0n);
    let tokenOutputs = groupOutputs.filter(output => BigInt(output.scriptPubKey.groupQuantity) > 0n);

    if (isNullOrEmpty(tokenInputs) && isNullOrEmpty(tokenOutputs)) {
        let group = groupInputs.find(input => BigInt(input.groupQuantity) < 0n)?.group ?? "none";
        let extraGroup = groupOutputs.find(output => BigInt(output.scriptPubKey.groupQuantity) < 0n && group != output.scriptPubKey.group)?.scriptPubKey.group ?? "none";
        return [TxTokenType.RENEW, extraGroup !== 'none' ? extraGroup : group, "0", extraGroup !== 'none' ? group : extraGroup];
    }

    if (isNullOrEmpty(tokenInputs)) {
        let group = tokenOutputs[0].scriptPubKey.group;
        let amount = new bigDecimal(0);
        tokenOutputs.forEach(utxo => {
            amount = amount.add(new bigDecimal(utxo.scriptPubKey.groupQuantity));
        });
        let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none";
        return [TxTokenType.MINT, group, amount.getValue(), extraGroup];
    }

    if (isNullOrEmpty(tokenOutputs)) {
        let group = tokenInputs[0].group;
        let amount = new bigDecimal(0);
        tokenInputs.forEach(utxo => {
            amount = amount.add(new bigDecimal(utxo.groupQuantity));
        });
        let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none";
        return [TxTokenType.MELT, group, amount.getValue(), extraGroup];
    }

    let outQuantitySum = tokenOutputs.map(output => BigInt(output.scriptPubKey.groupQuantity)).reduce((a, b) => a + b, 0n);
    let inQuantitySum = tokenInputs.map(input => BigInt(input.groupQuantity)).reduce((a, b) => a + b, 0n);

    if (outQuantitySum > inQuantitySum) {
        let group = tokenOutputs[0].scriptPubKey.group;
        let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none";
        return [TxTokenType.MINT, group, (outQuantitySum - inQuantitySum).toString(), extraGroup];
    }

    if (inQuantitySum > outQuantitySum) {
        let group = tokenInputs[0].group;
        let extraGroup = groupInputs.find(input => BigInt(input.groupQuantity) < 0n && group != input.group)?.group ?? "none";
        return [TxTokenType.MELT, group, (inQuantitySum - outQuantitySum).toString(), extraGroup];
    }

    let group = tokenOutputs[0].scriptPubKey.group;
    let amount = "";
    if (txState === 'incoming') {
        amount = tokenOutputs
            .filter(output => myAddresses.includes(output.scriptPubKey.addresses[0]))
            .map(output => BigInt(output.scriptPubKey.groupQuantity))
            .reduce((a, b) => a + b, 0n)
            .toString();
    } else if (txState === 'outgoing') {
        amount = tokenOutputs
            .filter(output => !myAddresses.includes(output.scriptPubKey.addresses[0]))
            .map(output => BigInt(output.scriptPubKey.groupQuantity))
            .reduce((a, b) => a + b, 0n)
            .toString();
    } else {
        amount = "0";
    }

    return [TxTokenType.TRANSFER, group, amount, "none"];
}