// Copyright (C) 2022-2025 IITC-CE - GPL-3.0 with Store Exception - see LICENSE and COPYING.STORE

import { ajaxGet, clearWait, getUID, isSet, parseMeta, wait } from './helpers.js';

/**
 * @namespace manager
 */

/**
 * @namespace storage
 */

/**
 * Environment parameters for an instance of Manager class.
 * Specifying only the "storage" parameter is enough to run in lightweight form,
 * but for full functionality you also need to specify callbacks.
 *
 * @typedef {Object} config
 * @memberOf manager
 * @property {storage.channel} channel - Update channel for IITC and plugins.
 * @property {storage.storage} storage - Platform-dependent data storage class.
 * For example, "browser.storage.local" in webextensions.
 * @property {boolean} is_daemon=true - In daemon mode, the class does not terminate
 * and runs a periodic check for updates.
 * @property {manager.network_host} network_host - URLs of repositories with IITC and plugins for different release branches.
 * If the parameter is not specified, the default values are used.
 * @property {manager.message} message - Function for sending an information message to a user.
 * @property {manager.progressbar} progressbar - Function for controls the display of progress bar.
 * @property {manager.inject_user_script} inject_user_script - Function for injecting UserScript code
 * into the Ingress Intel window.
 * @property {manager.inject_plugin} inject_plugin - Function for injecting UserScript plugin
 * into the Ingress Intel window.
 * @property {manager.plugin_event} plugin_event - The function is called when the plugin status changes
 * (enabled/disabled, updated).
 */

/**
 * Platform-dependent data storage class.
 * For example, when using this library in a browser extension, the
 * [storage.local API]{@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage}
 * is compatible.
 * Other platforms may have other ways of dealing with local storage,
 * but it is sufficient to create a small layer for storage to have the specified methods.
 *
 * @typedef {Object} storage.storage
 * @memberOf storage
 * @property {storage.get} get - Retrieves one or more items from the storage area.
 * @property {storage.set} set - Stores one or more items in the storage area, or update existing items.
 */

/**
 * Retrieves one or more items from the storage area.
 * This method accepts a key (string) or keys (an array of strings) to identify the item(s) to be retrieved from storage.
 * This is an asynchronous function that returns a Promise that resolves to a results object,
 * containing every object in keys that was found in the storage area.
 *
 * @typedef {Function} storage.get
 * @memberOf storage
 * @param {string|string[]} keys
 * @returns {Promise<storage.data>}
 */

/**
 * Stores one or more items in the storage area, or update existing items.
 * This is an asynchronous function that returns a Promise
 * that will be fulfilled with no arguments if the operation succeeded.
 *
 * @typedef {Function} storage.set
 * @memberOf storage
 * @param {storage.data} data
 * @returns {Promise<null>}
 */

/**
 * URLs of repositories with IITC and plugins for different release branches
 *
 * @typedef {Object} network_host
 * @memberOf manager
 * @property {string} release=https://iitc.app/build/release - Release branch.
 * @property {string} beta=https://iitc.app/build/beta - Beta branch.
 * @property {string} custom=http://localhost:8000 - URL address of a custom repository.
 */

/**
 * Sends an information message to user.
 *
 * @callback manager.message
 * @memberOf manager
 * @param {string} message - The name of the message sent to user.
 * You then need to map the message name to human-readable text in application.
 * @param {string|string[]} [args] - A single substitution string, or an array of substitution strings.
 */

/**
 * Controls progress bar display.
 *
 * @callback manager.progressbar
 * @memberOf manager
 * @param {boolean} is_show - Show progress bar.
 */

/**
 * Calls a function that injects UserScript code into the Ingress Intel window.
 *
 * @deprecated since version 1.5.0. Use {@link manager.inject_plugin} instead.
 * @callback manager.inject_user_script
 * @memberOf manager
 * @param {string} code - UserScript code to run in the Ingress Intel window
 */

/**
 * Calls a function that injects UserScript plugin into the Ingress Intel window.
 *
 * @callback manager.inject_plugin
 * @memberOf manager
 * @param {plugin} plugin - UserScript plugin to run in the Ingress Intel window
 */

