import {
    BONDING_CURVE_SEED,
    DEFAULT_COMMITMENT,
    DEFAULT_SLIPPAGE_BASIS,
    GLOBAL_ACCOUNT_SEED,
    METADATA_SEED,
    MPL_TOKEN_METADATA_PROGRAM_ID,
    PUMPFUN_PROGRAM_ID,
    type Events,
    type BuyInstructionParam,
    type CreateTokenInstructionParam,
    type SellInstructionParam,
    type TokenMeta,
    type TokenMetadataResponse,
    type TradeInstructionParam,
    type CompileBuyReturn,
    type EventCallback,
    type TokenDataAPI
} from "../constant";
import {
    GlobalAccount,
    BondingCurveAccount,
    calculateSlippageBuy,
    calculateSlippageSell
} from "../utils";
import { Buffer } from "buffer";
import { BN, Program, type Provider } from "@coral-xyz/anchor";
import { IDL, type PumpFun } from "../IDL";
import { PublicKey, TransactionInstruction, type Connection } from "@solana/web3.js";
import { AccountLayout, createAssociatedTokenAccountInstruction, getAssociatedTokenAddress } from "@solana/spl-token";


class Fun {
    private program: Program<PumpFun>;
    private connection: Connection;

    /**
     * Default commitment for the class - can be override.
     * @default "confirmed"
     *  
     * @example
     * // ...initialization codes
     * 
     * fun.commitment = "finalized"
     */
    public commitment = DEFAULT_COMMITMENT;

    /**
     * Default slippage basis points for the class - can be override.
     * 
     * @example
     * // ...initialization codes
     * 
     * fun.slippageBasis = 1000n; // or bigint(xyz)
     */
    public slippageBasis = DEFAULT_SLIPPAGE_BASIS;

    /**
     * @constructor
     * @param provider - Provider instance from anchor
     * @param user - User public key instance to interact with transactions
     * 
     * @example
     * import Fun from "funsdk";
     * import { Connection } from "@solana/web3.js";
     * 
     * const connection = new Connection("RPC_URL");
     * const fun = new Fun(connection);
     */
    constructor(connection: Connection, user?: PublicKey) {
        const provider: Provider = {
            connection: connection as any as Provider["connection"],
            publicKey: user
        }
        this.program = new Program(IDL, provider);
        this.connection = connection;
    }

    private getBondingCurvePDA(token: PublicKey) {
        return PublicKey.findProgramAddressSync(
            [Buffer.from(BONDING_CURVE_SEED), token.toBuffer()],
            this.program.programId
        )[0];
    }

    private getMetadataPDA(mpl: PublicKey, token: PublicKey) {
        const [metadataPDA] = PublicKey.findProgramAddressSync(
            [
                Buffer.from(METADATA_SEED),
                mpl.toBuffer(),
                token.toBuffer()
            ],
            mpl
        );

        return metadataPDA;
    }

    private async checkRentExempt(trader: PublicKey) {
        const rentExemptBalance = await this.connection.getMinimumBalanceForRentExemption(AccountLayout.span);
        const traderBalance = await this.connection.getBalance(trader);

        if (traderBalance < rentExemptBalance) {
            throw new Error(`${trader.toBase58()} account has insufficient funds for rent exemption. Required: ${rentExemptBalance}, Available: ${traderBalance}`);
        }

        return true;
    }

    private async createATAInstruct(owner: PublicKey, token: PublicKey) {
        const atad = await getAssociatedTokenAddress(
            token,
            owner,
            false
        );

        const createAta = createAssociatedTokenAccountInstruction(
            owner,
            atad,
            owner,
            token
        );

        return createAta;
    }

