# frozen_string_literal: true

module Bundler
  # Manages which plugins are installed and their sources. This also is supposed to map
  # which plugin does what (currently the features are not implemented so this class is
  # now a stub class).
  module Plugin
    class Index
      class CommandConflict < PluginError
        def initialize(plugin, commands)
          msg = "Command(s) `#{commands.join("`, `")}` declared by #{plugin} are already registered."
          super msg
        end
      end

      class SourceConflict < PluginError
        def initialize(plugin, sources)
          msg = "Source(s) `#{sources.join("`, `")}` declared by #{plugin} are already registered."
          super msg
        end
      end

      attr_reader :commands

      def initialize
        @plugin_paths = {}
        @commands = {}
        @sources = {}
        @hooks = {}
        @load_paths = {}

        begin
          load_index(global_index_file, true)
        rescue PermissionError
          # no need to fail when on a read-only FS, for example
          nil
        rescue ArgumentError => e
          # ruby 3.4 checks writability in Dir.tmpdir
          raise unless e.message&.include?("could not find a temporary directory")
          nil
        end
        load_index(local_index_file) if SharedHelpers.in_bundle?
      end

      # This function is to be called when a new plugin is installed. This
      # function shall add the functions of the plugin to existing maps and also
      # the name to source location.
      #
      # @param [String] name of the plugin to be registered
      # @param [String] path where the plugin is installed
      # @param [Array<String>] load_paths for the plugin
      # @param [Array<String>] commands that are handled by the plugin
      # @param [Array<String>] sources that are handled by the plugin
      def register_plugin(name, path, load_paths, commands, sources, hooks)
        old_commands = @commands.dup

        common = commands & @commands.keys
        raise CommandConflict.new(name, common) unless common.empty?
        commands.each {|c| @commands[c] = name }

        common = sources & @sources.keys
        raise SourceConflict.new(name, common) unless common.empty?
        sources.each {|k| @sources[k] = name }

        hooks.each do |event|
          event_hooks = (@hooks[event] ||= []) << name
          event_hooks.uniq!
        end

        @plugin_paths[name] = path
        @load_paths[name] = load_paths
        save_index
      rescue StandardError
        @commands = old_commands
        raise
      end

      def unregister_plugin(name)
        @commands.delete_if {|_, v| v == name }
        @sources.delete_if {|_, v| v == name }
        @hooks.each do |hook, names|
          names.delete(name)
          @hooks.delete(hook) if names.empty?
        end
        @plugin_paths.delete(name)
        @load_paths.delete(name)
        save_index
      end

      # Path of default index file
      def index_file
        Plugin.root.join("index")
      end

      # Path where the global index file is stored
      def global_index_file
        Plugin.global_root.join("index")
      end

      # Path where the local index file is stored
      def local_index_file
        Plugin.local_root.join("index")
      end

      def plugin_path(name)
        Pathname.new @plugin_paths[name]
      end

      def load_paths(name)
        @load_paths[name]
      end

      # Fetch the name of plugin handling the command
      def command_plugin(command)
        @commands[command]
      end

      def installed?(name)
        @plugin_paths[name]
      end

      def up_to_date?(spec)
        path = installed?(spec.name)

        path == spec.full_gem_path
      end

      def installed_plugins
        @plugin_paths.keys
      end

      def plugin_commands(plugin)
        @commands.find_all {|_, n| n == plugin }.map(&:first)
      end

      def source?(source)
        @sources.key? source
      end

      def source_plugin(name)
        @sources[name]
      end

      # Returns the list of plugin names handling the passed event
      def hook_plugins(event)
        @hooks[event] || []
      end

      # This plugin is installed inside the .bundle/plugin directory,
      # and thus is managed solely by Bundler
      def installed_in_plugin_root?(name)
        return false unless (path = installed?(name))

        path.start_with?("#{Plugin.root}/")
      end

      private

      # Reads the index file from the directory and initializes the instance
      # variables.
      #
      # It skips the sources if the second param is true
      # @param [Pathname] index file path
      # @param [Boolean] is the index file global index
      def load_index(index_file, global = false)
        base = base_for_index(global)

        SharedHelpers.filesystem_access(index_file, :read) do |index_f|
          valid_file = index_f&.exist? && !index_f.size.zero?
          break unless valid_file

          data = index_f.read

          require "rubygems/yaml_serializer"
          # Empty sections load as nil, e.g. from index files written by
          # older Bundler versions, which dumped empty hashes as a bare key.
          index = Gem::YAMLSerializer.load(data) || {}

          escaping = escaping_plugins(index, base)
          hooks = (index["hooks"] || {}).transform_values {|names| Array(names) - escaping }

          @commands.merge!(owned_by(index["commands"] || {}, escaping))
          # An event whose plugins all escaped is left out rather than merged in empty.
          @hooks.merge!(hooks.reject {|_, names| names.empty? })
          @load_paths.merge!(named(transform_index_paths(index["load_paths"]) {|p| absolutize_path(p, base) }, escaping))
          @plugin_paths.merge!(named(transform_index_paths(index["plugin_paths"]) {|p| absolutize_path(p, base) }, escaping))
          @sources.merge!(owned_by(index["sources"] || {}, escaping)) unless global
        end
      end

      # Should be called when any of the instance variables change. Stores the
      # instance variables in YAML format. (The instance variables are supposed
      # to be only String key value pairs)
      def save_index
        base = base_for_index(false)

        index = {
          "commands" => @commands,
          "hooks" => @hooks,
          "load_paths" => transform_index_paths(@load_paths) {|p| relativize_path(p, base) },
          "plugin_paths" => transform_index_paths(@plugin_paths) {|p| relativize_path(p, base) },
          "sources" => @sources,
        }

        require "rubygems/yaml_serializer"
        SharedHelpers.filesystem_access(index_file) do |index_f|
          FileUtils.mkdir_p(index_f.dirname)
          File.open(index_f, "w") {|f| f.puts Gem::YAMLSerializer.dump(index) }
        end
      end

      def base_for_index(global)
        global ? Plugin.global_root : Plugin.root
      end

      # A relative path only means anything inside the root, so an entry that escapes is not one Bundler installed.
      def escaping_plugins(index, base)
        names = []

        %w[load_paths plugin_paths].each do |key|
          (index[key] || {}).each do |name, value|
            escapes = Array(value).any? do |path|
              !Pathname.new(path).absolute? && !contained_in?(absolutize_path(path, base), base)
            end

            names << name if escapes
          end
        end

        names.uniq
      end

      # Expanded here, not by the caller: what gets stored stays joined, because
      # #installed_in_plugin_root? matches it against Plugin.root as written.
      def contained_in?(path, base)
        path = File.expand_path(path)
        base = File.expand_path(base)

        path == base || path.start_with?("#{base}#{File::SEPARATOR}")
      end

      # commands and sources are keyed by what they provide, load_paths and plugin_paths by the plugin.
      def owned_by(mapping, names)
        mapping.reject {|_, plugin| names.include?(plugin) }
      end

      def named(mapping, names)
        mapping.reject {|plugin, _| names.include?(plugin) }
      end

      def transform_index_paths(paths)
        return {} unless paths

        paths.transform_values do |value|
          if value.is_a?(Array)
            value.map {|path| yield path }
          else
            yield value
          end
        end
      end

      def relativize_path(path, base)
        pathname = Pathname.new(path)
        return path unless pathname.absolute?

        return path unless contained_in?(pathname.to_s, base)

        pathname.relative_path_from(Pathname.new(base)).to_s
      end

      def absolutize_path(path, base)
        pathname = Pathname.new(path)
        pathname = Pathname.new(base).join(pathname) unless pathname.absolute?
        pathname.to_s
      end
    end
  end
end
