// @flow

import * as BufferLayout from 'buffer-layout';

import {encodeData, decodeData} from './instruction';
import * as Layout from './layout';
import {NONCE_ACCOUNT_LENGTH} from './nonce-account';
import {PublicKey} from './publickey';
import {SYSVAR_RECENT_BLOCKHASHES_PUBKEY, SYSVAR_RENT_PUBKEY} from './sysvar';
import {Transaction, TransactionInstruction} from './transaction';

/**
 * Create account system transaction params
 * @typedef {Object} CreateAccountParams
 * @property {PublicKey} fromPubkey
 * @property {PublicKey} newAccountPubkey
 * @property {number} lamports
 * @property {number} space
 * @property {PublicKey} programId
 */
export type CreateAccountParams = {|
  fromPubkey: PublicKey,
  newAccountPubkey: PublicKey,
  lamports: number,
  space: number,
  programId: PublicKey,
|};

/**
 * Transfer system transaction params
 * @typedef {Object} TransferParams
 * @property {PublicKey} fromPubkey
 * @property {PublicKey} toPubkey
 * @property {number} lamports
 */
export type TransferParams = {|
  fromPubkey: PublicKey,
  toPubkey: PublicKey,
  lamports: number,
|};

/**
 * Assign system transaction params
 * @typedef {Object} AssignParams
 * @property {PublicKey} accountPubkey
 * @property {PublicKey} programId
 */
export type AssignParams = {|
  accountPubkey: PublicKey,
  programId: PublicKey,
|};

/**
 * Create account with seed system transaction params
 * @typedef {Object} CreateAccountWithSeedParams
 * @property {PublicKey} fromPubkey
 * @property {PublicKey} newAccountPubkey
 * @property {PublicKey} basePubkey
 * @property {string} seed
 * @property {number} lamports
 * @property {number} space
 * @property {PublicKey} programId
 */
export type CreateAccountWithSeedParams = {|
  fromPubkey: PublicKey,
  newAccountPubkey: PublicKey,
  basePubkey: PublicKey,
  seed: string,
  lamports: number,
  space: number,
  programId: PublicKey,
|};

/**
 * Create nonce account system transaction params
 * @typedef {Object} CreateNonceAccountParams
 * @property {PublicKey} fromPubkey
 * @property {PublicKey} noncePubkey
 * @property {PublicKey} authorizedPubkey
 * @property {number} lamports
 */
export type CreateNonceAccountParams = {|
  fromPubkey: PublicKey,
  noncePubkey: PublicKey,
  authorizedPubkey: PublicKey,
  lamports: number,
|};

/**
 * Create nonce account with seed system transaction params
 * @typedef {Object} CreateNonceAccountWithSeedParams
 * @property {PublicKey} fromPubkey
 * @property {PublicKey} noncePubkey
 * @property {PublicKey} authorizedPubkey
 * @property {PublicKey} basePubkey
 * @property {string} seed
 * @property {number} lamports
 */
export type CreateNonceAccountWithSeedParams = {|
  fromPubkey: PublicKey,
  noncePubkey: PublicKey,
  authorizedPubkey: PublicKey,
  lamports: number,
  basePubkey: PublicKey,
  seed: string,
|};

/**
 * Initialize nonce account system instruction params
 * @typedef {Object} InitializeNonceParams
 * @property {PublicKey} fromPubkey
 * @property {PublicKey} programId
 */
export type InitializeNonceParams = {|
  noncePubkey: PublicKey,
  authorizedPubkey: PublicKey,
|};

/**
 * Advance nonce account system instruction params
 * @typedef {Object} AdvanceNonceParams
 * @property {PublicKey} fromPubkey
 * @property {PublicKey} programId
 */
export type AdvanceNonceParams = {|
  noncePubkey: PublicKey,
  authorizedPubkey: PublicKey,
|};

/**
 * Withdraw nonce account system transaction params
 * @typedef {Object} WithdrawNonceParams
 * @property {PublicKey} noncePubkey
 * @property {PublicKey} authorizedPubkey
 * @property {PublicKey} toPubkey
 * @property {number} lamports
 */
export type WithdrawNonceParams = {|
  noncePubkey: PublicKey,
  authorizedPubkey: PublicKey,
  toPubkey: PublicKey,
  lamports: number,
|};

/**
 * Authorize nonce account system transaction params
 * @typedef {Object} AuthorizeNonceParams
 * @property {PublicKey} noncePubkey
 * @property {PublicKey} authorizedPubkey
 * @property {PublicKey} newAuthorizedPubkey
 */
