/**
 *
 */

import { toKebabCase, toTitleCase, toHash, toPlural } from '@vizality/util/string';
import fs, { stat, renameSync, readFileSync, existsSync, lstatSync, readdirSync } from 'fs';
import { AddonInfoMessage, AddonUninstallModal } from '@vizality/components/addon';
import { removeDirRecursive } from '@vizality/util/file';
import { log, warn, error } from '@vizality/util/logger';
import { openModal } from '@vizality/modal';
import { isArray } from '@vizality/util/array';
import { Avatars, Events as _Events, Protocols } from '@vizality/constants';
import http from 'isomorphic-git/http/node';
import { join, resolve, sep, extname } from 'path';
import { clone } from 'isomorphic-git';
import { Messages } from '@vizality/i18n';
import { watch } from 'chokidar';
import Events from 'events';
import React from 'react';

const requiredManifestKeys = [ 'name', 'version', 'description', 'author' ];

const ErrorTypes = Object.freeze({
  ADDON_ALREADY_INSTALLED: 'ADDON_ALREADY_INSTALLED',
  ADDON_ALREADY_INSTALLING: 'ADDON_ALREADY_INSTALLING',
  DIRECTORY_ALREADY_EXISTS: 'DIRECTORY_ALREADY_EXISTS'
});

/**
 * @extends Events
 */
export default class AddonManager extends Events {
  constructor (type, dir) {
    super();
    this.dir = dir;
    this.type = type;
    this._items = new Map();
    this._watcherEnabled = null;
    this._watcher = {};
    this._labels = [ 'Manager', this.type ];
  }

  /**
   *
   */
  get count () {
    return this._items.size;
  }

  /**
   *
   */
  get values () {
    return this._items.values();
  }

