import {AnchorProvider, BN, Program, Wallet} from '@project-serum/anchor';
import web3, {
    Connection,
    Keypair,
    PublicKey,
    SystemProgram,
    SYSVAR_RENT_PUBKEY,
    Transaction,
    TransactionInstruction
} from '@solana/web3.js';
import {ASSOCIATED_TOKEN_PROGRAM_ID, getAssociatedTokenAddress, TOKEN_PROGRAM_ID} from '@solana/spl-token';
import {AssetMinting, IDL} from './types/asset_minting';
import * as base58 from "bs58";
import * as nacl from "tweetnacl";
import {createSignMetadataInstruction, PROGRAM_ID as METADATA_PROGRAM_ID} from "@metaplex-foundation/mpl-token-metadata"

import {
    ASSET_EVENT_NAME,
    AssetEvent,
    COLLECTION_EVENT_NAME,
    CollectionEvent,
    MINT_EVENT_NAME,
    MintEvent,
    SUB_COLLECTION_EVENT_NAME,
    SubCollectionEvent
} from './events-types'

const CONFIG_ACCOUNT_PREFIX = "CONFIG";
const NAME = "MirrorWorld";

export class AssetMintingLib {
    program: Program<AssetMinting>;
    connection: Connection;

    constructor(programId: PublicKey, connection: Connection, wallet: Wallet) {
        this.connection = connection;
        const provider = new AnchorProvider(connection, wallet, AnchorProvider.defaultOptions());
        this.program = new Program(IDL, programId, provider);
    }

    /**
     * Sign the transaction and add the signature in the transaction.
     * @param tx Transaction object which needs to sign.
     * @param secretKey Transaction signer secret key (secret key in base58 string).
     */
    signTransaction(tx: Transaction, secretKey: string): Transaction {
        const keypair: Keypair = Keypair.fromSecretKey(base58.decode(secretKey));
        const signature = nacl.sign.detached(tx.serializeMessage(), keypair.secretKey);
        tx.addSignature(keypair.publicKey, Buffer.from(signature));

        return tx;
    }

    /**
     * Add signature in the transaction.
     * @param tx Transaction object where signature needs to add.
     * @param signerAddress Signer public key from which secret key transaction is signed.
     * @param signature Signed transaction message signature.
     */
    addSignatureInTransaction(tx: Transaction, signerAddress: PublicKey, signature: Buffer): Transaction {
        tx.addSignature(signerAddress, signature);

        return tx;
    }

    /**
     * Add signature in the transaction.
     * @param tx Transaction object where signature needs to add.
     * @param feePayer public key of the address which going to pay the fee.
     */
    async addFeePayerAndRecentBlockHashInTransaction(tx: Transaction, feePayer: PublicKey): Promise<Transaction> {
        tx.feePayer = feePayer;
        tx.recentBlockhash = (await this.connection.getLatestBlockhash()).blockhash;

        return tx;
    }

    /**
     * Is pda address initialize.
     */
    async isPdaAddressInitialize(pdaAddress: PublicKey): Promise<boolean> {
        const pdaAccountInfo = await this.connection.getAccountInfo(pdaAddress);

        return pdaAccountInfo != null;
    }


    /**
     * get the config account pda and bump.
     * @param name seed for the config account.
     */
    async getConfigAccountPdaAndBump(name: String = NAME): Promise<[PublicKey, number]> {
        return PublicKey.findProgramAddressSync([Buffer.from(name), Buffer.from(CONFIG_ACCOUNT_PREFIX)],
            this.program.programId);
    }

    /**
     * get the mint account pda and bump.
     * @param uuid seed for the mint account.
     */
    async getMintAccountPdaAndBump(uuid: String): Promise<[PublicKey, number]> {
        return PublicKey.findProgramAddressSync([Buffer.from(uuid)],
            this.program.programId);
    }

    /**
     * Get metadata account pda.
     * @param mintAccountPda token mint account
     * @param mplProgramId metadata program id
     */
    async getMetadataAccountPda(mintAccountPda: PublicKey, mplProgramId: PublicKey = METADATA_PROGRAM_ID): Promise<[PublicKey, number]> {
        return PublicKey.findProgramAddressSync(
            [
                Buffer.from('metadata'),
                mplProgramId.toBuffer(),
                mintAccountPda.toBuffer(),
            ],
            mplProgramId
        );
    }

