# frozen_string_literal: true

require_relative "plugin/api"

module Bundler
  module Plugin
    autoload :DSL,            File.expand_path("plugin/dsl", __dir__)
    autoload :Events,         File.expand_path("plugin/events", __dir__)
    autoload :Index,          File.expand_path("plugin/index", __dir__)
    autoload :Installer,      File.expand_path("plugin/installer", __dir__)
    autoload :SourceList,     File.expand_path("plugin/source_list", __dir__)
    autoload :UnloadedSource, File.expand_path("plugin/unloaded_source", __dir__)

    class MalformattedPlugin < PluginError; end
    class UndefinedCommandError < PluginError; end
    class UnknownSourceError < PluginError; end
    class PluginInstallError < PluginError; end

    PLUGIN_FILE_NAME = "plugins.rb"

    # Module-level flag set while .gemfile_install parses the Gemfile and
    # consulted by .from_lock to substitute plugin sources with
    # UnloadedSource. It relies on definitions being built one at a time in
    # a single thread; if they are ever built concurrently or reentrantly,
    # this needs to be replaced by explicit state passed down to the
    # lockfile parser.
    @gemfile_parse = false

    module_function

    def reset!
      instance_variables.each {|i| remove_instance_variable(i) }

      @sources = {}
      @commands = {}
      @hooks_by_event = Hash.new {|h, k| h[k] = [] }
      @loaded_plugin_names = []
      @index = nil
    end

    reset!

    # Installs a new plugin by the given name
    #
    # @param [Array<String>] names the name of plugin to be installed
    # @param [Hash] options various parameters as described in description.
    #               Refer to cli/plugin for available options
    def install(names, options)
      raise InvalidOption, "You cannot specify `--branch` and `--ref` at the same time." if options["branch"] && options["ref"]

      specs = Installer.new.install(names, options)

      save_plugins specs.slice(*names)
    rescue PluginError
      specs_to_delete = specs.select {|k, _v| names.include?(k) && !index.commands.values.include?(k) }
      specs_to_delete.each_value {|spec| Bundler.rm_rf(spec.full_gem_path) }

      raise
    end

    # Uninstalls plugins by the given names
    #
    # @param [Array<String>] names the names of plugins to be uninstalled
    def uninstall(names, options)
      if names.empty? && !options[:all]
        Bundler.ui.error "No plugins to uninstall. Specify at least 1 plugin to uninstall.\n"\
          "Use --all option to uninstall all the installed plugins."
        return
      end

      names = index.installed_plugins if options[:all]
      if names.any?
        names.each do |name|
          if index.installed?(name)
            path = index.plugin_path(name).to_s
            Bundler.rm_rf(path) if index.installed_in_plugin_root?(name)
            index.unregister_plugin(name)
            Bundler.ui.info "Uninstalled plugin #{name}"
          else
            Bundler.ui.error "Plugin #{name} is not installed \n"
          end
        end
      else
        Bundler.ui.info "No plugins installed"
      end
    end

    # List installed plugins and commands
    #
    def list
      installed_plugins = index.installed_plugins
      if installed_plugins.any?
        output = String.new
        installed_plugins.each do |plugin|
          output << "#{plugin}\n"
          output << "-----\n"
          index.plugin_commands(plugin).each do |command|
            output << "  #{command}\n"
          end
          output << "\n"
        end
      else
        output = "No plugins installed"
      end
      Bundler.ui.info output
    end

    # Evaluates the Gemfile with a limited DSL and installs the plugins
    # specified by plugin method
    #
    # @param [Pathname] gemfile path
    # @param [Proc] block that can be evaluated for (inline) Gemfile
    def gemfile_install(gemfile = nil, lockfile = nil, unlock = {}, &inline)
      @gemfile_parse = true
      Bundler.configure
      builder = DSL.new
      if block_given?
        builder.instance_eval(&inline)
      else
        builder.eval_gemfile(gemfile)
      end
      builder.check_primary_source_safety

      plugins = builder.dependencies.map(&:name)
      return if plugins.empty?

      # skip the update if unlocking specific gems, but none of them are plugins
      # declared in the Gemfile
      if unlock.is_a?(Hash) && unlock[:gems] && !unlock[:gems].empty? &&
         (unlock[:gems] & plugins).empty?
        unlock = {}
      end

      # resolve remotely when unlocking, so that plugins can be updated.
      # Definition#initialize consumes the unlock hash, so this must be decided
      # before building the definition.
      updating = unlock == true || (unlock.is_a?(Hash) && !unlock.empty?)

      definition = builder.to_definition(lockfile, unlock)

      installed_specs = Installer.new.install_definition(definition, updating)

      save_plugins installed_specs.slice(*plugins), builder.inferred_plugins
    rescue RuntimeError => e
      unless e.is_a?(GemfileError)
        Bundler.ui.error "Failed to install plugin: #{e.message}\n  #{e.backtrace[0]}"
      end
      raise
    ensure
      @gemfile_parse = false
    end

    # The index object used to store the details about the plugin
    def index
      @index ||= Index.new
    end

    # The directory root for all plugin related data
    #
    # If run in an app, points to local root, in app_config_path
    # Otherwise, points to global root, in Bundler.user_bundle_path("plugin")
    def root
      @root ||= if SharedHelpers.in_bundle?
        local_root
      else
        global_root
      end
    end

    def local_root
      Bundler.app_config_path.join("plugin")
    end

    # The global directory root for all plugin related data
    def global_root
      Bundler.user_bundle_path("plugin")
    end

    # The cache directory for plugin stuffs
    def cache
      @cache ||= root.join("cache")
    end

    # To be called via the API to register to handle a command
    def add_command(command, cls)
      @commands[command] = cls
    end

    # Checks if any plugin handles the command
    def command?(command)
      !index.command_plugin(command).nil?
    end

    # To be called from Cli class to pass the command and argument to
    # appropriate plugin class
    def exec_command(command, args)
      raise UndefinedCommandError, "Command `#{command}` not found" unless command? command

      load_plugin index.command_plugin(command) unless @commands.key? command

      @commands[command].new.exec(command, args)
    end

    # To be called via the API to register to handle a source plugin
    def add_source(source, cls)
      @sources[source] = cls
    end

    # Checks if any plugin declares the source
    def source?(name)
      !!source_plugin(name)
    end

    # Returns the plugin that handles the source +name+ if any
    def source_plugin(name)
      index.source_plugin(name.to_s)
    end

    # @return [Class] that handles the source. The class includes API::Source
    def source(name)
      raise UnknownSourceError, "Source #{name} not found" unless source_plugin(name)

      load_plugin(index.source_plugin(name)) unless @sources.key? name

      @sources[name]
    end

    # @param [Hash] The options that are present in the lockfile
    # @return [API::Source] the instance of the class that handles the source
    #                       type passed in locked_opts
    def from_lock(locked_opts)
      opts = locked_opts.merge("uri" => locked_opts["remote"])
      # when reading the lockfile while doing the plugin-install-from-gemfile phase,
      # we need to ignore any plugin sources
      return UnloadedSource.new(opts) if @gemfile_parse
      # use an inert placeholder when the plugin handling this source is not
      # installed, so that the lockfile can still be parsed
      return UnloadedSource.new(opts) unless source?(locked_opts["type"])

      src = source(locked_opts["type"])

      src.new(opts)
    end

    # To be called via the API to register a hooks and corresponding block that
    # will be called to handle the hook
    def add_hook(event, &block)
      unless Events.defined_event?(event)
        raise ArgumentError, "Event '#{event}' not defined in Bundler::Plugin::Events"
      end
      @hooks_by_event[event.to_s] << block
    end

    # Runs all the hooks that are registered for the passed event
    #
    # It passes the passed arguments and block to the block registered with
    # the api.
    #
    # @param [String] event
    def hook(event, *args, &arg_blk)
      return unless Bundler.settings[:plugins]
      unless Events.defined_event?(event)
        raise ArgumentError, "Event '#{event}' not defined in Bundler::Plugin::Events"
      end

      plugins = index.hook_plugins(event)
      return unless plugins.any?

      plugins.each {|name| load_plugin(name) }

      @hooks_by_event[event].each {|blk| blk.call(*args, &arg_blk) }
    end

    # currently only intended for specs
    #
    # @return [String, nil] installed path
    def installed?(plugin)
      (path = index.installed?(plugin)) &&
        index.plugin_path(plugin).join(PLUGIN_FILE_NAME).file? &&
        path
    end

    # @return [true, false] whether the plugin is loaded
    def loaded?(plugin)
      @loaded_plugin_names.include?(plugin)
    end

    # Post installation processing and registering with index
    #
    # @param [Hash] specs of plugins mapped to installation path (currently they
    #               contain all the installed specs, including plugins)
    # @param [Array<String>] names of inferred source plugins that can be ignored
    def save_plugins(specs, optional_plugins = [])
      specs.each do |name, spec|
        save_plugin(name, spec, optional_plugins.include?(name))
      end
    end

    # Checks if the gem is good to be a plugin
    #
    # At present it only checks whether it contains plugins.rb file
    #
    # @param [Pathname] plugin_path the path plugin is installed at
    # @raise [MalformattedPlugin] if plugins.rb file is not found
    def validate_plugin!(plugin_path)
      plugin_file = plugin_path.join(PLUGIN_FILE_NAME)
      raise MalformattedPlugin, "#{PLUGIN_FILE_NAME} was not found in the plugin." unless plugin_file.file?
    end

    # Validates and registers a plugin.
    #
    # @param [String] name the name of the plugin
    # @param [Specification] spec of installed plugin
    # @param [Boolean] optional_plugin, removed if there is conflict with any
    #                     other plugin (used for default source plugins)
    #
    # @raise [PluginInstallError] if validation or registration raises any error
    def save_plugin(name, spec, optional_plugin = false)
      return if index.up_to_date?(spec)

      validate_plugin! Pathname.new(spec.full_gem_path)
      installed = register_plugin(name, spec, optional_plugin)
      Bundler.ui.info "Installed plugin #{name}" if installed
    rescue PluginError => e
      raise PluginInstallError, "Failed to install plugin `#{spec.name}`, due to #{e.class} (#{e.message})"
    end

    # Runs the plugins.rb file in an isolated namespace, records the plugin
    # actions it registers for and then passes the data to index to be stored.
    #
    # @param [String] name the name of the plugin
    # @param [Specification] spec of installed plugin
    # @param [Boolean] optional_plugin, removed if there is conflict with any
    #                     other plugin (used for default source plugins)
    #
    # @raise [MalformattedPlugin] if plugins.rb raises any error
    def register_plugin(name, spec, optional_plugin = false)
      commands = @commands
      sources = @sources
      hooks = @hooks_by_event

      @commands = {}
      @sources = {}
      @hooks_by_event = Hash.new {|h, k| h[k] = [] }

      load_paths = spec.load_paths
      Gem.add_to_load_path(*load_paths)
      path = Pathname.new spec.full_gem_path

      begin
        load path.join(PLUGIN_FILE_NAME), true
      rescue StandardError => e
        raise MalformattedPlugin, "#{e.class}: #{e.message}"
      end

      if optional_plugin && @sources.keys.any? {|s| source_plugin(s) }
        Bundler.rm_rf(path)
        false
      else
        index.register_plugin(name, path.to_s, load_paths, @commands.keys,
          @sources.keys, @hooks_by_event.keys)
        true
      end
    ensure
      @commands = commands
      @sources = sources
      @hooks_by_event = hooks
    end

    # Executes the plugins.rb file
    #
    # @param [String] name of the plugin
    def load_plugin(name)
      return unless name && !name.empty?
      return if loaded?(name)

      # Need to ensure before this that plugin root where the rest of gems
      # are installed to be on load path to support plugin deps. Currently not
      # done to avoid conflicts
      path = index.plugin_path(name)

      paths = index.load_paths(name)
      invalid_paths = paths.reject {|p| File.directory?(p) }

      if invalid_paths.any?
        Bundler.ui.warn <<~MESSAGE
          The following plugin paths don't exist: #{invalid_paths.join(", ")}.

          This can happen if the plugin was installed with a different version of Ruby that has since been uninstalled.

          If you would like to reinstall the plugin, run:

          bundler plugin uninstall #{name} && bundler plugin install #{name}

          Continuing without installing plugin #{name}.
        MESSAGE

        return
      end

      Gem.add_to_load_path(*paths)

      load path.join(PLUGIN_FILE_NAME)

      @loaded_plugin_names << name
    rescue RuntimeError => e
      Bundler.ui.error "Failed loading plugin #{name}: #{e.message}"
      raise
    end

    class << self
      private :load_plugin, :register_plugin, :save_plugins, :validate_plugin!
    end
  end
end