/**
 * Called to handle changes in plugin status for multiple plugins at once, such as enabling, disabling, or updating.
 * This function is invoked with detailed information about the plugin events, encapsulating the changes in a single call.
 * The input object contains the type of event and a mapping of unique identifiers (UIDs) to plugin data, enabling
 * batch processing of plugin state changes.
 *
 * @callback manager.plugin_event
 * @memberOf manager
 * @param {Object} plugin_event - An object containing the event type and a mapping of plugin data.
 * @param {'add'|'update'|'remove'} plugin_event.event - The type of event that occurred,
 *        indicating the action being taken on the plugins.
 * @param {Object.<string, Object|{}>} plugin_event.plugins - A mapping of plugin UIDs to their respective data objects.
 *        For 'add' and 'update' events, these objects contain the relevant plugin data.
 *        For 'remove' events, the corresponding data object will be an empty object ({}).
 */

/**
 * Key-value data in storage
 *
 * @memberOf storage
 * @typedef {Object.<string, string|number|object>} storage.data
 */

/**
 * URLs of repositories with IITC and plugins for different release branches
 *
 * @typedef {Object} plugin
 * @property {string} uid - Unique identifier (UID) of plugin. Created by lib-iitc-manager.
 * @property {string} id
 * @property {string} name
 * @property {string} author
 * @property {string} category
 * @property {string} version
 * @property {string} description
 * @property {string} namespace
 * @property {string} status
 * @property {string} code
 * @property {boolean} user
 * @property {boolean} override
 * @property {string[]} match
 * @property {string[]} include
 * @property {string[]} exclude-match
 * @property {string[]} exclude
 * @property {string[]} require
 * @property {string[]} grant
 * @property {number} [addedAt] - Unix timestamp of when the external plugin was first added. Only for external plugins.
 * @property {number} [statusChangedAt] - Unix timestamp of when the plugin's status (on/off) was last changed.
 * @property {number} [updatedAt] - Unix timestamp of when the plugin's code was last updated.
 */

/**
 * Parameters for retrieving backup data.
 *
 * @typedef {Object} BackupParams
 * @property {boolean} settings - Whether to import/export IITC settings.
 * @property {boolean} data - Whether to import/export plugins' data.
 * @property {boolean} external - Whether to import/export external plugins.
 */

/**
 * @classdesc This class contains methods for managing IITC and plugins.
 */
export class Worker {
    /**
     * Creates an instance of Manager class with the specified parameters
     *
     * @param {manager.config} config - Environment parameters for an instance of Manager class.
     * @return {void}
     */
    constructor(config) {
        this.config = config;
        this.progress_interval_id = null;
        this.update_timeout_id = null;
        this.external_update_timeout_id = null;
        this.iitc_main_script_uid = 'IITC: Ingress intel map total conversion+https://github.com/IITC-CE/ingress-intel-total-conversion';

        this.storage = typeof this.config.storage !== 'undefined' ? this.config.storage : console.error("config key 'storage' is not set");
        this.message = this.config.message;
        this.progressbar = this.config.progressbar;
        this.inject_user_script = this.config.inject_user_script || function () {};
        this.inject_plugin = this.config.inject_plugin || function () {};
        this.plugin_event = this.config.plugin_event || function () {};

        this.is_initialized = false;
        this._init().then();
    }

    /**
     * Set values for the class properties.
     *
     * @async
     * @return {Promise<void>}
     * @private
     */
    async _init() {
        this.channel = await this._syncStorage('channel', 'release', this.config.channel);
        this.network_host = await this._syncStorage(
            'network_host',
            {
                release: 'https://iitc.app/build/release',
                beta: 'https://iitc.app/build/beta',
                custom: 'http://localhost:8000',
            },
            this.config.network_host
        );
        this.is_daemon = await this._syncStorage('is_daemon', true, this.config.is_daemon);
        this.is_initialized = true;
    }

    /**
     * Overwrites the values in the storage and returns the new value.
     * If the value is not set, the default value is returned.
     *
     * @async
     * @param {string} key - Storage entry key.
     * @param {string|number|object} defaults - Default value.
     * @param {string|number|object|undefined} [override=undefined] - Value to override the default value.
     * @return {Promise<string|number|object>}
     * @private
     */
    async _syncStorage(key, defaults, override) {
        let data;
        if (typeof override !== 'undefined') {
            data = override;
        } else {
            data = await this.storage.get([key]).then((result) => result[key]);
        }
        if (!isSet(data)) data = defaults;

        this[key] = data;
        await this._save(this.channel, { [key]: data });
        return data;
    }