  /**
   *
   * @returns
   */
  get keys () {
    return [ ...this._items.keys() ];
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  has (addonId) {
    return this._items.has(addonId);
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  get (addonId) {
    return this._items.get(addonId);
  }

  /**
   *
   * @returns
   */
  getAll () {
    return this._items;
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  isInstalled (addonId) {
    return this.has(addonId);
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  isEnabled (addonId) {
    return !vizality.settings.get(`disabled${toTitleCase(toPlural(this.type))}`, [])
      .filter(addon => this.isInstalled(addon))
      .includes(addonId);
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  isDisabled (addonId) {
    return !this.isEnabled(addonId);
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  hasSettings (addonId) {
    try {
      const addon = this.get(addonId);
      return Boolean(addon?.sections?.settings);
    } catch (err) {
      return this._error(`An error occurred while checking for settings for "${addonId}"!`, err);
    }
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  hasScreenshots (addonId) {
    try {
      const addon = this.get(addonId);
      return Boolean(addon?.manifest?.screenshots);
    } catch (err) {
      return this._error(`An error occurred while checking for screenshots for "${addonId}"!`, err);
    }
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  hasReadme (addonId) {
    try {
      const addon = this.get(addonId);
      return Boolean(addon?.sections?.readme);
    } catch (err) {
      return this._error(`An error occurred while checking for readme for "${addonId}"!`, err);
    }
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns
   */
  hasChangelog (addonId) {
    try {
      const addon = this.get(addonId);
      return Boolean(addon?.sections?.changelog);
    } catch (err) {
      return this._error(`An error occurred while checking for changelog for "${addonId}"!`, err);
    }
  }

  /**
   *
   * @returns
   */
  getEnabledKeys () {
    const addons = this.keys;
    return addons.filter(addon => this.isEnabled(addon));
  }

  /**
   *
   * @returns
   */
  getEnabled () {
    const enabled = new Map();
    this.getEnabledKeys()
      .sort((a, b) => a - b)
      .map(addon => this.get(addon))
      .forEach(addon => enabled.set(addon.addonId, addon));

    return enabled;
  }

  /**
   *
   * @returns
   */
  getDisabledKeys () {
    const addons = this.keys;
    return addons.filter(addon => this.isDisabled(addon));
  }

  /**
   *
   * @returns
   */
  getDisabled () {
    const disabled = new Map();
    this.getDisabledKeys()
      .sort((a, b) => a - b)
      .map(addon => this.get(addon))
      .forEach(addon => disabled.set(addon.addonId, addon));

    return disabled;
  }

  /**
   * Initializes an addon.
   * @param {string} addonId Addon ID
   */
  async mount (addonId) {
    let manifest;
    try {
      manifest = Object.assign({
        appMode: 'app'
      }, await import(resolve(this.dir, addonId, 'manifest.json')));
    } catch (err) {
      return this._error(`${toTitleCase(this.type)} "${addonId}" doesn't have a valid manifest. Initialization aborted.`);
    }

    if (!requiredManifestKeys.every(key => manifest.hasOwnProperty(key))) {
      return this._error(`${toTitleCase(this.type)} "${addonId}" doesn't have a valid manifest. Initialization aborted.`);
    }

    try {
      const addonModule = await import(resolve(this.dir, addonId));
      const Addon = addonModule?.__esModule ? addonModule.default : addonModule;

      Object.defineProperties(Addon?.prototype, {
        addonId: {
          get: () => addonId,
          set: () => {
            throw new Error(`${toTitleCase(toPlural(this.type))} cannot update their ID at runtime!`);
          }
        },
        manifest: {
          get: () => manifest,
          set: () => {
            throw new Error(`${toTitleCase(toPlural(this.type))} cannot update manifest at runtime!`);
          }
        }
      });

      this._setAddonIcon(addonId, manifest);
      this._setScreenshotImages(addonId, manifest);
      this._items.set(addonId, new Addon());
    } catch (err) {
      return this._error(`An error occurred while initializing "${addonId}"!`, err);
    }
  }

  /**
   * Uninitializes an addon.
   * @param {string} addonId Addon ID
   * @param {boolean} showLogs Whether to show unloading log messages
   */
  async unmount (addonId, showLogs = true) {
    try {
      const addon = this.get(addonId);
      if (!addon) {
        throw new Error(`Tried to unmount a non-installed ${this.type}: "${addon}"!`);
      }
      await addon._unload(showLogs);
      Object.keys(require.cache).forEach(key => {
        if (key.includes(addonId)) {
          delete require.cache[key];
        }
      });
      this._items.delete(addonId);
    } catch (err) {
      return this._error(`An error occurred while unmounting "${addonId}"!`, err);
    }
  }

  /**
   * Re-initializes an addon.
   * @param {string} addonId Addon ID
   * @param {boolean} showLogs Whether to show loading and unloading log messages
   */
  async remount (addonId, showLogs = true) {
    try {
      await this.unmount(addonId, showLogs);
    } catch (err) {
      return this._error(`An error occurred while remounting "${addonId}"!`, err);
    }
    /*
     * @note I have these separated like this because it seems to cause problems if they're
     * not separated for some reason. Not confirmed yet though.
     */
    try {
      await this.mount(addonId);
      await this.get(addonId)?._load(showLogs);
    } catch (err) {
      return this._error(`An error occurred while remounting "${addonId}"!`, err);
    }
  }

  /**
   * Reinitializes all addons.
   */
  async remountAll () {
    try {
      const addons = this.getEnabledKeys();
      for (const addon of addons) {
        await this.remount(addon, false);
      }
    } catch (err) {
      return this._error(`An error occurred while remounting all ${toPlural(this.type)}!`, err);
    }
    return this._log(`All ${toPlural(this.type)} have been re-initialized!`);
  }

  /**
   * Initializes all addons.
   */
  async initialize () {
    let addonId;
    try {
      this._enableWatcher();
      if (this._watcherEnabled) {
        await this._watchFiles();
      }
      const ignorePath = join(this.dir, '.vzignore');
      const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf-8').trim().split(/\r?\n/) : [];
      const files = readdirSync(this.dir).sort(this._sortBuiltins);
      for (const filename of files) {
        /**
         * Remove any leftover addons that were being installed.
         */
        if (filename.startsWith('__installing__')) {
          await removeDirRecursive(resolve(this.dir, filename));
          continue;
        }
        addonId = filename;
        // If it's a file or listed in .vzignore, skip it
        if (lstatSync(join(this.dir, addonId)).isFile() || ignore.includes(addonId)) {
          continue;
        }
        // Mount the addon
        await this.mount(addonId);
        // If addon didn't mount, skip it
        if (!this.get(addonId)) {
          continue;
        }
        /**
         * If the addon is not disabled in the user's settings, load it.
         */
        if (!this.getDisabledKeys().includes(addonId)) {
          await this.get(addonId)?._load();
        }
      }
      this.emit(_Events.VIZALITY_ADDONS_READY, { type: this.type });
    } catch (err) {
      return this._error(`An error occurred while initializing "${addonId}"!`, err);
    }
  }

  /**
   * Shuts down and unloads all addons.
   */
  async stop () {
    try {
      this._disableWatcher();
      const addons = this.keys;
      for (const addon of addons) {
        if (this.isEnabled(addon)) {
          await this.unmount(addon, false);
        } else {
          Object.keys(require.cache).forEach(key => {
            if (key.includes(addon)) {
              delete require.cache[key];
            }
          });
          this._items.delete(addon);
        }
      }
    } catch (err) {
      return this._error(`There was a problem shutting down ${toPlural(this.type)}!`, err);
    }
    return this._log(`All ${toPlural(this.type)} have been unloaded!`);
  }

  /**
   * Toggles an addon with a given ID.
   * @param {string} addonId Addon ID
   */
  async toggle (addonId, sendEvent = true) {
    try {
      const addon = this.get(addonId);

      /**
       * Make sure the addon is installed.
       */
      if (!addon) {
        throw new Error(`Tried to toggle a non-installed ${this.type}: "${addonId}"!`);
      }

      /**
       * Toggle the addon.
       */
      let enabled;
      if (this.isEnabled(addonId)) {
        await this.disable(addonId, false);
        enabled = false;
      } else if (this.isDisabled(addonId, false)) {
        await this.enable(addonId);
        enabled = true;
      }

      /**
       * Check if we should send the event.
       */
      if (sendEvent) {
        this.emit(_Events.VIZALITY_ADDON_TOGGLE, { addonId, type: this.type, enabled });
      }
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Enables an addon with a given ID.
   * @param {string} addonId Addon ID
   */
  async enable (addonId, sendEvent = true) {
    try {
      const addon = this.get(addonId);

      if (!addon) {
        throw new Error(`Tried to enable a non-installed ${this.type}: "${addonId}"!`);
      }

      if ((this.type === 'plugin' || this.type === 'builtin') && addon._ready) {
        throw new Error(`Tried to enable an already-loaded ${this.type}: "${addonId}"!`);
      }

      vizality.settings.set(`disabled${toTitleCase(toPlural(this.type))}`,
        vizality.settings.get(`disabled${toTitleCase(toPlural(this.type))}`, [])
          .filter(addon => addon !== addonId));

      await addon._load(true);

      /**
       * Check if we should send the event.
       */
      if (sendEvent) {
        this.emit(_Events.VIZALITY_ADDON_ENABLE, { addonId, type: this.type });
      }
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Disables an addon with a given ID.
   * @param {string} addonId Addon ID
   */
  async disable (addonId) {
    try {
      const addon = this.get(addonId);
      if (!addon) {
        throw new Error(`Tried to disable a non-installed ${this.type}: "${addonId}"!`);
      }

      if ((this.type !== 'theme') && !addon._ready) {
        throw new Error(`Tried to disable a non-loaded ${this.type}: "${addon}"!`);
      }

      vizality.settings.set(`disabled${toTitleCase(toPlural(this.type))}`, [
        ...vizality.settings.get(`disabled${toTitleCase(toPlural(this.type))}`, []),
        addonId
      ]);

      await addon._unload(true);
      this.emit(_Events.VIZALITY_ADDON_DISABLE, { addonId, type: this.type });
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Reloads an addon.
   * @param {string} addonId Addon ID
   */
  async reload (addonId) {
    try {
      await this.disable(addonId);
      await this.enable(addonId);
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Reloads all addons.
   */
  async reloadAll () {
    try {
      const addons = this.getEnabledKeys();
      for (const addon of addons) {
        await this.reload(addon);
      }
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Enables all addons.
   */
  async enableAll () {
    try {
      const addons = this.getDisabledKeys();
      for (const addon of addons) {
        await this.enable(addon);
      }
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Disables all addons.
   */
  async disableAll () {
    try {
      const addons = this.getEnabledKeys();
      for (const addon of addons) {
        await this.disable(addon);
      }
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Installs an addon.
   * @param {string} addonId Addon ID or GitHub repository URL
   */
  async install (addonId) {
    try {
      let git;
      if (vizality.manager.community[toPlural(this.type)].has(addonId)) {
        ({ git } = vizality.manager.community[toPlural(this.type)].get(addonId));
      }

      if (!new RegExp(/^(((https?:\/\/)(((([a-zA-Z0-9][a-zA-Z0-9\-_]{1,252})\.){1,8}[a-zA-Z]{2,63})\/))|((ssh:\/\/)?git@)(((([a-zA-Z0-9][a-zA-Z0-9\-_]{1,252})\.){1,8}[a-zA-Z]{2,63})(:)))([a-zA-Z0-9][a-zA-Z0-9_-]{1,36})(\/)([a-zA-Z0-9][a-zA-Z0-9_-]{1,36})((\.git)?)$/).test(git || addonId)) {
        throw new Error('You must provide a valid GitHub repository URL or an addon ID from https://github.com/vizality-community!');
      } else if (vizality.manager.community.plugins.has(addonId)) {
        git = `https://github.com/vizality-community/${addonId}`;
      }

      /**
       * The addonId supplied might be a GitHub repository URL
       */
      if (!git) {
        git = addonId;
      }

      /**
       * The URL must end in git to get processed by isomorphic-git below.
       */
      if (!git.endsWith('.git')) {
        git = `${git}.git`;
      }

      addonId = git.split('.git')[0].split('/')[git.split('.git')[0].split('/').length - 1];
      addonId = toKebabCase(addonId);

      if (this.isInstalled(addonId)) {
        throw {
          name: ErrorTypes.ADDON_ALREADY_INSTALLED,
          message: Messages.VIZALITY_ADDON_ALREADY_INSTALLED_DESC.format({ type: this.type, addonId }),
          stack: `\n${(new Error()).stack.replace('Error\n', '')}`,
          addonId
        };
      }

      if (existsSync(join(this.dir, `__installing__${addonId}`)) && lstatSync(join(this.dir, `__installing__${addonId}`))?.isDirectory()) {
        throw {
          name: ErrorTypes.ADDON_ALREADY_INSTALLING,
          message: Messages.VIZALITY_ADDON_ALREADY_INSTALLING_DESC.format({ type: this.type, addonId }),
          stack: `\n${(new Error()).stack.replace('Error\n', '')}`,
          addonId
        };
      }

      if (existsSync(join(this.dir, addonId)) && lstatSync(join(this.dir, addonId))?.isDirectory()) {
        throw {
          name: ErrorTypes.DIRECTORY_ALREADY_EXISTS,
          message: Messages.VIZALITY_ADDON_DIRECTORY_ALREADY_EXISTS_DESC.format({ type: this.type, addonId }),
          stack: `\n${(new Error()).stack.replace('Error\n', '')}`,
          addonId
        };
      }

      try {
        await clone({
          fs,
          http,
          singleBranch: true,
          depth: 1,
          dir: join(this.dir, `__installing__${addonId}`),
          url: git,
          onProgress: evt => {
            // console.log(evt);
          }
        });
      } catch (err) {
        /**
         * @note isomorphic-git creates the directory before it checks anything, whether there is
         * a response or not, so let's remove it if there's an error here.
         */
        await removeDirRecursive(resolve(this.dir, addonId));
        throw new Error(`There was a problem while attempting to install "${addonId}"!`, err);
      }

      try {
        renameSync(join(this.dir, `__installing__${addonId}`), join(this.dir, addonId));
      } catch (err) {
        console.log(err);
      }

      /**
       * Send a success toast.
       */
      vizality.api.notifications.sendToast({
        id: 'addon-installed',
        header: Messages.VIZALITY_ADDON_SUCCESSFULLY_INSTALLED.format({ type: toTitleCase(this.type) }),
        content: <AddonInfoMessage addon={this.get(addonId) || { manifest: { name: addonId } }} message={Messages.VIZALITY_ADDON_SUCCESSFULLY_INSTALLED_DESC.format({ type: toTitleCase(this.type) })} />,
        icon: this.type,
        buttons: [
          {
            text: 'View',
            onClick: () => vizality.api.routes.navigateTo(`/vizality/${this.type}/${addonId}`)
          }
        ]
      });
    } catch (err) {
      let addon;
      if (err.addonId) {
        addon = this.get(err.addonId);
      }
      switch (err.name) {
        /**
         * Addon Already Installed
         */
        case ErrorTypes.ADDON_ALREADY_INSTALLED: {
          this._error(err.message, err.addonId, err.stack);
          vizality.api.notifications.sendToast({
            id: ErrorTypes.ADDON_ALREADY_INSTALLED,
            header: Messages.VIZALITY_ADDON_ALREADY_INSTALLED_DESC.format({ type: toTitleCase(this.type) }),
            content: <AddonInfoMessage addon={addon} message={err.message} />,
            icon: this.type,
            buttons: [
              {
                text: Messages.VIZALITY_UNINSTALL,
                color: 'red',
                onClick: () => {
                  vizality.api.notifications.closeToast(ErrorTypes.ADDON_ALREADY_INSTALLED);
                  this.uninstall(err.addonId);
                }
              },
              {
                text: 'View',
                onClick: () => vizality.api.routes.navigateTo(`/${toPlural(this.type)}/${err.addonId}`)
              }
            ]
          });
          break;
        }
        /**
         * Addon Already Installing
         */
        case ErrorTypes.ADDON_ALREADY_INSTALLING: {
          this._error(err.message, err.addonId, err.stack);
          vizality.api.notifications.sendToast({
            id: ErrorTypes.ADDON_ALREADY_INSTALLING,
            header: Messages.VIZALITY_ADDON_ALREADY_INSTALLING_DESC.format({ type: toTitleCase(this.type) }),
            content: <AddonInfoMessage addon={addon} message={err.message} />,
            icon: this.type,
            buttons: [
              {
                text: 'Cancel',
                color: 'red',
                onClick: async () => {
                  vizality.api.notifications.closeToast(ErrorTypes.ADDON_ALREADY_INSTALLING);
                  await removeDirRecursive(resolve(this.dir, `__installing__${err.addonId}`));
                }
              }
            ]
          });
          break;
        }
        /**
         * Addon Directory Already Exists
         */
        case ErrorTypes.ADDON_DIRECTORY_ALREADY_EXISTS: {
          this._error(err.message, err.addonId, err.stack);
          vizality.api.notifications.sendToast({
            id: ErrorTypes.ADDON_DIRECTORY_ALREADY_EXISTS,
            header: Messages.ADDON_DIRECTORY_ALREADY_EXISTS_DESC.format({ type: toTitleCase(this.type) }),
            content: <AddonInfoMessage addon={addon} message={err.message} />,
            icon: this.type
          });
          break;
        }
      }
    }
  }

  /**
   * Uninstalls a plugin or theme.
   * @param {string} addonId Addon ID
   * @returns {Promise<void>}
   * @private
   */
  async _uninstall (addonId) {
    try {
      if (!this.isInstalled(addonId)) {
        console.error(`Can't uninstall non-installed thing`);
      }
      await this.unmount(addonId);
      await removeDirRecursive(resolve(this.dir, addonId));
      this.emit(_Events.VIZALITY_ADDON_UNINSTALL, { addonId, type: this.type });
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Sets an addon's icon image URL.
   * @private
   * @param {string} addonId Addon ID
   * @param {object} manifest Addon manifest
   * @returns {Promise<void>}
   */
  async _setAddonIcon (addonId, manifest) {
    try {
      const validExtensions = [ '.png', '.jpg', '.jpeg' ];
      if (manifest.icon) {
        if (!validExtensions.some(ext => manifest.icon.endsWith(ext))) {
          this._warn(`${toTitleCase(this.type)} icon must be of type .png, .jpg, or .jpeg.`);
        } else {
          return manifest.icon = `${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/${manifest.icon}`;
        }
      }
      if (validExtensions.some(ext => existsSync(resolve(this.dir, addonId, 'assets', `icon${ext}`)))) {
        for (const ext of validExtensions) {
          if (existsSync(resolve(this.dir, addonId, 'assets', `icon${ext}`))) {
            manifest.icon = `${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/assets/icon${ext}`;
            break;
          }
        }
      } else {
        return manifest.icon = this._getDefaultAddonIcon(addonId);
      }
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Gets a default addon icon image URL.
   * @private
   * @param {string} addonId Addon ID
   * @returns {string}
   */
  _getDefaultAddonIcon (addonId) {
    try {
      const addonIdHash = toHash(addonId);
      return Avatars[`DEFAULT_${this.type.toUpperCase()}_${(addonIdHash % 5) + 1}`];
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @param {AddonManifest} manifest Addon manifest
   * @returns {void}
   */
  _setScreenshotImages (addonId, manifest) {
    try {
      const validExtensions = [ '.png', '.jpg', '.jpeg', '.gif', '.webp' ];
      const screenshotUrls = [];
      if (isArray(manifest.screenshots)) {
        manifest.screenshot.forEach(screenshot => {
          if (!validExtensions.some(ext => screenshot.endsWith(ext))) {
            this._warn(`${toTitleCase(this.type)} screenshots must be of type .png, .jpg, .jpeg, .gif, or .webp`);
          } else {
            screenshotUrls.push(`${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/${screenshot}`);
          }
        });
      } else {
        const screenshotsDir = join(this.dir, addonId, 'screenshots');
        const hasScreenshots = existsSync(screenshotsDir) && lstatSync(screenshotsDir)?.isDirectory();
        if (hasScreenshots) {
          const previewImages = [];
          const validExtensions = [ '.png', '.gif', '.jpg', '.jpeg', '.webp' ];
          readdirSync(screenshotsDir)
            .filter(file => validExtensions.indexOf(extname(file).toLowerCase()) !== -1)
            .map(file => previewImages.push(`${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/screenshots/${file}`));
          return manifest.screenshots = previewImages;
        }
      }
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * Enables the addon directory watcher.
   * @private
   * @returns {void}
   */
  _enableWatcher () {
    this._watcherEnabled = true;
  }

  /**
   * Disables the addon directory watcher.
   * @private
   * @returns {Promise<void>}
   */
  async _disableWatcher () {
    this._watcherEnabled = false;
    if (this._watcher?.close) {
      await this._watcher.close();
      this._watcher = {};
    }
  }

  /**
   * Initiates the addon directory watcher.
   * @private
   * @returns {Promise<void>}
   */
  async _watchFiles () {
    this._watcher = watch(this.dir, {
      ignored: [ /.exists/, /__installing__/ ],
      ignoreInitial: true,
      depth: 0
    });

    /**
     * Makes sure that the directory added has been completely copied by the operating
     * system before it attempts to do anything with the addon.
     * @see {@link https://memorytin.com/2015/07/08/node-js-chokidar-wait-for-file-copy-to-complete-before-modifying/}
     * @param {string} path Addon folder path
     * @param {object} prev Previous folder stats info @see {@link https://nodejs.org/api/fs.html#fs_class_fs_stats}
     * @returns {void}
     */
    const checkAddDirComplete = (path, prev) => {
      try {
        stat(path, async (err, stat) => {
          if (err) {
            throw err;
          }
          if (stat.mtime.getTime() === prev.mtime.getTime()) {
            const addonId = path.replace(this.dir + sep, '');
            if (addonId !== toKebabCase(addonId)) {
              renameSync(path, join(this.dir, toKebabCase(addonId)));
            }
            await this.mount(addonId);
            await this.get(addonId)?._load();
          } else {
            setTimeout(checkAddDirComplete, 2000, path, stat);
          }
        });
      } catch (err) {
        this._error(err);
      }
    };

    /**
     *
     */
    this._watcher
    /**
     *
     */
      .on('addDir', (path, stat) => {
        setTimeout(checkAddDirComplete, 2000, path, stat);
      })
      /**
       *
       */
      .on('unlinkDir', path => {
        const addonId = path.replace(this.dir + sep, '');
        Object.keys(require.cache).forEach(key => {
          if (key.includes(addonId)) {
            delete require.cache[key];
          }
        });
        this._items.delete(addonId);
      });
  }

  /**
   *
   * @param {string} addonId Addon ID
   * @returns {void}
   */
  uninstall (addonId) {
    try {
      if (!this.isInstalled(addonId)) {
        console.error(`Can't uninstall non-installed thing`);
      }
      const addon = this.get(addonId);
      if (!addon) {
        return;
      }

      openModal(() => props => <AddonUninstallModal {...props} addon={addon} type={this.type} />);
    } catch (err) {
      return this._error(err);
    }
  }

  /**
   * @private
   */
  _log (...message) {
    // In case the addon wants to provide their own labels
    if (isArray(message[0])) {
      const _message = message.slice(1);
      log({ labels: message[0], message: _message });
    } else {
      log({ labels: this._labels, message });
    }
  }

  /**
   * @private
   */
  _warn (...message) {
    // In case the addon wants to provide their own labels
    if (isArray(message[0])) {
      const _message = message.slice(1);
      warn({ labels: message[0], message: _message });
    } else {
      warn({ labels: this._labels, message });
    }
  }

  /**
   * @private
   */
  _error (...message) {
    // In case the addon wants to provide their own labels
    if (isArray(message[0])) {
      const _message = message.slice(1);
      error({ labels: message[0], message: _message });
    } else {
      error({ labels: this._labels, message });
    }
  }
}