export type AuthorizeNonceParams = {|
  noncePubkey: PublicKey,
  authorizedPubkey: PublicKey,
  newAuthorizedPubkey: PublicKey,
|};

/**
 * Allocate account system transaction params
 * @typedef {Object} AllocateParams
 * @property {PublicKey} accountPubkey
 * @property {number} space
 */
export type AllocateParams = {|
  accountPubkey: PublicKey,
  space: number,
|};

/**
 * Allocate account with seed system transaction params
 * @typedef {Object} AllocateWithSeedParams
 * @property {PublicKey} accountPubkey
 * @property {PublicKey} basePubkey
 * @property {string} seed
 * @property {number} space
 * @property {PublicKey} programId
 */
export type AllocateWithSeedParams = {|
  accountPubkey: PublicKey,
  basePubkey: PublicKey,
  seed: string,
  space: number,
  programId: PublicKey,
|};

/**
 * Assign account with seed system transaction params
 * @typedef {Object} AssignWithSeedParams
 * @property {PublicKey} accountPubkey
 * @property {PublicKey} basePubkey
 * @property {string} seed
 * @property {PublicKey} programId
 */
export type AssignWithSeedParams = {|
  accountPubkey: PublicKey,
  basePubkey: PublicKey,
  seed: string,
  programId: PublicKey,
|};

/**
 * System Instruction class
 */
export class SystemInstruction {
  /**
   * Decode a system instruction and retrieve the instruction type.
   */
  static decodeInstructionType(
    instruction: TransactionInstruction,
  ): SystemInstructionType {
    this.checkProgramId(instruction.programId);

    const instructionTypeLayout = BufferLayout.u32('instruction');
    const typeIndex = instructionTypeLayout.decode(instruction.data);

    let type;
    for (const t of Object.keys(SYSTEM_INSTRUCTION_LAYOUTS)) {
      if (SYSTEM_INSTRUCTION_LAYOUTS[t].index == typeIndex) {
        type = t;
      }
    }

    if (!type) {
      throw new Error('Instruction type incorrect; not a SystemInstruction');
    }

    return type;
  }