    /**
     * Saves passed data to local storage.
     * Adds the name of release branch before key, if necessary.
     *
     * @async
     * @param {string} channel - Current channel.
     * @param {storage.data} options - Key-value data to be saved.
     * @return {Promise<void>}
     * @private
     */
    async _save(channel, options) {
        const data = {};
        Object.keys(options).forEach((key) => {
            if (
                ['iitc_version', 'last_modified', 'iitc_core', 'iitc_core_user', 'categories', 'plugins_flat', 'plugins_local', 'plugins_user'].indexOf(key) !==
                -1
            ) {
                data[`${channel}_${key}`] = options[key];
            } else {
                data[key] = options[key];
            }
        });
        await this.storage.set(data);
    }

    /**
     * The method requests data from the specified URL.
     * It is a wrapper over {@link ajaxGet} function, with the addition of retries to load in case of problems
     * and a message to user about errors.
     *
     * @async
     * @param {string} url - URL of the resource you want to fetch.
     * @param {"parseJSON" | "Last-Modified" | null} [variant=null] - Type of request (see {@link ajaxGet}).
     * @param {boolean|number} [retry] - Is retry in case of an error | number of request attempt.
     * @return {Promise<string|object|null>}
     * @private
     */
    async _getUrl(url, variant, retry) {
        if (retry > 1) {
            let seconds = retry * retry;
            if (seconds > 30 * 60) seconds = 30 * 60; // maximum is 30 minutes
            try {
                this.message('serverNotAvailableRetry', String(seconds));
            } catch {
                // Ignore if there is no message receiver
            }
            await wait(seconds);
        }

        clearInterval(this.progress_interval_id);
        this.progress_interval_id = setInterval(async () => {
            this.progressbar(true);
        }, 300);
        try {
            const response = await ajaxGet(url, variant);
            if (response) {
                clearInterval(this.progress_interval_id);
                this.progressbar(false);
            }
            return response;
        } catch {
            if (retry === undefined) {
                clearInterval(this.progress_interval_id);
                return null;
            }
            return await this._getUrl(url, variant, retry + 1);
        }
    }

    /**
     * Runs periodic checks and installs updates for IITC and plugins.
     *
     * @async
     * @param {boolean} [force=false] - Forced to run the update right now.
     * @return {Promise<void>}
     * @private
     */
    async _checkInternalUpdates(force) {
        const channel = this.channel;
        const storage = await this.storage.get([
            'last_check_update',
            `${channel}_update_check_interval`,
            `${channel}_last_modified`,
            `${channel}_categories`,
            `${channel}_plugins_flat`,
            `${channel}_plugins_local`,
            `${channel}_plugins_user`,
        ]);

        let update_check_interval = storage[`${channel}_update_check_interval`];
        if (!update_check_interval) update_check_interval = 24 * 60 * 60;

        if (!isSet(storage[`${channel}_last_modified`]) || !isSet(storage.last_check_update)) {
            clearWait();
            clearTimeout(this.update_timeout_id);
            this.update_timeout_id = null;
            await this._updateInternalIITC(channel, storage, null);
        } else {
            const time_delta = Math.floor(Date.now() / 1000) - update_check_interval - storage.last_check_update;
            if (time_delta >= 0 || force) {
                clearWait();
                clearTimeout(this.update_timeout_id);
                this.update_timeout_id = null;
                const last_modified = await this._getUrl(this.network_host[channel] + `${channel}/meta.json`, 'Last-Modified', true);
                if (last_modified !== storage[`${channel}_last_modified`] || force) {
                    await this._updateInternalIITC(channel, storage, last_modified);
                }
            }
        }
        if (!this.update_timeout_id) {
            await this._save(channel, {
                last_check_update: Math.floor(Date.now() / 1000),
            });

            if (this.is_daemon) {
                this.update_timeout_id = setTimeout(async () => {
                    await this._checkInternalUpdates();
                }, update_check_interval * 1000);
            } else {
                this.update_timeout_id = null;
            }
        }
    }

