import assert from "assert";
import BigNumber from "bignumber.js";

import { BN, utils } from "@project-serum/anchor";
import {
	createAssociatedTokenAccountInstruction,
	getAssociatedTokenAddressSync,
} from "@solana/spl-token";
import {
	Connection,
	LAMPORTS_PER_SOL,
	PublicKey,
	Signer,
	Transaction,
	VersionedTransaction,
} from "@solana/web3.js";

import { SOLMASH_SEEDS } from "./constants";
import { ISolmashWhitelistInstructions } from "./definitions";
import { AuctionInfo } from "./definitions/auctionInfo";
import { BuyerInfo } from "./definitions/buyerInfo";
import { SolmashWhitelistPayload } from "./payload";
import { getDecimals } from "./utils";

export class SolmashWhitelistService {
	private readonly _programId: PublicKey;

	static deriveAuctionAddress(auctionName: string, programId: PublicKey): PublicKey {
		const [auctionAddress] = PublicKey.findProgramAddressSync(
			[
				Buffer.from(utils.bytes.utf8.encode(SOLMASH_SEEDS.auctionSeed)),
				Buffer.from(utils.bytes.utf8.encode(auctionName)),
			],
			programId,
		);

		return auctionAddress;
	}

	static deriveAuctionVaultAddress(auctionAddress: PublicKey, programId: PublicKey): PublicKey {
		const [auctionVaultAddress] = PublicKey.findProgramAddressSync(
			[
				Buffer.from(utils.bytes.utf8.encode(SOLMASH_SEEDS.auctionVaultSeed)),
				auctionAddress.toBuffer(),
			],
			programId,
		);
		return auctionVaultAddress;
	}

	static deriveBuyerPdaAddress(
		auctionAddress: PublicKey,
		buyer: PublicKey,
		programId: PublicKey,
	): PublicKey {
		const [auctionVaultAddress] = PublicKey.findProgramAddressSync(
			[
				Buffer.from(utils.bytes.utf8.encode(SOLMASH_SEEDS.buyerSeed)),
				buyer.toBuffer(),
				auctionAddress.toBuffer(),
			],
			programId,
		);

		return auctionVaultAddress;
	}

	constructor(
		readonly instructions: ISolmashWhitelistInstructions,
		readonly _connection: Connection,
		private readonly _signTransaction: <T extends Transaction | VersionedTransaction>(
			transaction: T,
		) => Promise<T>,
	) {
		this._programId = this.instructions.program.programId;
	}

	private _createPayload(tx: Transaction, signers: Signer[]) {
		const errorMap: Map<number, string> = new Map();
		this.instructions.program.idl.errors.forEach((error) => errorMap.set(error.code, error.msg));

		return new SolmashWhitelistPayload(
			this._connection,
			this._signTransaction,
			errorMap,
			tx,
			signers,
		);
	}

