module Services
  class Base
    module UniquenessChecker
      KEY_PREFIX = %w(
        services
        uniqueness
      ).join(':').freeze

      ON_ERROR = %i(
        fail
        ignore
        reschedule
        return
      ).freeze

      MAX_RETRIES = 10.freeze
      THIRTY_DAYS = (60 * 60 * 24 * 30).freeze

      def self.prepended(mod)
        mod.const_set :NotUniqueError, Class.new(mod::Error)
      end

      def check_uniqueness(*args, on_error: :fail)
        raise "on_error must be one of #{ON_ERROR.join(', ')}, but was #{on_error}" unless ON_ERROR.include?(on_error.to_sym)
        @_on_error = on_error
        raise 'Service args not found.' if @_service_args.nil?
        @_uniqueness_args = args.empty? ? @_service_args : args
        new_uniqueness_key = uniqueness_key(@_uniqueness_args)
        raise "A uniqueness key with args #{@_uniqueness_args.inspect} already exists." if @_uniqueness_keys && @_uniqueness_keys.include?(new_uniqueness_key)
        if @_similar_service_id = Services.redis.get(new_uniqueness_key)
          if on_error.to_sym == :ignore
            return false
          else
            @_retries_exhausted = on_error.to_sym == :reschedule && error_count >= MAX_RETRIES
            raise_not_unique_error
          end
        else
          @_uniqueness_keys ||= []
          @_uniqueness_keys << new_uniqueness_key
          Services.redis.setex new_uniqueness_key, THIRTY_DAYS, @id
          true
        end
      end

      def call(*args, **kwargs)
        @_service_args = args
        super
      rescue self.class::NotUniqueError => e
        case @_on_error.to_sym
        when :fail
          raise e
        when :reschedule
          if @_retries_exhausted
            raise e
          else
            increase_error_count
            reschedule
          end
        when :return
          return e
        else
          raise "Unexpected on_error: #{@_on_error}"
        end
      ensure
        Services.redis.del @_uniqueness_keys unless Array(@_uniqueness_keys).empty?
        Services.redis.del error_count_key
      end

      private

      def raise_not_unique_error
        message = "Service #{self.class} #{@id} with uniqueness args #{@_uniqueness_args} is not unique, a similar service is already running: #{@_similar_service_id}."
        message << " The service has been retried #{MAX_RETRIES} times." if @_retries_exhausted
        raise self.class::NotUniqueError.new(message)
      end

      def convert_for_rescheduling(arg)
        case arg
        when Array
          arg.map do |array_arg|
            convert_for_rescheduling array_arg
          end
        when Integer, String, TrueClass, FalseClass, NilClass
          arg
        when object_class
          arg.id
        else
          raise "Don't know how to convert arg #{arg.inspect} for rescheduling."
        end
      end

      def reschedule
        # Convert service args for rescheduling first
        reschedule_args = @_service_args.map do |arg|
          convert_for_rescheduling arg
        end
        log "Rescheduling to be executed in #{retry_delay} seconds." if self.respond_to?(:log)
        self.class.call_in retry_delay, *reschedule_args
      end

      def error_count
        (Services.redis.get(error_count_key) || 0).to_i
      end

      def increase_error_count
        Services.redis.setex error_count_key, retry_delay + THIRTY_DAYS, error_count + 1
      end

      def uniqueness_key(args)
        [
          KEY_PREFIX,
          self.class.to_s.gsub(':', '_')
        ].tap do |key|
          key << Digest::MD5.hexdigest(args.to_s) unless args.empty?
        end.join(':')
      end

      def error_count_key
        [
          KEY_PREFIX,
          'errors',
          self.class.to_s.gsub(':', '_')
        ].tap do |key|
          key << Digest::MD5.hexdigest(@_service_args.to_s) unless @_service_args.empty?
        end.join(':')
      end

      def retry_delay
        error_count ** 3 + 5
      end
    end
  end
end