    /**
     * Updates IITC, passes control to {@link _updateLocalPlugins} function to update plugins.
     *
     * @async
     * @param {string} channel - Current channel.
     * @param {storage.data} local - Data from storage.
     * @param {string|null} last_modified - Last modified date of "meta.json" file.
     * @return {Promise<void>}
     * @private
     */
    async _updateInternalIITC(channel, local, last_modified) {
        const response = await this._getUrl(this.network_host[channel] + '/meta.json', 'parseJSON', true);
        if (!response) return;

        let plugins_flat = this._getPluginsFlat(response);
        let categories = this._getCategories(response);
        let plugins_local = local[`${channel}_plugins_local`];
        let plugins_user = local[`${channel}_plugins_user`];

        if (!isSet(plugins_user)) plugins_user = {};
        categories = this._rebuildingCategories(categories, plugins_user);

        const p_iitc = async () => {
            const iitc_code = await this._getUrl(this.network_host[channel] + '/total-conversion-build.user.js');
            if (iitc_code) {
                const iitc_core = parseMeta(iitc_code);
                iitc_core['uid'] = getUID(iitc_core);
                iitc_core['code'] = iitc_code;
                await this._save(channel, {
                    iitc_core: iitc_core,
                });
                await this._sendPluginsEvent(channel, [iitc_core['uid']], 'update', 'local');
            }
        };

        const p_plugins = async () => {
            plugins_local = await this._updateLocalPlugins(channel, plugins_flat, plugins_local);

            plugins_flat = this._rebuildingArrayCategoriesPlugins(plugins_flat, plugins_local, plugins_user);
            await this._save(channel, {
                iitc_version: response['iitc_version'],
                last_modified: last_modified,
                categories: categories,
                plugins_flat: plugins_flat,
                plugins_local: plugins_local,
                plugins_user: plugins_user,
            });
        };

        await Promise.all([p_iitc, p_plugins].map((fn) => fn()));
    }

    /**
     * Builds a dictionary from received meta.json file, in which it places names and descriptions of categories.
     *
     * @param {Object} data - Data from received meta.json file.
     * @return {Object.<string, Object.<string, string>>}} - Dictionary with names and descriptions of categories.
     * @private
     */
    _getCategories(data) {
        if (!('categories' in data)) return {};
        const categories = data['categories'];

        Object.keys(categories).forEach((cat) => {
            if ('plugins' in categories[cat]) {
                delete categories[cat]['plugins'];
            }
        });

        return categories;
    }

    /**
     * Converting a list of categories with plugins inside into a flat structure.
     *
     * @param {Object} data - Data from received meta.json file.
     * @return {Object.<string, plugin>} - Dictionary of plugin name and plugin data.
     * @private
     */
    _getPluginsFlat(data) {
        if (!('categories' in data)) return {};
        const plugins = {};
        const categories = data['categories'];

        Object.keys(categories).forEach((cat) => {
            if (cat === 'Obsolete' || cat === 'Deleted') return;

            if ('plugins' in categories[cat]) {
                Object.keys(categories[cat]['plugins']).forEach((id) => {
                    const plugin = categories[cat]['plugins'][id];
                    plugin['uid'] = getUID(plugin);
                    plugin['status'] = 'off';
                    plugin['category'] = cat;
                    plugins[plugin['uid']] = plugin;
                });
            }
        });
        return plugins;
    }

    /**
     * Runs periodic checks and installs updates for external plugins.
     *
     * @async
     * @param {boolean} [force=false] - Forced to run the update right now.
     * @return {Promise<void>}
     * @private
     */
    async _checkExternalUpdates(force) {
        const channel = this.channel;
        const local = await this.storage.get(['channel', 'last_check_external_update', 'external_update_check_interval', `${channel}_plugins_user`]);

        let update_check_interval = local['external_update_check_interval'];
        if (!update_check_interval) {
            update_check_interval = 24 * 60 * 60;
        }

        const time_delta = Math.floor(Date.now() / 1000) - update_check_interval - local.last_check_external_update;
        if (time_delta >= 0 || force) {
            clearTimeout(this.external_update_timeout_id);
            this.external_update_timeout_id = null;
            await this._updateExternalPlugins(channel, local);
        }

        if (!this.external_update_timeout_id) {
            await this._save(channel, {
                last_check_external_update: Math.floor(Date.now() / 1000),
            });

            if (this.is_daemon) {
                this.external_update_timeout_id = setTimeout(async () => {
                    await this._checkExternalUpdates();
                }, update_check_interval * 1000);
            } else {
                this.external_update_timeout_id = null;
            }
        }
    }

