# frozen_string_literal: true

require 'bolt/inventory'
require 'bolt/executor'
require 'bolt/module'
require 'bolt/pal'
require 'bolt/plugin/cache'
require 'bolt/plugin/puppetdb'

module Bolt
  class Plugin
    KNOWN_HOOKS = %i[
      puppet_library
      resolve_reference
      secret_encrypt
      secret_decrypt
      secret_createkeys
      validate_resolve_reference
    ].freeze

    class PluginError < Bolt::Error
      class ExecutionError < PluginError
        def initialize(msg, plugin_name, location)
          mess = "Error executing plugin #{plugin_name} from #{location}: #{msg}"
          super(mess, 'bolt/plugin-error')
        end
      end

      class Unknown < PluginError
        def initialize(plugin_name)
          super("Unknown plugin: '#{plugin_name}'", 'bolt/unknown-plugin')
        end
      end

      class UnsupportedHook < PluginError
        def initialize(plugin_name, hook)
          super("Plugin #{plugin_name} does not support #{hook}", 'bolt/unsupported-hook')
        end
      end

      class LoadingDisabled < PluginError
        def initialize(plugin_name)
          msg = "Cannot load plugin #{plugin_name}: plugin loading is disabled"
          super(msg, 'bolt/plugin-loading-disabled', { 'plugin_name' => plugin_name })
        end
      end
    end

    class PluginContext
      def initialize(config, pal, plugins)
        @pal = pal
        @config = config
        @plugins = plugins
      end

      def serial_executor
        @serial_executor ||= Bolt::Executor.new(1)
      end
      private :serial_executor

      def empty_inventory
        @empty_inventory ||= Bolt::Inventory.empty
      end
      private :empty_inventory

      def with_a_compiler
        # If we're already inside a pal compiler block use that compiler
        # This may blow up if you try to load a task in catalog pal. Should we
        # guard against that?
        compiler = nil
        if defined?(Puppet)
          begin
            compiler = Puppet.lookup(:pal_compiler)
          rescue Puppet::Context::UndefinedBindingError; end # rubocop:disable Lint/SuppressedException
        end

        if compiler
          yield compiler
        else
          @pal.in_bolt_compiler do |temp_compiler|
            yield temp_compiler
          end
        end
      end
      private :with_a_compiler

      def get_validated_task(task_name, params = nil)
        with_a_compiler do |compiler|
          tasksig = compiler.task_signature(task_name)

          raise Bolt::Error.unknown_task(task_name) unless tasksig

          Bolt::Task::Run.validate_params(tasksig, params) if params
          Bolt::Task.from_task_signature(tasksig)
        end
      end

      def validate_params(task_name, params)
        with_a_compiler do |compiler|
          tasksig = compiler.task_signature(task_name)

          raise Bolt::Error.new("#{task_name} could not be found", 'bolt/plugin-error') unless tasksig

          Bolt::Task::Run.validate_params(tasksig, params)
        end
        nil
      end

      # By passing `_` keys in params the caller can send metaparams directly to the task
      # _catch_errors must be passed as an executor option not a param
      def run_local_task(task, params, options)
        # Make sure we're in a compiler to use the sensitive type
        with_a_compiler do |_comp|
          params = Bolt::Task::Run.wrap_sensitive(task, params)
          Bolt::Task::Run.run_task(
            task,
            empty_inventory.get_targets('localhost'),
            params,
            options,
            serial_executor
          )
        end
      end

      def boltdir
        @config.project.path
      end
    end

    def self.setup(config, pal, analytics = Bolt::Analytics::NoopClient.new, **opts)
      plugins = new(config, pal, analytics, **opts)

      config.plugins.each_key do |plugin|
        plugins.by_name(plugin)
      end

      plugins.plugin_hooks.merge!(plugins.resolve_references(config.plugin_hooks))

      plugins
    end

    RUBY_PLUGINS = %w[task prompt env_var puppetdb puppet_connect_data].freeze
    BUILTIN_PLUGINS = %w[task terraform pkcs7 prompt vault aws_inventory puppetdb azure_inventory
                         yaml env_var gcloud_inventory].freeze
    DEFAULT_PLUGIN_HOOKS = { 'puppet_library' => { 'plugin' => 'puppet_agent', 'stop_service' => true } }.freeze

    attr_reader :pal, :plugin_context
    attr_accessor :plugin_hooks

    private_class_method :new

    def initialize(config, pal, analytics, load_plugins: true)
      @config = config
      @analytics = analytics
      @plugin_context = PluginContext.new(config, pal, self)
      @plugins = {}
      @pal = pal
      @load_plugins = load_plugins
      @unknown = Set.new
      @resolution_stack = []
      @unresolved_plugin_configs = config.plugins.dup
      # The puppetdb plugin config comes from the puppetdb section, not from
      # the plugins section
      if @unresolved_plugin_configs.key?('puppetdb')
        msg = "Configuration for the PuppetDB plugin must be in the 'puppetdb' config section, not 'plugins'"
        raise Bolt::Error.new(msg, 'bolt/plugin-error')
      end
      @unresolved_plugin_configs['puppetdb'] = config.puppetdb if config.puppetdb
      @plugin_hooks = DEFAULT_PLUGIN_HOOKS.dup
    end

    def modules
      @modules ||= Bolt::Module.discover(@pal.full_modulepath, @config.project)
    end

    def add_plugin(plugin)
      @plugins[plugin.name] = plugin
    end

    def add_ruby_plugin(plugin_name)
      cls_name = Bolt::Util.snake_name_to_class_name(plugin_name)
      filename = "bolt/plugin/#{plugin_name}"
      require filename
      cls = Kernel.const_get("Bolt::Plugin::#{cls_name}")
      opts = {
        context: @plugin_context,
        config: config_for_plugin(plugin_name)
      }

      plugin = cls.new(**opts)
      add_plugin(plugin)
    end

    def add_module_plugin(plugin_name)
      opts = {
        context: @plugin_context,
        # Make sure that the plugin's config is validated _before_ the unknown-plugin
        # and loading-disabled checks. This way, we can fail early on invalid plugin
        # config instead of _after_ loading the modulepath (which can be expensive).
        config: config_for_plugin(plugin_name)
      }

      mod = modules[plugin_name]

      plugin = Bolt::Plugin::Module.load(mod, opts)
      add_plugin(plugin)
    end

    def config_for_plugin(plugin_name)
      return {} unless @unresolved_plugin_configs.include?(plugin_name)
      if @resolution_stack.include?(plugin_name)
        msg = "Configuration for plugin '#{plugin_name}' depends on the plugin itself"
        raise PluginError.new(msg, 'bolt/plugin-error')
      else
        @resolution_stack.push(plugin_name)
        config = resolve_references(@unresolved_plugin_configs[plugin_name])
        @unresolved_plugin_configs.delete(plugin_name)
        @resolution_stack.pop
        config
      end
    end

    def known_plugin?(plugin_name)
      @plugins.include?(plugin_name) ||
        RUBY_PLUGINS.include?(plugin_name) ||
        (modules.include?(plugin_name) && modules[plugin_name].plugin?)
    end

    def get_hook(plugin_name, hook)
      plugin = by_name(plugin_name)
      raise PluginError::Unknown, plugin_name unless plugin
      raise PluginError::UnsupportedHook.new(plugin_name, hook) unless plugin.hooks.include?(hook)
      @analytics.report_bundled_content("Plugin #{hook}", plugin_name)

      plugin.method(hook)
    end

    # Calling by_name or get_hook will load any module based plugin automatically
    def by_name(plugin_name)
      if known_plugin?(plugin_name)
        if @plugins.include?(plugin_name)
          @plugins[plugin_name]
        elsif !@load_plugins
          raise PluginError::LoadingDisabled, plugin_name
        elsif RUBY_PLUGINS.include?(plugin_name)
          add_ruby_plugin(plugin_name)
        else
          add_module_plugin(plugin_name)
        end
      end
    end

    # Loads all plugins and returns a map of plugin names to hooks.
    #
    def list_plugins
      load_all_plugins

      hooks = KNOWN_HOOKS.map { |hook| [hook, {}] }.to_h

      @plugins.sort.each do |name, plugin|
        # Don't show the Puppet Connect plugin for now.
        next if name == 'puppet_connect_data'

        case plugin
        when Bolt::Plugin::Module
          plugin.hook_map.each do |hook, spec|
            next unless hooks.include?(hook)
            hooks[hook][name] = spec['task'].description
          end
        else
          plugin.hook_descriptions.each do |hook, description|
            hooks[hook][name] = description
          end
        end
      end

      hooks
    end

    # Loads all plugins available to the project.
    #
    private def load_all_plugins
      modules.each do |name, mod|
        next unless mod.plugin?
        by_name(name)
      end

      RUBY_PLUGINS.each { |name| by_name(name) }
    end

    def puppetdb_client
      by_name('puppetdb').puppetdb_client
    end

    # Evaluate all _plugin references in a data structure. Leaves are
    # evaluated and then their parents are evaluated with references replaced
    # by their values. If the result of a reference contains more references,
    # they are resolved again before continuing to ascend the tree. The final
    # result will not contain any references.
    def resolve_references(data)
      Bolt::Util.postwalk_vals(data) do |value|
        reference?(value) ? resolve_references(resolve_single_reference(value)) : value
      end
    rescue SystemStackError
      raise Bolt::Error.new("Stack depth exceeded while recursively resolving references.",
                            "bolt/recursive-reference-loop")
    end

    # Iteratively resolves "top-level" references until the result no longer
    # has top-level references. A top-level reference is one which is not
    # contained within another hash. It may be either the actual top-level
    # result or arbitrarily nested within arrays. If parameters of the
    # reference are themselves references, they will be looked. Any remaining
    # references nested inside the result will *not* be evaluated once the
    # top-level result is not a reference.  This is used to resolve the
    # `targets` and `groups` keys which are allowed to be references or
    # arrays of references, but which may return data with nested references
    # that should be resolved lazily. The end result will either be a single
    # hash or a flat array of hashes.
    def resolve_top_level_references(data)
      if data.is_a?(Array)
        data.flat_map { |elem| resolve_top_level_references(elem) }
      elsif reference?(data)
        partially_resolved = data.transform_values do |v|
          resolve_references(v)
        end
        fully_resolved = resolve_single_reference(partially_resolved)
        # The top-level reference may have returned more references, so repeat the process
        resolve_top_level_references(fully_resolved)
      else
        data
      end
    end

    # Evaluates a single reference. The value returned may be another
    # reference.
    def resolve_single_reference(reference)
      plugin_cache = if cache?(reference)
                       cache = Bolt::Plugin::Cache.new(reference,
                                                       @config.project.plugin_cache_file,
                                                       @config.plugin_cache)
                       entry = cache.read_and_clean_cache
                       return entry unless entry.nil?

                       cache
                     end

      plugin_name = reference['_plugin']
      hook = get_hook(plugin_name, :resolve_reference)

      begin
        validate_proc = get_hook(plugin_name, :validate_resolve_reference)
      rescue PluginError
        validate_proc = proc { |*args| } # Nothing to do
      end

      validate_proc.call(reference)

      result = begin
        # Evaluate the plugin and then recursively evaluate any plugin returned by it.
        hook.call(reference)
      rescue StandardError => e
        loc = "resolve_reference in #{plugin_name}"
        raise PluginError::ExecutionError.new(e.message, plugin_name, loc)
      end

      plugin_cache.write_cache(result) if cache?(reference)

      result
    end
    private :resolve_single_reference

    private def cache?(reference)
      reference.key?('_cache') || @config.plugin_cache.key?('ttl')
    end

    # Checks whether a given value is a _plugin reference
    def reference?(input)
      input.is_a?(Hash) && input.key?('_plugin')
    end
  end
end

# references PluginError
require 'bolt/plugin/module'