import {
  Connection,
  Keypair,
  PublicKey,
  SystemProgram,
  Transaction,
  TransactionInstruction,
  ConfirmOptions,
} from "@solana/web3.js";
import {
  AccountLayout as TokenAccountLayout,
  AccountLayout,
  ASSOCIATED_TOKEN_PROGRAM_ID,
  MintInfo,
  MintLayout,
  Token,
  TOKEN_PROGRAM_ID,
  u64,
} from "@solana/spl-token";
import { Program, Provider } from "@project-serum/anchor";
import BN from "bn.js";
import Decimal from "decimal.js";
import { blob, struct, u8 } from "buffer-layout";
import { Zo, Wallet } from "../types";
import {
  IDL,
  RENT_PROGRAM_ID,
  WRAPPED_SOL_MINT,
  ZERO_ONE_DEVNET_PROGRAM_ID,
  ZERO_ONE_MAINNET_PROGRAM_ID,
} from "../config";

export * from "../types/dataTypes";

export * from "./rpc";
export * from "./units";

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export enum Cluster {
  Devnet = "Devnet",
  Mainnet = "Mainnet",
}

export function createProvider(
  conn: Connection,
  wallet: Wallet,
  opts: ConfirmOptions = {},
): Provider {
  return new Provider(conn, wallet, opts);
}

export function createProgram(
  provider: Provider,
  cluster: Cluster,
): Program<Zo> {
  if (cluster === Cluster.Devnet) {
    return new Program<Zo>(IDL, ZERO_ONE_DEVNET_PROGRAM_ID, provider);
  } else {
    return new Program<Zo>(IDL, ZERO_ONE_MAINNET_PROGRAM_ID, provider);
  }
}

export function loadWI80F48({ data }: { data: BN }): Decimal {
  return new Decimal(
    `${data.isNeg() ? "-" : ""}0b${data.abs().toString(2)}p-48`,
  );
}

const utf8Decoder = new TextDecoder("utf-8");

export function loadSymbol({ data: s }: { data: number[] }): string {
  // Need to convert symbol, which is a [u8; 24], to a JS String.
  // Can't use String.fromCodePoint since that takes in u16,
  // when we are receiving a UTF-8 byte array.
  let i = s.indexOf(0);
  i = i < 0 ? s.length : i;
  return utf8Decoder.decode(new Uint8Array(s.slice(0, i)));
}

/**
 * Instead of returning -1 if the element is not found,
 * return `array.length`. Much easier to use for slicing.
 */
export function findIndexOf<T>(l: readonly T[], p: (T) => boolean) {
  for (let i = 0; i < l.length; ++i) {
    if (p(l[i])) {
      return i;
    }
  }
  return l.length;
}

export function findLastIndexOf<T>(l: readonly T[], p: (T) => boolean) {
  for (let i = l.length - 1; i >= 0; --i) {
    if (p(l[i])) {
      return i;
    }
  }
  return -1;
}

export async function getMintInfo(
  provider: Provider,
  pubkey: PublicKey,
): Promise<MintInfo> {
  const data = (await provider.connection.getAccountInfo(pubkey))?.data;
  if (!data) throw Error(`Couldn't load mint data for ${pubkey.toBase58()}`);
  const m = MintLayout.decode(data);
  return {
    mintAuthority: new PublicKey(m.mintAuthority),
    supply: u64.fromBuffer(m.supply),
    decimals: m.decimals,
    isInitialized: !!m.isInitialized,
    freezeAuthority: new PublicKey(m.freezeAuthority),
  };
}

export async function createMintIxs(
  mint: Keypair,
  provider: Provider,
  authority: PublicKey,
  decimals: number,
  freezeAuthority?: PublicKey,
): Promise<TransactionInstruction[]> {
  return [
    SystemProgram.createAccount({
      fromPubkey: provider.wallet.publicKey,
      newAccountPubkey: mint.publicKey,
      space: MintLayout.span,
      lamports: await Token.getMinBalanceRentForExemptMint(provider.connection),
      programId: TOKEN_PROGRAM_ID,
    }),
    Token.createInitMintInstruction(
      TOKEN_PROGRAM_ID,
      mint.publicKey,
      decimals,
      authority,
      freezeAuthority ?? null,
    ),
  ];
}

export async function createTokenAccountIxs(
  vault: Keypair,
  provider: Provider,
  mint: PublicKey,
  owner: PublicKey,
): Promise<TransactionInstruction[]> {
  return [
    SystemProgram.createAccount({
      fromPubkey: provider.wallet.publicKey,
      newAccountPubkey: vault.publicKey,
      space: AccountLayout.span,
      lamports: await Token.getMinBalanceRentForExemptAccount(
        provider.connection,
      ),
      programId: TOKEN_PROGRAM_ID,
    }),
    Token.createInitAccountInstruction(
      TOKEN_PROGRAM_ID,
      mint,
      vault.publicKey,
      owner,
    ),
  ];
}

export function createMintToIxs(
  mint: PublicKey,
  dest: PublicKey,
  authority: PublicKey,
  amount: number,
): TransactionInstruction[] {
  return [
    Token.createMintToInstruction(
      TOKEN_PROGRAM_ID,
      mint,
      dest,
      authority,
      [],
      amount,
    ),
  ];
}

