import { Dictionary } from 'infinitymint/dist/app/helpers';
import { ProjectCache } from 'infinitymint/dist/app/projects';
import { InfinityMintClientConfig } from './core/interfaces';
import { fetchProject, hasSavedProject, Project } from './core/project';
import {
    apiGet,
    chainIds,
    getApiEndpoint,
    isApiAlive,
    log,
    toProjectFullName,
    warning,
} from './utils/helpers';
import {
    Web3Provider,
    Provider,
    JsonRpcProvider,
} from '@ethersproject/providers';
import { ethers, Signer } from 'ethers';
import storageController from './core/storage';
import WebEvents from './webEvents';
import { requestAccounts, requestBalance, requestChainId } from './utils/web3';

export class InfinityMintClient {
    public projects: Dictionary<Project> = {};
    public apiAccess = false;
    public web3Access = false;
    public loaded = false;
    public networkAccess = false;
    public signers: Dictionary<Signer> = {};
    public addresses: string[];
    public providers: Dictionary<
        Web3Provider | Provider | ethers.providers.Web3Provider
    > = {};
    public staticProviders: Dictionary<JsonRpcProvider> = {};
    public defaultNetwork: string;
    public currentNetwork: {
        chainId: number;
        name: string;
        url?: string;
    };
    public defaultProject: Project;
    public defaultChainId: number;
    public projectsCache: ProjectCache;
    public config: InfinityMintClientConfig;

    private walletError: Error[];

    constructor(config: InfinityMintClientConfig) {
        this.defaultNetwork =
            config?.network?.default?.developer ||
            config?.network?.default?.production ||
            'ganache';
        this.config = config;
        this.registerKeybindings();
    }

    public getCurrentAddress(): string {
        return this.addresses?.[0] || '0x00000000000000000000000000000000';
    }

    async reset() {
        this.destroy();
        await this.load();
    }

    private async setWalletError(reason: string) {
        if (!this.walletError) this.walletError = [];
        this.walletError.push(new Error(reason));
        return false;
    }

    async wallet() {
        this.web3Access = false;
        this.networkAccess = false;

        if (!(window as any).ethereum)
            return this.setWalletError(
                'Wallet has not been installed on this browser'
            );

        //our own custom implementation here
        if (!this.config?.network?.callbacks?.connect) {
            try {
                //check it with the balance request
                let accounts = await requestAccounts();

                if (accounts.length === 0)
                    return this.setWalletError(
                        'You have not given us any accounts to work with!'
                    );

                //now lets check what we've actually been given
                let provider = new ethers.providers.Web3Provider(
                    (window as any).ethereum
                );

                //we have network access
                this.networkAccess = true;

                let chainId = await requestChainId(provider);
                let network = Object.keys(chainIds)
                    .filter((network) => chainIds[network] === chainId)
                    .pop();

                let signer = await provider.getSigner();
                let account = await signer.getAddress();
                await requestBalance(provider, account);

                this.setProviderForNetwork(network, provider);

                this.addresses = accounts;
                this.signers[account] = signer;
                this.web3Access = true;
            } catch (error) {
                return this.setWalletError(error.message);
            }
        } else {
            try {
                await this.config?.network?.callbacks?.connect();
            } catch (error) {
                return this.setWalletError(error.message);
            }
        }

        return true;
    }

    public getCurrentProvider(): Web3Provider {
        return this.providers[this.defaultNetwork] as Web3Provider;
    }

    public getCurrentStaticProvider(): JsonRpcProvider {
        return this.staticProviders[this.defaultNetwork];
    }

    public addLocalRequiredProject(projectFullName: string) {
        let project = this.projects[projectFullName];
        if (!project) return;
        storageController.setGlobalPreference('requiredProjects', {
            ...storageController.getGlobalPreference('requiredProjects'),
            [projectFullName]: {
                name: project.name,
                version: project.version,
                network: project.deployedProject.network.name,
            },
        });
        storageController.save();
    }

