import PluginBase from '../abstracts/PluginBase';
import Singleton from '../abstracts/Singleton';
import PluginLoader from './PluginLoader';

import Cookie from '../utilities/Cookie';
import JsonParser from '../utilities/JsonParser';
import Sanitizer from '../utilities/Sanitizer';
import Url from '../utilities/Url';

/**
 * Snowboard - the Winter JavaScript framework.
 *
 * This class represents the base of a modern take on the Winter JS framework, being fully extensible and taking advantage
 * of modern JavaScript features by leveraging the Laravel Mix compilation framework. It also is coded up to remove the
 * dependency of jQuery.
 *
 * @copyright 2021 Winter.
 * @author Ben Thomson <git@alfreido.com>
 * @link https://wintercms.com/docs/snowboard/introduction
 */
export default class Snowboard {
    /**
     * Constructor.
     *
     * @param {boolean} autoSingletons Automatically load singletons when DOM is ready. Default: `true`.
     * @param {boolean} debug Whether debugging logs should be shown. Default: `false`.
     */
    constructor(autoSingletons, debug) {
        this.debugEnabled = (typeof debug === 'boolean' && debug === true);
        this.autoInitSingletons = (typeof autoSingletons === 'boolean' && autoSingletons === false);
        this.plugins = {};
        this.listeners = {};
        this.foundBaseUrl = null;
        this.readiness = {
            dom: false,
        };
        // Seal readiness from being added to further, but allow the properties to be modified.
        Object.seal(this.readiness);
        this.attachAbstracts();

        // Freeze the Snowboard class to prevent further modifications.
        Object.freeze(Snowboard.prototype);
        Object.freeze(this);

        this.loadUtilities();
        this.initialise();

        this.debug('Snowboard framework initialised');
    }

    /**
     * Attaches abstract classes as properties of the Snowboard class.
     *
     * This will allow Javascript functionality with no build process to still extend these abstracts by prefixing
     * them with "Snowboard".
     *
     * ```
     * class MyClass extends Snowboard.PluginBase {
     *     ...
     * }
     * ```
     */
    attachAbstracts() {
        this.PluginBase = PluginBase;
        this.Singleton = Singleton;

        Object.freeze(this.PluginBase.prototype);
        Object.freeze(this.PluginBase);
        Object.freeze(this.Singleton.prototype);
        Object.freeze(this.Singleton);
    }

    /**
     * Loads the default utilities.
     */
    loadUtilities() {
        this.addPlugin('cookie', Cookie);
        this.addPlugin('jsonParser', JsonParser);
        this.addPlugin('sanitizer', Sanitizer);
        this.addPlugin('url', Url);
    }

    /**
     * Initialises the framework.
     *
     * Attaches a listener for the DOM being ready and triggers a global "ready" event for plugins to begin attaching
     * themselves to the DOM.
     */
    initialise() {
        window.addEventListener('DOMContentLoaded', () => {
            if (this.autoInitSingletons) {
                this.initialiseSingletons();
            }
            this.globalEvent('ready');
            this.readiness.dom = true;
        });
    }

    /**
     * Initialises an instance of every singleton.
     */
    initialiseSingletons() {
        Object.values(this.plugins).forEach((plugin) => {
            if (plugin.isSingleton() && plugin.dependenciesFulfilled()) {
                plugin.initialiseSingleton();
            }
        });
    }