  /**
   * Decode a create account system instruction and retrieve the instruction params.
   */
  static decodeCreateAccount(
    instruction: TransactionInstruction,
  ): CreateAccountParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 2);

    const {lamports, space, programId} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.Create,
      instruction.data,
    );

    return {
      fromPubkey: instruction.keys[0].pubkey,
      newAccountPubkey: instruction.keys[1].pubkey,
      lamports,
      space,
      programId: new PublicKey(programId),
    };
  }

  /**
   * Decode a transfer system instruction and retrieve the instruction params.
   */
  static decodeTransfer(instruction: TransactionInstruction): TransferParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 2);

    const {lamports} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.Transfer,
      instruction.data,
    );

    return {
      fromPubkey: instruction.keys[0].pubkey,
      toPubkey: instruction.keys[1].pubkey,
      lamports,
    };
  }

  /**
   * Decode an allocate system instruction and retrieve the instruction params.
   */
  static decodeAllocate(instruction: TransactionInstruction): AllocateParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 1);

    const {space} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.Allocate,
      instruction.data,
    );

    return {
      accountPubkey: instruction.keys[0].pubkey,
      space,
    };
  }

  /**
   * Decode an allocate with seed system instruction and retrieve the instruction params.
   */
  static decodeAllocateWithSeed(
    instruction: TransactionInstruction,
  ): AllocateWithSeedParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 1);

    const {base, seed, space, programId} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.AllocateWithSeed,
      instruction.data,
    );

    return {
      accountPubkey: instruction.keys[0].pubkey,
      basePubkey: new PublicKey(base),
      seed,
      space,
      programId: new PublicKey(programId),
    };
  }

  /**
   * Decode an assign system instruction and retrieve the instruction params.
   */
  static decodeAssign(instruction: TransactionInstruction): AssignParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 1);

    const {programId} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.Assign,
      instruction.data,
    );

    return {
      accountPubkey: instruction.keys[0].pubkey,
      programId: new PublicKey(programId),
    };
  }

  /**
   * Decode an assign with seed system instruction and retrieve the instruction params.
   */
  static decodeAssignWithSeed(
    instruction: TransactionInstruction,
  ): AssignWithSeedParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 1);

    const {base, seed, programId} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.AssignWithSeed,
      instruction.data,
    );

    return {
      accountPubkey: instruction.keys[0].pubkey,
      basePubkey: new PublicKey(base),
      seed,
      programId: new PublicKey(programId),
    };
  }

  /**
   * Decode a create account with seed system instruction and retrieve the instruction params.
   */
  static decodeCreateWithSeed(
    instruction: TransactionInstruction,
  ): CreateAccountWithSeedParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 2);

    const {base, seed, lamports, space, programId} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.CreateWithSeed,
      instruction.data,
    );

    return {
      fromPubkey: instruction.keys[0].pubkey,
      newAccountPubkey: instruction.keys[1].pubkey,
      basePubkey: new PublicKey(base),
      seed,
      lamports,
      space,
      programId: new PublicKey(programId),
    };
  }

  /**
   * Decode a nonce initialize system instruction and retrieve the instruction params.
   */
  static decodeNonceInitialize(
    instruction: TransactionInstruction,
  ): InitializeNonceParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 3);

    const {authorized} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.InitializeNonceAccount,
      instruction.data,
    );

    return {
      noncePubkey: instruction.keys[0].pubkey,
      authorizedPubkey: new PublicKey(authorized),
    };
  }

  /**
   * Decode a nonce advance system instruction and retrieve the instruction params.
   */
  static decodeNonceAdvance(
    instruction: TransactionInstruction,
  ): AdvanceNonceParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 3);

    decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.AdvanceNonceAccount,
      instruction.data,
    );

    return {
      noncePubkey: instruction.keys[0].pubkey,
      authorizedPubkey: instruction.keys[2].pubkey,
    };
  }

  /**
   * Decode a nonce withdraw system instruction and retrieve the instruction params.
   */
  static decodeNonceWithdraw(
    instruction: TransactionInstruction,
  ): WithdrawNonceParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 5);

    const {lamports} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.WithdrawNonceAccount,
      instruction.data,
    );

    return {
      noncePubkey: instruction.keys[0].pubkey,
      toPubkey: instruction.keys[1].pubkey,
      authorizedPubkey: instruction.keys[4].pubkey,
      lamports,
    };
  }

  /**
   * Decode a nonce authorize system instruction and retrieve the instruction params.
   */
  static decodeNonceAuthorize(
    instruction: TransactionInstruction,
  ): AuthorizeNonceParams {
    this.checkProgramId(instruction.programId);
    this.checkKeyLength(instruction.keys, 2);

    const {authorized} = decodeData(
      SYSTEM_INSTRUCTION_LAYOUTS.AuthorizeNonceAccount,
      instruction.data,
    );

    return {
      noncePubkey: instruction.keys[0].pubkey,
      authorizedPubkey: instruction.keys[1].pubkey,
      newAuthorizedPubkey: new PublicKey(authorized),
    };
  }

  /**
   * @private
   */
  static checkProgramId(programId: PublicKey) {
    if (!programId.equals(SystemProgram.programId)) {
      throw new Error('invalid instruction; programId is not SystemProgram');
    }
  }

  /**
   * @private
   */
  static checkKeyLength(keys: Array<any>, expectedLength: number) {
    if (keys.length < expectedLength) {
      throw new Error(
        `invalid instruction; found ${keys.length} keys, expected at least ${expectedLength}`,
      );
    }
  }
}

/**
 * An enumeration of valid SystemInstructionType's
 * @typedef {'Create' | 'Assign' | 'Transfer' | 'CreateWithSeed'
 | 'AdvanceNonceAccount' | 'WithdrawNonceAccount' | 'InitializeNonceAccount'
 | 'AuthorizeNonceAccount'} SystemInstructionType
 */
export type SystemInstructionType = $Keys<typeof SYSTEM_INSTRUCTION_LAYOUTS>;

/**
 * An enumeration of valid system InstructionType's
 */
