import {rostrumProvider} from '../network/RostrumProvider';
import {AccountKeys} from '../models/wallet.entities';
import {isNullOrEmpty, MAX_INT64, tokenIdToHex} from './CommonUtils';
import {
    Address,
    AddressType,
    GroupToken,
    Networkish,
    PrivateKey,
    Transaction,
    TransactionBuilder,
    UnitUtils,
    UTXO
} from "libnexa-ts";
import {PermissionLabel, TxOptions} from "../models/transaction.entities";
import {dupAuthority, isAuthFit} from "./TokenUtils";

/** Maximum number of inputs/outputs allowed in a single transaction */
const MAX_INPUTS_OUTPUTS = 250;

/**
 * Populate a transaction with Nexa UTXO inputs and set change output if needed
 *
 * This function automatically selects and adds UTXOs from the provided account keys
 * to satisfy the transaction's Nexa amount requirements. It handles change calculation
 * and can consolidate UTXOs when requested.
 *
 * @param txBuilder - The transaction builder to populate
 * @param keys - Account keys containing receive and change addresses with balances
 * @param totalTxValue - Total amount of Nexa required for the transaction (in satoshis)
 * @param options - Transaction options including consolidation settings and change address
 * @returns Promise resolving to array of private keys needed for signing
 * @throws {Error} If insufficient balance or too many inputs required
 *
 * @example
 * ```typescript
 * const txBuilder = new TransactionBuilder();
 * const keys = await account.getKeys();
 * const privateKeys = await populateNexaInputsAndChange(
 *   txBuilder,
 *   keys,
 *   1000000n, // 1 NEXA
 *   { isConsolidate: false, feeFromAmount: false }
 * );
 * ```
 */
export async function populateNexaInputsAndChange(txBuilder: TransactionBuilder, keys: AccountKeys, totalTxValue: bigint, options: TxOptions): Promise<PrivateKey[]> {
    let rKeys = keys.receiveKeys.filter(k => BigInt(k.balance) > 0n);
    let cKeys = keys.changeKeys.filter(k => BigInt(k.balance) > 0n);
    let allKeys = rKeys.concat(cKeys);
    if (isNullOrEmpty(allKeys)) {
        throw new Error("Not enough Nexa balance.");
    }

    let usedKeys = new Map<string, PrivateKey>();
    let origAmount = options.isConsolidate ? 0 : Number(totalTxValue);

    for (let key of allKeys) {
        let utxos = await rostrumProvider.getNexaUtxos(key.address);
        for (let utxo of utxos) {
            let input: UTXO = {
                outpoint: utxo.outpoint_hash,
                address: key.address,
                satoshis: utxo.value,
                templateData: options.templateData
            }
            txBuilder.from(input);

            if (!usedKeys.has(key.address)) {
                usedKeys.set(key.address, key.key.privateKey);
            }

            if (options.isConsolidate) {
                txBuilder.change(options.toChange ?? keys.receiveKeys[keys.receiveKeys.length - 1].address);
                if (txBuilder.transaction.inputs.length > MAX_INPUTS_OUTPUTS) {
                    return Array.from(usedKeys.values());
                }
            } else {
                let tx = txBuilder.transaction;
                if (tx.inputs.length > MAX_INPUTS_OUTPUTS) {
                    throw new Error("Too many inputs. Consider consolidate transactions or reduce the send amount.");
                }

                let unspent = tx.getUnspentValue();
                if (unspent < 0n) {
                    continue;
                }

                if (unspent == 0n && options.feeFromAmount) {
                    let txFee = tx.estimateRequiredFee();
                    tx.updateOutputAmount(0, origAmount - txFee);
                    return Array.from(usedKeys.values());
                }

                txBuilder.change(options.toChange ?? keys.changeKeys[keys.changeKeys.length - 1].address);
                if (options.feeFromAmount) {
                    let hasChange = tx.getChangeOutput();
                    let txFee = tx.estimateRequiredFee();
                    tx.updateOutputAmount(0, origAmount - txFee);

                    // edge case where change added after update
                    if (!hasChange && tx.getChangeOutput()) {
                        txFee = tx.estimateRequiredFee();
                        tx.updateOutputAmount(0, origAmount - txFee);
                    }
                }

                // check again after change output manipulation
                if (tx.getUnspentValue() < tx.estimateRequiredFee()) {
                    // try to add more utxos to satisfy the minimum fee
                    continue;
                }
                return Array.from(usedKeys.values());
            }
        }
    }

    if (options.isConsolidate) {
        if (usedKeys.size > 0) {
            return Array.from(usedKeys.values());
        }
        throw new Error("Not enough Nexa balance.");
    }

    let err = {
        errorMsg: "Not enough Nexa balance.",
        amount: UnitUtils.formatNEXA(txBuilder.transaction.outputs[0].value),
        fee: UnitUtils.formatNEXA(txBuilder.transaction.estimateRequiredFee())
    }

    throw new Error(JSON.stringify(err));
}

