import { Address } from '@solana/addresses';
import { Instruction, isSignerRole } from '@solana/instructions';
import { TransactionMessageWithFeePayer } from '@solana/transaction-messages';

import { AccountSignerMeta, InstructionWithSigners, TransactionMessageWithSigners } from './account-signer-meta';
import { deduplicateSigners } from './deduplicate-signers';
import { isTransactionSigner, TransactionSigner } from './transaction-signer';

/**
 * Attaches the provided {@link TransactionSigner | TransactionSigners} to the
 * account metas of an instruction when applicable.
 *
 * For an account meta to match a provided signer it:
 * - Must have a signer role ({@link AccountRole.READONLY_SIGNER} or {@link AccountRole.WRITABLE_SIGNER}).
 * - Must have the same address as the provided signer.
 * - Must not have an attached signer already.
 *
 * @typeParam TInstruction - The inferred type of the instruction provided.
 *
 * @example
 * ```ts
 * import { AccountRole, Instruction } from '@solana/instructions';
 * import { addSignersToInstruction, TransactionSigner } from '@solana/signers';
 *
 * const instruction: Instruction = {
 *     accounts: [
 *         { address: '1111' as Address, role: AccountRole.READONLY_SIGNER },
 *         { address: '2222' as Address, role: AccountRole.WRITABLE_SIGNER },
 *     ],
 *     // ...
 * };
 *
 * const signerA: TransactionSigner<'1111'>;
 * const signerB: TransactionSigner<'2222'>;
 * const instructionWithSigners = addSignersToInstruction(
 *     [signerA, signerB],
 *     instruction
 * );
 *
 * // instructionWithSigners.accounts[0].signer === signerA
 * // instructionWithSigners.accounts[1].signer === signerB
 * ```
 */
export function addSignersToInstruction<TInstruction extends Instruction>(
    signers: TransactionSigner[],
    instruction: TInstruction | (InstructionWithSigners & TInstruction),
): InstructionWithSigners & TInstruction {
    if (!instruction.accounts || instruction.accounts.length === 0) {
        return instruction as InstructionWithSigners & TInstruction;
    }

    const signerByAddress = new Map(deduplicateSigners(signers).map(signer => [signer.address, signer]));
    return Object.freeze({
        ...instruction,
        accounts: instruction.accounts.map(account => {
            const signer = signerByAddress.get(account.address);
            if (!isSignerRole(account.role) || 'signer' in account || !signer) {
                return account;
            }
            return Object.freeze({ ...account, signer } as AccountSignerMeta);
        }),
    });
}

/**
 * Attaches the provided {@link TransactionSigner | TransactionSigners} to the
 * account metas of all instructions inside a transaction message and/or
 * the transaction message fee payer, when applicable.
 *
 * For an account meta to match a provided signer it:
 * - Must have a signer role ({@link AccountRole.READONLY_SIGNER} or {@link AccountRole.WRITABLE_SIGNER}).
 * - Must have the same address as the provided signer.
 * - Must not have an attached signer already.
 *
 * @typeParam TTransactionMessage - The inferred type of the transaction message provided.
 *
 * @example
 * ```ts
 * import { AccountRole, Instruction } from '@solana/instructions';
 * import { TransactionMessage } from '@solana/transaction-messages';
 * import { addSignersToTransactionMessage, TransactionSigner } from '@solana/signers';
 *
 * const instructionA: Instruction = {
 *     accounts: [{ address: '1111' as Address, role: AccountRole.READONLY_SIGNER }],
 *     // ...
 * };
 * const instructionB: Instruction = {
 *     accounts: [{ address: '2222' as Address, role: AccountRole.WRITABLE_SIGNER }],
 *     // ...
 * };
 * const transactionMessage: TransactionMessage = {
 *     instructions: [instructionA, instructionB],
 *     // ...
 * }
 *
 * const signerA: TransactionSigner<'1111'>;
 * const signerB: TransactionSigner<'2222'>;
 * const transactionMessageWithSigners = addSignersToTransactionMessage(
 *     [signerA, signerB],
 *     transactionMessage
 * );
 *
 * // transactionMessageWithSigners.instructions[0].accounts[0].signer === signerA
 * // transactionMessageWithSigners.instructions[1].accounts[0].signer === signerB
 * ```
 */
export function addSignersToTransactionMessage<
    TTransactionMessage extends Readonly<{ instructions: readonly Instruction[] }>,
>(
    signers: TransactionSigner[],
    transactionMessage: TTransactionMessage,
): TransactionMessageWithSigners & TTransactionMessage {
    const feePayerSigner = hasAddressOnlyFeePayer(transactionMessage)
        ? signers.find(signer => signer.address === transactionMessage.feePayer.address)
        : undefined;

    if (!feePayerSigner && transactionMessage.instructions.length === 0) {
        return transactionMessage as TransactionMessageWithSigners & TTransactionMessage;
    }

    return Object.freeze({
        ...transactionMessage,
        ...(feePayerSigner ? { feePayer: feePayerSigner } : null),
        instructions: transactionMessage.instructions.map(instruction => addSignersToInstruction(signers, instruction)),
    }) as TransactionMessageWithSigners & TTransactionMessage;
}

function hasAddressOnlyFeePayer(
    message: Partial<TransactionMessageWithFeePayer> & Readonly<{ instructions: readonly Instruction[] }>,
): message is Readonly<{ instructions: readonly Instruction[] }> & { feePayer: { address: Address } } {
    return (
        !!message &&
        'feePayer' in message &&
        !!message.feePayer &&
        typeof message.feePayer.address === 'string' &&
        !isTransactionSigner(message.feePayer)
    );
}
