# frozen_string_literal: true

require 'bolt/plan_result'
require 'bolt/result'
require 'bolt/util'

module BoltSpec
  module Plans
    # Nothing in the ActionDouble is 'public'
    class ActionDouble
      def initialize(action_stub)
        @stubs = []
        @action_stub = action_stub
      end

      def process(*args)
        matches = @stubs.select { |s| s.matches(*args) }
        unless matches.empty?
          matches[0].call(*args)
        end
      end

      def assert_called(object)
        @stubs.each { |s| s.assert_called(object) }
      end

      def add_stub(inventory = nil)
        stub = Plans.const_get(@action_stub).new(false, inventory)
        @stubs.unshift stub
        stub
      end
    end

    class ActionStub
      attr_reader :invocation

      def initialize(expect = false, inventory = nil)
        @calls = 0
        @expect = expect
        @expected_calls = nil
        # invocation spec
        @invocation = {}
        # return value
        @data = { default: {} }
        @inventory = inventory
      end

      def assert_called(object)
        satisfied = if @expect
                      (@expected_calls.nil? && @calls > 0) || @calls == @expected_calls
                    else
                      @expected_calls.nil? || @calls <= @expected_calls
                    end
        unless satisfied
          unless (times = @expected_calls)
            times = @expect ? "at least one" : "any number of"
          end
          message = "Expected #{object} to be called #{times} times"
          message += " with targets #{@invocation[:targets]}" if @invocation[:targets]
          if parameters
            # Print the parameters hash by converting it to JSON and then re-parsing.
            # This prevents issues in Bolt data types, such as Targets, from generating
            # gigantic, unreadable, data when converted to string by interpolation.
            # Targets exhibit this behavior because they have a reference to @inventory.
            # When the target is converted into a string, it converts the full Inventory
            # into a string recursively.
            parameters_str = JSON.parse(parameters.to_json)
            message += " with parameters #{parameters_str}"
          end
          raise message
        end
      end

      # This changes the stub from an allow to an expect which will validate
      # that it has been called.
      def expect_call
        @expected_calls = 1
        @expect = true
        self
      end

      # Used to create a valid Bolt::Result object from result data.
      def default_for(target)
        case @data[:default]
        when Bolt::PlanFailure
          # Bolt::PlanFailure needs to be declared before Bolt::Error because
          # Bolt::PlanFailure is an instance of Bolt::Error, so it can match both
          # in this case we need to treat Bolt::PlanFailure's in a different way
          #
          # raise Bolt::PlanFailure errors so that the PAL can catch them and wrap
          # them into Bolt::PlanResult's for us.
          raise @data[:default]
        when Bolt::Error
          Bolt::Result.from_exception(target, @data[:default])
        when Hash
          result_for(target, Bolt::Util.walk_keys(@data[:default], &:to_sym))
        else
          raise 'Default result must be a Hash'
        end
      end

      def check_resultset(result_set, object)
        unless result_set.is_a?(Bolt::ResultSet)
          raise "Return block for #{object} did not return a Bolt::ResultSet"
        end
        result_set
      end

      def check_plan_result(plan_result, plan_clj)
        unless plan_result.is_a?(Bolt::PlanResult)
          raise "Return block for #{plan_clj.closure_name} did not return a Bolt::PlanResult"
        end
        plan_result
      end

      # Below here are the intended 'public' methods of the stub

      # Restricts the stub to only match invocations with
      # the correct targets
      def with_targets(targets)
        targets = Array(targets)
        @invocation[:targets] = targets.map do |target|
          if target.is_a? String
            target
          else
            target.name
          end
        end
        self
      end

      # limit the maximum number of times an allow stub may be called or
      # specify how many times an expect stub must be called.
      def be_called_times(times)
        @expected_calls = times
        self
      end

      # error if the stub is called at all.
      def not_be_called
        @expected_calls = 0
        self
      end

      def return(&block)
        raise "Cannot set return values and return block." if @data_set
        @return_block = block
        self
      end

      # Set different result values for each target. May use string or symbol keys, but allowed key names
      # are restricted based on action.
      def return_for_targets(data)
        data.each_with_object(@data) do |(target, result), hsh|
          raise "Mocked results must be hashes: #{target}: #{result}" unless result.is_a? Hash
          # set the inventory from the BoltSpec::Plans, otherwise if we try to convert
          # this target to a string, it will fail to string conversion because the
          # inventory is nil
          hsh[target] = result_for(Bolt::Target.new(target, @inventory), Bolt::Util.walk_keys(result, &:to_sym))
        end
        raise "Cannot set return values and return block." if @return_block
        @data_set = true
        self
      end

      # Set a default return value for all targets, specific targets may be overridden with return_for_targets.
      # Follows the same rules for data as return_for_targets.
      def always_return(data)
        @data[:default] = data
        @data_set = true
        self
      end

      # Set a default error result for all targets.
      def error_with(data, clazz = Bolt::Error)
        data = Bolt::Util.walk_keys(data, &:to_s)
        if data['msg'] && data['kind'] && (data.keys - %w[msg kind details issue_code]).empty?
          @data[:default] = clazz.new(data['msg'], data['kind'], data['details'], data['issue_code'])
        else
          $stderr.puts "In the future 'error_with()' might require msg and kind, and " \
                      "optionally accept only details and issue_code."
          @data[:default] = data
        end
        @data_set = true
        self
      end
    end
  end
end

require_relative 'action_stubs/command_stub'
require_relative 'action_stubs/plan_stub'
require_relative 'action_stubs/script_stub'
require_relative 'action_stubs/task_stub'
require_relative 'action_stubs/upload_stub'
require_relative 'action_stubs/download_stub'