import type { Provider } from "@saberhq/solana-contrib";
import { TransactionEnvelope } from "@saberhq/solana-contrib";
import {
  createInitMintInstructions,
  createTokenAccount,
  getOrCreateATA,
  SPLToken,
  TOKEN_PROGRAM_ID,
  u64,
} from "@saberhq/token-utils";
import type {
  Signer,
  TransactionInstruction,
  TransactionSignature,
} from "@solana/web3.js";
import { Keypair, PublicKey, SystemProgram } from "@solana/web3.js";

import { SWAP_PROGRAM_ID, ZERO_TS } from "../constants.js";
import type {
  InitializeSwapInstruction,
  SwapTokenInfo,
} from "../instructions/swap.js";
import { initializeSwapInstruction as createInitializeStableSwapInstruction } from "../instructions/swap.js";
import { findSwapAuthorityKey, StableSwap } from "../stable-swap.js";
import { ZERO_FEES } from "../state/fees.js";
import { StableSwapLayout } from "../state/layout.js";
import type { TransactionInstructions } from "./instructions.js";
import {
  createMutableTransactionInstructions,
  mergeInstructions,
} from "./instructions.js";

export type ISeedPoolAccountsFn = (args: {
  tokenAAccount: PublicKey;
  tokenBAccount: PublicKey;
}) => TransactionInstructions;

/**
 * Arguments used to initialize a new swap.
 */
export interface InitializeNewStableSwapArgs
  extends Pick<
    InitializeSwapInstruction,
    "adminAccount" | "ampFactor" | "fees"
  > {
  provider: Provider;
  swapProgramID: PublicKey;

  tokenAMint: PublicKey;
  tokenBMint: PublicKey;

  /**
   * The owner of the account for the initial LP tokens to go to.
   * Defaults to the admin account.
   */
  initialLiquidityProvider?: PublicKey;
  /**
   * If true, create an associated account for the initial LP.
   */
  useAssociatedAccountForInitialLP?: boolean;

  /**
   * The signer for the pool's account. If unspecified, a new one is generated.
   */
  swapAccountSigner?: Signer;
  /**
   * The mint for the pool token. If unspecified, a new one is generated.
   */
  poolTokenMintSigner?: Signer;

  /**
   * Instructions to seed the pool accounts.
   */
  seedPoolAccounts: ISeedPoolAccountsFn;
}

/**
 * Initializes a new StableSwap pool with a payer and stableSwapAccount.
 *
 * If you want to use a non-filesystem wallet as a payer, you'll want to generate
 * this transaction using StableSwap.createInitializeStableSwapTransaction
 * then sign it using the wallet directly.
 */
export const initializeStableSwap = async (
  provider: Provider,
  stableSwapAccount: Signer,
  initializeSwapInstruction: InitializeSwapInstruction,
): Promise<StableSwap> => {
  if (
    !stableSwapAccount.publicKey.equals(
      initializeSwapInstruction.config.swapAccount,
    )
  ) {
    throw new Error("stable swap public key");
  }

  const { instructions } = await createInitializeStableSwapInstructionsRaw({
    provider,
    initializeSwapInstruction,
  });
  const tx = new TransactionEnvelope(provider, instructions.slice());

  console.log("createAccount and InitializeSwap");
  const txSig = (await tx.confirm()).signature;
  console.log(`TxSig: ${txSig}`);

  return loadSwapFromInitializeArgs(initializeSwapInstruction);
};

/**
 * Creates a new instance of StableSwap from create args.
 * @param connection
 * @param initializeArgs
 * @returns
 */
