/**
 * BetterDiscord Content Manager Module
 * Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks
 * All rights reserved.
 * https://betterdiscord.net
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
*/

import asar from 'asar';
import path, { dirname } from 'path';
import rimraf from 'rimraf';

import { remote } from 'electron';
import Content from './content';
import Globals from './globals';
import Database from './database';
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
import { SettingsSet, ErrorEvent } from 'structs';
import { Modals } from 'ui';
import Combokeys from 'combokeys';
import Settings from './settings';

/**
 * Base class for managing external content
 */
export default class {

    /**
     * Any errors that happened.
     * @return {Array}
     */
    static get errors() {
        return this._errors || (this._errors = []);
    }

    /**
     * Locally stored content.
     * @return {Array}
     */
    static get localContent() {
        return this._localContent ? this._localContent : (this._localContent = []);
    }

    /**
     * The type of content this content manager manages.
     */
    static get contentType() {
        return undefined;
    }

    /**
     * The name of this content manager.
     */
    static get moduleName() {
        return undefined;
    }

    /**
     * The path used to store this content manager's content.
     */
    static get pathId() {
        return undefined;
    }

    /**
     * Local path for content.
     * @return {String}
     */
    static get contentPath() {
        return Globals.getPath(this.pathId);
    }

    static async packContent(path, contentPath) {
        return new Promise((resolve, reject) => {
            remote.dialog.showSaveDialog({
                title: 'Save Package',
                defaultPath: path,
                filters: [
                    {
                        name: 'BetterDiscord Package',
                        extensions: ['bd']
                    }
                ]
            }, filepath => {
                if (!filepath) return;

                asar.uncache(filepath);
                asar.createPackage(contentPath, filepath, () => {
                    resolve(filepath);
                });
            });
        });
    }

    /**
     * Load all locally stored content.
     * @param {bool} suppressErrors Suppress any errors that occur during loading of content
     */
    static async loadAllContent(suppressErrors = false) {
        try {
            await FileUtils.ensureDirectory(this.contentPath);
            const directories = await FileUtils.listDirectory(this.contentPath);

            for (const dir of directories) {
                const packed = dir.endsWith('.bd');

                if (!packed) {
                    try {
                        await FileUtils.directoryExists(path.join(this.contentPath, dir));
                    } catch (err) { continue; }
                }

                try {
                    if (packed) {
                        await this.preloadPackedContent(dir);
                    } else {
                        await this.preloadContent(dir);
                    }
                } catch (err) {
                    this.errors.push(new ErrorEvent({
                        module: this.moduleName,
                        message: `Failed to load ${dir}`,
                        err
                    }));

                    Logger.err(this.moduleName, err);
                }
            }

            if (this.errors.length && !suppressErrors) {
                Modals.error({
                    header: `${this.moduleName} - ${this.errors.length} ${this.contentType}${this.errors.length !== 1 ? 's' : ''} failed to load`,
                    module: this.moduleName,
                    type: 'err',
                    content: this.errors
                });
                this._errors = [];
            }
        } catch (err) {
            throw err;
        }
    }

    /**
     * Refresh locally stored content
     * @param {bool} suppressErrors Suppress any errors that occur during loading of content
     */
    static async refreshContent(suppressErrors = false) {
        if (!this.localContent.length) return this.loadAllContent();

        try {
            await FileUtils.ensureDirectory(this.contentPath);
            const directories = await FileUtils.listDirectory(this.contentPath);

            for (const dir of directories) {
                const packed = dir.endsWith('.bd');

                // If content is already loaded this should resolve
                if (this.getContentByDirName(dir)) continue;

                try {
                    await FileUtils.directoryExists(path.join(this.contentPath, dir));
                } catch (err) { continue; }

                try {
                    // Load if not
                    await this.preloadContent(dir);
                } catch (err) {
                    // We don't want every plugin/theme to fail loading when one does
                    this.errors.push(new ErrorEvent({
                        module: this.moduleName,
                        message: `Failed to load ${dir}`,
                        err
                    }));

                    Logger.err(this.moduleName, err);
                }
            }

            for (const content of this.localContent) {
                if (directories.includes(content.dirName)) continue;

                try {
                    // Plugin/theme was deleted manually, stop it and remove any reference
                    await this.unloadContent(content);
                } catch (err) {
                    this.errors.push(new ErrorEvent({
                        module: this.moduleName,
                        message: `Failed to unload ${content.dirName}`,
                        err
                    }));

                    Logger.err(this.moduleName, err);
                }
            }

            if (this.errors.length && !suppressErrors) {
                Modals.error({
                    header: `${this.moduleName} - ${this.errors.length} ${this.contentType}${this.errors.length !== 1 ? 's' : ''} failed to load`,
                    module: this.moduleName,
                    type: 'err',
                    content: this.errors
                });
                this._errors = [];
            }
        } catch (err) {
            throw err;
        }
    }