    /**
     * Updates external plugins.
     *
     * @async
     * @param {string} channel - Current channel.
     * @param {storage.data} local - Data from storage.
     * @return {Promise<void>}
     * @private
     */
    async _updateExternalPlugins(channel, local) {
        const plugins_user = local[`${channel}_plugins_user`];
        if (plugins_user) {
            let exist_updates = false;
            const hash = `?${Date.now()}`;
            const updated_uids = [];
            const currentTime = Math.floor(Date.now() / 1000);

            for (const uid of Object.keys(plugins_user)) {
                const plugin = plugins_user[uid];

                if (plugin['updateURL'] && plugin['downloadURL']) {
                    // download meta info
                    const response_meta = await this._getUrl(plugin['updateURL'] + hash);
                    if (response_meta) {
                        let meta = parseMeta(response_meta);
                        // if new version
                        if (meta && meta['version'] && meta['version'] !== plugin['version']) {
                            // download userscript
                            let response_code = await this._getUrl(plugin['updateURL'] + hash);
                            if (response_code) {
                                exist_updates = true;
                                plugins_user[uid] = {
                                    ...meta,
                                    code: response_code,
                                    updatedAt: currentTime,
                                    addedAt: plugin.addedAt,
                                    statusChangedAt: plugin.statusChangedAt,
                                };
                                updated_uids.push(uid);
                            }
                        }
                    }
                }
            }

            if (exist_updates) {
                await this._save(channel, {
                    plugins_user: plugins_user,
                });
                await this._sendPluginsEvent(channel, updated_uids, 'update', 'user');
            }
        }
    }

    /**
     * Updates plugins.
     *
     * @async
     * @param {string} channel - Current channel.
     * @param {Object.<string, plugin>} plugins_flat - Data from storage, key "[channel]_plugins_flat".
     * @param {Object.<string, plugin>} plugins_local - Data from storage, key "[channel]_plugins_local".
     * @return {Promise<Object.<string, plugin>>}
     * @private
     */
    async _updateLocalPlugins(channel, plugins_flat, plugins_local) {
        // If no plugins installed
        if (!isSet(plugins_local)) return {};

        const updated_uids = [];
        const removed_uids = [];
        const currentTime = Math.floor(Date.now() / 1000);

        // Iteration local plugins
        for (const uid of Object.keys(plugins_local)) {
            let filename = plugins_local[uid]['filename'];

            if (filename && plugins_flat[uid]) {
                let code = await this._getUrl(`${this.network_host[channel]}/plugins/${filename}`);
                if (code) {
                    plugins_local[uid]['code'] = code;
                    plugins_local[uid]['updatedAt'] = currentTime;
                    updated_uids.push(uid);
                }
            } else {
                delete plugins_local[uid];
                removed_uids.push(uid);
            }
        }

        if (updated_uids.length) await this._sendPluginsEvent(channel, updated_uids, 'update', 'local');
        if (removed_uids.length) await this._sendPluginsEvent(channel, removed_uids, 'remove', 'local');

        return plugins_local;
    }

    /**
     * Updates categories by adding custom categories of external plugins.
     *
     * @param {Object.<string, Object.<string, string>>} categories - Dictionary with names and descriptions of categories.
     * @param {Object.<string, plugin>} plugins_user - Dictionary of external UserScripts.
     * @return {Object.<string, Object.<string, string>>} - Dictionary with names and descriptions of categories.
     */
    _rebuildingCategories(categories, plugins_user) {
        if (Object.keys(plugins_user).length) {
            Object.keys(plugins_user).forEach((plugin_uid) => {
                let category = plugins_user[plugin_uid]['category'];
                if (category === undefined) {
                    category = 'Misc';
                }
                if (!(category in categories)) {
                    categories[category] = {
                        name: category,
                        description: '',
                    };
                }
            });
        }
        return categories;
    }