    /**
     * Get master edition account pda.
     * @param mintAccountPda token mint account
     * @param mplProgramId metadata program id
     */
    async getMasterEditionAccountPda(mintAccountPda: PublicKey, mplProgramId: PublicKey = METADATA_PROGRAM_ID): Promise<[PublicKey, number]> {
        return PublicKey.findProgramAddressSync(
            [
                Buffer.from('metadata'),
                mplProgramId.toBuffer(),
                mintAccountPda.toBuffer(),
                Buffer.from('edition')
            ],
            mplProgramId
        );
    }

    /**
     * Get config account pda data
     * @param configAccountPda pda address for data
     */
    async getConfigAccountData(configAccountPda: PublicKey): Promise<any> {
        return await this.program.account.configAccount.fetch(configAccountPda);
    }

    /**
     * Get current block time
     */
    async getCurrentBlockTime(): Promise<BN> {
        return new BN(await this.connection.getBlockTime(
            await this.connection.getSlot(undefined)
        ));
    }

    /**
     * Create initialize config transaction
     * @param payer transaction fee payer public key
     * @param signingAuthority signing authority public key
     * @param configName config account seed for pda
     * @param systemProgram system program id
     * @param rent rent program id
     */
    async createInitializeConfigTransaction(payer: PublicKey, signingAuthority: PublicKey, configName: string = NAME,
                                            systemProgram: PublicKey = SystemProgram.programId, rent: PublicKey = SYSVAR_RENT_PUBKEY): Promise<Transaction> {

        const [configAccount] = await this.getConfigAccountPdaAndBump(configName);

        let tx: Transaction = await this.program.methods.initializeConfig(configName)
            .accounts({
                payer: payer,
                signingAuthority: signingAuthority,
                config: configAccount,
                systemProgram: systemProgram,
                rent: rent
            })
            .transaction();

        return tx;
    }

    /**
     * Create mint token transaction
     * @param assetUuid seed for the mint account
     * @param payer transaction fee payer public key
     * @param signingAuthority signing authority public key
     * @param mintAuthority token mint authority
     * @param configName config account seed for pda
     * @param tokenProgram token program id
     * @param associatedTokenProgram associated token program id
     * @param systemProgram system program id
     * @param rent rent program id
     */
    async createMintTokenTransaction(assetUuid: string, payer: PublicKey, signingAuthority: PublicKey,
                                     mintAuthority: PublicKey, configName: string = NAME,
                                     systemProgram: PublicKey = SystemProgram.programId, rent: PublicKey = SYSVAR_RENT_PUBKEY,
                                     associatedTokenProgram: PublicKey = ASSOCIATED_TOKEN_PROGRAM_ID, tokenProgram: PublicKey = TOKEN_PROGRAM_ID): Promise<Transaction> {

        const [configAccount, configAccountBump] = await this.getConfigAccountPdaAndBump(configName);

        const [mintAccount] = await this.getMintAccountPdaAndBump(assetUuid);

        let tx = await this.program.methods.createMintToken(configName, configAccountBump, assetUuid)
            .accounts({
                payer: payer,
                signingAuthority: signingAuthority,

                config: configAccount,

                mint: mintAccount,
                mintAuthority: mintAuthority,

                associatedTokenProgram: associatedTokenProgram,
                tokenProgram: tokenProgram,
                systemProgram: systemProgram,
                rent: rent
            })
            .transaction();

        return tx;
    }