    /**
     *
     */
    public destroy() {
        //disconnect providers
        Object.keys(this.providers).forEach((network) => {
            let provider = this.providers[network];
            if (provider) provider.removeAllListeners();
        });
        Object.keys(this.staticProviders).forEach((network) => {
            let provider = this.staticProviders[network];
            if (provider) provider.removeAllListeners();
        });
        this.providers = {};
        this.staticProviders = {};
        this.signers = {};
        this.addresses = [];
        this.web3Access = false;
        this.networkAccess = false;
        this.loaded = false;
        this.projects = {};
        delete this.projectsCache;
    }

    public getOldestProject(
        projectName: string,
        projectNetwork: string,
        startingVersion: string = '0.0.0'
    ) {
        return this.getLatestProject(
            projectName,
            projectNetwork,
            startingVersion,
            true
        );
    }

    public getLatestProject(
        projectName: string,
        projectNetwork: string,
        startingVersion: string = '0.0.0',
        getOldest = false
    ) {
        let selection = Object.keys(this.projects).filter((projectFullName) => {
            let project = this.projects[projectFullName];
            return (
                project.name === projectName &&
                project.deployedProject.network.name === projectNetwork &&
                parseFloat(project.version.version) >
                    parseFloat(startingVersion)
            );
        });

        if (selection.length === 0) return null;

        //filter selection so the latest version is first
        selection.sort((a, b) => {
            let projectA = this.projects[a];
            let projectB = this.projects[b];
            return (
                parseFloat(projectB.version.version) -
                parseFloat(projectA.version.version)
            );
        });

        if (getOldest) return this.projects[selection[selection.length - 1]];
        return this.projects[selection[0]];
    }

    public removeLocalRequiredProject(projectFullName: string) {
        let project = this.projects[projectFullName];
        if (!project) return;
        let requiredProjects =
            storageController.getGlobalPreference('requiredProjects');
        delete requiredProjects[projectFullName];
        storageController.setGlobalPreference(
            'requiredProjects',
            requiredProjects
        );
        storageController.save();
    }

