'use strict';

const path = require('path');
const _ = require('lodash');
const ServerlessError = require('../serverless-error');
const resolveCliInput = require('../cli/resolve-input');
const renderCommandHelp = require('../cli/render-help/command');
const processBackendNotificationRequest = require('../utils/process-backend-notification-request');
const tokenizeException = require('../utils/tokenize-exception');
const getRequire = require('../utils/get-require');
const importModule = require('../utils/require-with-import-fallback');
const { log, getPluginWriters } = require('@serverless/utils/log');

let hooksIdCounter = 0;
let nestTracker = 0;

const mergeCommands = (target, source) => {
  if (!target) return source;
  for (const key of Object.keys(source)) {
    if (target[key] == null) {
      target[key] = source[key];
      continue;
    }
    switch (key) {
      case 'options':
        for (const [name, value] of Object.entries(source.options)) {
          if (!target.options[name]) target.options[name] = value;
        }
        break;
      case 'commands':
        for (const [name, value] of Object.entries(source.commands)) {
          target.commands[name] = mergeCommands(target.commands[name], value);
        }
        break;
      case 'lifecycleEvents':
        if (source[key].length) target[key] = source[key];
        break;
      default:
    }
  }
  return target;
};

/**
 * @private
 * Error type to terminate the currently running hook chain successfully without
 * executing the rest of the current command's lifecycle chain.
 */
class TerminateHookChain extends Error {
  constructor(commands) {
    const commandChain = commands.join(':');
    const message = `Terminating ${commandChain}`;
    super(message);
    this.message = message;
    this.name = 'TerminateHookChain';
  }
}

let isRegisteringExternalPlugins = false;

class PluginManager {
  constructor(serverless) {
    this.serverless = serverless;

    this.cliOptions = {};
    this.cliCommands = [];
    this.pluginIndependentCommands = new Set(['help', 'plugin']);

    this.plugins = [];
    this.externalPlugins = new Set();
    this.localPluginsPaths = [];
    this.commands = {};
    this.aliases = {};
    this.hooks = {};
    this.deprecatedEvents = {};
  }

  setCliOptions(options) {
    this.cliOptions = options;
  }

  setCliCommands(commands) {
    this.cliCommands = commands;
  }

  addPlugin(Plugin) {
    const pluginUtils = {};
    if (Plugin._serverlessExternalPluginName) {
      Object.assign(pluginUtils, getPluginWriters(Plugin._serverlessExternalPluginName));
    }
    const pluginInstance = new Plugin(this.serverless, this.cliOptions, pluginUtils);
    if (isRegisteringExternalPlugins) {
      this.externalPlugins.add(pluginInstance);
    }

    let pluginProvider = null;
    // check if plugin is provider agnostic
    if (pluginInstance.provider) {
      if (typeof pluginInstance.provider === 'string') {
        pluginProvider = pluginInstance.provider;
      } else if (_.isObject(pluginInstance.provider)) {
        pluginProvider = pluginInstance.provider.constructor.getProviderName();
      }
    }

    // ignore plugins that specify a different provider than the current one
    if (pluginProvider && pluginProvider !== this.serverless.service.provider.name) {
      return null;
    }

    // don't load plugins twice
    if (this.plugins.some((plugin) => plugin instanceof Plugin)) {
      throw new ServerlessError(
        'Encountered duplicate plugin definition. Please remove duplicate plugins from your configuration.',
        'DUPLICATE_PLUGIN_DEFINITION'
      );
    }

    this.loadCommands(pluginInstance);
    this.loadHooks(pluginInstance);

    this.plugins.push(pluginInstance);

    return pluginInstance;
  }

  async loadAllPlugins(servicePlugins) {
    const EnterprisePlugin = this.resolveEnterprisePlugin();

    isRegisteringExternalPlugins = false;
    require('../plugins')
      .filter(Boolean)
      .forEach((Plugin) => this.addPlugin(Plugin));

    isRegisteringExternalPlugins = true;
    const resolvedServicePlugins = await this.resolveServicePlugins(servicePlugins);
    resolvedServicePlugins.filter(Boolean).forEach((Plugin) => this.addPlugin(Plugin));
    isRegisteringExternalPlugins = false;
    this.dashboardPlugin = this.addPlugin(EnterprisePlugin);
    isRegisteringExternalPlugins = true;
    return this.asyncPluginInit();
  }