    /**
     * Adds a plugin to the framework.
     *
     * Plugins are the cornerstone for additional functionality for Snowboard. A plugin must either be an ES2015 class
     * that extends the PluginBase or Singleton abstract classes, or a simple callback function.
     *
     * When a plugin is added, it is automatically assigned as a new magic method in the Snowboard class using the name
     * parameter, and can be called via this method. This method will always be the "lowercase" version of this name.
     *
     * For example, if a plugin is assigned to the name "myPlugin", it can be called via `Snowboard.myplugin()`.
     *
     * @param {string} name
     * @param {PluginBase|Function} instance
     */
    addPlugin(name, instance) {
        const lowerName = name.toLowerCase();

        if (this.hasPlugin(lowerName)) {
            throw new Error(`A plugin called "${name}" is already registered.`);
        }

        if (typeof instance !== 'function' && instance instanceof PluginBase === false) {
            throw new Error('The provided plugin must extend the PluginBase class, or must be a callback function.');
        }

        if (this[name] !== undefined || this[lowerName] !== undefined) {
            throw new Error('The given name is already in use for a property or method of the Snowboard class.');
        }

        this.plugins[lowerName] = new PluginLoader(lowerName, this, instance);

        this.debug(`Plugin "${name}" registered`);

        // Check if any singletons now have their dependencies fulfilled, and fire their "ready" handler if we're
        // in a ready state.
        Object.values(this.getPlugins()).forEach((plugin) => {
            if (
                plugin.isSingleton()
                && !plugin.isInitialised()
                && plugin.dependenciesFulfilled()
                && plugin.hasMethod('listens')
                && Object.keys(plugin.callMethod('listens')).includes('ready')
                && this.readiness.dom
            ) {
                const readyMethod = plugin.callMethod('listens').ready;
                plugin.callMethod(readyMethod);
            }
        });
    }

    /**
     * Removes a plugin.
     *
     * Removes a plugin from Snowboard, calling the destructor method for all active instances of the plugin.
     *
     * @param {string} name
     * @returns {void}
     */
    removePlugin(name) {
        const lowerName = name.toLowerCase();

        if (!this.hasPlugin(lowerName)) {
            this.debug(`Plugin "${name}" already removed`);
            return;
        }

        // Call destructors for all instances
        this.plugins[lowerName].getInstances().forEach((instance) => {
            instance.destruct();
        });

        delete this.plugins[lowerName];
        delete this[lowerName];
        delete this[name];

        this.debug(`Plugin "${name}" removed`);
    }

    /**
     * Determines if a plugin has been registered and is active.
     *
     * A plugin that is still waiting for dependencies to be registered will not be active.
     *
     * @param {string} name
     * @returns {boolean}
     */
    hasPlugin(name) {
        const lowerName = name.toLowerCase();

        return (this.plugins[lowerName] !== undefined);
    }

    /**
     * Returns an array of registered plugins as PluginLoader objects.
     *
     * @returns {PluginLoader[]}
     */
    getPlugins() {
        return this.plugins;
    }

    /**
     * Returns an array of registered plugins, by name.
     *
     * @returns {string[]}
     */
    getPluginNames() {
        return Object.keys(this.plugins);
    }

    /**
     * Returns a PluginLoader object of a given plugin.
     *
     * @returns {PluginLoader}
     */
    getPlugin(name) {
        const lowerName = name.toLowerCase();

        if (!this.hasPlugin(lowerName)) {
            throw new Error(`No plugin called "${lowerName}" has been registered.`);
        }

        return this.plugins[lowerName];
    }

    /**
     * Finds all plugins that listen to the given event.
     *
     * This works for both normal and promise events. It does NOT check that the plugin's listener actually exists.
     *
     * @param {string} eventName
     * @returns {string[]} The name of the plugins that are listening to this event.
     */
    listensToEvent(eventName) {
        const plugins = [];

        Object.entries(this.plugins).forEach((entry) => {
            const [name, plugin] = entry;

            if (plugin.isFunction()) {
                return;
            }
            if (!plugin.dependenciesFulfilled()) {
                return;
            }
            if (!plugin.hasMethod('listens')) {
                return;
            }

            const listeners = plugin.callMethod('listens');

            if (typeof listeners[eventName] === 'string' || typeof listeners[eventName] === 'function') {
                plugins.push(name);
            }
        });

        return plugins;
    }

    /**
     * Add a simple ready listener.
     *
     * Synonymous with jQuery's "$(document).ready()" functionality, this allows inline scripts to
     * attach themselves to Snowboard immediately but only fire when the DOM is ready.
     *
     * @param {Function} callback
     */
    ready(callback) {
        if (this.readiness.dom) {
            callback();
        }

        this.on('ready', callback);
    }

    /**
     * Adds a simple listener for an event.
     *
     * This can be used for ad-hoc scripts that don't need a full plugin. The given callback will be
     * called when the event name provided fires. This works for both normal and Promise events. For
     * a Promise event, your callback must return a Promise.
     *
     * @param {String} eventName
     * @param {Function} callback
     */
    on(eventName, callback) {
        if (!this.listeners[eventName]) {
            this.listeners[eventName] = [];
        }

        if (!this.listeners[eventName].includes(callback)) {
            this.listeners[eventName].push(callback);
        }
    }

