import {
	AddressLookupTableAccount,
	Connection,
	PublicKey,
	TransactionInstruction,
	TransactionMessage,
	VersionedTransaction,
} from '@solana/web3.js';
import fetch from 'node-fetch';
import { BN } from '../isomorphic/anchor';
import { SwapMode } from '../swap/UnifiedSwapClient';

export interface MarketInfo {
	id: string;
	inAmount: number;
	inputMint: string;
	label: string;
	lpFee: Fee;
	notEnoughLiquidity: boolean;
	outAmount: number;
	outputMint: string;
	platformFee: Fee;
	priceImpactPct: number;
}

export interface Fee {
	amount: number;
	mint: string;
	pct: number;
}

export interface Route {
	amount: number;
	inAmount: number;
	marketInfos: MarketInfo[];
	otherAmountThreshold: number;
	outAmount: number;
	priceImpactPct: number;
	slippageBps: number;
	swapMode: SwapMode;
}

/**
 *
 * @export
 * @interface RoutePlanStep
 */
export interface RoutePlanStep {
	/**
	 *
	 * @type {SwapInfo}
	 * @memberof RoutePlanStep
	 */
	swapInfo: SwapInfo;
	/**
	 *
	 * @type {number}
	 * @memberof RoutePlanStep
	 */
	percent: number;
}

export interface SwapInfo {
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	ammKey: string;
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	label?: string;
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	inputMint: string;
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	outputMint: string;
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	inAmount: string;
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	outAmount: string;
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	feeAmount: string;
	/**
	 *
	 * @type {string}
	 * @memberof SwapInfo
	 */
	feeMint: string;
}

/**
 *
 * @export
 * @interface PlatformFee
 */
export interface PlatformFee {
	/**
	 *
	 * @type {string}
	 * @memberof PlatformFee
	 */
	amount?: string;
	/**
	 *
	 * @type {number}
	 * @memberof PlatformFee
	 */
	feeBps?: number;
}

/**
 *
 * @export
 * @interface QuoteResponse
 */
export interface QuoteResponse {
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	inputMint: string;
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	inAmount: string;
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	outputMint: string;
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	outAmount: string;
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	otherAmountThreshold: string;
	/**
	 *
	 * @type {SwapMode}
	 * @memberof QuoteResponse
	 */
	swapMode: SwapMode;
	/**
	 *
	 * @type {number}
	 * @memberof QuoteResponse
	 */
	slippageBps: number;
	/**
	 *
	 * @type {PlatformFee}
	 * @memberof QuoteResponse
	 */
	platformFee?: PlatformFee;
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	priceImpactPct: string;
	/**
	 *
	 * @type {Array<RoutePlanStep>}
	 * @memberof QuoteResponse
	 */
	routePlan: Array<RoutePlanStep>;
	/**
	 *
	 * @type {number}
	 * @memberof QuoteResponse
	 */
	contextSlot?: number;
	/**
	 *
	 * @type {number}
	 * @memberof QuoteResponse
	 */
	timeTaken?: number;
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	error?: string;
	/**
	 *
	 * @type {string}
	 * @memberof QuoteResponse
	 */
	errorCode?: string;
}

export const RECOMMENDED_JUPITER_API_VERSION = '/v1';
/** @deprecated Use RECOMMENDED_JUPITER_API instead. lite-api.jup.ag requires migration to api.jup.ag with API key. */
export const LEGACY_JUPITER_API = 'https://lite-api.jup.ag/swap';
export const RECOMMENDED_JUPITER_API = 'https://api.jup.ag/swap';

export class JupiterClient {
	url: string;
	connection: Connection;
	lookupTableCahce = new Map<string, AddressLookupTableAccount>();
	private apiKey?: string;

	/**
	 * Create a Jupiter client
	 * @param connection - Solana connection
	 * @param url - Optional custom API URL. Defaults to https://api.jup.ag/swap
	 * @param apiKey - API key for Jupiter API. Required for api.jup.ag (free tier available at https://portal.jup.ag)
	 */
	constructor({
		connection,
		url,
		apiKey,
	}: {
		connection: Connection;
		url?: string;
		apiKey?: string;
	}) {
		this.connection = connection;
		this.url = url ?? RECOMMENDED_JUPITER_API;
		this.apiKey = apiKey;
	}

	/**
	 * Get the headers for API requests, including API key if configured
	 */
	private getHeaders(contentType?: string): Record<string, string> {
		const headers: Record<string, string> = {};
		if (contentType) {
			headers['Content-Type'] = contentType;
		}
		if (this.apiKey) {
			headers['x-api-key'] = this.apiKey;
		}
		return headers;
	}