    static async preloadPackedContent(pkg, reload = false, index) {
        try {
            const packagePath = path.join(this.contentPath, pkg);
            const packageName = pkg.replace('.bd', '');
            await FileUtils.fileExists(packagePath);

            const config = JSON.parse(asar.extractFile(packagePath, 'config.json').toString());
            const unpackedPath = path.join(Globals.getPath('tmp'), packageName);

            asar.extractAll(packagePath, unpackedPath);
            return this.preloadContent({
                config,
                contentPath: unpackedPath,
                packagePath: packagePath,
                pkg,
                packageName,
                packed: true
            }, reload, index);

        } catch (err) {
            throw err;
        }
    }

    /**
     * Common loading procedure for loading content before passing it to the actual loader
     * @param {any} dirName Base directory for content
     * @param {any} reload Is content being reloaded
     * @param {any} index Index of content in {localContent}
     */
    static async preloadContent(dirName, reload = false, index) {
        try {
            const unsafeAllowed = Settings.getSetting('security', 'default', 'unsafe-content').value;
            const packed = typeof dirName === 'object' && dirName.packed;

            // Block any unpacked content as they can't be verified
            if (!packed && !unsafeAllowed) {
                throw 'Blocked unsafe content';
            }

            const contentPath = packed ? dirName.contentPath : path.join(this.contentPath, dirName);

            await FileUtils.directoryExists(contentPath);

            if (!reload && this.getContentByPath(contentPath))
                throw { 'message': `Attempted to load already loaded user content: ${path}` };

            const configPath = path.resolve(contentPath, 'config.json');
            const readConfig = packed ? dirName.config : await FileUtils.readJsonFromFile(configPath);
            const mainPath = path.join(contentPath, readConfig.main || 'index.js');

            const defaultConfig = new SettingsSet({
                settings: readConfig.defaultConfig,
                schemes: readConfig.configSchemes
            });

            const userConfig = {
                enabled: false,
                config: undefined,
                data: {}
            };

            try {
                const id = readConfig.info.id || readConfig.info.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-');
                const readUserConfig = await Database.find({ type: `${this.contentType}-config`, id });
                if (readUserConfig.length) {
                    userConfig.enabled = readUserConfig[0].enabled || false;
                    userConfig.config = readUserConfig[0].config;
                    userConfig.data = readUserConfig[0].data || {};
                }
            } catch (err) {
                // We don't care if this fails it either means that user config doesn't exist or there's something wrong with it so we revert to default config
                Logger.warn(this.moduleName, [`Failed reading config for ${this.contentType} ${readConfig.info.name} in ${packed ? dirName.pkg : dirName}`, err]);
            }

            userConfig.config = defaultConfig.clone({ settings: userConfig.config });
            userConfig.config.setSaved();

            for (const setting of userConfig.config.findSettings(() => true)) {
                // This will load custom settings
                // Setting the content's path on only the live config (and not the default config) ensures that custom settings will not be loaded on the default settings
                setting.setContentPath(contentPath);
            }

            for (const scheme of userConfig.config.schemes) {
                scheme.setContentPath(contentPath);
            }

            Utils.deepfreeze(defaultConfig, object => object instanceof Combokeys);

            const configs = {
                defaultConfig,
                schemes: userConfig.schemes,
                userConfig
            };

            const paths = {
                contentPath,
                dirName,
                mainPath
            };

            const content = await this.loadContent(paths, configs, readConfig.info, readConfig.main, readConfig.dependencies, readConfig.permissions, readConfig.mainExport, packed ? dirName : false);
            if (!content) return undefined;
            if (!reload && this.getContentById(content.id))
                throw { message: `A ${this.contentType} with the ID ${content.id} already exists.` };

            if (reload) this.localContent.splice(index, 1, content);
            else this.localContent.push(content);
            return content;
        } catch (err) {
            throw err;
        } finally {
            if (typeof dirName === 'object' && dirName.packed) {
                rimraf(dirName.contentPath, err => {
                    if (err) Logger.err(err);
                });
            }
        }
    }