export const loadSwapFromInitializeArgs = (
  initializeArgs: InitializeSwapInstruction,
): StableSwap =>
  new StableSwap(initializeArgs.config, {
    isInitialized: true,
    nonce: initializeArgs.nonce,
    futureAdminDeadline: ZERO_TS,
    futureAdminAccount: PublicKey.default,
    adminAccount: initializeArgs.adminAccount,

    tokenA: initializeArgs.tokenA,
    tokenB: initializeArgs.tokenB,

    poolTokenMint: initializeArgs.poolTokenMint,
    initialAmpFactor: new u64(initializeArgs.ampFactor),
    isPaused: initializeArgs.isPaused ?? false,
    targetAmpFactor: new u64(initializeArgs.ampFactor),
    startRampTimestamp: ZERO_TS,
    stopRampTimestamp: ZERO_TS,
    fees: initializeArgs.fees ?? ZERO_FEES,
  });

/**
 * Creates a set of instructions to create a new StableSwap instance.
 *
 * After calling this, you must sign this transaction with the accounts:
 * - payer -- Account that holds the SOL to seed the account.
 * - args.config.stableSwapAccount -- This account is used once then its key is no longer relevant
 * - all returned signers
 */
export const createInitializeStableSwapInstructions = async ({
  provider,
  swapProgramID = SWAP_PROGRAM_ID,
  adminAccount,
  tokenAMint,
  tokenBMint,
  ampFactor,
  fees,

  initialLiquidityProvider = adminAccount,
  useAssociatedAccountForInitialLP,
  swapAccountSigner = Keypair.generate(),
  poolTokenMintSigner = Keypair.generate(),
  seedPoolAccounts,
}: InitializeNewStableSwapArgs): Promise<{
  initializeArgs: InitializeSwapInstruction;
  /**
   * Lamports needed to be rent exempt.
   */
  balanceNeeded: number;

  instructions: {
    /**
     * Create accounts for the LP token
     */
    createLPTokenMint: TransactionInstructions;
    /**
     * Create LP token account for the initial LP
     */
    createInitialLPTokenAccount: TransactionInstructions;
    /**
     * Create accounts for swap token A
     */
    createSwapTokenAAccounts: TransactionInstructions;
    /**
     * Create accounts for swap token B
     */
    createSwapTokenBAccounts: TransactionInstructions;
    /**
     * Seed the accounts for the pool
     */
    seedPoolAccounts: TransactionInstructions;
    /**
     * Initialize the swap
     */
    initializeSwap: TransactionInstructions;
  };
}> => {
  const instructions = {
    createLPTokenMint: new TransactionEnvelope(provider, []),
    createInitialLPTokenAccount: new TransactionEnvelope(provider, []),
    createSwapTokenAAccounts: new TransactionEnvelope(provider, []),
    createSwapTokenBAccounts: new TransactionEnvelope(provider, []),
    seedPoolAccounts: createMutableTransactionInstructions(),
    initializeSwap: createMutableTransactionInstructions(),
  };

  // Create swap account if not specified
  const swapAccount = swapAccountSigner.publicKey;
  instructions.initializeSwap.signers.push(swapAccountSigner);

  // Create authority and nonce
  const [authority, nonce] = await findSwapAuthorityKey(
    swapAccount,
    swapProgramID,
  );

  // Create LP token mint
  const { decimals } = await new SPLToken(
    provider.connection,
    tokenAMint,
    TOKEN_PROGRAM_ID,
    Keypair.generate(),
  ).getMintInfo();
  const mintBalanceNeeded = await SPLToken.getMinBalanceRentForExemptMint(
    provider.connection,
  );
  instructions.createLPTokenMint = await createInitMintInstructions({
    provider,
    mintKP: poolTokenMintSigner,
    mintAuthority: authority,
    decimals,
  });
  const poolTokenMint = poolTokenMintSigner.publicKey;

  // Create initial LP token account
  let initialLPAccount: PublicKey | undefined = undefined;
  if (useAssociatedAccountForInitialLP) {
    const lpAccount = await getOrCreateATA({
      provider,
      mint: poolTokenMint,
      owner: initialLiquidityProvider,
      payer: provider.wallet.publicKey,
    });
    initialLPAccount = lpAccount.address;

    if (lpAccount.instruction) {
      instructions.createInitialLPTokenAccount = new TransactionEnvelope(
        provider,
        [lpAccount.instruction],
      );
    }
  } else {
    const { key: unassociatedInitialLPAccount, tx: initialLPInstructions } =
      await createTokenAccount({
        provider,
        mint: poolTokenMint,
        owner: initialLiquidityProvider,
        payer: provider.wallet.publicKey,
      });
    initialLPAccount = unassociatedInitialLPAccount;
    instructions.createInitialLPTokenAccount = initialLPInstructions;
  }

  // Create Swap Token A account
  const { info: tokenA, instructions: tokenAInstructions } =
    await initializeSwapTokenInfo({
      provider,
      mint: tokenAMint,
      authority,
      admin: adminAccount,
    });
  mergeInstructions(instructions.createSwapTokenAAccounts, tokenAInstructions);

  // Create Swap Token B account
  const { info: tokenB, instructions: tokenBInstructions } =
    await initializeSwapTokenInfo({
      provider,
      mint: tokenBMint,
      authority,
      admin: adminAccount,
    });
  mergeInstructions(instructions.createSwapTokenBAccounts, tokenBInstructions);

  // Seed the swap's Token A and token B accounts with tokens
  // On testnet, this is usually a mint.
  // On mainnet, this is usually a token transfer.
  const seedPoolAccountsResult = seedPoolAccounts({
    tokenAAccount: tokenA.reserve,
    tokenBAccount: tokenB.reserve,
  });
  mergeInstructions(instructions.seedPoolAccounts, seedPoolAccountsResult);

  const initializeSwapInstruction: InitializeSwapInstruction = {
    config: {
      swapAccount: swapAccount,
      authority,
      swapProgramID,
      tokenProgramID: TOKEN_PROGRAM_ID,
    },

    adminAccount,
    tokenA,
    tokenB,

    poolTokenMint,
    destinationPoolTokenAccount: initialLPAccount,
    nonce,
    ampFactor,
    fees,
  };
  const {
    balanceNeeded: swapBalanceNeeded,
    instructions: initializeStableSwapInstructions,
  } = await createInitializeStableSwapInstructionsRaw({
    provider,
    initializeSwapInstruction,
  });
  mergeInstructions(instructions.initializeSwap, {
    instructions: initializeStableSwapInstructions,
    signers: [],
  });

  return {
    initializeArgs: initializeSwapInstruction,
    balanceNeeded: mintBalanceNeeded + swapBalanceNeeded,
    instructions,
  };
};