    /**
     * Removes a simple listener for an event.
     *
     * @param {String} eventName
     * @param {Function} callback
     */
    off(eventName, callback) {
        if (!this.listeners[eventName]) {
            return;
        }

        const index = this.listeners[eventName].indexOf(callback);
        if (index === -1) {
            return;
        }

        this.listeners[eventName].splice(index, 1);
    }

    /**
     * Calls a global event to all registered plugins.
     *
     * If any plugin returns a `false`, the event is considered cancelled.
     *
     * @param {string} eventName
     * @returns {boolean} If event was not cancelled
     */
    globalEvent(eventName, ...parameters) {
        this.debug(`Calling global event "${eventName}"`, ...parameters);

        // Find plugins listening to the event.
        const listeners = this.listensToEvent(eventName);
        if (listeners.length === 0) {
            this.debug(`No listeners found for global event "${eventName}"`);
            return true;
        }
        this.debug(`Listeners found for global event "${eventName}": ${listeners.join(', ')}`);

        let cancelled = false;

        listeners.forEach((name) => {
            const plugin = this.getPlugin(name);

            if (plugin.isFunction()) {
                return;
            }
            if (plugin.isSingleton() && plugin.getInstances().length === 0) {
                plugin.initialiseSingleton();
            }

            const listenMethod = plugin.callMethod('listens')[eventName];

            // Call event handler methods for all plugins, if they have a method specified for the event.
            plugin.getInstances().forEach((instance) => {
                // If a plugin has cancelled the event, no further plugins are considered.
                if (cancelled) {
                    return;
                }

                if (typeof listenMethod === 'function') {
                    try {
                        const result = listenMethod.apply(instance, parameters);
                        if (result === false) {
                            cancelled = true;
                        }
                    } catch (error) {
                        this.error(
                            `Error thrown in "${eventName}" event by "${name}" plugin.`,
                            error,
                        );
                    }
                } else if (typeof listenMethod === 'string') {
                    if (!instance[listenMethod]) {
                        throw new Error(`Missing "${listenMethod}" method in "${name}" plugin`);
                    }

                    try {
                        if (instance[listenMethod](...parameters) === false) {
                            cancelled = true;
                            this.debug(`Global event "${eventName}" cancelled by "${name}" plugin`);
                        }
                    } catch (error) {
                        this.error(
                            `Error thrown in "${eventName}" event by "${name}" plugin.`,
                            error,
                        );
                    }
                } else {
                    this.error(`Listen method for "${eventName}" event in "${name}" plugin is not a function or string.`);
                }
            });
        });

        // Find ad-hoc listeners for this event.
        if (!cancelled && this.listeners[eventName] && this.listeners[eventName].length > 0) {
            this.debug(`Found ${this.listeners[eventName].length} ad-hoc listener(s) for global event "${eventName}"`);

            this.listeners[eventName].forEach((listener) => {
                // If a listener has cancelled the event, no further listeners are considered.
                if (cancelled) {
                    return;
                }

                try {
                    if (listener(...parameters) === false) {
                        cancelled = true;
                        this.debug(`Global event "${eventName} cancelled by an ad-hoc listener.`);
                    }
                } catch (error) {
                    this.error(
                        `Error thrown in "${eventName}" event by an ad-hoc listener.`,
                        error,
                    );
                }
            });
        }

        return !cancelled;
    }