/**
 * Populate a transaction with token UTXO inputs and set token change output if needed
 *
 * This function selects and adds token UTXOs from the provided account keys
 * to satisfy the transaction's token amount requirements. It automatically
 * handles token change calculation.
 *
 * @param txBuilder - The transaction builder to populate
 * @param keys - Account keys containing addresses with token balances
 * @param token - The token ID to spend
 * @param outTokenAmount - Amount of tokens required for the transaction
 * @returns Promise resolving to array of private keys needed for signing
 * @throws {Error} If insufficient token balance or too many inputs required
 *
 * @example
 * ```typescript
 * const txBuilder = new TransactionBuilder();
 * const keys = await account.getKeys();
 * const privateKeys = await populateTokenInputsAndChange(
 *   txBuilder,
 *   keys,
 *   'token_id_hex',
 *   1000n // Amount of tokens
 * );
 * ```
 */
export async function populateTokenInputsAndChange(txBuilder: TransactionBuilder, keys: AccountKeys, token: string, outTokenAmount: bigint): Promise<PrivateKey[]> {
    let tokenHex = tokenIdToHex(token);
    let rKeys = keys.receiveKeys.filter(k => Object.keys(k.tokensBalance).includes(tokenHex));
    let cKeys = keys.changeKeys.filter(k => Object.keys(k.tokensBalance).includes(tokenHex));
    let allKeys = rKeys.concat(cKeys);

    if (isNullOrEmpty(allKeys)) {
        throw new Error("Not enough token balance.");
    }

    let usedKeys = new Map<string, PrivateKey>();
    let inTokenAmount = 0n;

    for (let key of allKeys) {
        let utxos = await rostrumProvider.getTokenUtxos(key.address, token);
        for (let utxo of utxos) {
            if (utxo.token_amount < 0) {
                continue;
            }
            txBuilder.from({
                outpoint: utxo.outpoint_hash,
                address: key.address,
                satoshis: utxo.value,
                groupId: utxo.group,
                groupAmount: BigInt(utxo.token_amount),
            });

            inTokenAmount = inTokenAmount + BigInt(utxo.token_amount);
            if (!usedKeys.has(key.address)) {
                usedKeys.set(key.address, key.key.privateKey);
            }

            if (inTokenAmount > MAX_INT64) {
                throw new Error("Token inputs exceeded max amount. Consider sending in small chunks");
            }
            if (txBuilder.transaction.inputs.length > MAX_INPUTS_OUTPUTS) {
                throw new Error("Too many inputs. Consider consolidating transactions or reduce the send amount.");
            }

            if (inTokenAmount == outTokenAmount) {
                return Array.from(usedKeys.values());
            }
            if (inTokenAmount > outTokenAmount) {
                // change
                txBuilder.to(keys.changeKeys[keys.changeKeys.length - 1].address, Transaction.DUST_AMOUNT, token, inTokenAmount - outTokenAmount);
                return Array.from(usedKeys.values());
            }
        }
    }

    throw new Error("Not enough token balance");
}