    /**
     * Rebuilds the plugins array maintaining proper isolation between channels.
     *
     * @param {Object.<string, plugin>} raw_plugins - Dictionary of plugins downloaded from the server.
     * @param {Object.<string, plugin>} plugins_local - Dictionary of installed plugins from IITC-CE distribution.
     * @param {Object.<string, plugin>} plugins_user - Dictionary of external UserScripts.
     * @return {Object<string, plugin>}
     * @private
     */
    _rebuildingArrayCategoriesPlugins(raw_plugins, plugins_local, plugins_user) {
        let data = { ...raw_plugins };

        if (!isSet(plugins_local)) plugins_local = {};
        if (!isSet(plugins_user)) plugins_user = {};

        // Get valid UIDs for current channel to prevent cross-channel contamination
        const currentChannelPluginUIDs = new Set(Object.keys(data));

        // Apply data from local plugins - only for plugins that exist in current channel
        Object.keys(plugins_local).forEach((plugin_uid) => {
            if (currentChannelPluginUIDs.has(plugin_uid)) {
                const localPlugin = plugins_local[plugin_uid];
                data[plugin_uid].status = localPlugin.status || 'off';
                data[plugin_uid].updatedAt = localPlugin.updatedAt;
                data[plugin_uid].statusChangedAt = localPlugin.statusChangedAt;
            }
        });

        // Apply user plugins
        if (Object.keys(plugins_user).length) {
            Object.keys(plugins_user).forEach((plugin_uid) => {
                const userPlugin = plugins_user[plugin_uid];
                if (plugin_uid in data) {
                    data[plugin_uid].status = userPlugin.status || 'off';
                    data[plugin_uid].code = userPlugin.code;
                    data[plugin_uid].user = true;
                    data[plugin_uid].override = true;
                    data[plugin_uid].addedAt = userPlugin.addedAt;
                    data[plugin_uid].updatedAt = userPlugin.updatedAt;
                    data[plugin_uid].statusChangedAt = userPlugin.statusChangedAt;
                } else {
                    data[plugin_uid] = userPlugin;
                }
                data[plugin_uid].user = true;
            });
        }

        return data;
    }

    /**
     * Asynchronously sends an event for a list of plugins based on the given parameters.
     * It calls `plugin_event` once with the event type and a map of the selected plugins.
     * If the action is "remove", the plugins are represented by empty objects.
     *
     * @async
     * @param {string} channel - Current channel.
     * @param {string[]} uids - Array of unique identifiers (UID) of plugins.
     * @param {'add'|'update'|'remove'} event - The type of event to handle.
     * @param {'local'|'user'} [update_type] - Specifies the update type to determine which plugin versions to use.
     * When set to 'local', actions with plugins marked as "user" are ignored, and vice versa.
     * This parameter is intended to ignore updates from 'local' plugins when a 'user' plugin is used, and vice versa.
     * If not specified, no ignoring logic is applied, and the function attempts to process the plugin event
     * based on available data.
     * @returns {Promise<void>} A promise that resolves when the event has been processed.
     */
    async _sendPluginsEvent(channel, uids, event, update_type) {
        const validEvents = ['add', 'update', 'remove'];
        if (!validEvents.includes(event)) return;

        const plugins = {};

        for (const uid of uids) {
            const isCore = uid === this.iitc_main_script_uid;
            if (isCore && event !== 'update') continue;

            const storageKeys = isCore ? [`${channel}_iitc_core`, `${channel}_iitc_core_user`] : [`${channel}_plugins_local`, `${channel}_plugins_user`];
            const storage = await this.storage.get(storageKeys);

            let plugin_local = isCore ? storage[`${channel}_iitc_core`] : storage[`${channel}_plugins_local`]?.[uid];
            let plugin_user = isCore ? storage[`${channel}_iitc_core_user`] : storage[`${channel}_plugins_user`]?.[uid];

            if (event === 'remove' || (!isSet(plugin_local) && !isSet(plugin_user))) {
                plugins[uid] = {};
                continue;
            }

            const useLocal = !isSet(plugin_user) && (update_type === undefined || update_type === 'local');
            const useUser = isSet(plugin_user) && (update_type === undefined || update_type === 'user');

            if (useLocal) {
                plugins[uid] = plugin_local || {};
            } else if (useUser) {
                plugins[uid] = plugin_user || {};
            }

            // Updating a disabled plugin should not trigger the event
            if (!isCore && event !== 'remove' && plugins[uid]?.status !== 'on') {
                delete plugins[uid];
            }
        }

        if (Object.keys(plugins).length) {
            this.plugin_event({
                event,
                plugins,
            });
        }
    }
}