    async load(abortController?: AbortController) {
        this.apiAccess = await isApiAlive();

        //do wallet
        if (!this.web3Access || this.networkAccess) await this.wallet();

        let requireProjects = this.config.projects?.require || [];
        requireProjects = {
            ...requireProjects,
            ...(storageController.getGlobalPreference('requiredProjects') ||
                {}),
        };

        for (let projectKey of Object.keys(requireProjects)) {
            let project = requireProjects[projectKey];

            if (project.network && project.network instanceof Array === false) {
                project.network = [project.network];
            } else project.network = [this.defaultNetwork];

            for (let network of project.network) {
                if (!project.version) project.version = '1.0.0';

                if (
                    storageController.getGlobalPreference(
                        toProjectFullName(
                            project.name || projectKey,
                            project.version,
                            network
                        )
                    )
                ) {
                    console.log(
                        'Using local project => ' + (project.name || projectKey)
                    );
                    this.projects[
                        toProjectFullName(
                            project.name || projectKey,
                            project.version,
                            network
                        )
                    ] = Project.load(
                        project.name || projectKey,
                        project.version,
                        this,
                        network
                    );
                    log(
                        `Loaded Project => ${project.name || projectKey}@${
                            project.version
                        }_${network}`
                    );
                } else
                    try {
                        let fetchedProject = await fetchProject(
                            project.name || projectKey,
                            project.version || '1.0.0',
                            network || this.defaultNetwork,
                            this,
                            abortController,
                            !this.apiAccess
                        );

                        log(
                            'Fetched Project => ' +
                                projectKey +
                                '@' +
                                project.version +
                                '_' +
                                (network || this.defaultNetwork)
                        );

                        let key =
                            fetchedProject.deployedProject?.name +
                            '@' +
                            fetchedProject.deployedProject?.version?.version +
                            '_' +
                            fetchedProject.deployedProject?.network?.name;
                        this.projects[key] = fetchedProject;

                        log(
                            `Loaded Project => ${project.name || projectKey}@${
                                project.version || '1.0.0'
                            }_${network || this.defaultNetwork}`
                        );
                    } catch (error) {
                        log(
                            'Bad Project => ' +
                                projectKey +
                                '@' +
                                project.version +
                                '_' +
                                (network || this.defaultNetwork)
                        );
                        console.error(error);
                    }
            }
        }

        if (!this.apiAccess) {
            warning('API is not available');
        } else {
            //fetch API related things
            this.projectsCache = await this.getProjectsCacheFromApi();

            if (this.config.network?.setDefaultUsingApi) {
                let result = await this.getNetworkFromApi();
                log('Setting default network using API', {
                    name: result.name,
                    chainId: result.chainId,
                });
                this.defaultNetwork = result.name;
                this.defaultChainId = result.chainId;
            }
        }

        if (
            this.config.network?.default &&
            (!this.apiAccess || !this.config.network?.setDefaultUsingApi)
        ) {
            this.defaultNetwork = this.config.network.default.developer;

            if (!chainIds[this.config.network.default.developer])
                throw new Error(`
                        Invalid network name has no chain Id: ${this.config.network.default.developer}
                    `);

            this.defaultChainId =
                chainIds[this.config.network.default.developer];
        }

        let defaultProject = this.config?.projects?.default?.dev;

        if (this.config?.projects?.forceProduction)
            defaultProject = this.config?.projects?.default?.production;

        if (!defaultProject) {
            warning(
                'No default project set, assuming first project (which is ' +
                    Object.keys(this.projects)[0] +
                    ')'
            );
        } else log('Default project set to ' + defaultProject);

        //just load the first required project if no default is found
        defaultProject = defaultProject || Object.keys(this.projects)[0];

        if (
            !this.config.projects?.ignoreCustomDefaultProject &&
            storageController.getGlobalPreference('defaultProject') &&
            this.projects[
                storageController.getGlobalPreference('defaultProject')
            ]
        ) {
            console.warn('Using local default project => ' + defaultProject);
            defaultProject =
                storageController.getGlobalPreference('defaultProject');
        }

        if (
            !this.web3Access ||
            !this.networkAccess ||
            this.config.network?.alwaysCreateStaticProviders
        )
            this.createStaticProviders();

        if (defaultProject) {
            if (this.config.projects.require[defaultProject])
                defaultProject =
                    defaultProject +
                    '@' +
                    this.config.projects.require[defaultProject].version +
                    '_' +
                    (!this.config.projects.require[defaultProject].network
                        ? this.defaultNetwork
                        : this.config.projects.require[defaultProject]
                              .network instanceof Array
                        ? this.config.projects.require[defaultProject]
                              .network[0]
                        : this.config.projects.require[defaultProject].network);

            await this.loadProject(defaultProject);

            this.defaultProject = this.projects[defaultProject];

            //will check chain for new project
            if (this.networkAccess) {
                log('Searching for updates from chain');
                let updates = await this.defaultProject.checkForUpdates();

                if (updates.newVersion) {
                    let newProject = Project.create(
                        await this.defaultProject.fetchProjectFromWeb3(),
                        this
                    );

                    this.projects[newProject.projectFullName] = newProject;

                    await this.loadProject(newProject.projectFullName);
                    this.defaultProject = newProject;
                    this.removeLocalRequiredProject(
                        toProjectFullName(
                            newProject.name,
                            updates.currentVersion,
                            newProject.deployedProject.network.name
                        )
                    );
                    this.addLocalRequiredProject(newProject.projectFullName);
                    this.saveDefaultProject(newProject);

                    WebEvents.emit(
                        'updatedProject',
                        newProject,
                        this.projects[
                            toProjectFullName(
                                newProject.name,
                                updates.currentVersion,
                                newProject.deployedProject.network.name
                            )
                        ]
                    );
                }
            }
        }

        this.loaded = true;
    }

    public registerKeybindings() {
        const toggleDashboard = (event) => {
            if (event.ctrlKey && event.key === 'q') {
                WebEvents.emit('toggleDashboard');
            }
        };

        document.removeEventListener('keydown', toggleDashboard);
        document.addEventListener('keydown', toggleDashboard);
    }