    /**
     * Calls a global event to all registered plugins, expecting a Promise to be returned by all.
     *
     * This collates all plugins responses into one large Promise that either expects all to be resolved, or one to reject.
     * If no listeners are found, a resolved Promise is returned.
     *
     * @param {string} eventName
     */
    globalPromiseEvent(eventName, ...parameters) {
        this.debug(`Calling global promise event "${eventName}"`);

        // Find plugins listening to this event.
        const listeners = this.listensToEvent(eventName);
        if (listeners.length === 0) {
            this.debug(`No listeners found for global promise event "${eventName}"`);
            return Promise.resolve();
        }
        this.debug(`Listeners found for global promise event "${eventName}": ${listeners.join(', ')}`);

        const promises = [];

        listeners.forEach((name) => {
            const plugin = this.getPlugin(name);

            if (plugin.isFunction()) {
                return;
            }
            if (plugin.isSingleton() && plugin.getInstances().length === 0) {
                plugin.initialiseSingleton();
            }

            const listenMethod = plugin.callMethod('listens')[eventName];

            // Call event handler methods for all plugins, if they have a method specified for the event.
            plugin.getInstances().forEach((instance) => {
                if (typeof listenMethod === 'function') {
                    try {
                        const instancePromise = listenMethod.apply(instance, parameters);

                        if (instancePromise instanceof Promise === false) {
                            return;
                        }

                        promises.push(instancePromise);
                    } catch (error) {
                        this.error(
                            `Error thrown in "${eventName}" event by "${name}" plugin.`,
                            error,
                        );
                    }
                } else if (typeof listenMethod === 'string') {
                    if (!instance[listenMethod]) {
                        throw new Error(`Missing "${listenMethod}" method in "${name}" plugin`);
                    }

                    try {
                        const instancePromise = instance[listenMethod](...parameters);

                        if (instancePromise instanceof Promise === false) {
                            return;
                        }

                        promises.push(instancePromise);
                    } catch (error) {
                        this.error(
                            `Error thrown in "${eventName}" promise event by "${name}" plugin.`,
                            error,
                        );
                    }
                } else {
                    this.error(`Listen method for "${eventName}" event in "${name}" plugin is not a function or string.`);
                }
            });
        });

        // Find ad-hoc listeners listening to this event.
        if (this.listeners[eventName] && this.listeners[eventName].length > 0) {
            this.debug(`Found ${this.listeners[eventName].length} ad-hoc listener(s) for global promise event "${eventName}"`);

            this.listeners[eventName].forEach((listener) => {
                try {
                    const listenerPromise = listener(...parameters);
                    if (listenerPromise instanceof Promise === false) {
                        return;
                    }

                    promises.push(listenerPromise);
                } catch (error) {
                    this.error(
                        `Error thrown in "${eventName}" promise event by an ad-hoc listener.`,
                        error,
                    );
                }
            });
        }

        if (promises.length === 0) {
            return Promise.resolve();
        }

        return Promise.all(promises);
    }

    /**
     * Log a styled message in the console.
     *
     * Includes parameters and a stack trace.
     *
     * @returns {void}
     */
    logMessage(color, bold, message, ...parameters) {
        /* eslint-disable */
        console.groupCollapsed(
            '%c[Snowboard]',
            `color: ${color}; font-weight: ${(bold) ? 'bold' : 'normal'};`,
            message
        );
        if (parameters.length) {
            console.groupCollapsed(
                `%cParameters %c(${parameters.length})`,
                'color: rgb(45, 167, 199); font-weight: bold;',
                'color: rgb(88, 88, 88); font-weight: normal;'
            );
            let index = 0;
            parameters.forEach((param) => {
                index += 1;
                console.log(`%c${index}:`, 'color: rgb(88, 88, 88); font-weight: normal;', param);
            });
            console.groupEnd();

            console.groupCollapsed('%cTrace', 'color: rgb(45, 167, 199); font-weight: bold;');
            console.trace();
            console.groupEnd();
        } else {
            console.trace();
        }
        console.groupEnd();
        /* eslint-enable */
    }

    /**
     * Log a message.
     *
     * @returns {void}
     */
    log(message, ...parameters) {
        this.logMessage('rgb(45, 167, 199)', false, message, ...parameters);
    }

    /**
     * Log a debug message.
     *
     * These messages are only shown when debugging is enabled.
     *
     * @returns {void}
     */
    debug(message, ...parameters) {
        if (!this.debugEnabled) {
            return;
        }

        this.logMessage('rgb(45, 167, 199)', false, message, ...parameters);
    }

    /**
     * Logs an error message.
     *
     * @returns {void}
     */
    error(message, ...parameters) {
        this.logMessage('rgb(229, 35, 35)', true, message, ...parameters);
    }
}