  async requireServicePlugin(serviceDir, pluginPath, legacyLocalPluginsPath) {
    if (legacyLocalPluginsPath && !pluginPath.startsWith('./')) {
      // TODO (BREAKING): Consider removing support for localPluginsPath with next major
      const absolutePluginPath = path.resolve(legacyLocalPluginsPath, pluginPath);

      const isLocatedAtLegacyPluginsPath = (() => {
        try {
          require.resolve(absolutePluginPath);
          return true;
        } catch {
          return false;
        }
      })();
      if (isLocatedAtLegacyPluginsPath) return require(absolutePluginPath);
    }
    const serviceDirRequire = getRequire(serviceDir);
    const localPluginPath = (() => {
      try {
        return serviceDirRequire.resolve(pluginPath);
      } catch {
        return null;
      }
    })();
    if (localPluginPath) {
      if (pluginPath.startsWith('./')) {
        this.localPluginsPaths.push({ resolvedPath: localPluginPath, inputPath: pluginPath });
      }
      return importModule(localPluginPath);
    }

    // Search in "node_modules" in which framework is placed
    const externalPluginPath = (() => {
      try {
        return require.resolve(pluginPath);
      } catch {
        throw Object.assign(new Error('Plugin not found'), { code: 'PLUGIN_NOT_FOUND' });
      }
    })();
    return importModule(externalPluginPath);
  }

  async resolveServicePlugins(servicePlugs) {
    const pluginsObject = this.parsePluginsObject(servicePlugs);
    const serviceDir = this.serverless.serviceDir;

    const pluginNames = pluginsObject.modules
      // Initially @serverless/enterprise-plugin was published as external plugin
      // We ensure to not load it, if for some reason it's still in service configuration
      .filter((name) => name !== '@serverless/enterprise-plugin');

    const plugins = [];
    for (const name of pluginNames) {
      let Plugin;
      try {
        Plugin = await this.requireServicePlugin(serviceDir, name, pluginsObject.localPath);
      } catch (error) {
        if (error.code !== 'PLUGIN_NOT_FOUND') throw error;

        // Plugin not installed
        if (
          resolveCliInput().isHelpRequest ||
          this.pluginIndependentCommands.has(this.cliCommands[0])
        ) {
          // User may intend to install plugins just listed in serverless config
          // Therefore skip on MODULE_NOT_FOUND case
          continue;
        }

        const isLocalPlugin = name.startsWith('./');

        throw new ServerlessError(
          [
            `Serverless plugin "${name}" not found.`,
            ' Make sure it\'s installed and listed in the "plugins" section',
            ' of your serverless config file.',
            isLocalPlugin ? '' : ` Run "serverless plugin install -n ${name}" to install it.`,
          ].join(''),
          'PLUGIN_NOT_FOUND'
        );
      }
      if (!Plugin) {
        throw new ServerlessError(
          `Serverless plugin "${name}", didn't export Plugin constructor.`,
          'MISSING_PLUGIN_NAME'
        );
      }
      Object.defineProperty(Plugin, '_serverlessExternalPluginName', {
        value: name,
        configurable: true,
        writable: true,
      });
      plugins.push(Plugin);
    }
    return plugins;
  }

  getLocalPluginsPathPatterns() {
    return this.localPluginsPaths
      .map(({ resolvedPath, inputPath }) => {
        const absoluteInputPath = path.resolve(this.serverless.serviceDir, inputPath);
        if (
          absoluteInputPath === resolvedPath ||
          absoluteInputPath + path.extname(resolvedPath) === resolvedPath
        ) {
          return path.relative(this.serverless.serviceDir, resolvedPath);
        }

        if (resolvedPath.startsWith(absoluteInputPath + path.sep)) {
          return path.relative(
            this.serverless.serviceDir,
            path.join(path.dirname(resolvedPath), '/**')
          );
        }

        return null;
      })
      .filter((v) => v !== null);
  }

  resolveEnterprisePlugin() {
    this.pluginIndependentCommands.add('login').add('logout').add('dashboard');
    return require('@serverless/dashboard-plugin');
  }

  parsePluginsObject(servicePlugs) {
    let localPath =
      this.serverless &&
      this.serverless.serviceDir &&
      path.join(this.serverless.serviceDir, '.serverless_plugins');
    let modules = [];

    if (Array.isArray(servicePlugs)) {
      modules = servicePlugs;
    } else if (servicePlugs) {
      localPath =
        servicePlugs.localPath && typeof servicePlugs.localPath === 'string'
          ? servicePlugs.localPath
          : localPath;
      if (Array.isArray(servicePlugs.modules)) {
        modules = servicePlugs.modules;
      }
    }

    return { modules, localPath };
  }

