module Resque
  module Plugins

    # If you want your job to retry on failure using a varying delay, simply
    # extend your module/class with this module:
    #
    #   class DeliverSMS
    #     extend Resque::Plugins::ExponentialBackoff
    #     @queue = :mt_messages
    #
    #     def self.perform(mt_id, mobile_number, message)
    #       heavy_lifting
    #     end
    #   end
    #
    # Easily do something custom:
    #
    #   class DeliverSMS
    #     extend Resque::Plugins::ExponentialBackoff
    #     @queue = :mt_messages
    #
    #     @retry_limit = 4
    #
    #     # retry delay in seconds; [0] => 1st retry, [1] => 2nd..4th retry.
    #     @backoff_strategy = [0, 60]
    #
    #     # used to build redis key, for counting job attempts.
    #     def self.retry_identifier(mt_id, mobile_number, message)
    #       "#{mobile_number}:#{mt_id}"
    #     end
    #
    #     self.perform(mt_id, mobile_number, message)
    #       heavy_lifting
    #     end
    #   end
    #
    module ExponentialBackoff
      include Resque::Plugins::Retry

      # Raised if the min/max retry-delay multiplicand configuration is invalid
      #
      # @api public
      class InvalidRetryDelayMultiplicandConfigurationException < StandardError; end

      # Constants
      #
      # @api public
      DEFAULT_RETRY_DELAY_MULTIPLICAND_MIN = 1.0
      DEFAULT_RETRY_DELAY_MULTIPLICAND_MAX = 1.0

      # Fail fast, when extended, if the "receiver" is misconfigured
      #
      # @api private
      def self.extended(receiver)
        retry_delay_multiplicand_min = DEFAULT_RETRY_DELAY_MULTIPLICAND_MIN
        retry_delay_multiplicand_min = \
          receiver.instance_variable_get(:@retry_delay_multiplicand_min) \
            if receiver.instance_variable_defined?(:@retry_delay_multiplicand_min)

        retry_delay_multiplicand_max = DEFAULT_RETRY_DELAY_MULTIPLICAND_MAX
        retry_delay_multiplicand_max = \
          receiver.instance_variable_get(:@retry_delay_multiplicand_max) \
            if receiver.instance_variable_defined?(:@retry_delay_multiplicand_max)

        if retry_delay_multiplicand_min > retry_delay_multiplicand_max
          raise InvalidRetryDelayMultiplicandConfigurationException.new(
            %{"@retry_delay_multiplicand_min" must be less than or equal to "@retry_delay_multiplicand_max"}
          )
        end
      end

      # Defaults to the number of delays in the backoff strategy
      #
      # @return [Number] maximum number of retries
      #
      # @api private
      def retry_limit
        @retry_limit ||= backoff_strategy.length
      end

      # Selects the delay from the backoff strategy
      #
      # @param _ [Exception] unused exception argument for signature parity
      # @return [Number] seconds to delay until the next retry.
      #
      # @api private
      def retry_delay(_ = nil)
        delay = backoff_strategy[retry_attempt] || backoff_strategy.last
        # if the values are the same don't bother generating a random number, if
        # the delta is zero, some platforms will raise an error
        if retry_delay_multiplicand_min == retry_delay_multiplicand_max
          delay_multiplicand = retry_delay_multiplicand_max
        else
          delay_multiplicand = \
            rand(retry_delay_multiplicand_min..retry_delay_multiplicand_max)
        end
        (delay * delay_multiplicand).to_i
      end

      # @abstract
      # The minimum value (lower-bound) for the range that is is used in
      # calculating the retry-delay product
      #
      # @return [Float]
      #
      # @api public
      def retry_delay_multiplicand_min
        @retry_delay_multiplicand_min ||= DEFAULT_RETRY_DELAY_MULTIPLICAND_MIN
      end

      # @abstract
      # The maximum value (upper-bound) for the range that is is used in
      # calculating the retry-delay product
      #
      # @return [Float]
      #
      # @api public
      def retry_delay_multiplicand_max
        @retry_delay_multiplicand_max ||= DEFAULT_RETRY_DELAY_MULTIPLICAND_MAX
      end

      # @abstract
      # The backoff strategy is used to vary the delay between retry attempts
      # 
      # @return [Array] array of delays. index = retry attempt
      #
      # @api public
      def backoff_strategy
        @backoff_strategy ||= [0, 60, 600, 3600, 10_800, 21_600]
      end
    end

  end
end