	/**
	 * Get routes for a swap
	 * @param inputMint the mint of the input token
	 * @param outputMint the mint of the output token
	 * @param amount the amount of the input token
	 * @param slippageBps the slippage tolerance in basis points
	 * @param swapMode the swap mode (ExactIn or ExactOut)
	 * @param onlyDirectRoutes whether to only return direct routes
	 */
	public async getQuote({
		inputMint,
		outputMint,
		amount,
		maxAccounts = 50, // 50 is an estimated amount with buffer
		slippageBps = 50,
		swapMode = 'ExactIn',
		onlyDirectRoutes = false,
		excludeDexes,
		autoSlippage = false,
		maxAutoSlippageBps,
		usdEstimate,
	}: {
		inputMint: PublicKey;
		outputMint: PublicKey;
		amount: BN;
		maxAccounts?: number;
		slippageBps?: number;
		swapMode?: SwapMode;
		onlyDirectRoutes?: boolean;
		excludeDexes?: string[];
		autoSlippage?: boolean;
		maxAutoSlippageBps?: number;
		usdEstimate?: number;
	}): Promise<QuoteResponse> {
		const params = new URLSearchParams({
			inputMint: inputMint.toString(),
			outputMint: outputMint.toString(),
			amount: amount.toString(),
			slippageBps: autoSlippage ? '0' : slippageBps.toString(),
			swapMode,
			onlyDirectRoutes: onlyDirectRoutes.toString(),
			maxAccounts: maxAccounts.toString(),
			autoSlippage: autoSlippage.toString(),
			maxAutoSlippageBps: autoSlippage ? maxAutoSlippageBps.toString() : '0',
			autoSlippageCollisionUsdValue: autoSlippage
				? usdEstimate.toString()
				: '0',
			...(excludeDexes && { excludeDexes: excludeDexes.join(',') }),
		});
		if (swapMode === 'ExactOut') {
			params.delete('maxAccounts');
		}
		const apiVersionParam =
			this.url === RECOMMENDED_JUPITER_API || this.url === LEGACY_JUPITER_API
				? RECOMMENDED_JUPITER_API_VERSION
				: '';
		const headers = this.getHeaders();
		const fetchOptions: RequestInit =
			Object.keys(headers).length > 0 ? { headers } : {};
		const quote = await (
			await fetch(
				`${this.url}${apiVersionParam}/quote?${params.toString()}`,
				fetchOptions
			)
		).json();
		return quote as QuoteResponse;
	}

	/**
	 * Get a swap transaction for quote
	 * @param quoteResponse quote to perform swap
	 * @param userPublicKey the signer's wallet public key
	 * @param slippageBps the slippage tolerance in basis points
	 */
	public async getSwap({
		quote,
		userPublicKey,
		slippageBps = 50,
	}: {
		quote: QuoteResponse;
		userPublicKey: PublicKey;
		slippageBps?: number;
	}): Promise<VersionedTransaction> {
		if (!quote) {
			throw new Error('Jupiter swap quote not provided. Please try again.');
		}

		const apiVersionParam =
			this.url === RECOMMENDED_JUPITER_API || this.url === LEGACY_JUPITER_API
				? RECOMMENDED_JUPITER_API_VERSION
				: '';
		const resp = await (
			await fetch(`${this.url}${apiVersionParam}/swap`, {
				method: 'POST',
				headers: this.getHeaders('application/json'),
				body: JSON.stringify({
					quoteResponse: quote,
					userPublicKey,
					slippageBps,
				}),
			})
		).json();
		if (!('swapTransaction' in resp)) {
			throw new Error(
				`swapTransaction not found, error from Jupiter: ${resp.error} ${
					', ' + (resp.message ?? '')
				}`
			);
		}
		const { swapTransaction } = resp;

		try {
			const swapTransactionBuf = Buffer.from(swapTransaction, 'base64');
			return VersionedTransaction.deserialize(swapTransactionBuf);
		} catch (err) {
			throw new Error(
				'Something went wrong with creating the Jupiter swap transaction. Please try again.'
			);
		}
	}

	/**
	 * Get the transaction message and lookup tables for a transaction
	 * @param transaction
	 */
	public async getTransactionMessageAndLookupTables({
		transaction,
	}: {
		transaction: VersionedTransaction;
	}): Promise<{
		transactionMessage: TransactionMessage;
		lookupTables: AddressLookupTableAccount[];
	}> {
		const message = transaction.message;

		const lookupTables = (
			await Promise.all(
				message.addressTableLookups.map(async (lookup) => {
					return await this.getLookupTable(lookup.accountKey);
				})
			)
		).filter((lookup) => lookup);

		const transactionMessage = TransactionMessage.decompile(message, {
			addressLookupTableAccounts: lookupTables,
		});
		return {
			transactionMessage,
			lookupTables,
		};
	}

	async getLookupTable(
		accountKey: PublicKey
	): Promise<AddressLookupTableAccount> {
		if (this.lookupTableCahce.has(accountKey.toString())) {
			return this.lookupTableCahce.get(accountKey.toString());
		}

		return (await this.connection.getAddressLookupTable(accountKey)).value;
	}

	/**
	 * Get the jupiter instructions from transaction by filtering out instructions to compute budget and associated token programs
	 * @param transactionMessage the transaction message
	 * @param inputMint the input mint
	 * @param outputMint the output mint
	 */
	public getJupiterInstructions({
		transactionMessage,
		inputMint,
		outputMint,
	}: {
		transactionMessage: TransactionMessage;
		inputMint: PublicKey;
		outputMint: PublicKey;
	}): TransactionInstruction[] {
		return transactionMessage.instructions.filter((instruction) => {
			if (
				instruction.programId.toString() ===
				'ComputeBudget111111111111111111111111111111'
			) {
				return false;
			}

			if (
				instruction.programId.toString() ===
				'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
			) {
				return false;
			}

			if (
				instruction.programId.toString() === '11111111111111111111111111111111'
			) {
				return false;
			}

			if (
				instruction.programId.toString() ===
				'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'
			) {
				const mint = instruction.keys[3].pubkey;
				if (mint.equals(inputMint) || mint.equals(outputMint)) {
					return false;
				}
			}

			return true;
		});
	}
}