export const SYSTEM_INSTRUCTION_LAYOUTS = Object.freeze({
  Create: {
    index: 0,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      BufferLayout.ns64('lamports'),
      BufferLayout.ns64('space'),
      Layout.publicKey('programId'),
    ]),
  },
  Assign: {
    index: 1,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      Layout.publicKey('programId'),
    ]),
  },
  Transfer: {
    index: 2,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      BufferLayout.ns64('lamports'),
    ]),
  },
  CreateWithSeed: {
    index: 3,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      Layout.publicKey('base'),
      Layout.rustString('seed'),
      BufferLayout.ns64('lamports'),
      BufferLayout.ns64('space'),
      Layout.publicKey('programId'),
    ]),
  },
  AdvanceNonceAccount: {
    index: 4,
    layout: BufferLayout.struct([BufferLayout.u32('instruction')]),
  },
  WithdrawNonceAccount: {
    index: 5,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      BufferLayout.ns64('lamports'),
    ]),
  },
  InitializeNonceAccount: {
    index: 6,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      Layout.publicKey('authorized'),
    ]),
  },
  AuthorizeNonceAccount: {
    index: 7,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      Layout.publicKey('authorized'),
    ]),
  },
  Allocate: {
    index: 8,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      BufferLayout.ns64('space'),
    ]),
  },
  AllocateWithSeed: {
    index: 9,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      Layout.publicKey('base'),
      Layout.rustString('seed'),
      BufferLayout.ns64('space'),
      Layout.publicKey('programId'),
    ]),
  },
  AssignWithSeed: {
    index: 10,
    layout: BufferLayout.struct([
      BufferLayout.u32('instruction'),
      Layout.publicKey('base'),
      Layout.rustString('seed'),
      Layout.publicKey('programId'),
    ]),
  },
});

/**
 * Factory class for transactions to interact with the System program
 */
export class SystemProgram {
  /**
   * Public key that identifies the System program
   */
  static get programId(): PublicKey {
    return new PublicKey('11111111111111111111111111111111');
  }

  /**
   * Generate a transaction instruction that creates a new account
   */
  static createAccount(params: CreateAccountParams): TransactionInstruction {
    const type = SYSTEM_INSTRUCTION_LAYOUTS.Create;
    const data = encodeData(type, {
      lamports: params.lamports,
      space: params.space,
      programId: params.programId.toBuffer(),
    });

    return new TransactionInstruction({
      keys: [
        {pubkey: params.fromPubkey, isSigner: true, isWritable: true},
        {pubkey: params.newAccountPubkey, isSigner: true, isWritable: true},
      ],
      programId: this.programId,
      data,
    });
  }

  /**
   * Generate a transaction instruction that transfers lamports from one account to another
   */
  static transfer(params: TransferParams): TransactionInstruction {
    const type = SYSTEM_INSTRUCTION_LAYOUTS.Transfer;
    const data = encodeData(type, {lamports: params.lamports});

    return new TransactionInstruction({
      keys: [
        {pubkey: params.fromPubkey, isSigner: true, isWritable: true},
        {pubkey: params.toPubkey, isSigner: false, isWritable: true},
      ],
      programId: this.programId,
      data,
    });
  }

  /**
   * Generate a transaction instruction that assigns an account to a program
   */
  static assign(
    params: AssignParams | AssignWithSeedParams,
  ): TransactionInstruction {
    let data;
    if (params.basePubkey) {
      const type = SYSTEM_INSTRUCTION_LAYOUTS.AssignWithSeed;
      data = encodeData(type, {
        base: params.basePubkey.toBuffer(),
        seed: params.seed,
        programId: params.programId.toBuffer(),
      });
    } else {
      const type = SYSTEM_INSTRUCTION_LAYOUTS.Assign;
      data = encodeData(type, {programId: params.programId.toBuffer()});
    }

    return new TransactionInstruction({
      keys: [{pubkey: params.accountPubkey, isSigner: true, isWritable: true}],
      programId: this.programId,
      data,
    });
  }

  /**
   * Generate a transaction instruction that creates a new account at
   *   an address generated with `from`, a seed, and programId
   */
  static createAccountWithSeed(
    params: CreateAccountWithSeedParams,
  ): TransactionInstruction {
    const type = SYSTEM_INSTRUCTION_LAYOUTS.CreateWithSeed;
    const data = encodeData(type, {
      base: params.basePubkey.toBuffer(),
      seed: params.seed,
      lamports: params.lamports,
      space: params.space,
      programId: params.programId.toBuffer(),
    });

    return new TransactionInstruction({
      keys: [
        {pubkey: params.fromPubkey, isSigner: true, isWritable: true},
        {pubkey: params.newAccountPubkey, isSigner: false, isWritable: true},
      ],
      programId: this.programId,
      data,
    });
  }

  /**
   * Generate a transaction that creates a new Nonce account
   */
  static createNonceAccount(
    params: CreateNonceAccountParams | CreateNonceAccountWithSeedParams,
  ): Transaction {
    const transaction = new Transaction();
    if (params.basePubkey && params.seed) {
      transaction.add(
        SystemProgram.createAccountWithSeed({
          fromPubkey: params.fromPubkey,
          newAccountPubkey: params.noncePubkey,
          basePubkey: params.basePubkey,
          seed: params.seed,
          lamports: params.lamports,
          space: NONCE_ACCOUNT_LENGTH,
          programId: this.programId,
        }),
      );
    } else {
      transaction.add(
        SystemProgram.createAccount({
          fromPubkey: params.fromPubkey,
          newAccountPubkey: params.noncePubkey,
          lamports: params.lamports,
          space: NONCE_ACCOUNT_LENGTH,
          programId: this.programId,
        }),
      );
    }

    const initParams = {
      noncePubkey: params.noncePubkey,
      authorizedPubkey: params.authorizedPubkey,
    };

    transaction.add(this.nonceInitialize(initParams));
    return transaction;
  }

