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

module Vagrant
  module Action
    # Action builder which provides a nice DSL for building up
    # a middleware sequence for Vagrant actions. This code is based
    # heavily off of `Rack::Builder` and `ActionDispatch::MiddlewareStack`
    # in Rack and Rails, respectively.
    #
    # Usage
    #
    # Building an action sequence is very easy:
    #
    #     app = Vagrant::Action::Builder.new.tap do |b|
    #       b.use MiddlewareA
    #       b.use MiddlewareB
    #     end
    #
    #     Vagrant::Action.run(app)
    #
    class Builder
      # Container for Action arguments
      MiddlewareArguments = Struct.new(:parameters, :block, :keywords, keyword_init: true)
      # Item within the stack
      StackItem = Struct.new(:middleware, :arguments, keyword_init: true)

      # This is the stack of middlewares added. This should NOT be used
      # directly.
      #
      # @return [Array]
      attr_reader :stack

      # Action Hooks allow plugin authors to inject their code wherever they
      # want in the action stack. The methods they get are:
      #
      #   - prepend/append, which puts their middleware at the beginning or end
      #     of the whole stack
      #   - before/after, which attaches their middleware to an existing item
      #     in the stack
      #
      # Applying Action Hooks properly gets tricky because the action stack
      # becomes deeply nested with things like Action::Builtin::Call and
      # Builder#use(other_builder). The way it breaks down is:
      #
      #  - prepend/append hooks should be applied only at the top level stack,
      #    so they run once at the beginning or end
      #  - before/after hooks should be applied in every sub-builder, because
      #    they will only actually attach if they find their target sibling
      #
      # We achieve this behavior by tracking if we are a "primary" Builder, and
      # only running prepend/append operations when we are.
      #
      # Note this difference only applies to action_hooks registered with
      # machine action names and not action_hooks which reference middleware
      # directly, which only support prepend/append and are handled in
      # #apply_dynamic_updates.
      #
      # @return [Boolean] true if this is a primary / top-level Builder
      # @see Vagrant::Action::PrimaryRunner
      # @see Vagrant::Action::Hook
      attr_accessor :primary

      # This is a shortcut for a middleware sequence with only one item
      # in it. For a description of the arguments and the documentation, please
      # see {#use} instead.
      #
      # @return [Builder]
      def self.build(middleware, *args, **keywords, &block)
        new.use(middleware, *args, **keywords, &block)
      end

      def initialize
        @stack = []
        @logger = Log4r::Logger.new("vagrant::action::builder")
      end

      # Implement a custom copy that copies the stack variable over so that
      # we don't clobber that.
      def initialize_copy(original)
        super

        @stack = original.stack.dup
      end

      # Returns a mergeable version of the builder. If `use` is called with
      # the return value of this method, then the stack will merge, instead
      # of being treated as a separate single middleware.
      def flatten
        lambda do |env|
          self.call(env)
        end
      end

      # Adds a middleware class to the middleware stack. Any additional
      # args and a block, if given, are saved and passed to the initializer
      # of the middleware.
      #
      # @param [Class] middleware The middleware class
      def use(middleware, *args, **keywords, &block)
        item = StackItem.new(
          middleware: middleware,
          arguments: MiddlewareArguments.new(
            parameters: args,
            keywords: keywords,
            block: block
          )
        )

        if middleware.kind_of?(Builder)
          # Merge in the other builder's stack into our own
          self.stack.concat(middleware.stack)
        else
          self.stack << item
        end

        self
      end

      # Inserts a middleware at the given index or directly before the
      # given middleware object.
      def insert(idx_or_item, middleware, *args, **keywords, &block)
        item = StackItem.new(
          middleware: middleware,
          arguments: MiddlewareArguments.new(
            parameters: args,
            keywords: keywords,
            block: block
          )
        )

        if idx_or_item.is_a?(Integer)
          index = idx_or_item
        else
          index = self.index(idx_or_item)
        end

        raise "no such middleware to insert before: #{index.inspect}" unless index

        if middleware.kind_of?(Builder)
          middleware.stack.reverse.each do |stack_item|
            stack.insert(index, stack_item)
          end
        else
          stack.insert(index, item)
        end
      end

      alias_method :insert_before, :insert

      # Inserts a middleware after the given index or middleware object.
      def insert_after(idx_or_item, middleware, *args, **keywords, &block)
        if idx_or_item.is_a?(Integer)
          index = idx_or_item
        else
          index = self.index(idx_or_item)
        end

        raise "no such middleware to insert after: #{index.inspect}" unless index
        insert(index + 1, middleware, *args, &block)
      end

      # Replaces the given middlware object or index with the new
      # middleware.
      def replace(index, middleware, *args, **keywords, &block)
        if index.is_a?(Integer)
          delete(index)
          insert(index, middleware, *args, **keywords, &block)
        else
          insert_before(index, middleware, *args, **keywords, &block)
          delete(index)
        end
      end

      # Deletes the given middleware object or index
      def delete(index)
        index = self.index(index) unless index.is_a?(Integer)
        stack.delete_at(index)
      end

      # Runs the builder stack with the given environment.
      def call(env)
        to_app(env).call(env)
      end

      # Returns the numeric index for the given middleware object.
      #
      # @param [Object] object The item to find the index for
      # @return [Integer]
      def index(object)
        stack.each_with_index do |item, i|
          return i if item == object
          return i if item.middleware == object
          return i if item.middleware.respond_to?(:name) &&
            item.middleware.name == object
        end

        nil
      end

      # Converts the builder stack to a runnable action sequence.
      #
      # @param [Hash] env The action environment hash
      # @return [Warden] A callable object
      def to_app(env)
        # Start with a duplicate of ourself which can
        # be modified
        builder = self.dup

        # Apply all dynamic modifications of the stack. This
        # will generate dynamic hooks for all actions within
        # the stack, load any triggers for action classes, and
        # apply them to the builder's stack
        builder.apply_dynamic_updates(env)

        # Now that the stack is fully expanded, apply any
        # action hooks that may be defined so they are on
        # the outermost locations of the stack
        builder.apply_action_name(env)

        # Wrap the middleware stack with the Warden to provide a consistent
        # and predictable behavior upon exceptions.
        Warden.new(builder.stack.dup, env)
      end

      # Find any action hooks or triggers which have been defined
      # for items within the stack. Update the stack with any
      # hooks or triggers found.
      #
      # @param [Hash] env Call environment
      # @return [Builder] self
      def apply_dynamic_updates(env)
        triggers = env[:triggers]

        # Use a Hook as a convenient interface for injecting
        # any applicable trigger actions within the stack
        machine_name = env[:machine].name if env[:machine]

        # Iterate over all items in the stack and apply new items
        # into the hook as they are found. Must be sure to dup the
        # stack here since we are modifying the stack in the loop.
        stack.dup.each do |item|
          hook = Hook.new

          action = item.first
          next if action.is_a?(Proc)

          # Start with adding any action triggers that may be defined
          if triggers && !triggers.find(action, :before, machine_name, :action).empty?
            hook.prepend(Vagrant::Action::Builtin::Trigger,
              action.name, triggers, :before, :action)
          end

          if triggers && !triggers.find(action, :after, machine_name, :action).empty?
            hook.append(Vagrant::Action::Builtin::Trigger,
              action.name, triggers, :after, :action)
          end

          # Next look for any hook triggers that may be defined against
          # the dynamically generated action class hooks
          if triggers && !triggers.find(action, :before, machine_name, :hook).empty?
            hook.prepend(Vagrant::Action::Builtin::Trigger,
              action.name, triggers, :before, :hook)
          end

          if triggers && !triggers.find(action, :after, machine_name, :hook).empty?
            hook.append(Vagrant::Action::Builtin::Trigger,
              action.name, triggers, :after, :hook)
          end

          # Finally load any registered hooks for dynamically generated
          # action class based hooks
          Vagrant.plugin("2").manager.find_action_hooks(action).each do |hook_proc|
            hook_proc.call(hook)
          end

          hook.apply(self, root: item)
        end

        # Apply the hook to ourself to update the stack
        self
      end

      # If action hooks have not already been set, this method
      # will perform three tasks:
      #   1. Load any hook triggers defined for the action_name
      #   2. Load any action_hooks defined from plugins
      #   3. Load any action triggers based on machine action called (not action classes)
      #
      # @param [Hash] env Call environment
      # @return [Builder]
      def apply_action_name(env)
        env[:builder_raw_applied] ||= []
        return self if !env[:action_name]

        hook = Hook.new
        machine_name = env[:machine].name if env[:machine]

        # Start with loading any hook triggers if applicable
        if env[:triggers]
          if !env[:triggers].find(env[:action_name], :before, machine_name, :hook).empty?
            hook.prepend(Vagrant::Action::Builtin::Trigger,
              env[:action_name], env[:triggers], :before, :hook)
          end
          if !env[:triggers].find(env[:action_name], :after, machine_name, :hook).empty?
            hook.append(Vagrant::Action::Builtin::Trigger,
              env[:action_name], env[:triggers], :after, :hook)
          end
        end

        # Next we load up all the action hooks that plugins may
        # have defined
        action_hooks = Vagrant.plugin("2").manager.action_hooks(env[:action_name])
        action_hooks.each do |hook_proc|
          hook_proc.call(hook)
        end

        # Finally load any action triggers defined. The action triggers
        # are the originally implemented trigger style. They run before
        # and after specific provider actions (like :up, :halt, etc) and
        # are different from true action triggers
        if env[:triggers] && !env[:builder_raw_applied].include?(env[:raw_action_name])
          env[:builder_raw_applied] << env[:raw_action_name]

          if !env[:triggers].find(env[:raw_action_name], :before, machine_name, :action, all: true).empty?
            hook.prepend(Vagrant::Action::Builtin::Trigger,
              env[:raw_action_name], env[:triggers], :before, :action, all: true)
          end
          if !env[:triggers].find(env[:raw_action_name], :after, machine_name, :action, all: true).empty?
            # NOTE: These after triggers need to be delayed before running to
            #       allow the rest of the call stack to complete before being
            #       run. The delayed action is prepended to the stack (not appended)
            #       to ensure it is called first, which results in it properly
            #       waiting for everything to finish before itself completing.
            builder = self.class.build(Vagrant::Action::Builtin::Trigger,
              env[:raw_action_name], env[:triggers], :after, :action, all: true)
            hook.prepend(Vagrant::Action::Builtin::Delayed, builder)
          end
        end

        # If the hooks are empty, then there was nothing to apply and
        # we can just send ourself back
        return self if hook.empty?

        # Apply all the hooks to the new builder instance
        hook.apply(self, {
          # Only primary builders run prepend/append, otherwise nested builders
          # would duplicate hooks. See explanation at self#primary.
          no_prepend_or_append: !primary,
        })

        self
      end
    end
  end
end