const initializeSwapTokenInfo = async ({
  provider,
  mint,
  authority,
  admin,
}: {
  provider: Provider;
  mint: PublicKey;
  authority: PublicKey;
  admin: PublicKey;
}): Promise<{
  info: SwapTokenInfo;
  instructions: TransactionInstructions;
}> => {
  // Create Swap Token Account
  const { key: tokenAccount, tx: createSwapTokenAccountInstructions } =
    await createTokenAccount({
      provider,
      mint,
      owner: authority,
      payer: provider.wallet.publicKey,
    });

  // Create Admin Fee Account
  const { key: adminFeeAccount, tx: createAdminFeeAccountInstructions } =
    await createTokenAccount({
      provider,
      mint,
      owner: admin,
      payer: provider.wallet.publicKey,
    });

  return {
    info: {
      mint,
      reserve: tokenAccount,
      adminFeeAccount: adminFeeAccount,
    },
    instructions: createSwapTokenAccountInstructions.combine(
      createAdminFeeAccountInstructions,
    ),
  };
};

/**
 * Creates an unsigned InitializeSwap transaction.
 *
 * After calling this, you must sign this transaction with the accounts:
 * - payer -- Account that holds the SOL to seed the account.
 * - args.config.stableSwapAccount -- This account is used once then its key is no longer relevant
 */