    /**
     * Will set the default project to load
     * @param project
     */
    public saveDefaultProject(project: Project) {
        storageController.setGlobalPreference(
            'defaultProject',
            project.projectFullName
        );
        storageController.save();
    }

    public getDefaultProject() {
        return this.defaultProject;
    }

    public async loadProject(
        projectFullName: string,
        project?: Project,
        instantiateContracts: boolean = true
    ) {
        project = project || this.projects[projectFullName];

        if (!project) throw new Error('Project not found: ' + projectFullName);

        await project.initializeResources();

        let setNetworkProvider = this.web3Access && this.networkAccess;

        log('has network provider => ' + (setNetworkProvider ? 'yes' : 'no'));

        this.setProjectStaticProvider(
            project.deployedProject.name,
            project.deployedProject.version.version,
            project.deployedProject.network.name
        );

        if (setNetworkProvider)
            this.setProjectProvider(
                project.deployedProject.name,
                project.deployedProject.version.version,
                project.deployedProject.network.name
            );

        if (instantiateContracts)
            await this.instantiateContracts(
                project.deployedProject.name,
                project.deployedProject.version.version,
                project.deployedProject.network.name
            );
    }

    public async instantiateContracts(
        projectName: string,
        version: string,
        network: string
    ) {
        let project = this.getProject(projectName, network, version);

        if (!project)
            throw new Error(
                'Project not found: ' +
                    toProjectFullName(projectName, version, network)
            );

        let contracts = {};
        Object.values(project.deployedProject.deployments).forEach(
            async (contract) => {
                if (
                    contracts[
                        contract.name || contract.contractName || contract.key
                    ]
                )
                    return;
                project.getContract(
                    contract.name || contract.contractName || contract.key,
                    null,
                    false,
                    true
                );
                contracts[
                    contract.name || contract.contractName || contract.key
                ] = true;
            }
        );
    }

    public setProjectProvider(
        projectName: string,
        version: string,
        network: string
    ) {
        let project = this.getProject(projectName, network, version);
        if (!project)
            throw new Error(
                'Project not found: ' +
                    toProjectFullName(projectName, version, network)
            );
        project.setProvider(this.providers[network]);
    }

    public createStaticProviders() {
        let providers = {
            ganache: 'http://localhost:8545',
            ...(this.config.network?.providers || {}),
        };

        log('Creating static providers');
        Object.keys(providers).forEach((network) => {
            if (this.staticProviders[network])
                delete this.staticProviders[network];

            this.staticProviders[network] = new JsonRpcProvider(
                providers[network]
            );
            log('Added static provider => ' + network);
        });
    }

    public setProjectStaticProvider(
        projectName: string,
        version: string,
        network: string
    ) {
        let project = this.getProject(projectName, network, version);
        if (!project) throw new Error('Project not found');
        if (!this.staticProviders[network])
            throw new Error('No static provider found for ' + network);

        project.setStaticProvider(this.staticProviders[network]);
    }

    public getProject(name: string, network: string, version?: string) {
        version = version || '1.0.0';
        let result = this.projects[toProjectFullName(name, version, network)];

        return result;
    }

    public async loadProjectFromStorage(
        name: string,
        version: string = '1.0.0',
        network?: string,
        instantiate: boolean = true,
        requireNetworkChange?: boolean
    ) {
        if (!hasSavedProject(name, version, network))
            throw new Error(
                'Saved project not found: ' +
                    toProjectFullName(name, version, network)
            );

        let result = Project.load(name, version, this, network);

        if (instantiate) await this.loadProject(null, result);

        return result;
    }

    public async createUpdate(projectName: string, newVersion?: string) {
        let project = this.getProject(projectName, this.defaultNetwork);
        if (!project) throw new Error('Project not found');

        newVersion =
            newVersion ||
            project.deployedProject.version.version
                .split('.')
                .map((v, i) => (i === 2 ? parseInt(v) + 1 : v))
                .join('.');

        if (this.hasProject(projectName, this.defaultNetwork, newVersion))
            throw new Error('Project already exists');

        let result = project.clone(newVersion);
        this.projects[
            toProjectFullName(projectName, newVersion, this.defaultNetwork)
        ] = result;
        return await this.loadProject(
            toProjectFullName(projectName, newVersion, this.defaultNetwork)
        );
    }

