import * as anchor from "@coral-xyz/anchor";
import { PublicKey, SystemProgram, Transaction, TransactionInstruction } from "@solana/web3.js";
import { createAssociatedTokenAccountInstruction, getAssociatedTokenAddress, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token";


import { ListingState, Listing as ListingType } from "../types/listing";
import ProgramLoader from "./ProgramLoader";
import { BidInfo, BidReceipt as BidReceiptType } from "../types/bid-receipt";
import BidReceipt from "./BidReciept";
import AuctionManager from "./AuctionManager";



/**
 * Listing
 * 
 * @description This is the main contract for the listing. It is responsible for managing the listing, including creating and updating the listing, and executing bids.
 * 
 *  - create a new listing
 *  - get a listing
 *  - fetch many listings
 *  - get all listings
 *  - cancel a listing
 *  - get all bids for a listing
 *  - get listings by state
 *  - get one-time sale listings
 *  - get auction listings
 *  - get highest bid on a listing
 *  - get highest bid amount and bidder
 *  - get bids to refund on a listing and bidder
 *  - then make a call to close the nft account and auction manager account
 */
export default class Listing {
    private auctionManagerProgram: AuctionManager;
    public constructor(protected readonly programLoader: ProgramLoader) {
        this.auctionManagerProgram = new AuctionManager(programLoader);
    }



    /**
     * @description Create a new listing
     * 
     * @param {PublicKey} nft - The NFT to be listed
     * @param {PublicKey} tokenMint - The token mint to be used for the listing
     * @param {PublicKey} tokenMintProgramId - The token mint program ID (SPL Token or SPL Token 2022)
     * 
     * @param {PublicKey} lister - The lister of the listing
     * @param {number} timeExtension - The time extension for the listing
     * @param {number} startingPrice - The starting price for the listing
     * @param {number} period - The period for the listing
     * @returns {TransactionInstruction[]}
     */

    public async create(nft: PublicKey, nftProgramId: PublicKey, tokenMint: PublicKey, tokenMintProgramId: PublicKey, lister: PublicKey, timeExtension: number, startingPrice: number, period?: number, minimumBidThreshold?: number): Promise<TransactionInstruction[]> {
        try {
            // const seeds1 = [anchor.utils.bytes.utf8.encode("deserialize"), Uint8Array.from(this.programLoader.wallet.publicKey.toBuffer())]
            // const [applicationState, _] = PublicKey.findProgramAddressSync(seeds1, this.programLoader.program.programId);

            const seeds2 = [anchor.utils.bytes.utf8.encode("auction_manager"), Uint8Array.from(nft.toBuffer()), Uint8Array.from(lister.toBuffer())];
            const [auctionManager, auctionManagerBump] = PublicKey.findProgramAddressSync(seeds2, this.programLoader.program.programId);
            console.log("[*] Auction manager: ", auctionManager);
            console.log("[*] Auction manager bump: ", auctionManagerBump);

            const seeds3 = [anchor.utils.bytes.utf8.encode("listing"), Uint8Array.from(nft.toBuffer()), Uint8Array.from(lister.toBuffer()), Uint8Array.from(auctionManager.toBuffer()), Uint8Array.from(tokenMint.toBuffer())];
            const [listing, listingBump] = PublicKey.findProgramAddressSync(seeds3, this.programLoader.program.programId);
            console.log("[*] Listing: ", listing);
            console.log("[*] Listing bump: ", listingBump);

            let appState = await this.programLoader.program.account.applicationState.fetch(this.programLoader.applicationState)

            const seeds = [anchor.utils.bytes.utf8.encode("user_account"), Uint8Array.from(lister.toBuffer()), Uint8Array.from(appState.listingAuthority.toBuffer()), anchor.utils.bytes.utf8.encode("deserialize")];
            const [userdata, userdataBump] = PublicKey.findProgramAddressSync(seeds, this.programLoader.program.programId);
            console.log("[*] Userdata: ", userdata);
            console.log("[*] Userdata bump: ", userdataBump);

            let txns: TransactionInstruction[] = [];

            //check if there's an nft escrow account
            let nftEscrow = await getAssociatedTokenAddress(nft, auctionManager, true);
            let nftOwner = await getAssociatedTokenAddress(nft, lister, true);
            let proceedsWallet = await getAssociatedTokenAddress(tokenMint, lister, true, tokenMintProgramId);

            //check if the accounts exists
            let nftEscrowAccountInfo = await this.programLoader.connection.getAccountInfo(nftEscrow);
            let nftOwnerAccountInfo = await this.programLoader.connection.getAccountInfo(nftOwner);
            let proceedsWalletAccountInfo = await this.programLoader.connection.getAccountInfo(proceedsWallet);
            let auctionManagerAccountInfo = await this.programLoader.connection.getAccountInfo(auctionManager);


            if (!auctionManagerAccountInfo) {
                let inx = await this.auctionManagerProgram.create(nft, lister, nftProgramId);
                txns.push(inx);
            }
            if (!nftEscrowAccountInfo) {
                //create the transactions
                let inx = createAssociatedTokenAccountInstruction(lister, nftEscrow, auctionManager, nft);
                txns.push(inx);
            }
            if (!nftOwnerAccountInfo) {
                //create the transactions
                let inx = createAssociatedTokenAccountInstruction(lister, nftOwner, lister, nft);
                txns.push(inx);
            }
            if (!proceedsWalletAccountInfo) {
                //create the transactions
                let inx = createAssociatedTokenAccountInstruction(lister, proceedsWallet, lister, tokenMint, tokenMintProgramId);
                txns.push(inx);
            }

            const startTime = Math.floor(Date.now() / 1000);
            let endTime = null;
            if (period) {
                endTime = startTime + ((60 * 60 * 24) * period);
            }

            console.log('tokenMintProgramId: ', tokenMintProgramId);
            const tx = await this.programLoader.program.methods
                .createListing(new anchor.BN(timeExtension), new anchor.BN(startingPrice), new anchor.BN(startTime), endTime ? new anchor.BN(endTime) : endTime, new anchor.BN(minimumBidThreshold))
                .accounts({
                    applicationState: this.programLoader.applicationState,
                    auctionManager,
                    listing,
                    owner: lister,
                    userdata,
                    nft,
                    nftEscrow,
                    proceedsWallet,
                    tokenMint,
                    feeAccount: appState.feeAccount,
                    nftOwner: nftOwner,
                    tokenProgram: nftProgramId,
                    systemProgram: SystemProgram.programId,
                    rent: anchor.web3.SYSVAR_RENT_PUBKEY
                })
                .instruction()
            txns.push(tx);


            return txns;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to create listing: ${e.message}`);
        }
    }


    /**
     * @description Create a new listing
     * 
     * @param {PublicKey} nft - The NFT to be listed
     * @param {PublicKey} nftProgramId - The NFT to be listed
     * @param {PublicKey} tokenMint - The token mint to be used for the listing
     * @param {PublicKey} tokenMintProgramId - The token mint to be used for the listing
     * @param {PublicKey} lister - The lister of the listing
     * @param {number} rewardPercentage - The reward percentage for the lister
     * @param {number} timeExtension - The time extension for the listing
     * @param {number} startingPrice - The starting price for the listing
     * @param {number} period - The period for the listing
     * @returns {Transaction}
     */
    public async createTransaction(nft: PublicKey, nftProgramId: PublicKey, tokenMint: PublicKey, tokenMintProgramId: PublicKey, lister: PublicKey, timeExtension: number, startingPrice: number, period?: number, minimumBidThreshold?: number): Promise<Transaction> {
        console.log('tokenMintProgramId 1: ', tokenMintProgramId);
        try {
            let instructions = await this.create(nft, nftProgramId, tokenMint, tokenMintProgramId, lister, timeExtension, startingPrice, period, minimumBidThreshold);
            let txn = new Transaction().add(...instructions);
            txn.recentBlockhash = (await this.programLoader.getRecentBlockHash()).blockhash;
            txn.feePayer = lister;

            return txn;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to create listing transaction: ${e.message}`);
        }
    }


    /**
     * @description Get a listing
     * 
     * @param {PublicKey} listingPubkey - The public key of the listing
     * @returns {ListingType} The listing
     */
    public async get(listingPubkey: PublicKey): Promise<ListingType> {
        try {
            console.log("[*] Listing publicKey is " + listingPubkey);
            const listing = await this.programLoader.program.account.listing.fetch(listingPubkey);
            console.log(JSON.stringify(listing, null, 4));
            return listing as unknown as ListingType;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to get listing: ${e.message}`);
        }
    }


    /**
     * @description Fetch many listings
     * 
     * @param {PublicKey[]} listingPubkeys - The public keys of the listings
     * @returns {ListingType[]} The listings
     */
    public async fetchMany(listingPubkeys: PublicKey[]): Promise<ListingType[]> {
        try {
            const listings = await this.programLoader.program.account.listing.fetchMultiple(listingPubkeys);
            return listings as unknown as ListingType[];
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to fetch listings: ${e.message}`);
        }
    }


    /**
     * @description Get all listings
     * 
     * @returns {ListingType[]} The listings
    */
    public async getAll(): Promise<ListingType[]> {
        try {
            const listings = await this.programLoader.program.account.listing.all();
            console.log("[*] Listings", listings);
            return listings as unknown as ListingType[];
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to get all listings: ${e.message}`);
        }
    }


    /**
     * @description Cancel a listing
     * 
     * @param {PublicKey} listingPubkey - The public key of the listing
     * @param {PublicKey} tokenProgramId - The program ID of the token mint
     * @returns {Transaction}
     */
    public async cancel(listingPubkey: PublicKey, tokenProgramId: PublicKey): Promise<Transaction> {
        try {
            let listingData = await this.get(listingPubkey);
            // const seeds1 = [anchor.utils.bytes.utf8.encode("deserialize"), Uint8Array.from(this.programLoader.wallet.publicKey.toBuffer())]
            // const [applicationState, applicationStateBump] = PublicKey.findProgramAddressSync(seeds1, this.programLoader.program.programId);
            const seeds2 = [anchor.utils.bytes.utf8.encode("auction_manager"), Uint8Array.from(listingData.nft.toBuffer()), Uint8Array.from(listingData.authority.toBuffer())];
            const [auctionManager, auctionManagerBump] = PublicKey.findProgramAddressSync(seeds2, this.programLoader.program.programId);

            const seeds3 = [anchor.utils.bytes.utf8.encode("listing"), Uint8Array.from(listingData.nft.toBuffer()), Uint8Array.from(listingData.authority.toBuffer()), Uint8Array.from(auctionManager.toBuffer()), Uint8Array.from(listingData.tokenMint.toBuffer())];
            const [listing, listingBump] = PublicKey.findProgramAddressSync(seeds3, this.programLoader.program.programId);
            console.log("[*] Listing: ", listing);
            console.log("[*] Listing bump: ", listingBump);

            let appState = await this.programLoader.program.account.applicationState.fetch(this.programLoader.applicationState)

            const seeds = [anchor.utils.bytes.utf8.encode("user_account"), Uint8Array.from(listingData.authority.toBuffer()), Uint8Array.from(appState.listingAuthority.toBuffer()), anchor.utils.bytes.utf8.encode("deserialize")];
            const [userdata, userdataBump] = PublicKey.findProgramAddressSync(seeds, this.programLoader.program.programId);
            console.log("[*] Userdata: ", userdata);
            console.log("[*] Userdata bump: ", userdataBump);

            //check if there's an nft escrow account
            let nftEscrow = await getAssociatedTokenAddress(listingData.nft, auctionManager, true);
            let originalNftHolder = await getAssociatedTokenAddress(listingData.nft, listingData.authority, true);


            const cancelledAt = Math.floor(Date.now() / 1000);
            const tx = await this.programLoader.program.methods
                .cancelListing(new anchor.BN(cancelledAt))
                .accounts({
                    applicationState: this.programLoader.applicationState,
                    auctionManager,
                    listing,
                    userdata,
                    nft: listingData.nft,
                    nftEscrow,
                    originalNftHolder,
                    tokenMint: listingData.tokenMint,
                    feeAccount: appState.feeAccount,
                    user: listingData.authority,
                    tokenProgram: tokenProgramId,
                    systemProgram: SystemProgram.programId,
                })
                .transaction()
            tx.recentBlockhash = (await this.programLoader.getRecentBlockHash()).blockhash;
            tx.feePayer = listingData.authority;

            return tx;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to cancel listing (${listingPubkey}): ${e.message}`);
        }
    }


    /**
     * @description Get all bids for a listing
     * 
     * @param {PublicKey} listingPubkey - The public key of the listing
     * @returns {BidReceiptType[]} The bids
     */
    public async getBids(listingPubkey: PublicKey): Promise<BidReceiptType[]> {
        try {
            const bidReceiptProgram = new BidReceipt(this.programLoader);
            const listing = await this.programLoader.program.account.listing.fetch(listingPubkey);
            const bids = await bidReceiptProgram.fetchMany(listing.bidReceipts);

            return bids;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to get bids for listing (${listingPubkey}): ${e.message}`);
        }
    }


    /**
     * @description Get listings by state
     * 
     * @param {ListingState} state - The state of the listings
     * @returns {ListingType[]} The listings
     */
    public async getByState(state: ListingState): Promise<ListingType[]> {
        try {
            const allListings = await this.getAll();
            const listings = allListings.filter((listing) => listing.state === state);

            return listings;
        } catch (e: any) {
            console.error(e)
            throw new Error(`Failed to get listings by state (${state}): ${e.message}`);
        }
    }


    /**
     * @description Get one-time sale listings
     * 
     * @returns {ListingType[]} The one-time sale listings
     */
    public async getOneTimeSales(): Promise<ListingType[]> {
        try {
            const allListings = await this.getAll();
            const oneTimeSaleListings = allListings.filter(listing => listing.endTime === null);

            return oneTimeSaleListings;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to get one-time sale listings: ${e.message}`);
        }
    }


    /**
     * @description Get auction listings
     * 
     * @returns {ListingType[]} The auction listings
     */
    public async getAuctions(): Promise<ListingType[]> {
        try {
            const allListings = await this.getAll();
            const auctionListings = allListings.filter(listing => listing.endTime !== null);

            return auctionListings;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to get auction listings: ${e.message}`);
        }
    }


    /**
     * @description Get the highest bid info
     * 
     * @param {PublicKey} listingPubkey - The public key of the listing
     * @returns {BidInfo} The highest bid info
     */
    public async getHighestBidInfo(listingPubkey: PublicKey): Promise<BidInfo> {
        try {
            const bidReceiptProgram = new BidReceipt(this.programLoader);
            const listing = await this.get(listingPubkey);
            if (!listing.highestBidReceipt) throw new Error("No bids on this listing");
            const bidReceipt = await bidReceiptProgram.get(listing.highestBidReceipt);

            return {
                bidder: bidReceipt.bidder,
                receipt: listing.highestBidReceipt,
                amount: bidReceipt.amount
            }
        } catch (e: any) {
            console.error(e)
            throw new Error(`Failed to get highest bid info: ${e.message}`);
        }
    }


    /**
     * @description Get bids info to refund
     * 
     * @param {PublicKey} listingPubkey - The public key of the listing
     * @returns {BidInfo[]} The bids info to refund
     */
    public async getBidsInfoToRefund(listingPubkey: PublicKey): Promise<BidInfo[]> {
        try {
            const listing = await this.get(listingPubkey);
            const bidsToRefund = listing.bidReceipts.filter(receipt => receipt !== listing.highestBidReceipt);
            const bidReceiptProgram = new BidReceipt(this.programLoader);

            if (listing.bidReceipts.length === 0) return [];

            const bidInfos: BidInfo[] = [];
            for await (let receipt of bidsToRefund) {
                if (!receipt.equals(listing.highestBidReceipt)) {
                    let bidReceipt = await bidReceiptProgram.get(receipt);
                    let bidInfo = {
                        bidder: bidReceipt.bidder,
                        receipt,
                        amount: bidReceipt.amount
                    }
                    bidInfos.push(bidInfo);
                }
            }

            return bidInfos;
        } catch (e: any) {
            console.error(e);
            throw new Error(`Failed to get bids info to refund: ${e.message}`);
        }
    }
}