export const createInitializeStableSwapInstructionsRaw = async ({
  provider,
  initializeSwapInstruction,
}: {
  provider: Provider;
  initializeSwapInstruction: InitializeSwapInstruction;
}): Promise<{
  balanceNeeded: number;
  instructions: readonly TransactionInstruction[];
}> => {
  // Allocate memory for the account
  const balanceNeeded = await StableSwap.getMinBalanceRentForExemptStableSwap(
    provider.connection,
  );
  return {
    balanceNeeded,
    instructions: [
      SystemProgram.createAccount({
        fromPubkey: provider.wallet.publicKey,
        newAccountPubkey: initializeSwapInstruction.config.swapAccount,
        lamports: balanceNeeded,
        space: StableSwapLayout.span,
        programId: initializeSwapInstruction.config.swapProgramID,
      }),
      createInitializeStableSwapInstruction(initializeSwapInstruction),
    ],
  };
};

/**
 * Deploys a new StableSwap pool.
 */
export const deployNewSwap = async ({
  enableLogging = false,
  ...args
}: Omit<InitializeNewStableSwapArgs, "connection"> & {
  provider: Provider;
  enableLogging?: boolean;
}): Promise<{
  swap: StableSwap;
  initializeArgs: InitializeSwapInstruction;
  txSigs: {
    setupAccounts1: TransactionSignature;
    setupAccounts2: TransactionSignature;
    initializeSwap: TransactionSignature;
  };
}> => {
  const result = await createInitializeNewSwapTx(args);
  const { txs } = result;

  const { signature: setupAccounts1 } = await txs.setupAccounts1.confirm();
  if (enableLogging) {
    console.log(`Set up accounts pt 1: ${setupAccounts1}`);
  }
  const { signature: setupAccounts2 } = await txs.setupAccounts2.confirm();
  if (enableLogging) {
    console.log(`Set up accounts pt 2: ${setupAccounts2}`);
  }
  const { signature: initializeSwap } = await txs.initializeSwap.confirm();
  if (enableLogging) {
    console.log(`Initialize swap: ${initializeSwap}`);
  }

  return {
    ...result,
    txSigs: {
      setupAccounts1,
      setupAccounts2,
      initializeSwap,
    },
  };
};

/**
 * Creates the transactions for creating a new swap.
 *
 * This is split into two transactions: setup and initialize, to ensure we are under the size limit.
 */
export const createInitializeNewSwapTx = async (
  args: InitializeNewStableSwapArgs,
): Promise<{
  swap: StableSwap;
  initializeArgs: InitializeSwapInstruction;
  txs: {
    setupAccounts1: TransactionEnvelope;
    setupAccounts2: TransactionEnvelope;
    initializeSwap: TransactionEnvelope;
  };
}> => {
  const { provider } = args;
  const { instructions, initializeArgs } =
    await createInitializeStableSwapInstructions({
      ...args,
    });

  const setupAccounts1 = (
    [
      "createLPTokenMint",
      "createSwapTokenAAccounts",
      "createSwapTokenBAccounts",
    ] as const
  )
    .map((method) => {
      return new TransactionEnvelope(
        provider,
        instructions[method].instructions.slice(),
        instructions[method].signers.slice(),
      );
    })
    .reduce((acc, tx) => acc.combine(tx));

  const setupAccounts2 = (
    ["createInitialLPTokenAccount", "seedPoolAccounts"] as const
  )
    .map((method) => {
      return new TransactionEnvelope(
        provider,
        instructions[method].instructions.slice(),
        instructions[method].signers.slice(),
      );
    })
    .reduce((acc, tx) => acc.combine(tx));

  const initializeSwap = new TransactionEnvelope(
    provider,
    instructions.initializeSwap.instructions.slice(),
    instructions.initializeSwap.signers.slice(),
  );

  const newSwap = loadSwapFromInitializeArgs(initializeArgs);
  return {
    swap: newSwap,
    initializeArgs,
    txs: {
      setupAccounts1,
      setupAccounts2,
      initializeSwap,
    },
  };
};
