import {Buffer} from 'buffer';
import * as BufferLayout from '@solana/buffer-layout';

import {PublicKey} from './publickey';
import {Transaction, PACKET_DATA_SIZE} from './transaction';
import {MS_PER_SLOT} from './timing';
import {SYSVAR_RENT_PUBKEY} from './sysvar';
import {sendAndConfirmTransaction} from './utils/send-and-confirm-transaction';
import {sleep} from './utils/sleep';
import type {Connection} from './connection';
import type {Signer} from './keypair';
import {SystemProgram} from './programs/system';
import {IInstructionInputData} from './instruction';

// Keep program chunks under PACKET_DATA_SIZE, leaving enough room for the
// rest of the Transaction fields
//
// TODO: replace 300 with a proper constant for the size of the other
// Transaction fields
const CHUNK_SIZE = PACKET_DATA_SIZE - 300;

/**
 * Program loader interface
 */
export class Loader {
  /**
   * @internal
   */
  constructor() {}

  /**
   * Amount of program data placed in each load Transaction
   */
  static chunkSize: number = CHUNK_SIZE;

  /**
   * Minimum number of signatures required to load a program not including
   * retries
   *
   * Can be used to calculate transaction fees
   */
  static getMinNumSignatures(dataLength: number): number {
    return (
      2 * // Every transaction requires two signatures (payer + program)
      (Math.ceil(dataLength / Loader.chunkSize) +
        1 + // Add one for Create transaction
        1) // Add one for Finalize transaction
    );
  }

  /**
   * Loads a generic program
   *
   * @param connection The connection to use
   * @param payer System account that pays to load the program
   * @param program Account to load the program into
   * @param programId Public key that identifies the loader
   * @param data Program octets
   * @return true if program was loaded successfully, false if program was already loaded
   */
  static async load(
    connection: Connection,
    payer: Signer,
    program: Signer,
    programId: PublicKey,
    data: Buffer | Uint8Array | Array<number>,
  ): Promise<boolean> {
    {
      const balanceNeeded = await connection.getMinimumBalanceForRentExemption(
        data.length,
      );

      // Fetch program account info to check if it has already been created
      const programInfo = await connection.getAccountInfo(
        program.publicKey,
        'confirmed',
      );

      let transaction: Transaction | null = null;
      if (programInfo !== null) {
        if (programInfo.executable) {
          console.error('Program load failed, account is already executable');
          return false;
        }

        if (programInfo.data.length !== data.length) {
          transaction = transaction || new Transaction();
          transaction.add(
            SystemProgram.allocate({
              accountPubkey: program.publicKey,
              space: data.length,
            }),
          );
        }

        if (!programInfo.owner.equals(programId)) {
          transaction = transaction || new Transaction();
          transaction.add(
            SystemProgram.assign({
              accountPubkey: program.publicKey,
              programId,
            }),
          );
        }

        if (programInfo.lamports < balanceNeeded) {
          transaction = transaction || new Transaction();
          transaction.add(
            SystemProgram.transfer({
              fromPubkey: payer.publicKey,
              toPubkey: program.publicKey,
              lamports: balanceNeeded - programInfo.lamports,
            }),
          );
        }
      } else {
        transaction = new Transaction().add(
          SystemProgram.createAccount({
            fromPubkey: payer.publicKey,
            newAccountPubkey: program.publicKey,
            lamports: balanceNeeded > 0 ? balanceNeeded : 1,
            space: data.length,
            programId,
          }),
        );
      }

      // If the account is already created correctly, skip this step
      // and proceed directly to loading instructions
      if (transaction !== null) {
        await sendAndConfirmTransaction(
          connection,
          transaction,
          [payer, program],
          {
            commitment: 'confirmed',
          },
        );
      }
    }

    const dataLayout = BufferLayout.struct<
      Readonly<{
        bytes: number[];
        bytesLength: number;
        bytesLengthPadding: number;
        instruction: number;
        offset: number;
      }>
    >([
      BufferLayout.u32('instruction'),
      BufferLayout.u32('offset'),
      BufferLayout.u32('bytesLength'),
      BufferLayout.u32('bytesLengthPadding'),
      BufferLayout.seq(
        BufferLayout.u8('byte'),
        BufferLayout.offset(BufferLayout.u32(), -8),
        'bytes',
      ),
    ]);

    const chunkSize = Loader.chunkSize;
    let offset = 0;
    let array = data;
    let transactions = [];
    while (array.length > 0) {
      const bytes = array.slice(0, chunkSize);
      const data = Buffer.alloc(chunkSize + 16);
      dataLayout.encode(
        {
          instruction: 0, // Load instruction
          offset,
          bytes: bytes as number[],
          bytesLength: 0,
          bytesLengthPadding: 0,
        },
        data,
      );

      const transaction = new Transaction().add({
        keys: [{pubkey: program.publicKey, isSigner: true, isWritable: true}],
        programId,
        data,
      });
      transactions.push(
        sendAndConfirmTransaction(connection, transaction, [payer, program], {
          commitment: 'confirmed',
        }),
      );

      // Delay between sends in an attempt to reduce rate limit errors
      if (connection._rpcEndpoint.includes('solana.com')) {
        const REQUESTS_PER_SECOND = 4;
        await sleep(1000 / REQUESTS_PER_SECOND);
      }

      offset += chunkSize;
      array = array.slice(chunkSize);
    }
    await Promise.all(transactions);

    // Finalize the account loaded with program data for execution
    {
      const dataLayout = BufferLayout.struct<IInstructionInputData>([
        BufferLayout.u32('instruction'),
      ]);

      const data = Buffer.alloc(dataLayout.span);
      dataLayout.encode(
        {
          instruction: 1, // Finalize instruction
        },
        data,
      );

      const transaction = new Transaction().add({
        keys: [
          {pubkey: program.publicKey, isSigner: true, isWritable: true},
          {pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false},
        ],
        programId,
        data,
      });
      const deployCommitment = 'processed';
      const finalizeSignature = await connection.sendTransaction(
        transaction,
        [payer, program],
        {preflightCommitment: deployCommitment},
      );
      const {context, value} = await connection.confirmTransaction(
        {
          signature: finalizeSignature,
          lastValidBlockHeight: transaction.lastValidBlockHeight!,
          blockhash: transaction.recentBlockhash!,
        },
        deployCommitment,
      );
      if (value.err) {
        throw new Error(
          `Transaction ${finalizeSignature} failed (${JSON.stringify(value)})`,
        );
      }
      // We prevent programs from being usable until the slot after their deployment.
      // See https://github.com/solana-labs/solana/pull/29654
      while (
        true // eslint-disable-line no-constant-condition
      ) {
        try {
          const currentSlot = await connection.getSlot({
            commitment: deployCommitment,
          });
          if (currentSlot > context.slot) {
            break;
          }
        } catch {
          /* empty */
        }
        await new Promise(resolve =>
          setTimeout(resolve, Math.round(MS_PER_SLOT / 2)),
        );
      }
    }

    // success
    return true;
  }
}