    public getContract(
        projectName: string,
        contractName: string,
        network?: string,
        version?: string
    ) {
        network = network || this.defaultNetwork;
        let project = this.getProject(projectName, network, version);
        if (!project)
            throw new Error(
                'Project not found: ' +
                    toProjectFullName(projectName, version, network)
            );
        return project.getContract(contractName, undefined, true);
    }

    public hasProject(name: string, network?: string, version?: string) {
        network = network || this.defaultNetwork;
        version = version || '1.0.0';
        return !!this.projects[toProjectFullName(name, version, network)];
    }

    public getSignedContract(
        projectName: string,
        contractName: string,
        network?: string,
        version?: string
    ) {
        network = network || this.defaultNetwork;
        let project = this.getProject(projectName, network, version);
        if (!project)
            throw new Error(
                'Project not found: ' +
                    toProjectFullName(projectName, version, network)
            );
        return project.getContract(contractName, undefined, true);
    }

    public setStaticProviderForNetwork(
        network: string,
        provider: JsonRpcProvider
    ) {
        this.staticProviders[network] = provider;
    }

    public getProviderForNetwork(network: string) {
        return this.providers[network];
    }

    public getStaticProviderForNetwork(network: string) {
        return this.staticProviders[network];
    }

    public setProviderForNetwork(
        network: string,
        provider: Web3Provider | Provider | ethers.providers.Web3Provider
    ) {
        if (network === 'mainnet') network = 'ethereum';
        log('Setting provider for network ' + network);
        this.providers[network] = provider;
    }

    public async getProjectFromPublic(
        projectName: string,
        version: string,
        network: string,
        instantiate: boolean = true,
        abortController?: AbortController
    ) {
        let project = await fetchProject(
            projectName,
            version,
            network,
            this,
            abortController,
            true
        );

        if (!instantiate) return project;

        this.projects[toProjectFullName(projectName, version, network)] =
            project;

        if (this.web3Access && this.networkAccess) {
            this.setProjectProvider(
                projectName,
                version,
                project.deployedProject.network.name
            );
        } else {
            this.setProjectStaticProvider(
                projectName,
                version,
                project.deployedProject.network.name
            );
        }

        return project;
    }

    public async getProjectFromApi(
        projectName: string,
        version: string,
        network: string,
        type: 'deployed' | 'compiled' = 'deployed',
        instantiate: boolean = true,
        forceDeveloper: boolean = false,
        abortController?: AbortController
    ): Promise<Project> {
        if (!this.apiAccess) throw new Error('API is not available');

        let json = await apiGet(
            '/projects/get',
            {
                name: projectName,
                version: version,
                network: network,
                type: type,
            },
            forceDeveloper,
            abortController
        );
        let project = Project.create(json, this);

        if (!instantiate) return project;

        this.projects[toProjectFullName(projectName, version, network)] =
            project;
        await this.loadProject(
            toProjectFullName(projectName, version, network)
        );

        return project;
    }

    public async getConfigFromApi(abortController?: AbortController) {
        if (!this.apiAccess) throw new Error('API is not available');

        let endPoint = getApiEndpoint('/meta/config');
        let response = await fetch(endPoint, {
            signal: abortController?.signal,
        });
        let json = await response.json();
        return json;
    }

    async getNetworkFromApi(): Promise<{ name: string; chainId: number }> {
        if (!this.apiAccess) throw new Error('API is not available');

        let endPoint = getApiEndpoint('/meta/network');
        let response = await fetch(endPoint);
        let json = await response.json();
        return json;
    }

    async getProjectsCacheFromApi(
        abortController?: AbortController
    ): Promise<ProjectCache> {
        if (!this.apiAccess) throw new Error('API is not available');
        let endPoint = getApiEndpoint('/meta/projects/all');
        let response = await fetch(endPoint, {
            signal: abortController?.signal,
        });
        let json = await response.json();
        return json;
    }
}
