text: 'use strict';
  
  const fs = require('fs');
  const path = require('path');
  const debug = require('debug')('egg-core:plugin');
  const sequencify = require('../../utils/sequencify');
  const loadFile = require('../../utils').loadFile;
  
  
  module.exports = {
  
    /**
     * Load config/plugin.js from {EggLoader#loadUnits}
     *
     * plugin.js is written below
     *
     * ```js
     * {
     *   'xxx-client': {
     *     enable: true,
     *     package: 'xxx-client',
     *     dep: [],
     *     env: [],
     *   },
     *   // short hand
     *   'rds': false,
     *   'depd': {
     *     enable: true,
     *     path: 'path/to/depd'
     *   }
     * }
     * ```
     *
     * If the plugin has path, Loader will find the module from it.
     *
     * Otherwise Loader will lookup follow the order by packageName
     *
     * 1. $APP_BASE/node_modules/${package}
     * 2. $EGG_BASE/node_modules/${package}
     *
     * You can call `loader.plugins` that retrieve enabled plugins.
     *
     * ```js
     * loader.plugins['xxx-client'] = {
     *   name: 'xxx-client',                 // the plugin name, it can be used in `dep`
     *   package: 'xxx-client',              // the package name of plugin
     *   enable: true,                       // whether enabled
     *   path: 'path/to/xxx-client',         // the directory of the plugin package
     *   dep: [],                            // the dependent plugins, you can use the plugin name
     *   env: [ 'local', 'unittest' ],       // specify the serverEnv that only enable the plugin in it
     * }
     * ```
     *
     * `loader.allPlugins` can be used when retrieve all plugins.
     * @function EggLoader#loadPlugin
     * @since 1.0.0
     */
    loadPlugin() {
      this.timing.start('Load Plugin');
  
      // loader plugins from application
      const appPlugins = this.readPluginConfigs(path.join(this.options.baseDir, 'config/plugin.default'));
      debug('Loaded app plugins: %j', Object.keys(appPlugins));
  
      // loader plugins from framework
      const eggPluginConfigPaths = this.eggPaths.map(eggPath => path.join(eggPath, 'config/plugin.default'));
      const eggPlugins = this.readPluginConfigs(eggPluginConfigPaths);
      debug('Loaded egg plugins: %j', Object.keys(eggPlugins));
  
      // loader plugins from process.env.EGG_PLUGINS
      let customPlugins;
      if (process.env.EGG_PLUGINS) {
        try {
          customPlugins = JSON.parse(process.env.EGG_PLUGINS);
        } catch (e) {
          debug('parse EGG_PLUGINS failed, %s', e);
        }
      }
  
      // loader plugins from options.plugins
      if (this.options.plugins) {
        customPlugins = Object.assign({}, customPlugins, this.options.plugins);
      }
  
      if (customPlugins) {
        for (const name in customPlugins) {
          this.normalizePluginConfig(customPlugins, name);
        }
        debug('Loaded custom plugins: %j', Object.keys(customPlugins));
      }
  
      this.allPlugins = {};
      this.appPlugins = appPlugins;
      this.customPlugins = customPlugins;
      this.eggPlugins = eggPlugins;
  
      this._extendPlugins(this.allPlugins, eggPlugins);
      this._extendPlugins(this.allPlugins, appPlugins);
      this._extendPlugins(this.allPlugins, customPlugins);
  
      const enabledPluginNames = []; // enabled plugins that configured explicitly
      const plugins = {};
      const env = this.serverEnv;
      for (const name in this.allPlugins) {
        const plugin = this.allPlugins[name];
  
        // resolve the real plugin.path based on plugin or package
        plugin.path = this.getPluginPath(plugin, this.options.baseDir);
  
        // read plugin information from ${plugin.path}/package.json
        this.mergePluginConfig(plugin);
  
        // disable the plugin that not match the serverEnv
        if (env && plugin.env.length && !plugin.env.includes(env)) {
          this.options.logger.info('Plugin %s is disabled by env unmatched, require env(%s) but got env is %s', name, plugin.env, env);
          plugin.enable = false;
          continue;
        }
  
        plugins[name] = plugin;
        if (plugin.enable) {
          enabledPluginNames.push(name);
        }
      }
  
      // retrieve the ordered plugins
      this.orderPlugins = this.getOrderPlugins(plugins, enabledPluginNames, appPlugins);
  
      const enablePlugins = {};
      for (const plugin of this.orderPlugins) {
        enablePlugins[plugin.name] = plugin;
      }
      debug('Loaded plugins: %j', Object.keys(enablePlugins));
  
      /**
       * Retrieve enabled plugins
       * @member {Object} EggLoader#plugins
       * @since 1.0.0
       */
      this.plugins = enablePlugins;
  
      this.timing.end('Load Plugin');
    },
  
    /*
     * Read plugin.js from multiple directory
     */
    readPluginConfigs(configPaths) {
      if (!Array.isArray(configPaths)) {
        configPaths = [ configPaths ];
      }
  
      // Get all plugin configurations
      // plugin.default.js
      // plugin.${scope}.js
      // plugin.${env}.js
      // plugin.${scope}_${env}.js
      const newConfigPaths = [];
      for (const filename of this.getTypeFiles('plugin')) {
        for (let configPath of configPaths) {
          configPath = path.join(path.dirname(configPath), filename);
          newConfigPaths.push(configPath);
        }
      }
  
      const plugins = {};
      for (const configPath of newConfigPaths) {
        let filepath = this.resolveModule(configPath);
  
        // let plugin.js compatible
        if (configPath.endsWith('plugin.default') && !filepath) {
          filepath = this.resolveModule(configPath.replace(/plugin\.default$/, 'plugin'));
        }
  
        if (!filepath) {
          continue;
        }
  
        const config = loadFile(filepath);
  
        for (const name in config) {
          this.normalizePluginConfig(config, name, filepath);
        }
  
        this._extendPlugins(plugins, config);
      }
  
      return plugins;
    },
  
    normalizePluginConfig(plugins, name, configPath) {
      const plugin = plugins[name];
  
      // plugin_name: false
      if (typeof plugin === 'boolean') {
        plugins[name] = {
          name,
          enable: plugin,
          dependencies: [],
          optionalDependencies: [],
          env: [],
          from: configPath,
        };
        return;
      }
  
      if (!('enable' in plugin)) {
        plugin.enable = true;
      }
      plugin.name = name;
      plugin.dependencies = plugin.dependencies || [];
      plugin.optionalDependencies = plugin.optionalDependencies || [];
      plugin.env = plugin.env || [];
      plugin.from = configPath;
      depCompatible(plugin);
    },
  
    // Read plugin information from package.json and merge
    // {
    //   eggPlugin: {
    //     "name": "",     plugin name, must be same as name in config/plugin.js
    //     "dep": [],      dependent plugins
    //     "env": ""       env
    //     "strict": true, whether check plugin name, default to true.
    //   }
    // }
    mergePluginConfig(plugin) {
      let pkg;
      let config;
      const pluginPackage = path.join(plugin.path, 'package.json');
      if (fs.existsSync(pluginPackage)) {
        pkg = require(pluginPackage);
        config = pkg.eggPlugin;
        if (pkg.version) {
          plugin.version = pkg.version;
        }
      }
  
      const logger = this.options.logger;
      if (!config) {
        logger.warn(`[egg:loader] pkg.eggPlugin is missing in ${pluginPackage}`);
        return;
      }
  
      if (config.name && config.strict !== false && config.name !== plugin.name) {
        // pluginName is configured in config/plugin.js
        // pluginConfigName is pkg.eggPlugin.name
        logger.warn(`[egg:loader] pluginName(${plugin.name}) is different from pluginConfigName(${config.name})`);
      }
  
      // dep compatible
      depCompatible(config);
  
      for (const key of [ 'dependencies', 'optionalDependencies', 'env' ]) {
        if (!plugin[key].length && Array.isArray(config[key])) {
          plugin[key] = config[key];
        }
      }
    },
  
    getOrderPlugins(allPlugins, enabledPluginNames, appPlugins) {
      // no plugins enabled
      if (!enabledPluginNames.length) {
        return [];
      }
  
      const result = sequencify(allPlugins, enabledPluginNames);
      debug('Got plugins %j after sequencify', result);
  
      // catch error when result.sequence is empty
      if (!result.sequence.length) {
        const err = new Error(`sequencify plugins has problem, missing: [${result.missingTasks}], recursive: [${result.recursiveDependencies}]`);
        // find plugins which is required by the missing plugin
        for (const missName of result.missingTasks) {
          const requires = [];
          for (const name in allPlugins) {
            if (allPlugins[name].dependencies.includes(missName)) {
              requires.push(name);
            }
          }
          err.message += `\n\t>> Plugin [${missName}] is disabled or missed, but is required by [${requires}]`;
        }
  
        err.name = 'PluginSequencifyError';
        throw err;
      }
  
      // log the plugins that be enabled implicitly
      const implicitEnabledPlugins = [];
      const requireMap = {};
      result.sequence.forEach(name => {
        for (const depName of allPlugins[name].dependencies) {
          if (!requireMap[depName]) {
            requireMap[depName] = [];
          }
          requireMap[depName].push(name);
        }
  
        if (!allPlugins[name].enable) {
          implicitEnabledPlugins.push(name);
          allPlugins[name].enable = true;
        }
      });
  
      // Following plugins will be enabled implicitly.
      //   - configclient required by [hsfclient]
      //   - eagleeye required by [hsfclient]
      //   - diamond required by [hsfclient]
      if (implicitEnabledPlugins.length) {
        let message = implicitEnabledPlugins
          .map(name => `  - ${name} required by [${requireMap[name]}]`)
          .join('\n');
        this.options.logger.info(`Following plugins will be enabled implicitly.\n${message}`);
  
        // should warn when the plugin is disabled by app
        const disabledPlugins = implicitEnabledPlugins.filter(name => appPlugins[name] && appPlugins[name].enable === false);
        if (disabledPlugins.length) {
          message = disabledPlugins
            .map(name => `  - ${name} required by [${requireMap[name]}]`)
            .join('\n');
          this.options.logger.warn(`Following plugins will be enabled implicitly that is disabled by application.\n${message}`);
        }
      }
  
      return result.sequence.map(name => allPlugins[name]);
    },
  
    // Get the real plugin path
    getPluginPath(plugin) {
      if (plugin.path) {
        return plugin.path;
      }
  
      const name = plugin.package || plugin.name;
      const lookupDirs = new Set();
  
      // try to locate the plugin in the following directories
      //  -> {APP_PATH}/node_modules
      //    -> {EGG_PATH}/node_modules
      //      -> $CWD/node_modules
      lookupDirs.add(path.join(this.options.baseDir, 'node_modules'));
  
      // try to locate the plugin at framework from upper to lower
      for (let i = this.eggPaths.length - 1; i >= 0; i--) {
        const eggPath = this.eggPaths[i];
        lookupDirs.add(path.join(eggPath, 'node_modules'));
      }
  
      // should find the $cwd/node_modules when test the plugins under npm3
      lookupDirs.add(path.join(process.cwd(), 'node_modules'));
  
      // should find the siblings directory of framework when using pnpm
      for (let i = this.eggPaths.length - 1; i >= 0; i--) {
        const eggPath = this.eggPaths[i];
        // 'node_modules/.pnpm/yadan@2.0.0/node_modules/yadan/node_modules',
        // 'node_modules/.pnpm/yadan@2.0.0/node_modules',  <- this is the sibling directory
        // 'node_modules/.pnpm/egg@2.33.1/node_modules/egg/node_modules',
        // 'node_modules/.pnpm/egg@2.33.1/node_modules', <- this is the sibling directory
        lookupDirs.add(path.dirname(eggPath));
      }
  
      for (let dir of lookupDirs) {
        dir = path.join(dir, name);
        if (fs.existsSync(dir)) {
          return fs.realpathSync(dir);
        }
      }
  
      throw new Error(`Can not find plugin ${name} in "${[ ...lookupDirs ].join(', ')}"`);
    },
  
    _extendPlugins(target, plugins) {
      if (!plugins) {
        return;
      }
      for (const name in plugins) {
        const plugin = plugins[name];
        let targetPlugin = target[name];
        if (!targetPlugin) {
          targetPlugin = target[name] = {};
        }
        if (targetPlugin.package && targetPlugin.package === plugin.package) {
          this.options.logger.warn('plugin %s has been defined that is %j, but you define again in %s',
            name, targetPlugin, plugin.from);
        }
        if (plugin.path || plugin.package) {
          delete targetPlugin.path;
          delete targetPlugin.package;
        }
        for (const prop in plugin) {
          if (plugin[prop] === undefined) {
            continue;
          }
          if (targetPlugin[prop] && Array.isArray(plugin[prop]) && !plugin[prop].length) {
            continue;
          }
          targetPlugin[prop] = plugin[prop];
        }
      }
    },
  
  };
  
  function depCompatible(plugin) {
    if (plugin.dep && !(Array.isArray(plugin.dependencies) && plugin.dependencies.length)) {
      plugin.dependencies = plugin.dep;
      delete plugin.dep;
    }
  }
  