    /**
     * Create collection token transaction
     * @param collectionUuid seed for the collection mint account
     * @param payer transaction fee payer public key
     * @param signingAuthority signing authority public key
     * @param collectionMintAuthority collection mint authority public key
     * @param collectionOwner collection owner
     * @param tokenName token name
     * @param tokenSymbol token symbol
     * @param tokenUrl token url
     * @param tokenIsMutable token is mutable
     * @param tokenSellerPoint token seller point
     * @param creators List of the creators
     * @param configName config account seed for pda
     * @param tokenProgram token program id
     * @param associatedTokenProgram associated token program id
     * @param systemProgram system program id
     * @param rent rent program id
     * @param mplProgram Metadata program id
     */
    async createCollectionTokenTransaction(collectionUuid: string, payer: PublicKey, signingAuthority: PublicKey, collectionMintAuthority: PublicKey, collectionOwner: PublicKey,
                                           tokenName: string, tokenSymbol: string, tokenUrl: string, tokenIsMutable: boolean, tokenSellerPoint: number, creators: CreatorType[], configName: string = NAME,
                                           systemProgram: PublicKey = SystemProgram.programId, rent: PublicKey = SYSVAR_RENT_PUBKEY,
                                           associatedTokenProgram: PublicKey = ASSOCIATED_TOKEN_PROGRAM_ID, tokenProgram: PublicKey = TOKEN_PROGRAM_ID,
                                           mplProgram: PublicKey = METADATA_PROGRAM_ID): Promise<Transaction> {


        const [configAccount, configAccountBump] = await this.getConfigAccountPdaAndBump(configName);

        const [collectionMintAccount, collectionMintAccountBump] = await this.getMintAccountPdaAndBump(collectionUuid);

        const [collectionMetadata] = await this.getMetadataAccountPda(collectionMintAccount, mplProgram);
        const [collectionMasterEdition] = await this.getMasterEditionAccountPda(collectionMintAccount, mplProgram);

        const collectionTokenAccount = await getAssociatedTokenAddress(collectionMintAccount, collectionOwner);

        let tx = await this.createMintTokenTransaction(collectionUuid, payer, signingAuthority, collectionMintAuthority, configName,
            systemProgram, rent, associatedTokenProgram, tokenProgram);

        let collectionTx = await this.program.methods.createCollection(configName, configAccountBump, collectionUuid, collectionMintAccountBump,
            tokenName, tokenSymbol, tokenUrl, tokenIsMutable, tokenSellerPoint, creators)
            .accounts({
                payer: payer,
                signingAuthority: signingAuthority,

                config: configAccount,

                collectionMintAuthority: collectionMintAuthority,
                collectionOwner: collectionOwner,

                collectionMintAccount: collectionMintAccount,
                collectionMintTokenAccount: collectionTokenAccount,
                collectionMetadata: collectionMetadata,
                collectionMasterEdition: collectionMasterEdition,

                mplProgram: mplProgram,

                associatedTokenProgram: associatedTokenProgram,
                tokenProgram: tokenProgram,
                systemProgram: systemProgram,
                rent: rent
            }).transaction();

        tx.add(collectionTx);

        return tx;
    }

    /**
     * Create sub collection token transaction
     * @param collectionUuid seed for the collection mint account
     * @param subCollectionUuid seed for the sub collection mint account
     * @param payer transaction fee payer public key
     * @param signingAuthority signing authority public key
     * @param subCollectionMintAuthority sub collection mint authority public key
     * @param subCollectionOwner sub collection owner public key
     * @param tokenName token name
     * @param tokenSymbol token symbol
     * @param tokenUrl token url
     * @param tokenIsMutable token is mutable
     * @param tokenSellerPoint token seller point
     * @param creators List of the creators
     * @param configName config account seed for pda
     * @param collectionUpdateAuthority collection update authority (By default it's singing authority)
     * @param tokenProgram token program id
     * @param associatedTokenProgram associated token program id
     * @param systemProgram system program id
     * @param rent rent program id
     * @param mplProgram Metadata program id
     */
    async createSubCollectionTokenTransaction(collectionUuid: string, subCollectionUuid: string, payer: PublicKey, signingAuthority: PublicKey,
                                              subCollectionMintAuthority: PublicKey, subCollectionOwner: PublicKey, tokenName: string, tokenSymbol: string, tokenUrl: string,
                                              tokenIsMutable: boolean, tokenSellerPoint: number, creators: CreatorType[], configName: string = NAME,
                                              collectionUpdateAuthority: PublicKey = signingAuthority, systemProgram: PublicKey = SystemProgram.programId, rent: PublicKey = SYSVAR_RENT_PUBKEY,
                                              associatedTokenProgram: PublicKey = ASSOCIATED_TOKEN_PROGRAM_ID, tokenProgram: PublicKey = TOKEN_PROGRAM_ID,
                                              mplProgram: PublicKey = METADATA_PROGRAM_ID): Promise<Transaction> {


        const [configAccount, configAccountBump] = await this.getConfigAccountPdaAndBump(configName);

        const [collectionMintAccount, collectionMintAccountBump] = await this.getMintAccountPdaAndBump(collectionUuid);
        const [collectionMetadata] = await this.getMetadataAccountPda(collectionMintAccount, mplProgram);
        const [collectionMasterEdition] = await this.getMasterEditionAccountPda(collectionMintAccount, mplProgram);


        const [subCollectionMintAccount, subCollectionMintAccountBump] = await this.getMintAccountPdaAndBump(subCollectionUuid);
        const [subCollectionMetadata] = await this.getMetadataAccountPda(subCollectionMintAccount, mplProgram);
        const [subCollectionMasterEdition] = await this.getMasterEditionAccountPda(subCollectionMintAccount, mplProgram);
        const subCollectionTokenAccount = await getAssociatedTokenAddress(subCollectionMintAccount, subCollectionOwner);

        let tx = await this.createMintTokenTransaction(subCollectionUuid, payer, signingAuthority, subCollectionMintAuthority, configName,
            systemProgram, rent, associatedTokenProgram, tokenProgram);

        let collectionTx = await this.program.methods.createSubCollection(configName, configAccountBump, collectionUuid, collectionMintAccountBump, subCollectionUuid, subCollectionMintAccountBump,
            tokenName, tokenSymbol, tokenUrl, tokenIsMutable, tokenSellerPoint, creators)
            .accounts({
                payer: payer,
                signingAuthority: signingAuthority,

                config: configAccount,

                collectionUpdateAuthority: collectionUpdateAuthority,
                collectionMintAccount: collectionMintAccount,
                collectionMetadata: collectionMetadata,
                collectionMasterEdition: collectionMasterEdition,

                subCollectionMintAuthority: subCollectionMintAuthority,
                subCollectionOwner: subCollectionOwner,
                subCollectionMintAccount: subCollectionMintAccount,
                subCollectionMetadata: subCollectionMetadata,
                subCollectionMasterEdition: subCollectionMasterEdition,
                subCollectionMintTokenAccount: subCollectionTokenAccount,

                mplProgram: mplProgram,

                associatedTokenProgram: associatedTokenProgram,
                tokenProgram: tokenProgram,
                systemProgram: systemProgram,
                rent: rent
            }).transaction();

        tx.add(collectionTx);

        return tx;
    }

