# This file is not required by default.

require "singleton" unless defined?(Singleton)
require "forwardable" unless defined?(Forwardable)
require "fileutils" unless defined?(FileUtils)
require "uri" unless defined?(URI)

# Gem extensions for doing unusual things - not loaded by Gem default
require "rubygems/package" unless defined?(Gem::Package)
require "rubygems/name_tuple"
require "rubygems/uninstaller"
require "rubygems/remote_fetcher"

require "inspec/plugin/v2/filter"

module Inspec::Plugin::V2
  # Handles all actions modifying the user's plugin set:
  # * Modifying the plugins.json file
  # * Installing, updating, and removing gem-based plugins
  # Loading plugins is handled by Loader.
  # Listing plugins is handled by Loader.
  # Searching for plugins is handled by ???
  class Installer
    include Singleton
    extend Forwardable

    Gem.configuration["verbose"] = false

    attr_reader :conf_file, :loader, :registry
    def_delegator :loader, :plugin_gem_path, :gem_path
    def_delegator :loader, :plugin_conf_file_path
    def_delegator :loader, :list_managed_gems
    def_delegator :loader, :list_installed_plugin_gems

    def initialize
      @loader = Inspec::Plugin::V2::Loader.new
      @registry = Inspec::Plugin::V2::Registry.instance
    end

    def plugin_installed?(name)
      list_installed_plugin_gems.detect { |spec| spec.name == name }
    end

    def plugin_version_installed?(name, version)
      list_installed_plugin_gems.detect { |spec| spec.name == name && spec.version == Gem::Version.new(version) }
    end

    # Installs a plugin. Defaults to assuming the plugin provided is a gem, and will try to install
    # from whatever gemsources `rubygems` thinks it should use.
    # If it's a gem, installs it and its dependencies to the `gem_path`. The gem is not activated.
    # If it's a path, leaves it in place.
    # Finally, updates the plugins.json file with the new information.
    # No attempt is made to load the plugin.
    #
    # @param [String] plugin_name
    # @param [Hash] opts The installation options
    # @option opts [String] :gem_file Path to a local gem file to install from
    # @option opts [String] :path Path to a file to be used as the entry point for a path-based plugin
    # @option opts [String] :version Version constraint for remote gem installs
    # @option opts [String] :source Alternate URL to use instead of rubygems.org
    def install(plugin_name, opts = {})
      # TODO: - check plugins.json for validity before trying anything that needs to modify it.
      validate_installation_opts(plugin_name, opts)

      # TODO: return installed thingy
      if opts[:path]
        install_from_path(plugin_name, opts)
      elsif opts[:gem_file]
        gem_version = install_from_gem_file(plugin_name, opts)
        opts[:version] = gem_version.to_s
      else
        gem_version = install_from_remote_gems(plugin_name, opts)
        opts[:version] = gem_version.to_s
      end

      update_plugin_config_file(plugin_name, opts.merge({ action: :install }))
    end

    # Updates a plugin. Most options same as install, but will not handle path installs.
    # If no :version is provided, updates to the latest.
    # If a version is provided, the plugin becomes pinned at that specified version.
    #
    # @param [String] plugin_name
    # @param [Hash] opts The installation options
    # @option opts [String] :gem_file Reserved for future use.  No effect.
    # @option opts [String] :version Version constraint for remote gem updates
    def update(plugin_name, opts = {})
      # TODO: - check plugins.json for validity before trying anything that needs to modify it.
      validate_update_opts(plugin_name, opts)
      opts[:update_mode] = true

      # TODO: Handle installing from a local file
      # TODO: Perform dependency checks to make sure the new solution is valid
      gem_version = install_from_remote_gems(plugin_name, opts)

      update_plugin_config_file(plugin_name, opts.merge({ action: :update, version: gem_version.to_s }))
    end

    # Uninstalls (removes) a plugin. Refers to plugin.json to determine if it
    # was a gem-based or path-based install.
    # If it's a gem, uninstalls it, and all other unused plugins.
    # If it's a path, removes the reference from the plugins.json, but does not
    # tamper with the plugin source tree.
    # Either way, the plugins.json file is updated with the new information.
    #
    # @param [String] plugin_name
    # @param [Hash] opts The uninstallation options. Currently unused.
    def uninstall(plugin_name, opts = {})
      # TODO: - check plugins.json for validity before trying anything that needs to modify it.
      validate_uninstall_opts(plugin_name, opts)

      if registry.path_based_plugin?(plugin_name)
        uninstall_via_path(plugin_name, opts)
      else
        uninstall_via_gem(plugin_name, opts)
      end

      update_plugin_config_file(plugin_name, opts.merge({ action: :uninstall }))
    end

    # Search rubygems.org for a plugin gem.
    #
    # @param [String] plugin_seach_term
    # @param [Hash] opts Search options
    # @option opts [TrueClass, FalseClass] :exact If true, use plugin_search_term exactly.  If false (default), append a wildcard.
    # @option opts [Symbol] :scope Which versions to search for.  :released (default) - all released versions.  :prerelease - Also include versioned marked prerelease. :latest - only return one version, the latest one.
    # @return [Hash of Arrays] - Keys are String names of gems, arrays contain String versions.
    def search(plugin_query, opts = {}) # rubocop: disable Metrics/AbcSize
      validate_search_opts(plugin_query, opts)

      fetcher = Gem::SpecFetcher.fetcher
      if opts[:source]
        source_list = Gem::SourceList.from([opts[:source]])
        fetcher = Gem::SpecFetcher.new(source_list)
      end

      matched_tuples = []
      if opts[:exact]
        matched_tuples = fetcher.detect(opts[:scope]) { |tuple| tuple.name == plugin_query }
      else
        regex = Regexp.new("^" + plugin_query + ".*")
        matched_tuples = fetcher.detect(opts[:scope]) do |tuple|
          tuple.name =~ regex && !Inspec::Plugin::V2::PluginFilter.exclude?(tuple.name)
        end
      end

      # sort tuples
      matched_tuples.sort! { |a, b| b.first.version <=> a.first.version }

      gem_info = {}
      matched_tuples.each do |tuple|
        gem_info[tuple.first.name] ||= {}
        gem_info[tuple.first.name]["versions"] ||= []
        gem_info[tuple.first.name]["versions"] << tuple.first.version.to_s
        gem_info[tuple.first.name]["description"] ||= fetch_plugin_specs(fetcher, tuple.first.name)&.summary
      end
      gem_info
    end

    def fetch_plugin_specs(fetcher, gem_name)
      plugin_dependency = Gem::Dependency.new(gem_name)
      fetcher.spec_for_dependency(plugin_dependency).flatten.first
    end

    # Testing API.  Performs a hard reset on the installer and registry, and reloads the loader.
    # Not for public use.
    # TODO: bad timing coupling in tests
    def __reset
      registry.__reset
    end

    def __reset_loader
      @loader = Loader.new
    end

    private

    #===================================================================#
    #                       Validation Methods                          #
    #===================================================================#

    # rubocop: disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
    # rationale for rubocop exemption: While there are many conditionals, they are all of the same form;
    # its goal is to check for several subtle combinations of params, and raise an error if needed. It's
    # straightforward to understand, but has to handle many cases.
    def validate_installation_opts(plugin_name, opts)
      unless plugin_name =~ /^(inspec|train)-/
        raise InstallError, "All inspec plugins must begin with either 'inspec-' or 'train-' - refusing to install #{plugin_name}"
      end

      if opts.key?(:gem_file) && opts.key?(:path)
        raise InstallError, "May not specify both gem_file and a path (for installing from source)"
      end

      if opts.key?(:version) && (opts.key?(:gem_file) || opts.key?(:path))
        raise InstallError, "May not specify a version when installing from a gem file or source path"
      end

      if opts.key?(:gem_file)
        unless opts[:gem_file].end_with?(".gem")
          raise InstallError, "When installing from a local gem file, gem file must have '.gem' extension - saw #{opts[:gem_file]}"
        end
        unless File.exist?(opts[:gem_file])
          raise InstallError, "Could not find local gem file to install - #{opts[:gem_file]}"
        end
      elsif opts.key?(:path)
        unless File.exist?(opts[:path])
          raise InstallError, "Could not find path for install from source path - #{opts[:path]}"
        end
      end

      if plugin_installed?(plugin_name)
        if opts.key?(:version) && plugin_version_installed?(plugin_name, opts[:version])
          raise InstallError, "#{plugin_name} version #{opts[:version]} is already installed."
        else
          raise InstallError, "#{plugin_name} is already installed. Use 'inspec plugin update' to change version."
        end
      end

      reason = Inspec::Plugin::V2::PluginFilter.exclude?(plugin_name)
      if reason
        ex = PluginExcludedError.new("Refusing to install #{plugin_name}.  It is on the Plugin Exclusion List.  Rationale: #{reason.rationale}")
        ex.details = reason
        raise ex
      end
    end
    # rubocop: enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize

    def validate_update_opts(plugin_name, opts)
      # Only update plugins we know about
      unless plugin_name =~ /^(inspec|train)-/
        raise UpdateError, "All inspec plugins must begin with either 'inspec-' or 'train-' - refusing to update #{plugin_name}"
      end
      unless registry.known_plugin?(plugin_name.to_sym)
        raise UpdateError, "'#{plugin_name}' is not installed - use 'inspec plugin install' to install it"
      end

      # No local path support for update
      if registry[plugin_name.to_sym].installation_type == :path
        raise UpdateError, "'inspec plugin update' will not handle path-based plugins like '#{plugin_name}'. Use 'inspec plugin uninstall' to remove the reference, then install as a gem."
      end
      if opts.key?(:path)
        raise UpdateError, "'inspec plugin update' will not install from a path."
      end

      if opts.key?(:version) && plugin_version_installed?(plugin_name, opts[:version])
        raise UpdateError, "#{plugin_name} version #{opts[:version]} is already installed."
      end
    end

    def validate_uninstall_opts(plugin_name, _opts)
      # Only uninstall plugins we know about
      unless plugin_name =~ /^(inspec|train)-/
        raise UnInstallError, "All inspec plugins must begin with either 'inspec-' or 'train-' - refusing to uninstall #{plugin_name}"
      end
      unless registry.known_plugin?(plugin_name.to_sym)
        raise UnInstallError, "'#{plugin_name}' is not installed, refusing to uninstall."
      end
    end

    def validate_search_opts(search_term, opts)
      unless search_term =~ /^(inspec|train)-/
        raise SearchError, "All inspec plugins must begin with either 'inspec-' or 'train-'."
      end

      opts[:scope] ||= :released
      unless %i{prerelease released latest}.include?(opts[:scope])
        raise SearchError, "Search scope for listing versons must be :prerelease, :released, or :latest."
      end
    end

    #===================================================================#
    #                   Install / Upgrade Methods                       #
    #===================================================================#

    def install_from_path(requested_plugin_name, opts)
      # Nothing to do here; we will later update the plugins file with the path.
    end

    def install_from_gem_file(requested_plugin_name, opts)
      # Make Set that encompasses just the gemfile that was provided
      plugin_local_source = Gem::Source::SpecificFile.new(opts[:gem_file])

      plugin_dependency = Gem::Dependency.new(
        requested_plugin_name,
        plugin_local_source.spec.version
      )

      requested_local_gem_set = Gem::Resolver::InstallerSet.new(:both)
      requested_local_gem_set.add_local(
        plugin_dependency.name,
        plugin_local_source.spec,
        plugin_local_source
      )

      install_gem_to_plugins_dir(plugin_dependency, [requested_local_gem_set])
    end

    def install_from_remote_gems(requested_plugin_name, opts)
      plugin_dependency = Gem::Dependency.new(requested_plugin_name, opts[:version] || "> 0")

      # BestSet is rubygems.org API + indexing, APISet is for custom sources
      sources = if opts[:source]
                  Gem::Resolver::APISet.new(URI.join(opts[:source] + "/api/v1/dependencies"))
                else
                  Gem::Resolver::BestSet.new
                end

      begin
        install_gem_to_plugins_dir(plugin_dependency, [sources], opts[:update_mode])
      rescue Gem::RemoteFetcher::FetchError => gem_ex
        # TODO: Give a hint if the host was not resolvable or a 404 occured
        ex = Inspec::Plugin::V2::InstallError.new(gem_ex.message)
        ex.plugin_name = requested_plugin_name
        raise ex
      end
    end

    def install_gem_to_plugins_dir(new_plugin_dependency, # rubocop: disable Metrics/AbcSize
      extra_request_sets = [],
      update_mode = false)

      # Get a list of all the gems available to us.
      gem_to_force_update = update_mode ? new_plugin_dependency.name : nil
      set_available_for_resolution = build_gem_request_universe(extra_request_sets, gem_to_force_update)

      # Solve the dependency (that is, find a way to install the new plugin and anything it needs)
      request_set = Gem::RequestSet.new(new_plugin_dependency)

      begin
        solution = request_set.resolve(set_available_for_resolution)
      rescue Gem::UnsatisfiableDependencyError => gem_ex
        # TODO: use search facility to determine if the requested gem exists at all, vs if the constraints are impossible
        ex = Inspec::Plugin::V2::InstallError.new(gem_ex.message)
        ex.plugin_name = new_plugin_dependency.name
        raise ex
      end

      # Activate all current plugins before trying to activate the new one
      loader.list_managed_gems.each do |spec|
        next if spec.name == new_plugin_dependency.name && update_mode

        spec.activate
      end

      # Make sure we remove any previously loaded gem on update
      Gem.loaded_specs.delete(new_plugin_dependency.name) if update_mode

      # Test activating the solution. This makes sure we do not try to load two different versions
      # of the same gem on the stack or a malformed dependency.
      begin
        solution.each do |activation_request|
          unless activation_request.full_spec.activated?
            activation_request.full_spec.activate
          end
        end
      rescue Gem::LoadError => gem_ex
        ex = Inspec::Plugin::V2::InstallError.new(gem_ex.message)
        ex.plugin_name = new_plugin_dependency.name
        raise ex
      end

      # OK, perform the installation.
      # Ignore deps here, because any needed deps should already be baked into new_plugin_dependency
      request_set.install_into(gem_path, true, ignore_dependencies: true, document: [])

      # Painful aspect of rubygems: the VendorSet request set type needs to be able to find a gemspec
      # file within the source of the gem (and not all gems include it in their source tree; they are
      # not obliged to during packaging.)
      # So, after each install, run a scan for all gem(specs) we manage, and copy in their gemspec file
      # into the exploded gem source area if absent.
      loader.list_managed_gems.each do |spec|
        path_inside_source = File.join(spec.gem_dir, "#{spec.name}.gemspec")
        unless File.exist?(path_inside_source)
          File.write(path_inside_source, spec.to_ruby)
        end
      end

      # Locate the GemVersion for the new dependency and return it
      solution.detect { |g| g.name == new_plugin_dependency.name }.version
    end

    #===================================================================#
    #                        UnInstall Methods                          #
    #===================================================================#

    def uninstall_via_path(requested_plugin_name, opts)
      # Nothing to do here; we will later update the plugins file to remove the plugin entry.
    end

    def uninstall_via_gem(plugin_name_to_be_removed, _opts)
      # Strategy: excluding the plugin we want to uninstall, determine a gem install solution
      # based on gems we already have, then remove anything not needed.  This removes 3 kinds
      # of cruft:
      #  1. All versions of the unwanted plugin gem
      #  2. All dependencies of the unwanted plugin gem (that aren't needed by something else)
      #  3. All other gems installed under the ~/.inspec/gems area that are not needed
      #     by a plugin gem. TODO: ideally this would be a separate 'clean' operation.

      # Create a list of plugins dependencies, including any version constraints,
      # excluding any that are path-or-core-based, excluding the gem to be removed
      plugin_deps_we_still_must_satisfy = registry.plugin_statuses
      plugin_deps_we_still_must_satisfy = plugin_deps_we_still_must_satisfy.select do |status|
        status.installation_type == :user_gem && status.name != plugin_name_to_be_removed.to_sym
      end
      plugin_deps_we_still_must_satisfy = plugin_deps_we_still_must_satisfy.map do |status|
        constraint = status.version || "> 0"
        Gem::Dependency.new(status.name.to_s, constraint)
      end

      # Make a Request Set representing the still-needed deps
      request_set_we_still_must_satisfy = Gem::RequestSet.new(*plugin_deps_we_still_must_satisfy)
      request_set_we_still_must_satisfy.remote = false

      # Find out which gems we still actually need...
      names_of_gems_we_actually_need = \
        request_set_we_still_must_satisfy.resolve(build_gem_request_universe)
          .map(&:full_spec).map(&:full_name)

      # ... vs what we currently have, which should have some cruft
      cruft_gem_specs = loader.list_managed_gems.reject do |spec|
        names_of_gems_we_actually_need.include?(spec.full_name)
      end

      # Ok, delete the unneeded gems
      cruft_gem_specs.each do |cruft_spec|
        Gem::Uninstaller.new(
          cruft_spec.name,
          version: cruft_spec.version,
          install_dir: gem_path,
          # Docs on this class are poor.  Next 4 are reasonable, but cargo-culted.
          all: true,
          executables: true,
          force: true,
          ignore: true
        ).uninstall_gem(cruft_spec)
      end
    end

    #===================================================================#
    #                        Utilities
    #===================================================================#

    # This class allows us to build a Resolver set with the gems that are
    # already included either with Ruby or with the InSpec install
    #
    # This code is heavily based on:
    # https://github.com/hashicorp/vagrant/blob/32237377/lib/vagrant/bundler.rb#L400
    # https://github.com/hashicorp/vagrant/blob/32237377/lib/vagrant/bundler.rb#L565
    class InstalledVendorSet < Gem::Resolver::Set
      def initialize
        super
        @remote = false
        @specs = []

        # Grab any pre loaded gems
        Gem::Specification.find_all do |spec|
          @specs << spec
        end

        # find all gem specification directories

        spec_dir = if Gem.respond_to? :default_specifications_dir
                     Gem.default_specifications_dir
                   else
                     Gem::Specification.default_specifications_dir
                   end
        directories = [spec_dir]
        unless defined?(::Bundler)
          # add in any others that do not start with the user directory
          directories += Gem::Specification.dirs.find_all do |path|
            !path.start_with?(Gem.user_dir)
          end
        end

        # add them all to the specs array
        Gem::Specification.each_spec(directories) do |spec|
          @specs << spec
        end

        # resolver expects one of each spec so uniq here.
        @specs.uniq!
      end

      def find_all(req)
        @specs.select { |spec| req.match?(spec) }.map do |spec|
          Gem::Resolver::InstalledSpecification.new(self, spec)
        end
      end
    end

    # Provides a RequestSet (a set of gems representing the gems that are available to
    # solve a dependency request) that represents a combination of:
    # * the gems included in the system
    # * the gems included in the inspec install
    # * the currently installed gems in the ~/.inspec/gems directory
    # * any other sets you provide
    def build_gem_request_universe(extra_request_sets = [], gem_to_force_update = nil)
      installed_plugins_gem_set = Gem::Resolver::VendorSet.new
      loader.list_managed_gems.each do |spec|
        next if spec.name == gem_to_force_update

        installed_plugins_gem_set.add_vendor_gem(spec.name, spec.gem_dir)
      end

      # Combine the Sets, so the resolver has one composite place to look
      Gem::Resolver.compose_sets(
        installed_plugins_gem_set, # The gems that are in the plugin gem path directory tree
        InstalledVendorSet.new,
        *extra_request_sets # Anything else our caller wanted to include
      )
    end

    #===================================================================#
    #                 plugins.json Maintenance Methods                  #
    #===================================================================#
    def update_plugin_config_file(plugin_name, opts)
      # Be careful no to initialize this until just before we write.
      # Under testing, ENV['INSPEC_CONFIG_DIR'] may have changed.
      @conf_file = Inspec::Plugin::V2::ConfigFile.new

      # Remove, then optionally rebuild, the entry for the plugin being modified.
      conf_file.remove_entry(plugin_name) if conf_file.existing_entry?(plugin_name)
      unless opts[:action] == :uninstall
        entry = { name: plugin_name }
        # Parsing by Requirement handles lot of awkward formattoes
        entry[:version] = Gem::Requirement.new(opts[:version]).to_s if opts.key?(:version)
        if opts.key?(:path)
          entry[:installation_type] = :path
          entry[:installation_path] = opts[:path]
        end
        conf_file.add_entry(entry)
      end

      conf_file.save

      conf_file
    end
  end
end