/**
 * Build a transaction to create a new token group
 *
 * This function creates a group token by using the first UTXO's outpoint
 * to generate a unique group ID. The group token represents the authority
 * to create fungible tokens within this group.
 *
 * @param txBuilder - The transaction builder to populate
 * @param keys - Account keys to use for funding the transaction
 * @param opReturnData - Optional data to include in the group creation
 * @param network - Network to create the group on
 * @returns Promise resolving to array of private keys needed for signing
 * @throws {Error} If insufficient balance for group creation
 *
 * @example
 * ```typescript
 * const txBuilder = new TransactionBuilder();
 * const keys = await account.getKeys();
 * const privateKeys = await buildCreateGroupTransaction(
 *   txBuilder,
 *   keys,
 *   'my_token_data',
 *   Networks.mainnet
 * );
 * ```
 */
export async function buildCreateGroupTransaction(
    txBuilder: TransactionBuilder,
    keys: AccountKeys,
    opReturnData: string,
    network: Networkish
): Promise<PrivateKey[]> {
    // TODO validate opreturn data
    const allKeys = keys.receiveKeys.concat(keys.changeKeys)
    let outpoint = '';
    let usedKeys: PrivateKey[] = [];
    let signKey: PrivateKey | undefined = undefined;
    for (let key of allKeys) {
        let utxos = await rostrumProvider.getNexaUtxos(key.address);
        for (let utxo of utxos) {
            txBuilder.from({
                outpoint: utxo.outpoint_hash,
                address: key.address,
                satoshis: utxo.value
            });

            if (isNullOrEmpty(outpoint)) {
                outpoint = utxo.outpoint_hash;
                let id = GroupToken.findGroupId(Buffer.from(outpoint, 'hex'), Buffer.from(opReturnData, 'hex'), GroupToken.authFlags.ACTIVE_FLAG_BITS);
                const groupId = new Address(id.hashBuffer, network, AddressType.GroupIdAddress).toString()
                txBuilder.to(keys.receiveKeys.at(-1)!.address, Transaction.DUST_AMOUNT, groupId, GroupToken.authFlags.ACTIVE_FLAG_BITS | id.nonce)
                signKey = key.key.privateKey
                usedKeys.push(signKey);
                return usedKeys
            }
        }
    }

    throw new Error("Not enough Nexa balance.");
}

/**
 * Prepare a transaction to delete/spend a specific UTXO
 *
 * This function adds a specific UTXO as input to the transaction.
 * It's commonly used for spending token authority UTXOs or consolidating specific outputs.
 *
 * @param txBuilder - The transaction builder to populate
 * @param keys - Account keys to find the private key for the UTXO
 * @param outpoint - The outpoint (txid:vout) of the UTXO to spend
 * @returns Promise resolving to array containing the private key for the UTXO
 * @throws {Error} If the UTXO is not found or the associated key is not in the wallet
 *
 * @example
 * ```typescript
 * const txBuilder = new TransactionBuilder();
 * const keys = await account.getKeys();
 * const privateKeys = await prepareDeleteTransaction(
 *   txBuilder,
 *   keys,
 *   'txid:0'
 * );
 * ```
 */
export async function prepareDeleteTransaction(txBuilder: TransactionBuilder, keys: AccountKeys, outpoint: string): Promise<PrivateKey[]> {
    let utxo = await rostrumProvider.getUtxo(outpoint);
    let address = utxo.addresses[0];

    txBuilder.from({
        outpoint: outpoint,
        address: address,
        satoshis: utxo.amount
    });

    let allKeys = keys.receiveKeys.concat(keys.changeKeys);
    let addrKey = allKeys.find(k => k.address === address);

    if (!addrKey) {
        throw new Error('UTXO associated key not found in the wallet');
    }
    return [addrKey.key.privateKey];
}