    /**
     * Create asset token transaction
     * @param assetUuid asset uuid
     * @param collectionUuid seed for the collection mint account
     * @param payer transaction fee payer public key
     * @param signingAuthority signing authority public key
     * @param user user public key
     * @param assetMintAuthority: asset mint authority
     * @param tokenName token name
     * @param tokenSymbol token symbol
     * @param tokenUrl token url
     * @param tokenIsMutable token is mutable
     * @param tokenSellerPoint token seller point
     * @param creators List of the creators
     * @param configName config account seed for pda
     * @param collectionUpdateAuthority collection update authority (by default it's singing )
     * @param tokenProgram token program id
     * @param associatedTokenProgram associated token program id
     * @param systemProgram system program id
     * @param rent rent program id
     * @param mplProgram Metadata program id
     */
    async createAssetTokenTransaction(assetUuid: string, collectionUuid: string, payer: PublicKey, signingAuthority: PublicKey, user: PublicKey, assetMintAuthority: PublicKey,
                                      tokenName: string, tokenSymbol: string, tokenUrl: string, tokenIsMutable: boolean, tokenSellerPoint: number, creators: CreatorType[],
                                      configName: string = NAME, collectionUpdateAuthority: PublicKey = signingAuthority,
                                      systemProgram: PublicKey = SystemProgram.programId, rent: PublicKey = SYSVAR_RENT_PUBKEY,
                                      associatedTokenProgram: PublicKey = ASSOCIATED_TOKEN_PROGRAM_ID, tokenProgram: PublicKey = TOKEN_PROGRAM_ID,
                                      mplProgram: PublicKey = METADATA_PROGRAM_ID): Promise<Transaction> {


        const [configAccount, configAccountBump] = await this.getConfigAccountPdaAndBump(configName);

        const [collectionMintAccount, collectionMintAccountBump] = await this.getMintAccountPdaAndBump(collectionUuid);

        const [collectionMetadata] = await this.getMetadataAccountPda(collectionMintAccount, mplProgram);
        const [collectionMasterEdition] = await this.getMasterEditionAccountPda(collectionMintAccount, mplProgram);

        const [assetMintAccount, assetMintAccountBump] = await this.getMintAccountPdaAndBump(assetUuid);

        const [assetMetadata] = await this.getMetadataAccountPda(assetMintAccount, mplProgram);
        const [assetMasterEdition] = await this.getMasterEditionAccountPda(assetMintAccount, mplProgram);

        const assetTokenAccount = await getAssociatedTokenAddress(assetMintAccount, user);

        const currentBlockTime = await this.getCurrentBlockTime();

        let tx = await this.createMintTokenTransaction(assetUuid, payer, signingAuthority, assetMintAuthority, configName,
            systemProgram, rent, associatedTokenProgram, tokenProgram);

        const assetTx = await this.program.methods.createAsset(configName, configAccountBump, collectionUuid, collectionMintAccountBump, assetUuid, assetMintAccountBump,
            tokenName, tokenSymbol, tokenUrl, tokenIsMutable, tokenSellerPoint, creators)
            .accounts({
                payer: payer,
                signingAuthority: signingAuthority,
                user: user,

                config: configAccount,

                assetMintAuthority: assetMintAuthority,
                assetMintAccount: assetMintAccount,
                assetMetadata: assetMetadata,
                assetMasterEdition: assetMasterEdition,
                assetMintTokenAccount: assetTokenAccount,

                collectionUpdateAuthority: collectionUpdateAuthority,
                collectionMintAccount: collectionMintAccount,
                collectionMetadata: collectionMetadata,
                collectionMasterEdition: collectionMasterEdition,

                mplProgram: mplProgram,

                associatedTokenProgram: associatedTokenProgram,
                tokenProgram: tokenProgram,
                systemProgram: systemProgram,
                rent: rent
            })
            .transaction();

        tx.add(assetTx);

        return tx;
    }

