# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

require "pathname"
require "set"

require_relative "../bundler"
require_relative "../shared_helpers"
require_relative "state_file"

module Vagrant
  module Plugin
    # The Manager helps with installing, listing, and initializing plugins.
    class Manager
      # Returns the path to the [StateFile] for user plugins.
      #
      # @return [Pathname]
      def self.user_plugins_file
        Vagrant.user_data_path.join("plugins.json")
      end

      # Returns the path to the [StateFile] for system plugins.
      def self.system_plugins_file
        dir = Vagrant.installer_embedded_dir
        return nil if !dir
        Pathname.new(dir).join("plugins.json")
      end

      def self.instance
        @instance ||= self.new(user_plugins_file)
      end

      attr_reader :user_file
      attr_reader :system_file
      attr_reader :local_file

      # @param [Pathname] user_file
      def initialize(user_file)
        @logger = Log4r::Logger.new("vagrant::plugin::manager")
        @user_file   = StateFile.new(user_file)

        system_path  = self.class.system_plugins_file
        @system_file = nil
        @system_file = StateFile.new(system_path) if system_path && system_path.file?

        @local_file = nil
        @globalized = @localized = false
      end

      # Enable global plugins
      #
      # @return [Hash] list of plugins
      def globalize!
        @globalized = true
        @logger.debug("Enabling globalized plugins")
        plugins = installed_plugins
        bundler_init(plugins, global: user_file.path)
        plugins
      end

      # Enable environment local plugins
      #
      # @param [Environment] env Vagrant environment
      # @return [Hash, nil] list of plugins
      def localize!(env)
        @localized = true
        if env.local_data_path
          @logger.debug("Enabling localized plugins")
          @local_file = StateFile.new(env.local_data_path.join("plugins.json"))
          Vagrant::Bundler.instance.environment_path = env.local_data_path
          plugins = local_file.installed_plugins
          bundler_init(plugins, local: local_file.path)
          plugins
        end
      end

      # @return [Boolean] local and global plugins are loaded
      def ready?
        @globalized && @localized
      end

      # Initialize bundler with given plugins
      #
      # @param [Hash] plugins List of plugins
      # @return [nil]
      def bundler_init(plugins, **opts)
        if !Vagrant.plugins_init?
          @logger.warn("Plugin initialization is disabled")
          return nil
        end

        @logger.info("Plugins:")
        plugins.each do |plugin_name, plugin_info|
          installed_version = plugin_info["installed_gem_version"]
          version_constraint = plugin_info["gem_version"]
          installed_version = 'undefined' if installed_version.to_s.empty?
          version_constraint = '> 0' if version_constraint.to_s.empty?
          @logger.info(
            "  - #{plugin_name} = [installed: " \
              "#{installed_version} constraint: " \
              "#{version_constraint}]"
          )
        end
        begin
          Vagrant::Bundler.instance.init!(plugins, **opts)
        rescue StandardError, ScriptError => err
          @logger.error("Plugin initialization error - #{err.class}: #{err}")
          err.backtrace.each do |backtrace_line|
            @logger.debug(backtrace_line)
          end
          raise Vagrant::Errors::PluginInitError, message: err.to_s
        end
      end

      # Installs another plugin into our gem directory.
      #
      # @param [String] name Name of the plugin (gem)
      # @return [Gem::Specification]
      def install_plugin(name, **opts)
        if opts[:env_local] && @local_file.nil?
          raise Errors::PluginNoLocalError
        end

        if name =~ /\.gem$/
          # If this is a gem file, then we install that gem locally.
          local_spec = Vagrant::Bundler.instance.install_local(name, opts)
          name       = local_spec.name
          opts[:version] = local_spec.version.to_s
        end

        plugins = installed_plugins
        plugins[name] = {
          "require"     => opts[:require],
          "gem_version" => opts[:version],
          "sources"     => opts[:sources],
        }

        if local_spec.nil?
          result = nil
          install_lambda = lambda do
            Vagrant::Bundler.instance.install(plugins, opts[:env_local]).each do |spec|
              next if spec.name != name
              next if result && result.version >= spec.version
              result = spec
            end
          end

          if opts[:verbose]
            Vagrant::Bundler.instance.verbose(&install_lambda)
          else
            install_lambda.call
          end
        else
          result = local_spec
        end

        if result
          # Add the plugin to the state file
          plugin_file = opts[:env_local] ? @local_file : @user_file
          plugin_file.add_plugin(
            result.name,
            version: opts[:version],
            require: opts[:require],
            sources: opts[:sources],
            env_local: !!opts[:env_local],
            installed_gem_version: result.version.to_s
          )
        else
          r = Gem::Dependency.new(name, opts[:version])
          result = Gem::Specification.find { |s|
            s.satisfies_requirement?(r) &&
              s.activated?
          }
          raise Errors::PluginInstallFailed,
            name: name if result.nil?
          @logger.warn("Plugin install returned no result as no new plugins were installed.")
        end
        # After install clean plugin gems to remove any cruft. This is useful
        # for removing outdated dependencies or other versions of an installed
        # plugin if the plugin is upgraded/downgraded
        Vagrant::Bundler.instance.clean(installed_plugins, local: !!opts[:local])
        result
      rescue Gem::GemNotFoundException
        raise Errors::PluginGemNotFound, name: name
      rescue Gem::Exception => err
        @logger.warn("Failed to install plugin: #{err}")
        @logger.debug("#{err.class}: #{err}\n#{err.backtrace.join("\n")}")
        # Try and determine a cause for the failure
        case err.message
        when /install development tools first/
          raise Errors::PluginNeedsDeveloperTools
        when /library not found in default locations/
          lib = err.message.match(/(\w+) library not found in default locations/)
          if lib.nil?
            raise Errors::BundlerError, message: err.message
          end
          raise Errors::PluginMissingLibrary,
            library: lib.captures.first,
            name: name
        when /find header files for ruby/
          raise Errors::PluginMissingRubyDev
        else
          raise Errors::BundlerError, message: err.message
        end
      end

      # Uninstalls the plugin with the given name.
      #
      # @param [String] name
      def uninstall_plugin(name, **opts)
        if @system_file
          if !@user_file.has_plugin?(name) && @system_file.has_plugin?(name)
            raise Errors::PluginUninstallSystem,
              name: name
          end
        end

        if opts[:env_local] && @local_file.nil?
          raise Errors::PluginNoLocalError
        end

        plugin_file = opts[:env_local] ? @local_file : @user_file

        if !plugin_file.has_plugin?(name)
          raise Errors::PluginNotInstalled,
            name: name
        end

        plugin_file.remove_plugin(name)

        # Clean the environment, removing any old plugins
        Vagrant::Bundler.instance.clean(installed_plugins)
      rescue Gem::Exception => e
        raise Errors::BundlerError, message: e.to_s
      end

      # Updates all or a specific set of plugins.
      def update_plugins(specific, **opts)
        if opts[:env_local] && @local_file.nil?
          raise Errors::PluginNoLocalError
        end

        plugin_file = opts[:env_local] ? @local_file : @user_file

        result = Vagrant::Bundler.instance.update(plugin_file.installed_plugins, specific)
        plugin_file.installed_plugins.each do |name, info|
          matching_spec = result.detect{|s| s.name == name}
          info = Hash[
            info.map do |key, value|
              [key.to_sym, value]
            end
          ]
          if matching_spec
            plugin_file.add_plugin(name, **info.merge(
              version: "> 0",
              installed_gem_version: matching_spec.version.to_s
            ))
          end
        end
        Vagrant::Bundler.instance.clean(installed_plugins)
        result
      rescue Gem::Exception => e
        raise Errors::BundlerError, message: e.to_s
      end

      # This returns the list of plugins that should be enabled.
      #
      # @return [Hash]
      def installed_plugins
        system = {}
        if @system_file
          @system_file.installed_plugins.each do |k, v|
            system[k] = v.merge("system" => true)
          end
        end
        plugin_list = Util::DeepMerge.deep_merge(system, @user_file.installed_plugins)

        if @local_file
          plugin_list = Util::DeepMerge.deep_merge(plugin_list,
            @local_file.installed_plugins)
        end

        # Sort plugins by name
        Hash[
          plugin_list.map{|plugin_name, plugin_info|
            [plugin_name, plugin_info]
          }.sort_by(&:first)
        ]
      end

      # This returns the list of plugins that are installed as
      # Gem::Specifications.
      #
      # @return [Array<Gem::Specification>]
      def installed_specs
        installed_plugin_info = installed_plugins
        installed = Set.new(installed_plugin_info.keys)
        installed_versions = Hash[
          installed_plugin_info.map{|plugin_name, plugin_info|
            gem_version = plugin_info["gem_version"].to_s
            gem_version = "> 0" if gem_version.empty?
            [plugin_name, Gem::Requirement.new(gem_version)]
          }
        ]

        # Go through the plugins installed in this environment and
        # get the latest version of each.
        installed_map = {}
        Gem::Specification.find_all.each do |spec|
          # Ignore specs that aren't in our installed list
          next if !installed.include?(spec.name)

          next if installed_versions[spec.name] &&
            !installed_versions[spec.name].satisfied_by?(spec.version)

          # If we already have a newer version in our list of installed,
          # then ignore it
          next if installed_map.key?(spec.name) &&
            installed_map[spec.name].version >= spec.version

          installed_map[spec.name] = spec
        end

        installed_map.values
      end

      # Loads the requested plugins into the Vagrant runtime
      #
      # @param [Hash] plugins List of plugins to load
      # @return [nil]
      def load_plugins(plugins)
        if !Vagrant.plugins_enabled?
          @logger.warn("Plugin loading is disabled")
          return
        end

        if plugins.nil?
          @logger.debug("No plugins provided for loading")
          return
        end

        begin
          @logger.info("Loading plugins...")
          plugins.each do |plugin_name, plugin_info|
            if plugin_info["require"].to_s.empty?
              begin
                @logger.info("Loading plugin `#{plugin_name}` with default require: `#{plugin_name}`")
                require plugin_name
              rescue LoadError => err
                if plugin_name.include?("-")
                  plugin_slash = plugin_name.gsub("-", "/")
                  @logger.error("Failed to load plugin `#{plugin_name}` with default require. - #{err.class}: #{err}")
                  @logger.info("Loading plugin `#{plugin_name}` with slash require: `#{plugin_slash}`")
                  require plugin_slash
                else
                  raise
                end
              end
            else
              @logger.debug("Loading plugin `#{plugin_name}` with custom require: `#{plugin_info["require"]}`")
              require plugin_info["require"]
            end
            @logger.debug("Successfully loaded plugin `#{plugin_name}`.")
          end
          if defined?(::Bundler)
            @logger.debug("Bundler detected in use. Loading `:plugins` group.")
            ::Bundler.require(:plugins)
          end
        rescue ScriptError, StandardError => err
          @logger.error("Plugin loading error: #{err.class} - #{err}")
          err.backtrace.each do |backtrace_line|
            @logger.debug(backtrace_line)
          end
          raise Vagrant::Errors::PluginLoadError, message: err.to_s
        end
        nil
      end

      # Check if the requested plugin is installed
      #
      # @param [String] name Name of plugin
      # @param [String] version Specific version of the plugin
      # @return [Boolean]
      def plugin_installed?(name, version=nil)
        # Make the requirement object
        version = Gem::Requirement.new([version.to_s]) if version

        # If plugins are loaded, check for match in loaded specs
        if ready?
          return installed_specs.any? do |s|
            match = s.name == name
            next match if !version
            next match && version.satisfied_by?(s.version)
          end
        end

        # Plugins are not loaded yet so check installed plugin data
        plugin_info = installed_plugins[name]
        return false if !plugin_info
        return !!plugin_info if version.nil? || plugin_info["installed_gem_version"].nil?
        installed_version = Gem::Version.new(plugin_info["installed_gem_version"])
        version.satisfied_by?(installed_version)
      end
    end
  end
end