  createCommandAlias(alias, command) {
    // Deny self overrides
    if (command.startsWith(alias)) {
      throw new ServerlessError(
        `Command "${alias}" cannot be overriden by an alias`,
        'INVALID_COMMAND_ALIAS'
      );
    }

    const splitAlias = alias.split(':');
    const aliasTarget = splitAlias.reduce((__, aliasPath) => {
      const currentAlias = __;
      if (!currentAlias[aliasPath]) {
        currentAlias[aliasPath] = {};
      }
      return currentAlias[aliasPath];
    }, this.aliases);
    // Check if the alias is already defined
    if (aliasTarget.command) {
      throw new ServerlessError(
        `Alias "${alias}" is already defined for command ${aliasTarget.command}`,
        'COMMAND_ALIAS_ALREADY_DEFINED'
      );
    }
    // Check if the alias would overwrite an exiting command
    if (
      splitAlias.reduce((__, aliasPath) => {
        if (!__ || !__.commands || !__.commands[aliasPath]) {
          return null;
        }
        return __.commands[aliasPath];
      }, this)
    ) {
      throw new ServerlessError(
        `Command "${alias}" cannot be overriden by an alias`,
        'INVALID_COMMAND_ALIAS'
      );
    }
    aliasTarget.command = command;
  }

  loadCommand(pluginName, details, key, isEntryPoint) {
    const commandIsEntryPoint = details.type === 'entrypoint' || isEntryPoint;
    log.get('lifecycle:command:register').debug(key);
    // Check if there is already an alias for the same path as the command
    const aliasCommand = this.getAliasCommandTarget(key.split(':'));
    if (aliasCommand) {
      throw new ServerlessError(
        `Command "${key}" cannot override an existing alias`,
        'INVALID_COMMAND_OVERRIDE_EXISTING_ALIAS'
      );
    }
    // Load the command
    const commands = _.mapValues(details.commands, (subDetails, subKey) =>
      this.loadCommand(pluginName, subDetails, `${key}:${subKey}`, commandIsEntryPoint)
    );
    // Handle command aliases
    (details.aliases || []).forEach((alias) => {
      log.get('lifecycle:command:register').debug(`  -> @${alias}`);
      this.createCommandAlias(alias, key);
    });
    return Object.assign({}, details, { key, pluginName, commands });
  }

  loadCommands(pluginInstance) {
    const pluginName = pluginInstance.constructor.name;
    if (pluginInstance.commands) {
      Object.entries(pluginInstance.commands).forEach(([key, details]) => {
        const command = this.loadCommand(pluginName, details, key);
        if (!command.lifecycleEvents) command.lifecycleEvents = [];
        this.commands[key] = mergeCommands(
          this.commands[key],
          _.merge({}, command, {
            isExternal: isRegisteringExternalPlugins,
          })
        );
      });
    }
  }

  loadHooks(pluginInstance) {
    const pluginName = pluginInstance.constructor.name;
    if (pluginInstance.hooks) {
      Object.entries(pluginInstance.hooks).forEach(([event, hook]) => {
        let target = event;
        const baseEvent = event.replace(/^(?:after:|before:)/, '');
        if (this.deprecatedEvents[baseEvent]) {
          const redirectedEvent = this.deprecatedEvents[baseEvent];
          log.info(
            `Plugin "${pluginName}" uses deprecated hook "${event}". Use "${redirectedEvent}" hook instead.`
          );
          if (redirectedEvent) {
            target = event.replace(baseEvent, redirectedEvent);
          }
        }
        this.hooks[target] = this.hooks[target] || [];
        this.hooks[target].push({
          pluginName,
          hook,
        });
      });
    }
  }

