module BusinessFlow
  # Isolate the logic around invoking a 'callable' -- a symbol representing a
  # method, a symbol representing another class which implements .call, or a
  # proc/lambda
  class Callable
    def initialize(callable)
      @callable = callable
      check_callable
    end

    # :reek:ManualDispatch At this stage @callable is a symbol and is either
    # a method or a constant. Checking respond_to? is easier and faster than
    # generating a NoMethodError and catching it.
    def call(instance, inputs)
      if instance.respond_to?(@callable, true)
        send_callable(instance, inputs)
      else
        @callable = lookup_callable(instance) ||
                    raise(NameError, "undefined constant #{@callable}")
        check_callable
        call(instance, inputs)
      end
    end

    def to_s
      @callable.to_s
    end

    private

    def send_callable(instance, inputs)
      instance_eval %{
        def call(instance, _inputs)
          instance.send(@callable)
        end
      }, __FILE__, __LINE__ - 4
      call(instance, inputs)
    end

    def check_callable
      if @callable.is_a?(Proc)
        proc_callable
      else
        call_callable
      end
    rescue NameError
      unless @callable.is_a?(Symbol)
        raise ArgumentError, 'callable must be a symbol or respond to #call'
      end
    end

    def proc_callable
      instance_eval %{
        def call(instance, inputs)
          instance.instance_exec(
            #{@callable.arity.zero? ? '' : 'inputs, '}&@callable
          )
        end
      }, __FILE__, __LINE__ - 6
    end

    def call_callable
      case @callable.method(:call).arity
      when 1, -1
        single_inputs_callable
      when 0
        zero_inputs_callable
      else two_inputs_callable
      end
    end

    def single_inputs_callable
      instance_eval %{
        def call(_instance, inputs)
          @callable.call(inputs)
        end
      }, __FILE__, __LINE__ - 4
    end

    def zero_inputs_callable
      instance_eval %{
        def call(_instance, _inputs)
          @callable.call
        end
      }, __FILE__, __LINE__ - 4
    end

    def two_inputs_callable
      instance_eval %{
        def call(instance, inputs)
          @callable.call(instance, inputs)
        end
      }, __FILE__, __LINE__ - 4
    end

    def lookup_callable(first_instance)
      constant_name = @callable.to_s.camelcase
      first_instance.class.parents.each do |parent|
        begin
          return parent.const_get(constant_name)
        rescue NameError
          next
        end
      end
      nil
    end
  end
end