    private async getPumpfunGlobal() {
        const [globalAccPDA] = PublicKey.findProgramAddressSync(
            [Buffer.from(GLOBAL_ACCOUNT_SEED)],
            new PublicKey(PUMPFUN_PROGRAM_ID)
        );

        const tokenAcc = await this.connection.getAccountInfo(
            globalAccPDA,
            this.commitment
        );

        if (!tokenAcc) {
            throw new Error("Failed getting pumpfun global account.");
        }

        return GlobalAccount.fromBuffer(tokenAcc.data)
    }

    /**
     * ABC = Associated Bonding Curve;
     * @param {PublicKey} token - Token public key
     * @returns {Promise<PublicKey>} Returns a bonding curve pubkey of the token
     */
    private async getABC(token: PublicKey) {
        return await getAssociatedTokenAddress(
            token,
            this.getBondingCurvePDA(token),
            true
        );
    }

    private async getBondingCurveAccount(token: PublicKey) {
        const tokenAcc = await this.connection.getAccountInfo(
            this.getBondingCurvePDA(token),
            this.commitment
        );

        if (!tokenAcc) {
            throw new Error(`Bonding curve not found for: ${token.toBase58()}`)
        }

        return BondingCurveAccount.fromBuffer(tokenAcc.data);
    }

    private async createTokenMetadata(data: TokenMeta) {
        const form = new FormData();

        form.append("file", data.image);
        form.append("name", data.name);
        form.append('symbol', data.symbol);
        form.append("description", data.description);
        form.append("twitter", data?.twitter ?? "");
        form.append("telegram", data?.telegram ?? "");
        form.append("website", data?.website ?? "");

        const res = await fetch("https://pump.fun/api/ipfs", {
            method: "POST",
            body: form
        });

        if (!res.ok) {
            throw new Error(res.statusText);
        }

        return await res.json() as TokenMetadataResponse;
    }

    private async compileTradeInstruction(params: TradeInstructionParam, method: "BUY" | "SELL"): Promise<TransactionInstruction> {
        const ABC = await this.getABC(params.token);
        const buyerTokenAcc = await getAssociatedTokenAddress(params.token, params.trader, false);
        const globalAcc = await this.getPumpfunGlobal();

        if (method === "BUY") {
            return await this.program.methods
                .buy(new BN(params.amount), new BN(params.slippageCut))
                .accounts({
                    feeRecipient: globalAcc.feeRecipient,
                    mint: params.token,
                    associatedBondingCurve: ABC,
                    associatedUser: buyerTokenAcc,
                    user: params.trader
                })
                .instruction()
        } else {
            return await this.program.methods
                .sell(new BN(params.amount), new BN(params.slippageCut))
                .accounts({
                    feeRecipient: globalAcc.feeRecipient,
                    mint: params.token,
                    associatedBondingCurve: ABC,
                    associatedUser: buyerTokenAcc,
                    user: params.trader
                })
                .instruction()
        }
    }

    /**
     * @function listen
     * 
     * @param {Events} event - Event type
     * @param {EventCallback<Events>} callback - Event callback
     * @returns {() => Promise<void>} Returns the remove listener function
     * 
     * @example
     * // ...initialization codes
     * 
     * const removeListener = fun.listen("createEvent", (event) => {
     *      console.log(event);
     * });
     * 
     * // ...some codes
     * 
     * await removeListener();
     */
    public listen<E extends Events>(event: E, callback: EventCallback<E>) {
        const program = this.program;
        const listener = program.addEventListener(event, callback as any);

        const removeListener = async () => {
            await program.removeEventListener(listener);
        }

        return removeListener;
    }

    /**
     * @async
     * @function getTokenDataAPI
     * 
     * @param { PublicKey } token - The assign token public key
     * @returns {Promise<TokenDataAPI>} Returns a Promise\<TokenDataAPI\> instance
     * 
     * @example
     * // ...initialization codes
     * 
     * const token = new PublicKey("token address");
     * const tokenData = await fun.getTokenDataAPI(token);
     * 
     * console.log(tokenData);
     * // name, symbol, description, image_uri, metadata_uri, etc.
     */
    public async getTokenDataAPI(token: PublicKey) {
        const url = "https://frontend-api.pump.fun/coins";
        const res = await fetch(`${url}/${token.toBase58()}`);

        if (!res.ok) {
            throw new Error(res.statusText);
        }

        return await res.json() as TokenDataAPI;
    }

