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

import { fetchResource, 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 {boolean} use_fetch_head_method=true - Allow HEAD requests for version checking.
 * Some fetch implementations don't support HEAD method, set to false to always use GET.
 * @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.use_fetch_head_method = await this._syncStorage(
      'use_fetch_head_method',
      true,
      this.config.use_fetch_head_method
    );
    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 fetchResource} 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" | "head" | null} [variant=null] - Type of request:
   * "parseJSON" - Load the resource and parse it as a JSON response.
   * "head" - Requests only headers (returns version: ETag or Last-Modified).
   * null - Get resource as text.
   * @param {boolean|number} [retry] - Is retry in case of an error | number of request attempt.
   * @return {Promise<{data: string|object|null, version: string|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 options = {
        use_fetch_head_method: this.use_fetch_head_method,
      };

      if (variant === 'parseJSON') {
        options.parseJSON = true;
      } else if (variant === 'head') {
        options.headOnly = true;
      }

      const result = await fetchResource(url, options);

      if (result.data !== null || result.version !== null) {
        clearInterval(this.progress_interval_id);
        this.progressbar(false);
      }
      return result;
    } catch (error) {
      if (retry === undefined) {
        clearInterval(this.progress_interval_id);
        return { data: null, version: 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);
    } 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 result = await this._getUrl(this.network_host[channel] + '/meta.json', 'head', true);
        if (result.version !== storage[`${channel}_last_modified`] || force) {
          await this._updateInternalIITC(channel, storage);
        }
      }
    }
    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.
   * @return {Promise<void>}
   * @private
   */
  async _updateInternalIITC(channel, local) {
    const result = await this._getUrl(this.network_host[channel] + '/meta.json', 'parseJSON', true);
    if (!result.data) return;

    const response = result.data;
    const last_modified = result.version;

    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 result = await this._getUrl(
        this.network_host[this.channel] + '/total-conversion-build.user.js'
      );
      if (result.data) {
        const iitc_core = parseMeta(result.data);
        iitc_core['uid'] = getUID(iitc_core);
        iitc_core['code'] = result.data;
        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 result_meta = await this._getUrl(plugin['updateURL'] + hash);
          if (result_meta.data) {
            let meta = parseMeta(result_meta.data);
            // if new version
            if (meta && meta['version'] && meta['version'] !== plugin['version']) {
              // download userscript
              let result_code = await this._getUrl(plugin['updateURL'] + hash);
              if (result_code.data) {
                exist_updates = true;
                plugins_user[uid] = {
                  ...meta,
                  code: result_code.data,
                  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 result = await this._getUrl(`${this.network_host[this.channel]}/plugins/${filename}`);
        if (result.data) {
          plugins_local[uid]['code'] = result.data;
          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,
      });
    }
  }
}