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

import {PublicKey} from './publickey';
import {TransactionInstruction} from './transaction';
import assert from './util/assert';
import {toBuffer} from './util/to-buffer';

const {publicKeyCreate, ecdsaSign} = secp256k1;

const PRIVATE_KEY_BYTES = 32;
const ETHEREUM_ADDRESS_BYTES = 20;
const PUBLIC_KEY_BYTES = 64;
const SIGNATURE_OFFSETS_SERIALIZED_SIZE = 11;

/**
 * Params for creating an secp256k1 instruction using a public key
 */
export type CreateSecp256k1InstructionWithPublicKeyParams = {
  publicKey: Buffer | Uint8Array | Array<number>;
  message: Buffer | Uint8Array | Array<number>;
  signature: Buffer | Uint8Array | Array<number>;
  recoveryId: number;
  instructionIndex?: number;
};

/**
 * Params for creating an secp256k1 instruction using an Ethereum address
 */
export type CreateSecp256k1InstructionWithEthAddressParams = {
  ethAddress: Buffer | Uint8Array | Array<number> | string;
  message: Buffer | Uint8Array | Array<number>;
  signature: Buffer | Uint8Array | Array<number>;
  recoveryId: number;
  instructionIndex?: number;
};

/**
 * Params for creating an secp256k1 instruction using a private key
 */
export type CreateSecp256k1InstructionWithPrivateKeyParams = {
  privateKey: Buffer | Uint8Array | Array<number>;
  message: Buffer | Uint8Array | Array<number>;
  instructionIndex?: number;
};

const SECP256K1_INSTRUCTION_LAYOUT = BufferLayout.struct<
  Readonly<{
    ethAddress: Uint8Array;
    ethAddressInstructionIndex: number;
    ethAddressOffset: number;
    messageDataOffset: number;
    messageDataSize: number;
    messageInstructionIndex: number;
    numSignatures: number;
    recoveryId: number;
    signature: Uint8Array;
    signatureInstructionIndex: number;
    signatureOffset: number;
  }>
>([
  BufferLayout.u8('numSignatures'),
  BufferLayout.u16('signatureOffset'),
  BufferLayout.u8('signatureInstructionIndex'),
  BufferLayout.u16('ethAddressOffset'),
  BufferLayout.u8('ethAddressInstructionIndex'),
  BufferLayout.u16('messageDataOffset'),
  BufferLayout.u16('messageDataSize'),
  BufferLayout.u8('messageInstructionIndex'),
  BufferLayout.blob(20, 'ethAddress'),
  BufferLayout.blob(64, 'signature'),
  BufferLayout.u8('recoveryId'),
]);

export class Secp256k1Program {
  /**
   * @internal
   */
  constructor() {}

  /**
   * Public key that identifies the secp256k1 program
   */
  static programId: PublicKey = new PublicKey(
    'KeccakSecp256k11111111111111111111111111111',
  );

  /**
   * Construct an Ethereum address from a secp256k1 public key buffer.
   * @param {Buffer} publicKey a 64 byte secp256k1 public key buffer
   */
  static publicKeyToEthAddress(
    publicKey: Buffer | Uint8Array | Array<number>,
  ): Buffer {
    assert(
      publicKey.length === PUBLIC_KEY_BYTES,
      `Public key must be ${PUBLIC_KEY_BYTES} bytes but received ${publicKey.length} bytes`,
    );

    try {
      return Buffer.from(
        sha3.keccak_256.update(toBuffer(publicKey)).digest(),
      ).slice(-ETHEREUM_ADDRESS_BYTES);
    } catch (error) {
      throw new Error(`Error constructing Ethereum address: ${error}`);
    }
  }

  /**
   * Create an secp256k1 instruction with a public key. The public key
   * must be a buffer that is 64 bytes long.
   */
  static createInstructionWithPublicKey(
    params: CreateSecp256k1InstructionWithPublicKeyParams,
  ): TransactionInstruction {
    const {publicKey, message, signature, recoveryId, instructionIndex} =
      params;
    return Secp256k1Program.createInstructionWithEthAddress({
      ethAddress: Secp256k1Program.publicKeyToEthAddress(publicKey),
      message,
      signature,
      recoveryId,
      instructionIndex,
    });
  }

  /**
   * Create an secp256k1 instruction with an Ethereum address. The address
   * must be a hex string or a buffer that is 20 bytes long.
   */
  static createInstructionWithEthAddress(
    params: CreateSecp256k1InstructionWithEthAddressParams,
  ): TransactionInstruction {
    const {
      ethAddress: rawAddress,
      message,
      signature,
      recoveryId,
      instructionIndex = 0,
    } = params;

    let ethAddress;
    if (typeof rawAddress === 'string') {
      if (rawAddress.startsWith('0x')) {
        ethAddress = Buffer.from(rawAddress.substr(2), 'hex');
      } else {
        ethAddress = Buffer.from(rawAddress, 'hex');
      }
    } else {
      ethAddress = rawAddress;
    }

    assert(
      ethAddress.length === ETHEREUM_ADDRESS_BYTES,
      `Address must be ${ETHEREUM_ADDRESS_BYTES} bytes but received ${ethAddress.length} bytes`,
    );

    const dataStart = 1 + SIGNATURE_OFFSETS_SERIALIZED_SIZE;
    const ethAddressOffset = dataStart;
    const signatureOffset = dataStart + ethAddress.length;
    const messageDataOffset = signatureOffset + signature.length + 1;
    const numSignatures = 1;

    const instructionData = Buffer.alloc(
      SECP256K1_INSTRUCTION_LAYOUT.span + message.length,
    );

    SECP256K1_INSTRUCTION_LAYOUT.encode(
      {
        numSignatures,
        signatureOffset,
        signatureInstructionIndex: instructionIndex,
        ethAddressOffset,
        ethAddressInstructionIndex: instructionIndex,
        messageDataOffset,
        messageDataSize: message.length,
        messageInstructionIndex: instructionIndex,
        signature: toBuffer(signature),
        ethAddress: toBuffer(ethAddress),
        recoveryId,
      },
      instructionData,
    );

    instructionData.fill(toBuffer(message), SECP256K1_INSTRUCTION_LAYOUT.span);

    return new TransactionInstruction({
      keys: [],
      programId: Secp256k1Program.programId,
      data: instructionData,
    });
  }

  /**
   * Create an secp256k1 instruction with a private key. The private key
   * must be a buffer that is 32 bytes long.
   */
  static createInstructionWithPrivateKey(
    params: CreateSecp256k1InstructionWithPrivateKeyParams,
  ): TransactionInstruction {
    const {privateKey: pkey, message, instructionIndex} = params;

    assert(
      pkey.length === PRIVATE_KEY_BYTES,
      `Private key must be ${PRIVATE_KEY_BYTES} bytes but received ${pkey.length} bytes`,
    );

    try {
      const privateKey = toBuffer(pkey);
      const publicKey = publicKeyCreate(privateKey, false).slice(1); // throw away leading byte
      const messageHash = Buffer.from(
        sha3.keccak_256.update(toBuffer(message)).digest(),
      );
      const {signature, recid: recoveryId} = ecdsaSign(messageHash, privateKey);

      return this.createInstructionWithPublicKey({
        publicKey,
        message,
        signature,
        recoveryId,
        instructionIndex,
      });
    } catch (error) {
      throw new Error(`Error creating instruction; ${error}`);
    }
  }
}