export async function createMint(
  provider: Provider,
  authority: PublicKey,
  decimals: number,
  freezeAuthority?: PublicKey,
): Promise<PublicKey> {
  const mint = new Keypair();
  const tx = new Transaction();
  tx.add(
    ...(await createMintIxs(
      mint,
      provider,
      authority,
      decimals,
      freezeAuthority,
    )),
  );
  await provider.send(tx, [mint]);
  return mint.publicKey;
}

export async function createTokenAccount(
  provider: Provider,
  mint: PublicKey,
  owner: PublicKey,
): Promise<PublicKey> {
  const vault = Keypair.generate();
  const tx = new Transaction();
  tx.add(...(await createTokenAccountIxs(vault, provider, mint, owner)));
  await provider.send(tx, [vault]);
  return vault.publicKey;
}

export async function findAssociatedTokenAddress(
  walletAddress: PublicKey,
  tokenMintAddress: PublicKey,
): Promise<PublicKey> {
  return (
    await PublicKey.findProgramAddress(
      [
        walletAddress.toBuffer(),
        TOKEN_PROGRAM_ID.toBuffer(),
        tokenMintAddress.toBuffer(),
      ],
      ASSOCIATED_TOKEN_PROGRAM_ID,
    )
  )[0];
}

export function getAssociatedTokenTransactionWithPayer(
  tokenMintAddress: PublicKey,
  associatedTokenAddress: PublicKey,
  owner: PublicKey,
) {
  const keys = [
    {
      pubkey: owner,
      isSigner: true,
      isWritable: true,
    },
    {
      pubkey: associatedTokenAddress,
      isSigner: false,
      isWritable: true,
    },
    {
      pubkey: owner,
      isSigner: false,
      isWritable: false,
    },
    {
      pubkey: tokenMintAddress,
      isSigner: false,
      isWritable: false,
    },
    {
      pubkey: SystemProgram.programId,
      isSigner: false,
      isWritable: false,
    },
    {
      pubkey: TOKEN_PROGRAM_ID,
      isSigner: false,
      isWritable: false,
    },
    {
      pubkey: RENT_PROGRAM_ID,
      isSigner: false,
      isWritable: false,
    },
  ];

  return new TransactionInstruction({
    keys,
    programId: ASSOCIATED_TOKEN_PROGRAM_ID,
    data: Buffer.from([]),
  });
}

export async function mintTo(
  provider: Provider,
  mint: PublicKey,
  dest: PublicKey,
  amount: number,
): Promise<void> {
  const tx = new Transaction();
  tx.add(...createMintToIxs(mint, dest, provider.wallet.publicKey, amount));
  await provider.send(tx, []);
}

export function throwIfNull<T>(
  value: T | null,
  message = "account not found",
): T {
  if (value === null) {
    throw new Error(message);
  }
  return value;
}

const MINT_LAYOUT = struct([blob(44), u8("decimals"), blob(37)]);

export async function getMintDecimals(
  connection: Connection,
  mint: PublicKey,
): Promise<number> {
  if (mint.equals(WRAPPED_SOL_MINT)) {
    return 9;
  }
  const { data } = throwIfNull(
    await connection.getAccountInfo(mint),
    "mint not found",
  );
  const { decimals } = MINT_LAYOUT.decode(data);
  return decimals;
}

export async function getWrappedSolInstructionsAndKey(
  initialSmollAmount,
  provider,
): Promise<{
  createTokenAccountIx: TransactionInstruction;
  initTokenAccountIx: TransactionInstruction;
  closeTokenAccountIx: TransactionInstruction;
  intermediary: PublicKey;
  intermediaryKeypair: Keypair;
}> {
  // sol wrapping code taken from jet: https://github.com/jet-lab/jet-v1/blob/30c56d5c14b68685466164fc45c96080f1d9348a/app/src/scripts/jet.ts
  const intermediaryKeypair = Keypair.generate();
  const intermediary = intermediaryKeypair.publicKey;

  const rent = await provider.connection.getMinimumBalanceForRentExemption(
    TokenAccountLayout.span,
  );
  const createTokenAccountIx = SystemProgram.createAccount({
    fromPubkey: provider.wallet.publicKey,
    newAccountPubkey: intermediary,
    programId: TOKEN_PROGRAM_ID,
    space: TokenAccountLayout.span,
    lamports: parseInt(initialSmollAmount.addn(rent).toString()),
  });

  const initTokenAccountIx = Token.createInitAccountInstruction(
    TOKEN_PROGRAM_ID,
    WRAPPED_SOL_MINT,
    intermediary,
    provider.wallet.publicKey,
  );

  const closeTokenAccountIx = Token.createCloseAccountInstruction(
    TOKEN_PROGRAM_ID,
    intermediary,
    provider.wallet.publicKey,
    provider.wallet.publicKey,
    [],
  );

  return {
    createTokenAccountIx,
    initTokenAccountIx,
    closeTokenAccountIx,
    intermediary,
    intermediaryKeypair,
  };
}