  getCommands() {
    const result = {};

    // Iterate through the commands and stop at entrypoints to include only public
    // command throughout the hierarchy.
    const stack = [{ commands: this.commands, target: result }];
    while (stack.length) {
      const currentCommands = stack.pop();
      const commands = currentCommands.commands;
      const target = currentCommands.target;
      if (commands) {
        Object.entries(commands).forEach(([name, command]) => {
          if (command.type !== 'entrypoint') {
            target[name] = _.omit(command, 'commands');
            if (
              Object.values(command.commands).some(
                (childCommand) => childCommand.type !== 'entrypoint'
              )
            ) {
              target[name].commands = {};
              stack.push({ commands: command.commands, target: target[name].commands });
            }
          }
        });
      }
    }
    // Iterate through the existing aliases and add them as commands
    _.remove(stack);
    stack.push({ aliases: this.aliases, target: result });
    while (stack.length) {
      const currentAlias = stack.pop();
      const aliases = currentAlias.aliases;
      const target = currentAlias.target;
      if (aliases) {
        Object.entries(aliases).forEach(([name, alias]) => {
          if (name === 'command') {
            return;
          }
          if (alias.command) {
            const commandPath = alias.command.split(':').join('.commands.');
            target[name] = _.get(this.commands, commandPath);
          } else {
            target[name] = target[name] || {};
            target[name].commands = target[name].commands || {};
          }
          stack.push({ aliases: alias, target: target[name].commands });
        });
      }
    }
    return result;
  }

  getAliasCommandTarget(aliasArray) {
    // Check if the command references an alias
    const aliasCommand = aliasArray.reduce((__, commandPath) => {
      if (!__ || !__[commandPath]) {
        return null;
      }
      return __[commandPath];
    }, this.aliases);

    return _.get(aliasCommand, 'command');
  }

  /**
   * Retrieve the command specified by a command list. The method can be configured
   * to include entrypoint commands (which are invisible to the CLI and can only
   * be used by plugins).
   * @param commandsArray {Array<String>} Commands
   * @param allowEntryPoints {undefined|boolean} Allow entrypoint commands to be returned
   * @returns {Object} Command
   */
  getCommand(commandsArray, allowEntryPoints) {
    // Check if the command references an alias
    const aliasCommandTarget = this.getAliasCommandTarget(commandsArray);
    const commandOrAlias = aliasCommandTarget ? aliasCommandTarget.split(':') : commandsArray;

    return commandOrAlias.reduce(
      (current, name, index) => {
        const commandExists = name in current.commands;
        const isNotContainer = commandExists && current.commands[name].type !== 'container';
        const isNotEntrypoint = commandExists && current.commands[name].type !== 'entrypoint';
        const remainingIterationsLeft = index < commandOrAlias.length - 1;

        if (
          commandExists &&
          (isNotContainer || remainingIterationsLeft) &&
          (isNotEntrypoint || allowEntryPoints)
        ) {
          return current.commands[name];
        }

        if (!isNotContainer && isNotEntrypoint) return current.commands[name];

        // Invalid command, can happen only when Framework is used programmatically,
        // as otherwise command is validated in main script
        throw new ServerlessError(
          `Unrecognized command "${commandsArray.join(' ')}"`,
          'UNRECOGNIZED COMMAND'
        );
      },
      { commands: this.commands }
    );
  }

  getPlugins() {
    return this.plugins;
  }

  getLifecycleEventsData(command) {
    const lifecycleEventsData = [];
    let hooksLength = 0;
    for (const lifecycleEventSubName of command.lifecycleEvents || []) {
      const lifecycleEventName = `${command.key}:${lifecycleEventSubName}`;
      const hooksData = {
        before: this.hooks[`before:${lifecycleEventName}`] || [],
        at: this.hooks[lifecycleEventName] || [],
        after: this.hooks[`after:${lifecycleEventName}`] || [],
      };
      hooksLength += hooksData.before.length + hooksData.at.length + hooksData.after.length;
      lifecycleEventsData.push({
        command,
        lifecycleEventSubName,
        lifecycleEventName,
        hooksData,
      });
    }
    return { lifecycleEventsData, hooksLength };
  }

  async runHooks(hookName, hooks) {
    const debugLog = log.get('lifecycle:command:invoke:hook');
    const hookId = ++hooksIdCounter;
    for (const { hook } of hooks) {
      debugLog.debug(`[%d] ${'  '.repeat(nestTracker++)}< %s`, hookId, hookName);
      try {
        await hook();
      } finally {
        debugLog.debug(`[%d] ${'  '.repeat(--nestTracker)}> %s`, hookId, hookName);
      }
    }
  }