    /**
     * @async
     * @function getBondingCurveData
     * 
     * @param { PublicKey } bongingCurve - The assign bonding curve public key
     * @returns {Promise<{
     *      virtualTokenReserves: BN;
     *      virtualSolReserves: BN;
     *      realTokenReserves: BN;
     *      realSolReserves: BN;
     *      tokenTotalSupply: BN;
     *      complete: boolean;
     * }>} Returns a Promise\<BondingCurveData\> instance
     * 
     * @example
     * // ...initialization codes
     * 
     * const bondingCurve = new PublicKey("bonding curve address");
     * const bondingCurveData = await fun.getBondingCurveData(bondingCurve);
     * 
     * console.log(bondingCurveData);
     * // virtualTokenReserves, virtualSolReserves, realTokenReserves, realSolReserves, tokenTotalSupply, complete
     */
    public async getBondingCurveData(bongingCurve: PublicKey) {
        const data = await this.program.account.bondingCurve.fetch(bongingCurve);
        return data;
    }

    /**
     * @async
     * @function compileCreateTokenInstruction
     * 
     * @param { CreateTokenInstructionParam } params 
     * @prop { PublicKey } params.creator - Creator public key instance
     * @prop { TokenMeta } params.tokenMeta - Token metadata
     * @prop { Keypair } tokenMeta.keypair - Token keypair
     * @prop { string } tokenMeta.name - Token name
     * @prop { string } tokenMeta.symbol - Token symbol
     * @prop { string } tokenMeta.description - Token description
     * @prop { File } tokenMeta.image - Token image in File type
     * @prop { string } [tokenMeta.telegram] - Token telegram (mandatory)
     * @prop { string } [tokenMeta.twitter] - Token twitter (mandatory)
     * @prop { string } [tokenMeta.website] - Token website (mandatory)
     * 
     * @returns {Promise<TransactionInstruction>} Returns a Promise\<TransactionInstruction\> instance
     * 
     * @example
     * // ...initialization codes
     * 
     * const creator = Keypair.generate();
     * const token = Keypair.generate();
     * const imagePath = fs.readFileSync("./image.jpg", {encoding: base64});
     * const tokenMetadata = {
     *      name: "MyToken",
     *      symbol: "MTK",
     *      description: "This is my token",
     *      image: new File([imagePath], "image.jpg"),
     *      ...socials if any
     * }
     * 
     * const createInstruction = await fun.compileCreateTokenInstruction({
     *      creator: creator.publicKey,
     *      tokenMeta: {
     *          ...tokenMetadata,
     *          keypair: token
     *      }
     * });
     */
    public async compileCreateTokenInstruction(params: CreateTokenInstructionParam): Promise<TransactionInstruction> {
        await this.checkRentExempt(params.creator);

        const token = params.tokenMeta.keypair;
        const metadataUri = await this.createTokenMetadata(params.tokenMeta);

        const mpl = new PublicKey(MPL_TOKEN_METADATA_PROGRAM_ID);
        const metadataPDA = this.getMetadataPDA(mpl, token.publicKey);
        const ABC = await this.getABC(token.publicKey);

        return await this.program.methods
            .create(
                params.tokenMeta.name,
                params.tokenMeta.symbol,
                metadataUri.metadataUri
            )
            .accounts({
                metadata: metadataPDA,
                associatedBondingCurve: ABC,
                user: params.creator,
                mint: token.publicKey
            })
            .signers([token])
            .instruction()
    }