/**
 * Populate a transaction with token authority inputs for specific permissions
 *
 * This function finds and adds token authority UTXOs that have the required
 * permissions for token operations like minting, melting, or creating subgroups.
 * It automatically handles authority renewal if the authority allows it.
 *
 * @param txBuilder - The transaction builder to populate
 * @param keys - Account keys containing addresses with token authorities
 * @param token - The token ID to find authorities for
 * @param perm - The permission type required ('mint', 'melt', 'subgroup', etc.)
 * @param subgroup - Optional subgroup token ID for subgroup operations
 * @param subgroupAddr - Optional address to receive subgroup authority
 * @returns Promise resolving to array of private keys needed for signing
 * @throws {Error} If the required authority is not found
 *
 * @example
 * ```typescript
 * const txBuilder = new TransactionBuilder();
 * const keys = await account.getKeys();
 * const privateKeys = await populateTokenAuth(
 *   txBuilder,
 *   keys,
 *   'token_id_hex',
 *   'mint'
 * );
 * ```
 */
export async function populateTokenAuth(txBuilder: TransactionBuilder, keys: AccountKeys, token: string, perm: PermissionLabel, subgroup = '', subgroupAddr = ''): Promise<PrivateKey[]> {
    let allKeys = keys.receiveKeys.concat(keys.changeKeys);
    for (let key of allKeys) {
        let utxos = await rostrumProvider.getTokenUtxos(key.address, token);
        for (let utxo of utxos) {
            if (!isAuthFit(utxo.token_amount, perm)) {
                continue;
            }

            txBuilder.from({
                outpoint: utxo.outpoint_hash,
                address: key.address,
                satoshis: utxo.value
            });

            if (perm === 'subgroup') {
                txBuilder.to(subgroupAddr, Transaction.DUST_AMOUNT, subgroup, dupAuthority(utxo.token_amount, false));
            }

            // if renew flag included, we don't want to burn it
            if (GroupToken.allowsRenew(BigInt.asUintN(64, BigInt(utxo.token_amount)))) {
                txBuilder.to(keys.receiveKeys.at(-1)!.address, Transaction.DUST_AMOUNT, token, dupAuthority(utxo.token_amount));
            }

            return [key.key.privateKey];
        }
    }

    throw new Error("The requested authority not found");
}

/**
 * Populate a transaction with multiple token authorities and create duplicates
 *
 * This function finds token authority UTXOs for multiple permissions and
 * creates duplicate outputs for each authority. This is useful for complex
 * token operations that require multiple permissions while preserving the authorities.
 *
 * @param txBuilder - The transaction builder to populate
 * @param keys - Account keys containing addresses with token authorities
 * @param token - The token ID to find authorities for
 * @param perms - Array of permission types required
 * @param toAddr
 * @returns Promise resolving to array of private keys needed for signing
 * @throws {Error} If any required authority is not found
 *
 * @example
 * ```typescript
 * const txBuilder = new TransactionBuilder();
 * const keys = await account.getKeys();
 * const privateKeys = await populateAndDuplicateTokenAuths(
 *   txBuilder,
 *   keys,
 *   'token_id_hex',
 *   ['mint', 'melt']
 * );
 * ```
 */
export async function populateAndDuplicateTokenAuths(txBuilder: TransactionBuilder, keys: AccountKeys, token: string, perms: PermissionLabel[], toAddr?: string): Promise<PrivateKey[]> {
    let allKeys = keys.receiveKeys.concat(keys.changeKeys);
    let usedKeys: PrivateKey[] = [];

    let reqiredPerms = new Set(perms);
    reqiredPerms.add('authorise');

    for (let key of allKeys) {
        let utxos = await rostrumProvider.getTokenUtxos(key.address, token);
        for (let utxo of utxos) {
            if (utxo.token_amount > 0) {
                continue;
            }

            let found = false;
            for (let perm of reqiredPerms) {
                if (isAuthFit(utxo.token_amount, perm)) {
                    reqiredPerms.delete(perm);
                    found = true;
                }
            }

            if (!found) {
                continue;
            }

            txBuilder.from({
                outpoint: utxo.outpoint_hash,
                address: key.address,
                satoshis: utxo.value
            });
            usedKeys.push(key.key.privateKey);

            // duplicate
            txBuilder.to(toAddr != null ? toAddr : keys.receiveKeys.at(-1)!.address, Transaction.DUST_AMOUNT, token, dupAuthority(utxo.token_amount));

            if (reqiredPerms.size === 0) {
                return usedKeys;
            }
        }
    }

    throw new Error("The required authorities not found");
}
