module BusinessFlow
  # Core DSL for BusinessFlow. The relevant methods are all in
  # ClassMethods.
  module DSL
    # Contains the DSL for BusinessFlow
    # The class that includes this must implement a parameter_object reader
    # which returns a hash or object representing the parameters the flow
    # was initialized with. The provided .call will instantiate the including
    # class with a parameter_object as the only argument.
    module ClassMethods
      # Requires that a field be retrievable from the initialization parameters
      #
      # This will only require that the field is not nil. The field may still
      # be #empty?
      #
      # @param fields The fields required from the initialization parameters
      def needs(*fields)
        @needs ||= []
        return @needs if fields.blank?
        @needs.push(*fields)
        fields.each do |field|
          PrivateHelpers.create_parameter_field(self, field)
        end
      end

      # Allows a field to be retrieved from the initialiaztion parameters
      def wants(field, default = proc { nil }, opts = {})
        internal_name = "wants_#{field}".to_sym
        uses(internal_name, default, opts)
        PrivateHelpers.create_parameter_field(self, field, internal_name)
      end

      # Declares that you will expose a field to the outside world.
      def provides(*fields)
        @provides ||= []
        return @provides if fields.blank?
        @provides.push(*fields)
        fields.each { |field| PrivateHelpers.create_field(self, field) }
      end

      # Declares that you expect to set this field during the course of
      # processing, and that it should meet the given ActiveModel
      # validations.
      def expects(field, options = {})
        validates field, options.merge(on: field)
        PrivateHelpers.create_field(self, field)
      end

      def uses(field, klass, opts = {})
        callable = Callable.new(klass, self)
        step = Step.new(callable, opts)
        PrivateHelpers.create_memoized_field(self, field, step)
        private field
      end

      def step(klass, opts = {})
        callable = Callable.new(klass, self)
        opts = opts.merge(
          condition: PrivateHelpers.create_conditional_callable(self, opts)
        )
        step_queue << step = Step.new(callable, opts)
        step.outputs.values.each do |field|
          PrivateHelpers.create_field(self, field)
        end
      end

      def call(parameter_object)
        new(parameter_object).tap(&:call)
      end

      def call!(*args)
        ret = call(*args)
        raise FlowFailedException, ret if ret.errors.any?
        ret
      end

      def step_queue
        @step_queue ||= []
      end
    end

    def self.included(klass)
      # That we include ActiveModel::Validations is considered part of our
      # public API, even though we provide our own aliases.
      klass.include(ActiveModel::Validations)
      klass.extend(ClassMethods)
      klass.instance_eval do
        class << self
          # See above -- that this is an alias is considered public API.
          alias invariant validates
        end
        validates_with NotNilValidator
      end
    end

    # Keep our internal helpers in a different module to avoid polluting the
    # namespace of whoever includes us.
    module PrivateHelpers
      # Handle some logic around conditions
      class ConditionList
        def initialize(if_stmts, unless_stmts, klass)
          @klass = klass
          @conditions = Array.wrap(if_stmts).map(&method(:to_if)) +
                        Array.wrap(unless_stmts).map(&method(:to_unless))
        end

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

        private

        def to_if(cond)
          Callable.new(cond, @klass)
        end

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

      def self.create_parameter_field(klass, field, fallback = nil)
        klass.send(:define_method, field, &parameter_proc(field, fallback))
        klass.send(:private, field)
      end

      def self.read_from_parameter_object(field)
        proc do
          if parameter_object.is_a?(Hash) && parameter_object.key?(field)
            parameter_object[field]
          else
            parameter_object.public_send(field)
          end
        end
      end

      def self.parameter_proc(field, fallback)
        read = read_from_parameter_object(field)
        proc do
          begin
            instance_exec(&read)
          rescue NoMethodError
            nil
          end || (fallback && send(fallback))
        end
      end

      def self.create_conditional_callable(klass, opts)
        return unless opts[:if] || opts[:unless]
        Callable.new(condition(klass, opts), klass)
      end

      def self.condition(klass, opts)
        if_stmts = opts.fetch(:if, proc { true })
        unless_stmts = opts.fetch(:unless, proc { false })
        ConditionList.new(if_stmts, unless_stmts, klass)
      end

      def self.create_field(klass, field)
        return unless field.is_a?(Symbol)
        define_getter(klass, field)
        setter_name = "#{field}=".to_sym
        define_setter(klass, setter_name, field)
        klass.send(:private, setter_name)
      end

      def self.define_getter(klass, field)
        return if klass.method_defined?(field) ||
                  klass.private_method_defined?(field)
        klass.send(:attr_reader, field)
      end

      def self.define_setter(klass, setter_name, field)
        return if klass.method_defined?(setter_name) ||
                  klass.private_method_defined?(setter_name)
        klass.send(:define_method, setter_name,
                   &setter_proc("@#{field}", field))
      end

      def self.safe_ivar_name(field)
        ivar_name = "@business_flow_dsl_#{field}"
        if ivar_name.end_with?('?')
          ivar_name.sub!(/\?$/, '_query')
        elsif ivar_name.end_with?('!')
          ivar_name.sub!(/\!$/, '_bang')
        end
        ivar_name.to_sym
      end

      def self.setter_proc(ivar_name, field)
        proc do |new_value|
          instance_variable_set(ivar_name, new_value)
          throw :halt_step unless valid?(field)
          new_value
        end
      end

      def self.create_memoized_field(klass, field, step)
        ivar_name = safe_ivar_name(field)
        setter_proc = self.setter_proc(ivar_name, field)
        klass.send(:define_method, field) do
          if instance_variable_defined?(ivar_name)
            instance_variable_get(ivar_name)
          else
            instance_exec(step.call(self).output, &setter_proc)
          end
        end
      end
    end
  end
end