  async invoke(commandsArray, allowEntryPoints) {
    const command = this.getCommand(commandsArray, allowEntryPoints);
    if (command.type === 'container') {
      renderCommandHelp(commandsArray.join(' '));
      return;
    }

    this.convertShortcutsIntoOptions(command);
    this.validateServerlessConfigDependency(command);
    this.assignDefaultOptions(command);

    const { lifecycleEventsData, hooksLength } = this.getLifecycleEventsData(command);

    log
      .get('lifecycle:command:invoke')
      .debug(
        `Invoke ${commandsArray.join(':')}${
          !hooksLength ? ' (noop due to no registered hooks)' : ''
        }`
      );

    try {
      for (const {
        lifecycleEventName,
        hooksData: { before, at, after },
      } of lifecycleEventsData) {
        await this.runHooks(`before:${lifecycleEventName}`, before);
        await this.runHooks(lifecycleEventName, at);
        await this.runHooks(`after:${lifecycleEventName}`, after);
      }
    } catch (error) {
      if (error instanceof TerminateHookChain) {
        log.debug(`Terminate ${commandsArray.join(':')}`);
        return;
      }
      throw error;
    }
  }

  /**
   * Invokes the given command and starts the command's lifecycle.
   * This method can be called by plugins directly to spawn a separate sub lifecycle.
   */
  async spawn(commandsArray, options) {
    let commands = commandsArray;
    if (typeof commandsArray === 'string') {
      commands = commandsArray.split(':');
    }
    await this.invoke(commands, true);
    if (_.get(options, 'terminateLifecycleAfterExecution', false)) {
      throw new TerminateHookChain(commands);
    }
  }

  /**
   * Called by the CLI to start a public command.
   */
  async run(commandsArray) {
    this.commandRunStartTime = Date.now();
    if (resolveCliInput().commands[0] !== 'plugin') {
      // first initialize hooks
      for (const { hook } of this.hooks.initialize || []) await hook();
    }

    let deferredBackendNotificationRequest;

    try {
      await this.invoke(commandsArray);
    } catch (commandException) {
      try {
        for (const { hook } of this.hooks.error || []) await hook(commandException);
      } catch (errorHookException) {
        const errorHookExceptionMeta = tokenizeException(errorHookException);
        log.warning(
          `The "error" hook crashed with:\n${
            errorHookExceptionMeta.stack || errorHookExceptionMeta.message
          }`
        );
      } finally {
        await deferredBackendNotificationRequest;
        throw commandException; // eslint-disable-line no-unsafe-finally
      }
    }

    try {
      for (const { hook } of this.hooks.finalize || []) await hook();
    } catch (finalizeHookException) {
      await deferredBackendNotificationRequest;
      throw finalizeHookException;
    }

    if (deferredBackendNotificationRequest) {
      await processBackendNotificationRequest(await deferredBackendNotificationRequest);
    }
  }

  /**
   * Check if the command is valid. Internally this function will only find
   * CLI accessible commands (command.type !== 'entrypoint')
   */
  validateCommand(commandsArray) {
    this.getCommand(commandsArray);
  }

  /**
   * If the command has no use when operated in a working directory with no serverless
   * configuration file, throw an error
   */
  validateServerlessConfigDependency(command) {
    if (command.configDependent || command.serviceDependencyMode === 'required') {
      if (!this.serverless.configurationInput) {
        const msg = [
          'This command can only be run in a Serverless service directory. ',
          "Make sure to reference a valid config file in the current working directory if you're using a custom config file",
        ].join('');
        throw new ServerlessError(msg, 'INVALID_COMMAND_MISSING_SERVICE_DIRECTORY');
      }
    }
  }

  convertShortcutsIntoOptions(command) {
    if (command.options) {
      Object.entries(command.options).forEach(([optionKey, optionObject]) => {
        if (optionObject.shortcut && Object.keys(this.cliOptions).includes(optionObject.shortcut)) {
          Object.keys(this.cliOptions).forEach((option) => {
            if (option === optionObject.shortcut) {
              this.cliOptions[optionKey] = this.cliOptions[option];
            }
          });
        }
      });
    }
  }

  assignDefaultOptions(command) {
    if (command.options) {
      Object.entries(command.options).forEach(([key, value]) => {
        if (value.default != null && (!this.cliOptions[key] || this.cliOptions[key] === true)) {
          this.cliOptions[key] = value.default;
        }
      });
    }
  }

  async asyncPluginInit() {
    return Promise.all(this.plugins.map((plugin) => plugin.asyncInit && plugin.asyncInit()));
  }
}

module.exports = PluginManager;