  /**
   * Generate an instruction to initialize a Nonce account
   */
  static nonceInitialize(
    params: InitializeNonceParams,
  ): TransactionInstruction {
    const type = SYSTEM_INSTRUCTION_LAYOUTS.InitializeNonceAccount;
    const data = encodeData(type, {
      authorized: params.authorizedPubkey.toBuffer(),
    });
    const instructionData = {
      keys: [
        {pubkey: params.noncePubkey, isSigner: false, isWritable: true},
        {
          pubkey: SYSVAR_RECENT_BLOCKHASHES_PUBKEY,
          isSigner: false,
          isWritable: false,
        },
        {pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false},
      ],
      programId: this.programId,
      data,
    };
    return new TransactionInstruction(instructionData);
  }

  /**
   * Generate an instruction to advance the nonce in a Nonce account
   */
  static nonceAdvance(params: AdvanceNonceParams): TransactionInstruction {
    const type = SYSTEM_INSTRUCTION_LAYOUTS.AdvanceNonceAccount;
    const data = encodeData(type);
    const instructionData = {
      keys: [
        {pubkey: params.noncePubkey, isSigner: false, isWritable: true},
        {
          pubkey: SYSVAR_RECENT_BLOCKHASHES_PUBKEY,
          isSigner: false,
          isWritable: false,
        },
        {pubkey: params.authorizedPubkey, isSigner: true, isWritable: false},
      ],
      programId: this.programId,
      data,
    };
    return new TransactionInstruction(instructionData);
  }

  /**
   * Generate a transaction instruction that withdraws lamports from a Nonce account
   */
  static nonceWithdraw(params: WithdrawNonceParams): TransactionInstruction {
    const type = SYSTEM_INSTRUCTION_LAYOUTS.WithdrawNonceAccount;
    const data = encodeData(type, {lamports: params.lamports});

    return new TransactionInstruction({
      keys: [
        {pubkey: params.noncePubkey, isSigner: false, isWritable: true},
        {pubkey: params.toPubkey, isSigner: false, isWritable: true},
        {
          pubkey: SYSVAR_RECENT_BLOCKHASHES_PUBKEY,
          isSigner: false,
          isWritable: false,
        },
        {
          pubkey: SYSVAR_RENT_PUBKEY,
          isSigner: false,
          isWritable: false,
        },
        {pubkey: params.authorizedPubkey, isSigner: true, isWritable: false},
      ],
      programId: this.programId,
      data,
    });
  }

  /**
   * Generate a transaction instruction that authorizes a new PublicKey as the authority
   * on a Nonce account.
   */
  static nonceAuthorize(params: AuthorizeNonceParams): TransactionInstruction {
    const type = SYSTEM_INSTRUCTION_LAYOUTS.AuthorizeNonceAccount;
    const data = encodeData(type, {
      authorized: params.newAuthorizedPubkey.toBuffer(),
    });

    return new TransactionInstruction({
      keys: [
        {pubkey: params.noncePubkey, isSigner: false, isWritable: true},
        {pubkey: params.authorizedPubkey, isSigner: true, isWritable: false},
      ],
      programId: this.programId,
      data,
    });
  }

  /**
   * Generate a transaction instruction that allocates space in an account without funding
   */
  static allocate(
    params: AllocateParams | AllocateWithSeedParams,
  ): TransactionInstruction {
    let data;
    if (params.basePubkey) {
      const type = SYSTEM_INSTRUCTION_LAYOUTS.AllocateWithSeed;
      data = encodeData(type, {
        base: params.basePubkey.toBuffer(),
        seed: params.seed,
        space: params.space,
        programId: params.programId.toBuffer(),
      });
    } else {
      const type = SYSTEM_INSTRUCTION_LAYOUTS.Allocate;
      data = encodeData(type, {
        space: params.space,
      });
    }

    return new TransactionInstruction({
      keys: [{pubkey: params.accountPubkey, isSigner: true, isWritable: true}],
      programId: this.programId,
      data,
    });
  }
}
