# frozen_string_literal: true

module BusinessFlow
  # Step is a conditional callable which can marshal its own inputs, and
  # returns a value which can marshal errors and outputs into a given object.
  class Step
    # Represents inputs needed to execute a step.
    class Inputs
      attr_reader :inputs

      def initialize(inputs)
        @inputs = inputs
      end

      def parameters_from_source(source)
        return source if inputs.blank?

        inputs.to_h do |input_name, input_value| # rubocop:disable Style/HashTransformValues
          [
            input_name,
            Inputs.process_input(source, input_value)
          ]
        end
      end

      def self.process_input(source, input_value)
        case input_value
        when Symbol
          source.send(input_value)
        when Proc
          source.instance_exec(&input_value)
        else
          input_value
        end
      end
    end

    # Represents the result of a step, and allows setting response values on
    # an object, and merging error data into the same object.
    class Result
      attr_reader :output

      def initialize(result, output_map, output)
        @result = result
        @output = output
        @output_map = output_map
      end

      def merge_into(object)
        merge_errors_into(object) if mergeable_errors?
        merge_outputs_into(object) if @output_map
        output
      end

      def executed?
        true
      end

      # :reek:ManualDispatch Checking respond_to? is signficantly faster than
      # eating the NoMethodError when grabbing our error object.
      def errors?
        if @result.respond_to?(:errors?)
          @result.errors?
        # This is here to support ActiveRecord. We don't want to call valid?
        # because that will run validations and a step may return a partially
        # constructed model. By instead pulling out the errors instance variable
        # we'll only merge errors if validations have already been run.
        elsif @result.class.ancestors.include?(ActiveModel::Validations) &&
              @result.instance_variable_defined?(:@errors)
          @result.instance_variable_get(:@errors).present?
        elsif @result.respond_to?(:errors)
          @result.errors.present?
        else
          false
        end
      end

      def self.process_output(object, output, output_setter)
        case output_setter
        when Symbol
          object.send("#{output_setter}=", output)
        when Proc
          object.instance_exec(output, &output_setter)
        end
      end

      private

      # :reek:ManualDispatch Checking respond_to? is signficantly faster than
      # eating the NoMethodError when grabbing our error object.
      def mergeable_errors?
        @result.respond_to?(:errors) && errors?
      end

      def merge_errors_into(object)
        object.errors.merge!(@result.errors)
        throw :halt_step
      end

      def merge_outputs_into(object)
        @output_map.each do |(output_name, output_setter)|
          output = @result.public_send(output_name)
          Result.process_output(object, output, output_setter)
        end
      end
    end

    # Returned if our conditional check failed. Does nothing.
    class ConditionFailedResult
      def executed?
        false
      end

      def errors?
        false
      end

      def merge_into(_object); end
    end

    CONDITION_FAILED = ConditionFailedResult.new.freeze

    # Manage creating results for our step
    ResultFactory = Struct.new(:outputs, :output_callable, :default_output) do
      def result(step_result, parameter_source)
        callable = callable_for(step_result)

        output = if callable
                   parameter_source.instance_exec(step_result, &callable)
                 else
                   step_result
                 end

        Result.new(step_result, outputs, output)
      end

      private

      # :reek:ManualDispatch This is faster.
      def callable_for(step_result)
        if output_callable
          output_callable
        elsif default_output && step_result.respond_to?(default_output)
          default_output
        end
      end
    end

    # Handle some logic around conditions
    class ConditionList
      def initialize(if_stmts, unless_stmts)
        @conditions = Array.wrap(if_stmts).map(&Callable.method(:new)) +
                      Array.wrap(unless_stmts).map(&method(:to_unless))
      end

      def call(instance, inputs)
        @conditions.all? { |cond| cond.call(instance, inputs) }
      end

      private

      def to_unless(cond)
        if_stmt = Callable.new(cond)
        proc do |instance, input|
          !if_stmt.call(instance, input)
        end
      end
    end

    # Responsible for creating objects based on our input options
    Options = Struct.new(:opts) do
      def input_object
        inputs = opts[:inputs]
        inputs.present? ? Inputs.new(inputs) : nil
      end

      def result_factory
        ResultFactory.new(opts[:outputs],
                          opts[:output],
                          opts[:default_output])
      end

      def condition
        opts.fetch(:condition) do
          if_stmts = opts[:if]
          unless_stmts = opts[:unless]
          ConditionList.new(if_stmts, unless_stmts) if if_stmts.present? || unless_stmts.present?
        end
      end
    end

    def initialize(callable, opts)
      @callable = callable
      opts = Options.new(opts)
      @input_object = opts.input_object
      @result_factory = opts.result_factory
      @condition = opts.condition

      update_call_method
    end

    # This will show up as not covered in coverage reports, because it is
    # dynamically redefined by #update_call_method below.
    def call(parameter_source)
      parameters = @input_object.parameters_from_source(parameter_source)
      if !@condition || @condition.call(parameter_source, parameters)
        @result_factory.result(@callable.call(parameter_source, parameters),
                               parameter_source)
      else
        CONDITION_FAILED
      end
    end

    def inputs
      @input_object.inputs
    end

    def outputs
      @result_factory.outputs
    end

    def output_fields
      return [] unless outputs

      outputs.values.select { |field| field.is_a?(Symbol) }
    end

    def to_s
      @callable.to_s
    end

    private

    PARAMETERS_NO_INPUT = 'parameter_source'
    PARAMETERS_WITH_INPUT =
      '@input_object.parameters_from_source(parameter_source)'
    WITHOUT_CONDITION = %(
      @result_factory.result(@callable.call(parameter_source, parameters),
                             parameter_source)
    )
    WITH_CONDITION = %(
      if @condition.call(parameter_source, parameters)
        #{WITHOUT_CONDITION}
      else
        CONDITION_FAILED
      end
    )

    def update_call_method
      params = @input_object ? PARAMETERS_WITH_INPUT : PARAMETERS_NO_INPUT
      code = %(
        def call(parameter_source)
          parameters = #{params}
          #{@condition ? WITH_CONDITION : WITHOUT_CONDITION}
        end
      )
      instance_eval code
    end
  end
end