	async initAuction(params: {
		ownerAddress: string;
		auctionTokenAddress: string;
		bidTokenAddress: string;
		enabled: boolean;
		name: string;
		preSaleEndTime: number;
		preSaleStartTime: number;
		ticketPriceInSobb: string;
		ticketPriceInSol: string;
		ticketsInPool: string;
		tokenQuantityPerTicket: string;
	}) {
		const owner = new PublicKey(params.ownerAddress);
		const auctionToken = new PublicKey(params.auctionTokenAddress);
		const bidToken = new PublicKey(params.bidTokenAddress);
		const auction = SolmashWhitelistService.deriveAuctionAddress(params.name, this._programId);

		console.debug("auction address:", auction.toString());

		const auctionVault = SolmashWhitelistService.deriveAuctionVaultAddress(
			auction,
			this._programId,
		);

		const ticketPriceInSol = BigNumber(params.ticketPriceInSol).times(LAMPORTS_PER_SOL).toFixed(0);
		const bidTokenDecimals = await getDecimals(this._connection, bidToken);
		const ticketPriceInSobb = BigNumber(params.ticketPriceInSobb)
			.times(BigNumber(10).pow(bidTokenDecimals))
			.toFixed(0);

		const ix = await this.instructions.getInitAuctionInstruction(auction, auctionVault, owner, {
			enabled: params.enabled,
			name: params.name,
			preSaleEndTime: new BN(params.preSaleEndTime),
			preSaleStartTime: new BN(params.preSaleStartTime),
			ticketPriceInSobb: new BN(ticketPriceInSobb),
			ticketPriceInSol: new BN(ticketPriceInSol),
			ticketsInPool: new BN(params.ticketsInPool),
			tokenQuantityPerTicket: new BN(params.tokenQuantityPerTicket),
			auctionToken,
			bidToken,
		});

		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: owner,
			blockhash,
			lastValidBlockHeight,
		}).add(ix);

		return this._createPayload(tx, []);
	}

	async addToken(params: {
		auctionAddress: string;
		auctionTokenAddress: string;
		ownerAddress: string;
	}) {
		const owner = new PublicKey(params.ownerAddress);
		const auction = new PublicKey(params.auctionAddress);
		const auctionToken = new PublicKey(params.auctionTokenAddress);
		const auctionVault = SolmashWhitelistService.deriveAuctionVaultAddress(
			auction,
			this._programId,
		);
		const auctionVaultTokenAccount = getAssociatedTokenAddressSync(
			auctionToken,
			auctionVault,
			true,
		);
		const ownerAuctionTokenAccount = getAssociatedTokenAddressSync(auctionToken, owner);

		const ix = await this.instructions.getAddTokenInstruction(
			auction,
			auctionToken,
			auctionVault,
			auctionVaultTokenAccount,
			owner,
			ownerAuctionTokenAccount,
		);
		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: owner,
			blockhash,
			lastValidBlockHeight,
		}).add(ix);

		return this._createPayload(tx, []);
	}

	async withdrawFunds(params: {
		auctionAddress: string;
		auctionTokenAddress: string;
		bidTokenAddress: string;
		creatorAddress: string;
	}) {
		const { auctionAddress, auctionTokenAddress, bidTokenAddress, creatorAddress } = params;
		const auction = new PublicKey(auctionAddress);
		const auctionToken = new PublicKey(auctionTokenAddress);
		const bidToken = new PublicKey(bidTokenAddress);
		const creator = new PublicKey(creatorAddress);
		const auctionVault = SolmashWhitelistService.deriveAuctionVaultAddress(
			auction,
			this._programId,
		);
		const auctionBidTokenAccount = getAssociatedTokenAddressSync(bidToken, auctionVault, true);
		const auctionVaultTokenAccount = getAssociatedTokenAddressSync(
			auctionToken,
			auctionVault,
			true,
		);
		const createAuctionTokenAccount = getAssociatedTokenAddressSync(auctionToken, creator);
		const creatorBidTokenAccount = getAssociatedTokenAddressSync(bidToken, creator);

		const creatorBidTokenAccountInfo = await this._connection.getAccountInfo(
			creatorBidTokenAccount,
			"confirmed",
		);

		const auctionVaultTokenAccountInfo = await this._connection.getAccountInfo(
			auctionVaultTokenAccount,
			"confirmed",
		);

		const ix = await this.instructions.getWithdrawFundsInstruction(
			auction,
			auctionBidTokenAccount,
			auctionToken,
			auctionVault,
			auctionVaultTokenAccount,
			bidToken,
			creator,
			createAuctionTokenAccount,
			creatorBidTokenAccount,
		);

		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: creator,
			blockhash,
			lastValidBlockHeight,
		});

		if (!auctionVaultTokenAccountInfo) {
			tx.add(
				createAssociatedTokenAccountInstruction(
					creator,
					auctionVaultTokenAccount,
					auctionVault,
					auctionToken,
				),
			);
		}

		if (!creatorBidTokenAccountInfo) {
			console.info("Creator doesn't have bid token account");
			tx.add(
				createAssociatedTokenAccountInstruction(creator, creatorBidTokenAccount, creator, bidToken),
			);
		}

		tx.add(ix);

		return this._createPayload(tx, []);
	}

	async whitelist(params: {
		auctionAddress: string;
		creatorAddress: string;
		whitelistUserAddressList: string[];
	}) {
		const { auctionAddress, creatorAddress, whitelistUserAddressList } = params;

		const auction = new PublicKey(auctionAddress);
		const creator = new PublicKey(creatorAddress);

		assert(whitelistUserAddressList.length, "Empty whitelist user list");

		const ixs = await Promise.all(
			whitelistUserAddressList.map(async (user) => {
				const whitelistUser = new PublicKey(user);
				const buyerPda = SolmashWhitelistService.deriveBuyerPdaAddress(
					auction,
					whitelistUser,
					this._programId,
				);
				const ix = await this.instructions.getWhitelistInstruction(
					auction,
					buyerPda,
					creator,
					whitelistUser,
				);

				return ix;
			}),
		);

		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: creator,
			blockhash,
			lastValidBlockHeight,
		}).add(...ixs);

		return this._createPayload(tx, []);
	}

	async preSaleBuyUsingSpl(params: {
		auctionAddress: string;
		bidTokenAddress: string;
		buyerAddress: string;
	}) {
		const { auctionAddress, bidTokenAddress, buyerAddress } = params;

		const auction = new PublicKey(auctionAddress);
		const bidToken = new PublicKey(bidTokenAddress);
		const buyer = new PublicKey(buyerAddress);
		const auctionVault = SolmashWhitelistService.deriveAuctionVaultAddress(
			auction,
			this._programId,
		);
		const buyerPda = SolmashWhitelistService.deriveBuyerPdaAddress(auction, buyer, this._programId);
		const auctionVaultBidTokenAccount = getAssociatedTokenAddressSync(bidToken, auctionVault, true);
		const buyerBidTokenAccount = getAssociatedTokenAddressSync(bidToken, buyer);

		const auctionVaultBidTokenAccountInfo = await this._connection.getAccountInfo(
			auctionVaultBidTokenAccount,
			"confirmed",
		);

		const ix = await this.instructions.getPreSaleBuyUsingSplInstruction(
			auction,
			auctionVault,
			auctionVaultBidTokenAccount,
			bidToken,
			buyer,
			buyerBidTokenAccount,
			buyerPda,
		);

		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: buyer,
			blockhash,
			lastValidBlockHeight,
		});

		if (!auctionVaultBidTokenAccountInfo) {
			tx.add(
				createAssociatedTokenAccountInstruction(
					buyer,
					auctionVaultBidTokenAccount,
					auctionVault,
					bidToken,
				),
			);
		}

		tx.add(ix);

		return this._createPayload(tx, []);
	}

	async preSaleBuyUsingSol(params: { auctionAddress: string; buyerAddress: string }) {
		const { auctionAddress, buyerAddress } = params;
		const auction = new PublicKey(auctionAddress);
		const buyer = new PublicKey(buyerAddress);

		const auctionVault = SolmashWhitelistService.deriveAuctionVaultAddress(
			auction,
			this._programId,
		);
		const buyerPda = SolmashWhitelistService.deriveBuyerPdaAddress(auction, buyer, this._programId);

		const ix = await this.instructions.getPreSaleBuyUsingSolInstruction(
			auction,
			auctionVault,
			buyer,
			buyerPda,
		);

		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: buyer,
			blockhash,
			lastValidBlockHeight,
		}).add(ix);

		return this._createPayload(tx, []);
	}

	async claimTokens(params: {
		auctionAddress: string;
		auctionTokenAddress: string;
		buyerAddress: string;
	}) {
		const { auctionAddress, auctionTokenAddress, buyerAddress } = params;

		const auction = new PublicKey(auctionAddress);
		const auctionToken = new PublicKey(auctionTokenAddress);
		const buyer = new PublicKey(buyerAddress);
		const auctionVault = SolmashWhitelistService.deriveAuctionVaultAddress(
			auction,
			this._programId,
		);
		const buyerPda = SolmashWhitelistService.deriveBuyerPdaAddress(auction, buyer, this._programId);
		const auctionVaultTokenAccount = getAssociatedTokenAddressSync(
			auctionToken,
			auctionVault,
			true,
		);
		const buyerAuctionTokenAccount = getAssociatedTokenAddressSync(auctionToken, buyer);

		const buyerAuctionTokenAccountInfo =
			await this._connection.getAccountInfo(buyerAuctionTokenAccount);

		const ix = await this.instructions.getClaimTokensInstruction(
			auction,
			auctionToken,
			auctionVault,
			auctionVaultTokenAccount,
			buyer,
			buyerAuctionTokenAccount,
			buyerPda,
		);

		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: buyer,
			blockhash,
			lastValidBlockHeight,
		});

		if (!buyerAuctionTokenAccountInfo) {
			console.info("Claimer doesn't have auction token account");
			tx.add(
				createAssociatedTokenAccountInstruction(
					buyer,
					buyerAuctionTokenAccount,
					buyer,
					auctionToken,
				),
			);
		}

		tx.add(ix);

		return this._createPayload(tx, []);
	}

	async changeTime(params: {
		auctionAddress: string;
		creatorAddress: string;
		newStartTime: number;
		newEndTime: number;
	}) {
		const auction = new PublicKey(params.auctionAddress);
		const creator = new PublicKey(params.creatorAddress);
		const newStartTime = new BN(params.newStartTime);
		const newEndTime = new BN(params.newEndTime);

		const ix = await this.instructions.getChangeTimeInstruction(
			auction,
			creator,
			newStartTime,
			newEndTime,
		);
		const { blockhash, lastValidBlockHeight } = await this._connection.getLatestBlockhash();

		const tx = new Transaction({
			feePayer: creator,
			blockhash,
			lastValidBlockHeight,
		}).add(ix);

		return this._createPayload(tx, []);
	}

	async getAuctionInfo(auctionAddress: string): Promise<AuctionInfo> {
		const auction = new PublicKey(auctionAddress);
		const decoded = await this.instructions.program.account.auction.fetch(auction);

		return {
			accumulatedSobb: decoded.accumulatedSobb.toString(),
			accumulatedSol: decoded.accumulatedSol.toString(),
			auctionToken: decoded.auctionToken.toString(),
			bidToken: decoded.bidToken.toString(),
			enabled: decoded.enabled,
			name: decoded.name,
			owner: decoded.owner.toString(),
			preSaleEndTime: decoded.preSaleEndTime.toNumber(),
			preSaleStartTime: decoded.preSaleStartTime.toNumber(),
			ticketPriceInSobb: decoded.ticketPriceInSobb.toString(),
			ticketPriceInSol: decoded.ticketPriceInSol.toString(),
			ticketsInPool: decoded.ticketsInPool.toString(),
			tokenQuantityPerTicket: decoded.tokenQuantityPerTicket.toString(),
		};
	}

	async getBuyerInfo(auctionAddress: string, buyerAddress: string): Promise<BuyerInfo> {
		const auction = new PublicKey(auctionAddress);
		const buyer = new PublicKey(buyerAddress);
		const buyerPda = SolmashWhitelistService.deriveBuyerPdaAddress(auction, buyer, this._programId);

		const decoded = await this.instructions.program.account.buyer.fetch(buyerPda);
		return {
			claimed: decoded.claimed,
			participated: decoded.participated,
			whitelisted: decoded.whitelisted,
			blacklisted: decoded.blacklisted,
		};
	}

	async isBuyerWhitelisted(auctionAddress: string, buyerAddress: string) {
		const auction = new PublicKey(auctionAddress);
		const buyer = new PublicKey(buyerAddress);

		const buyerPda = SolmashWhitelistService.deriveBuyerPdaAddress(auction, buyer, this._programId);
		const accountInfo = await this._connection.getAccountInfo(buyerPda, "confirmed");

		return accountInfo !== null;
	}

	async buyerParticipated(auctionAddress: string, buyerAddress: string) {
		const info = await this.getBuyerInfo(auctionAddress, buyerAddress);
		return info.participated;
	}

	async buyerClaimed(auctionAddress: string, buyerAddress: string) {
		const info = await this.getBuyerInfo(auctionAddress, buyerAddress);
		return info.claimed;
	}
}