    /**
     * @async
     * @function compileBuyInstruction<B>
     * 
     * @param {BuyInstructionParam} params - Buy instruction parameter object
     *  - params.trader { PublicKey } - Trader public key
     *  - params.token { PublicKey } - Token public key
     *  - params.solAmount { bigint } - Token buy amount (in SOL)
     * 
     * @param { boolean } haveCurve - is initial buy transaction for token
     * @param { boolean } needAta - need create associated token account instruction
     * @default false
     * 
     * @returns {Promise<CompileBuyReturn<T>>} Returns a Promise\<TransactionInstruction[] | TransactionInstruction\> instance
     * 
     * @example
     * // ...initialization codes
     * 
     * const trader = Keypair.generate();
     * const token = new PublicKey("token address");
     * const buyAmount = 1 * LAMPORTS_PER_SOL;
     * 
     * const buy = await fun.compileBuyInstruction({
     *      trader,
     *      token,
     *      solAmount: bigint(buyAmount)
     * }, true);
     * 
     * // If true was passed, function will include createAssociatedTokenAccount along with
     * // the buy instruction [createATA, buyInstruct]
     * 
     *  // with type annotation. Same return as above
        // fun.compileBuyInstruction<true>({
        //     solAmount: BigInt(1 * LAMPORTS_PER_SOL),
        //     token: token.publicKey,
        //     trader: creator.publicKey
        // })
     */
    public async compileBuyInstruction<B extends boolean = false>(
        params: BuyInstructionParam,
        haveCurve = false,
        needAta: B
    ): Promise<CompileBuyReturn<B>> {
        await this.checkRentExempt(params.trader);

        let tokenPrice: bigint;

        if (!haveCurve) {
            const globalAcc = await this.getPumpfunGlobal();
            tokenPrice = globalAcc.getInitialBuyPrice(params.solAmount);
        } else {
            const bondingCurve = await this.getBondingCurveAccount(params.token);
            tokenPrice = bondingCurve.getBuyPrice(params.solAmount);
        }

        const slippageCut = calculateSlippageBuy(params.solAmount, this.slippageBasis);

        if (needAta) {
            const ataInstruct = await this.createATAInstruct(params.trader, params.token);
            const buyInstruct = await this.compileTradeInstruction({
                token: params.token,
                trader: params.trader,
                amount: tokenPrice,
                slippageCut
            }, "BUY");

            return [ataInstruct, buyInstruct] as CompileBuyReturn<B>;
        } else {
            return await this.compileTradeInstruction({
                token: params.token,
                trader: params.trader,
                amount: tokenPrice,
                slippageCut
            }, "BUY") as CompileBuyReturn<B>;
        }
    }

    /**
     * @async
     * @function compileSellInstruction
     * 
     * @param {SellInstructionParam} params - Buy instruction parameter object
     * @prop { PublicKey } params.trader - Trader public key
     * @prop { PublicKey } params.token - Token public key
     * @prop { bigint } params.tokenAmount - Token sell amount (in Token)
     * 
     * @returns {Promise<TransactionInstruction>} Returns a Promise\<TransactionInstruction\> instance
     * 
     * @example
     * // ...initialization codes
     * 
     * const trader = Keypair.generate();
     * const token = new PublicKey("token address");
     * const sellAmount = 1000000000n;
     * 
     * const sell = await fun.compileBuyInstruction({
     *      trader,
     *      token,
     *      tokenAmount: bigint(sellAmount)
     * });
     */
    public async compileSellInstruction(params: SellInstructionParam): Promise<TransactionInstruction> {
        const globalAcc = await this.getPumpfunGlobal();
        const bondingCurve = await this.getBondingCurveAccount(params.token);

        const solAmount = bondingCurve.getSellPrice(params.tokenAmount, globalAcc.feeBasisPoints);
        const slippageCut = calculateSlippageSell(solAmount, this.slippageBasis);

        return await this.compileTradeInstruction({
            token: params.token,
            trader: params.trader,
            amount: params.tokenAmount,
            slippageCut
        }, "SELL");
    }
}

export default Fun;