# frozen_string_literal: true
#
# = ServiceRunner
#
# This class is to be used via its helper +with_service+ in any class. Its
# main purpose is to ease how actions can be run upon a service completion.
# Since a service will likely return the same kind of things over and over,
# this allows us to not have to repeat the same boilerplate code in every
# object.
#
# There are several available actions and we can add new ones very easily:
#
# * +on_success+: will execute the provided block if the service succeeds
# * +on_failure+: will execute the provided block if the service fails
# * +on_failed_step(name)+: will execute the provided block if the step named
#   `name` fails
# * +on_failed_policy(name)+: will execute the provided block if the policy
#   named `name` fails
# * +on_failed_contract(name)+: will execute the provided block if the contract
#   named `name` fails
# * +on_model_not_found(name)+: will execute the provided block if the model
#   named `name` is not present
# * +on_model_errors(name)+: will execute the provided block if the model named
#   `name` contains validation errors
#
# All the specialized steps receive the failing step result object as an
# argument to their block. `on_model_errors` receives the actual model so it’s
# easier to inspect it.
#
# @example In a controller
#   def create
#     with_service MyService do
#       on_success do
#         flash[:notice] = "Success!"
#         redirect_to a_path
#       end
#       on_failed_policy(:a_named_policy) { |policy| redirect_to root_path, alert: policy.reason }
#       on_failure { render :new }
#     end
#   end
#
# @example In a job (inheriting from +ServiceJob+)
#   def execute(args = {})
#     with_service(MyService, **args) do
#       on_success { Rails.logger.info "SUCCESS" }
#       on_failure { Rails.logger.error "FAILURE" }
#     end
#   end
#
# The actions will be evaluated in the order they appear. So even if the
# service will ultimately fail with a failed policy, in this example only the
# +on_failed_policy+ action will be executed and not the +on_failure+ one.
# The only exception to this being +on_failure+ as it will always be executed
# last.
#

class ServiceRunner
  # @!visibility private
  AVAILABLE_ACTIONS = {
    on_success: {
      condition: -> { result.success? },
      key: [],
    },
    on_failure: {
      condition: -> { result.failure? },
      key: [],
    },
    on_failed_step: {
      condition: ->(name) { failure_for?("result.step.#{name}") },
      key: %w[result step],
    },
    on_failed_policy: {
      condition: ->(name = "default") { failure_for?("result.policy.#{name}") },
      key: %w[result policy],
      default_name: "default",
    },
    on_failed_contract: {
      condition: ->(name = "default") { failure_for?("result.contract.#{name}") },
      key: %w[result contract],
      default_name: "default",
    },
    on_model_not_found: {
      condition: ->(name = "model") { failure_for?("result.model.#{name}") && result[name].blank? },
      key: %w[result model],
      default_name: "model",
    },
    on_model_errors: {
      condition: ->(name = "model") do
        failure_for?("result.model.#{name}") && result["result.model.#{name}"].invalid
      end,
      key: [],
      default_name: "model",
    },
  }.with_indifferent_access.freeze

  # @!visibility private
  attr_reader :service, :object, :dependencies

  delegate :result, to: :object

  # @!visibility private
  def initialize(service, object, **dependencies)
    @service = service
    @object = object
    @dependencies = dependencies
    @actions = {}
  end

  # @param service [Class] a class including {Service::Base}
  # @param block [Proc] a block containing the steps to match on
  # @return [void]
  def self.call(service, object, **dependencies, &block)
    new(service, object, **dependencies).call(&block)
  end

  # @!visibility private
  def call(&block)
    instance_eval(&block)
    object.run_service(service, dependencies)
    # Always have `on_failure` as the last action
    (
      actions
        .except(:on_failure)
        .merge(actions.slice(:on_failure))
        .detect { |name, (condition, _)| condition.call } || [-> {}]
    ).flatten.last.call
  end

  private

  attr_reader :actions

  def failure_for?(key)
    object.result[key]&.failure?
  end

  def add_action(name, *args, &block)
    action = AVAILABLE_ACTIONS[name]
    actions[[name, *args].join("_").to_sym] = [
      -> { instance_exec(*args, &action[:condition]) },
      -> do
        object.instance_exec(
          result[[*action[:key], args.first || action[:default_name]].join(".")],
          &block
        )
      end,
    ]
  end

  def method_missing(method_name, *args, &block)
    return super unless AVAILABLE_ACTIONS[method_name]
    add_action(method_name, *args, &block)
  end

  def respond_to_missing?(method_name, include_private = false)
    AVAILABLE_ACTIONS[method_name] || super
  end
end