    /**
     * Remove event listener.
     * @param eventId Event id which need to remove.
     */
    async removeEventListener(eventId: number) {
        await this.program.removeEventListener(eventId);
    }

    /**
     * Add mint event listener.
     * @param callback Callback for the event to manage the event response on emit.
     */
    addMintEventListener(callback: (event: MintEvent) => void): number {
        return this.program.addEventListener(MINT_EVENT_NAME, callback);
    }

    /**
     * Add collection event listener.
     * @param callback Callback for the event to manage the event response on emit.
     */
    addCollectionEventListener(callback: (event: CollectionEvent) => void): number {
        return this.program.addEventListener(COLLECTION_EVENT_NAME, callback);
    }

    /**
     * Add sub collection event listener.
     * @param callback Callback for the event to manage the event response on emit.
     */
    addSubCollectionEventListener(callback: (event: SubCollectionEvent) => void): number {
        return this.program.addEventListener(SUB_COLLECTION_EVENT_NAME, callback);
    }

    /**
     * Add asset event listener.
     * @param callback Callback for the event to manage the event response on emit.
     */
    addAssetEventListener(callback: (event: AssetEvent) => void): number {
        return this.program.addEventListener(ASSET_EVENT_NAME, callback);
    }

    /**
     * Creator sign metadata instruction.
     * @param uuid Seed for the mint account
     * @param creatorPublicKey Creator public key
     * @param mplProgram Metadata program id
     */
    async creatorSignMetadataInstruction(uuid: string, creatorPublicKey: PublicKey, mplProgram: PublicKey = METADATA_PROGRAM_ID): Promise<TransactionInstruction> {
        const [mintPda] = await this.getMintAccountPdaAndBump(uuid);
        const [metadataPda] = await this.getMetadataAccountPda(mintPda, mplProgram);

        return createSignMetadataInstruction({
            metadata: metadataPda,
            creator: creatorPublicKey
        });
    }

    /**
     * Create and add creator sign metadata instruction.
     * @param tx Transaction object where instruction needs to add.
     * @param uuid Seed for the mint account
     * @param creatorPublicKey Creator public key
     * @param mplProgram Metadata program id
     */
    async createAndAddCreatorSignMetadataInstruction(tx: Transaction, uuid: string, creatorPublicKey: PublicKey, mplProgram: PublicKey = METADATA_PROGRAM_ID): Promise<Transaction> {
        const [mintPda] = await this.getMintAccountPdaAndBump(uuid);
        const [metadataPda] = await this.getMetadataAccountPda(mintPda, mplProgram);

        return tx.add(createSignMetadataInstruction({
            metadata: metadataPda,
            creator: creatorPublicKey
        }));
    }
}

export interface CreatorType {
    address: web3.PublicKey;
    verified: boolean;
    share: number;
}