    /**
     * Delete content.
     * @param {Content|String} content Content to delete
     * @param {Boolean} force If true the content will be deleted even if an exception is thrown when disabling/unloading/deleting
     */
    static async deleteContent(content, force) {
        content = this.findContent(content);
        if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`};

        try {
            await Modals.confirm(`Delete ${this.contentType}?`, `Are you sure you want to delete ${content.info.name} ?`, 'Delete').promise;
        } catch (err) {
            return false;
        }

        try {
            const unload = this.unloadContent(content, force, false);

            if (!force)
                await unload;

            await FileUtils.recursiveDeleteDirectory(content.paths.contentPath);
            return true;
        } catch (err) {
            Logger.err(this.moduleName, err);
            throw err;
        }
    }

    /**
     * Unload content.
     * @param {Content|String} content Content to unload
     * @param {Boolean} force If true the content will be unloaded even if an exception is thrown when disabling/unloading
     * @param {Boolean} reload Whether to reload the content after
     * @return {Content}
     */
    static async unloadContent(content, force, reload) {
        content = this.findContent(content);
        if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`};

        try {
            const disablePromise = content.disable(false);
            const unloadPromise = content.emit('unload', reload);

            if (!force) {
                await disablePromise;
                await unloadPromise;
            }

            const index = this.getContentIndex(content);

            if (this.unloadContentHook) this.unloadContentHook(content);

            if (reload) return content.packed ? this.preloadPackedContent(content.packed.pkg, true, index) : this.preloadContent(content.dirName, true, index);

            this.localContent.splice(index, 1);
        } catch (err) {
            Logger.err(this.moduleName, err);
            throw err;
        }
    }

    /**
     * Reload content.
     * @param {Content|String} content Content to reload
     * @param {Boolean} force If true the content will be unloaded even if an exception is thrown when disabling/unloading
     * @return {Content}
     */
    static reloadContent(content, force) {
        return this.unloadContent(content, force, true);
    }

    /**
     * Checks if the passed object is an instance of this content type.
     * @param {Any} content Object to check
     * @return {Boolean}
     */
    static isThisContent(content) {
        return content instanceof Content;
    }

    /**
     * Returns the first content where calling {function} returns true.
     * @param {Function} function A function to call to filter content
     */
    static find(f) {
        return this.localContent.find(f);
    }

    /**
     * Wildcard content finder
     * @param {String} wild Content ID / directory name / path / name
     * @param {Boolean} nonunique Allow searching attributes that may not be unique
     * @return {Content}
     */
    static findContent(wild, nonunique) {
        if (this.isThisContent(wild)) return wild;
        let content;
        content = this.getContentById(wild); if (content) return content;
        content = this.getContentByDirName(wild); if (content) return content;
        content = this.getContentByPath(wild); if (content) return content;
        content = this.getContentByName(wild); if (content && nonunique) return content;
    }

    static getContentIndex(content) { return this.localContent.findIndex(c => c === content) }
    static getContentById(id) { return this.localContent.find(c => c.id === id) }
    static getContentByDirName(dirName) { return this.localContent.find(c => c.dirName === dirName) }
    static getContentByPath(path) { return this.localContent.find(c => c.contentPath === path) }
    static getContentByName(name) { return this.localContent.find(c => c.name === name) }

    /**
     * Wait for content to load
     * @param {String} content_id
     * @return {Promise => Content}
     */
    static waitForContent(content_id) {
        return Utils.until(() => this.getContentById(content_id), 100);
    }

}
