lib/redis/commands/sorted_sets.rb



# frozen_string_literal: true

class Redis
  module Commands
    module SortedSets
      # Get the number of members in a sorted set.
      #
      # @example
      #   redis.zcard("zset")
      #     # => 4
      #
      # @param [String] key
      # @return [Integer]
      def zcard(key)
        send_command([:zcard, key])
      end

      # Add one or more members to a sorted set, or update the score for members
      # that already exist.
      #
      # @example Add a single `[score, member]` pair to a sorted set
      #   redis.zadd("zset", 32.0, "member")
      # @example Add an array of `[score, member]` pairs to a sorted set
      #   redis.zadd("zset", [[32.0, "a"], [64.0, "b"]])
      #
      # @param [String] key
      # @param [[Float, String], Array<[Float, String]>] args
      #   - a single `[score, member]` pair
      #   - an array of `[score, member]` pairs
      # @param [Hash] options
      #   - `:xx => true`: Only update elements that already exist (never
      #   add elements)
      #   - `:nx => true`: Don't update already existing elements (always
      #   add new elements)
      #   - `:lt => true`: Only update existing elements if the new score
      #   is less than the current score
      #   - `:gt => true`: Only update existing elements if the new score
      #   is greater than the current score
      #   - `:ch => true`: Modify the return value from the number of new
      #   elements added, to the total number of elements changed (CH is an
      #   abbreviation of changed); changed elements are new elements added
      #   and elements already existing for which the score was updated
      #   - `:incr => true`: When this option is specified ZADD acts like
      #   ZINCRBY; only one score-element pair can be specified in this mode
      #
      # @return [Boolean, Integer, Float]
      #   - `Boolean` when a single pair is specified, holding whether or not it was
      #   **added** to the sorted set.
      #   - `Integer` when an array of pairs is specified, holding the number of
      #   pairs that were **added** to the sorted set.
      #   - `Float` when option :incr is specified, holding the score of the member
      #   after incrementing it.
      def zadd(key, *args, nx: nil, xx: nil, lt: nil, gt: nil, ch: nil, incr: nil)
        command = [:zadd, key]
        command << "NX" if nx
        command << "XX" if xx
        command << "LT" if lt
        command << "GT" if gt
        command << "CH" if ch
        command << "INCR" if incr

        if args.size == 1 && args[0].is_a?(Array)
          members_to_add = args[0]
          return 0 if members_to_add.empty?

          # Variadic: return float if INCR, integer if !INCR
          send_command(command + members_to_add, &(incr ? Floatify : nil))
        elsif args.size == 2
          # Single pair: return float if INCR, boolean if !INCR
          send_command(command + args, &(incr ? Floatify : Boolify))
        else
          raise ArgumentError, "wrong number of arguments"
        end
      end

      # Increment the score of a member in a sorted set.
      #
      # @example
      #   redis.zincrby("zset", 32.0, "a")
      #     # => 64.0
      #
      # @param [String] key
      # @param [Float] increment
      # @param [String] member
      # @return [Float] score of the member after incrementing it
      def zincrby(key, increment, member)
        send_command([:zincrby, key, increment, member], &Floatify)
      end

      # Remove one or more members from a sorted set.
      #
      # @example Remove a single member from a sorted set
      #   redis.zrem("zset", "a")
      # @example Remove an array of members from a sorted set
      #   redis.zrem("zset", ["a", "b"])
      #
      # @param [String] key
      # @param [String, Array<String>] member
      #   - a single member
      #   - an array of members
      #
      # @return [Boolean, Integer]
      #   - `Boolean` when a single member is specified, holding whether or not it
      #   was removed from the sorted set
      #   - `Integer` when an array of pairs is specified, holding the number of
      #   members that were removed to the sorted set
      def zrem(key, member)
        if member.is_a?(Array)
          members_to_remove = member
          return 0 if members_to_remove.empty?
        end

        send_command([:zrem, key, member]) do |reply|
          if member.is_a? Array
            # Variadic: return integer
            reply
          else
            # Single argument: return boolean
            Boolify.call(reply)
          end
        end
      end

      # Removes and returns up to count members with the highest scores in the sorted set stored at key.
      #
      # @example Popping a member
      #   redis.zpopmax('zset')
      #   #=> ['b', 2.0]
      # @example With count option
      #   redis.zpopmax('zset', 2)
      #   #=> [['b', 2.0], ['a', 1.0]]
      #
      # @params key [String] a key of the sorted set
      # @params count [Integer] a number of members
      #
      # @return [Array<String, Float>] element and score pair if count is not specified
      # @return [Array<Array<String, Float>>] list of popped elements and scores
      def zpopmax(key, count = nil)
        command = [:zpopmax, key]
        command << Integer(count) if count
        send_command(command) do |members|
          members = FloatifyPairs.call(members)
          count.to_i > 1 ? members : members.first
        end
      end

      # Removes and returns up to count members with the lowest scores in the sorted set stored at key.
      #
      # @example Popping a member
      #   redis.zpopmin('zset')
      #   #=> ['a', 1.0]
      # @example With count option
      #   redis.zpopmin('zset', 2)
      #   #=> [['a', 1.0], ['b', 2.0]]
      #
      # @params key [String] a key of the sorted set
      # @params count [Integer] a number of members
      #
      # @return [Array<String, Float>] element and score pair if count is not specified
      # @return [Array<Array<String, Float>>] list of popped elements and scores
      def zpopmin(key, count = nil)
        command = [:zpopmin, key]
        command << Integer(count) if count
        send_command(command) do |members|
          members = FloatifyPairs.call(members)
          count.to_i > 1 ? members : members.first
        end
      end

      # Removes and returns up to count members with scores in the sorted set stored at key.
      #
      # @example Popping a member
      #   redis.bzmpop('zset')
      #   #=> ['zset', ['a', 1.0]]
      # @example With count option
      #   redis.bzmpop('zset', count: 2)
      #   #=> ['zset', [['a', 1.0], ['b', 2.0]]
      #
      # @params timeout [Float] a float value specifying the maximum number of seconds to block) elapses.
      #   A timeout of zero can be used to block indefinitely.
      # @params key [String, Array<String>] one or more keys with sorted sets
      # @params modifier [String]
      #  - when `"MIN"` - the elements popped are those with lowest scores
      #  - when `"MAX"` - the elements popped are those with the highest scores
      # @params count [Integer] a number of members to pop
      #
      # @return [Array<String, Array<String, Float>>] list of popped elements and scores
      def bzmpop(timeout, *keys, modifier: "MIN", count: nil)
        raise ArgumentError, "Pick either MIN or MAX" unless modifier == "MIN" || modifier == "MAX"

        args = [:bzmpop, timeout, keys.size, *keys, modifier]
        args << "COUNT" << Integer(count) if count

        send_blocking_command(args, timeout) do |response|
          response&.map do |entry|
            case entry
            when String then entry
            when Array then entry.map { |pair| FloatifyPairs.call(pair) }.flatten(1)
            end
          end
        end
      end

      # Removes and returns up to count members with scores in the sorted set stored at key.
      #
      # @example Popping a member
      #   redis.zmpop('zset')
      #   #=> ['zset', ['a', 1.0]]
      # @example With count option
      #   redis.zmpop('zset', count: 2)
      #   #=> ['zset', [['a', 1.0], ['b', 2.0]]
      #
      # @params key [String, Array<String>] one or more keys with sorted sets
      # @params modifier [String]
      #  - when `"MIN"` - the elements popped are those with lowest scores
      #  - when `"MAX"` - the elements popped are those with the highest scores
      # @params count [Integer] a number of members to pop
      #
      # @return [Array<String, Array<String, Float>>] list of popped elements and scores
      def zmpop(*keys, modifier: "MIN", count: nil)
        raise ArgumentError, "Pick either MIN or MAX" unless modifier == "MIN" || modifier == "MAX"

        args = [:zmpop, keys.size, *keys, modifier]
        args << "COUNT" << Integer(count) if count

        send_command(args) do |response|
          response&.map do |entry|
            case entry
            when String then entry
            when Array then entry.map { |pair| FloatifyPairs.call(pair) }.flatten(1)
            end
          end
        end
      end

      # Removes and returns up to count members with the highest scores in the sorted set stored at keys,
      #   or block until one is available.
      #
      # @example Popping a member from a sorted set
      #   redis.bzpopmax('zset', 1)
      #   #=> ['zset', 'b', 2.0]
      # @example Popping a member from multiple sorted sets
      #   redis.bzpopmax('zset1', 'zset2', 1)
      #   #=> ['zset1', 'b', 2.0]
      #
      # @params keys [Array<String>] one or multiple keys of the sorted sets
      # @params timeout [Integer] the maximum number of seconds to block
      #
      # @return [Array<String, String, Float>] a touple of key, member and score
      # @return [nil] when no element could be popped and the timeout expired
      def bzpopmax(*args)
        _bpop(:bzpopmax, args) do |reply|
          reply.is_a?(Array) ? [reply[0], reply[1], Floatify.call(reply[2])] : reply
        end
      end

      # Removes and returns up to count members with the lowest scores in the sorted set stored at keys,
      #   or block until one is available.
      #
      # @example Popping a member from a sorted set
      #   redis.bzpopmin('zset', 1)
      #   #=> ['zset', 'a', 1.0]
      # @example Popping a member from multiple sorted sets
      #   redis.bzpopmin('zset1', 'zset2', 1)
      #   #=> ['zset1', 'a', 1.0]
      #
      # @params keys [Array<String>] one or multiple keys of the sorted sets
      # @params timeout [Integer] the maximum number of seconds to block
      #
      # @return [Array<String, String, Float>] a touple of key, member and score
      # @return [nil] when no element could be popped and the timeout expired
      def bzpopmin(*args)
        _bpop(:bzpopmin, args) do |reply|
          reply.is_a?(Array) ? [reply[0], reply[1], Floatify.call(reply[2])] : reply
        end
      end

      # Get the score associated with the given member in a sorted set.
      #
      # @example Get the score for member "a"
      #   redis.zscore("zset", "a")
      #     # => 32.0
      #
      # @param [String] key
      # @param [String] member
      # @return [Float] score of the member
      def zscore(key, member)
        send_command([:zscore, key, member], &Floatify)
      end

      # Get the scores associated with the given members in a sorted set.
      #
      # @example Get the scores for members "a" and "b"
      #   redis.zmscore("zset", "a", "b")
      #     # => [32.0, 48.0]
      #
      # @param [String] key
      # @param [String, Array<String>] members
      # @return [Array<Float>] scores of the members
      def zmscore(key, *members)
        send_command([:zmscore, key, *members]) do |reply|
          reply.map(&Floatify)
        end
      end

      # Get one or more random members from a sorted set.
      #
      # @example Get one random member
      #   redis.zrandmember("zset")
      #     # => "a"
      # @example Get multiple random members
      #   redis.zrandmember("zset", 2)
      #     # => ["a", "b"]
      # @example Get multiple random members with scores
      #   redis.zrandmember("zset", 2, with_scores: true)
      #     # => [["a", 2.0], ["b", 3.0]]
      #
      # @param [String] key
      # @param [Integer] count
      # @param [Hash] options
      #   - `:with_scores => true`: include scores in output
      #
      # @return [nil, String, Array<String>, Array<[String, Float]>]
      #   - when `key` does not exist or set is empty, `nil`
      #   - when `count` is not specified, a member
      #   - when `count` is specified and `:with_scores` is not specified, an array of members
      #   - when `:with_scores` is specified, an array with `[member, score]` pairs
      def zrandmember(key, count = nil, withscores: false, with_scores: withscores)
        if with_scores && count.nil?
          raise ArgumentError, "count argument must be specified"
        end

        args = [:zrandmember, key]
        args << Integer(count) if count

        if with_scores
          args << "WITHSCORES"
          block = FloatifyPairs
        end

        send_command(args, &block)
      end

      # Return a range of members in a sorted set, by index, score or lexicographical ordering.
      #
      # @example Retrieve all members from a sorted set, by index
      #   redis.zrange("zset", 0, -1)
      #     # => ["a", "b"]
      # @example Retrieve all members and their scores from a sorted set
      #   redis.zrange("zset", 0, -1, :with_scores => true)
      #     # => [["a", 32.0], ["b", 64.0]]
      #
      # @param [String] key
      # @param [Integer] start start index
      # @param [Integer] stop stop index
      # @param [Hash] options
      #   - `:by_score => false`: return members by score
      #   - `:by_lex => false`: return members by lexicographical ordering
      #   - `:rev => false`: reverse the ordering, from highest to lowest
      #   - `:limit => [offset, count]`: skip `offset` members, return a maximum of
      #   `count` members
      #   - `:with_scores => true`: include scores in output
      #
      # @return [Array<String>, Array<[String, Float]>]
      #   - when `:with_scores` is not specified, an array of members
      #   - when `:with_scores` is specified, an array with `[member, score]` pairs
      def zrange(key, start, stop, byscore: false, by_score: byscore, bylex: false, by_lex: bylex,
                 rev: false, limit: nil, withscores: false, with_scores: withscores)

        if by_score && by_lex
          raise ArgumentError, "only one of :by_score or :by_lex can be specified"
        end

        args = [:zrange, key, start, stop]

        if by_score
          args << "BYSCORE"
        elsif by_lex
          args << "BYLEX"
        end

        args << "REV" if rev

        if limit
          args << "LIMIT"
          args.concat(limit.map { |l| Integer(l) })
        end

        if with_scores
          args << "WITHSCORES"
          block = FloatifyPairs
        end

        send_command(args, &block)
      end

      # Select a range of members in a sorted set, by index, score or lexicographical ordering
      # and store the resulting sorted set in a new key.
      #
      # @example
      #   redis.zadd("foo", [[1.0, "s1"], [2.0, "s2"], [3.0, "s3"]])
      #   redis.zrangestore("bar", "foo", 0, 1)
      #     # => 2
      #   redis.zrange("bar", 0, -1)
      #     # => ["s1", "s2"]
      #
      # @return [Integer] the number of elements in the resulting sorted set
      # @see #zrange
      def zrangestore(dest_key, src_key, start, stop, byscore: false, by_score: byscore,
                      bylex: false, by_lex: bylex, rev: false, limit: nil)
        if by_score && by_lex
          raise ArgumentError, "only one of :by_score or :by_lex can be specified"
        end

        args = [:zrangestore, dest_key, src_key, start, stop]

        if by_score
          args << "BYSCORE"
        elsif by_lex
          args << "BYLEX"
        end

        args << "REV" if rev

        if limit
          args << "LIMIT"
          args.concat(limit.map { |l| Integer(l) })
        end

        send_command(args)
      end

      # Return a range of members in a sorted set, by index, with scores ordered
      # from high to low.
      #
      # @example Retrieve all members from a sorted set
      #   redis.zrevrange("zset", 0, -1)
      #     # => ["b", "a"]
      # @example Retrieve all members and their scores from a sorted set
      #   redis.zrevrange("zset", 0, -1, :with_scores => true)
      #     # => [["b", 64.0], ["a", 32.0]]
      #
      # @see #zrange
      def zrevrange(key, start, stop, withscores: false, with_scores: withscores)
        args = [:zrevrange, key, Integer(start), Integer(stop)]

        if with_scores
          args << "WITHSCORES"
          block = FloatifyPairs
        end

        send_command(args, &block)
      end

      # Determine the index of a member in a sorted set.
      #
      # @example Retrieve member rank
      #   redis.zrank("zset", "a")
      #     # => 3
      # @example Retrieve member rank with their score
      #   redis.zrank("zset", "a", :with_score => true)
      #     # => [3, 32.0]
      #
      # @param [String] key
      # @param [String] member
      #
      # @return [Integer, [Integer, Float]]
      #   - when `:with_score` is not specified, an Integer
      #   - when `:with_score` is specified, a `[rank, score]` pair
      def zrank(key, member, withscore: false, with_score: withscore)
        args = [:zrank, key, member]

        if with_score
          args << "WITHSCORE"
          block = FloatifyPair
        end

        send_command(args, &block)
      end

      # Determine the index of a member in a sorted set, with scores ordered from
      # high to low.
      #
      # @example Retrieve member rank
      #   redis.zrevrank("zset", "a")
      #     # => 3
      # @example Retrieve member rank with their score
      #   redis.zrevrank("zset", "a", :with_score => true)
      #     # => [3, 32.0]
      #
      # @param [String] key
      # @param [String] member
      #
      # @return [Integer, [Integer, Float]]
      #   - when `:with_score` is not specified, an Integer
      #   - when `:with_score` is specified, a `[rank, score]` pair
      def zrevrank(key, member, withscore: false, with_score: withscore)
        args = [:zrevrank, key, member]

        if with_score
          args << "WITHSCORE"
          block = FloatifyPair
        end

        send_command(args, &block)
      end

      # Remove all members in a sorted set within the given indexes.
      #
      # @example Remove first 5 members
      #   redis.zremrangebyrank("zset", 0, 4)
      #     # => 5
      # @example Remove last 5 members
      #   redis.zremrangebyrank("zset", -5, -1)
      #     # => 5
      #
      # @param [String] key
      # @param [Integer] start start index
      # @param [Integer] stop stop index
      # @return [Integer] number of members that were removed
      def zremrangebyrank(key, start, stop)
        send_command([:zremrangebyrank, key, start, stop])
      end

      # Count the members, with the same score in a sorted set, within the given lexicographical range.
      #
      # @example Count members matching a
      #   redis.zlexcount("zset", "[a", "[a\xff")
      #     # => 1
      # @example Count members matching a-z
      #   redis.zlexcount("zset", "[a", "[z\xff")
      #     # => 26
      #
      # @param [String] key
      # @param [String] min
      #   - inclusive minimum is specified by prefixing `(`
      #   - exclusive minimum is specified by prefixing `[`
      # @param [String] max
      #   - inclusive maximum is specified by prefixing `(`
      #   - exclusive maximum is specified by prefixing `[`
      #
      # @return [Integer] number of members within the specified lexicographical range
      def zlexcount(key, min, max)
        send_command([:zlexcount, key, min, max])
      end

      # Return a range of members with the same score in a sorted set, by lexicographical ordering
      #
      # @example Retrieve members matching a
      #   redis.zrangebylex("zset", "[a", "[a\xff")
      #     # => ["aaren", "aarika", "abagael", "abby"]
      # @example Retrieve the first 2 members matching a
      #   redis.zrangebylex("zset", "[a", "[a\xff", :limit => [0, 2])
      #     # => ["aaren", "aarika"]
      #
      # @param [String] key
      # @param [String] min
      #   - inclusive minimum is specified by prefixing `(`
      #   - exclusive minimum is specified by prefixing `[`
      # @param [String] max
      #   - inclusive maximum is specified by prefixing `(`
      #   - exclusive maximum is specified by prefixing `[`
      # @param [Hash] options
      #   - `:limit => [offset, count]`: skip `offset` members, return a maximum of
      #   `count` members
      #
      # @return [Array<String>, Array<[String, Float]>]
      def zrangebylex(key, min, max, limit: nil)
        args = [:zrangebylex, key, min, max]

        if limit
          args << "LIMIT"
          args.concat(limit.map { |l| Integer(l) })
        end

        send_command(args)
      end

      # Return a range of members with the same score in a sorted set, by reversed lexicographical ordering.
      # Apart from the reversed ordering, #zrevrangebylex is similar to #zrangebylex.
      #
      # @example Retrieve members matching a
      #   redis.zrevrangebylex("zset", "[a", "[a\xff")
      #     # => ["abbygail", "abby", "abagael", "aaren"]
      # @example Retrieve the last 2 members matching a
      #   redis.zrevrangebylex("zset", "[a", "[a\xff", :limit => [0, 2])
      #     # => ["abbygail", "abby"]
      #
      # @see #zrangebylex
      def zrevrangebylex(key, max, min, limit: nil)
        args = [:zrevrangebylex, key, max, min]

        if limit
          args << "LIMIT"
          args.concat(limit.map { |l| Integer(l) })
        end

        send_command(args)
      end

      # Return a range of members in a sorted set, by score.
      #
      # @example Retrieve members with score `>= 5` and `< 100`
      #   redis.zrangebyscore("zset", "5", "(100")
      #     # => ["a", "b"]
      # @example Retrieve the first 2 members with score `>= 0`
      #   redis.zrangebyscore("zset", "0", "+inf", :limit => [0, 2])
      #     # => ["a", "b"]
      # @example Retrieve members and their scores with scores `> 5`
      #   redis.zrangebyscore("zset", "(5", "+inf", :with_scores => true)
      #     # => [["a", 32.0], ["b", 64.0]]
      #
      # @param [String] key
      # @param [String] min
      #   - inclusive minimum score is specified verbatim
      #   - exclusive minimum score is specified by prefixing `(`
      # @param [String] max
      #   - inclusive maximum score is specified verbatim
      #   - exclusive maximum score is specified by prefixing `(`
      # @param [Hash] options
      #   - `:with_scores => true`: include scores in output
      #   - `:limit => [offset, count]`: skip `offset` members, return a maximum of
      #   `count` members
      #
      # @return [Array<String>, Array<[String, Float]>]
      #   - when `:with_scores` is not specified, an array of members
      #   - when `:with_scores` is specified, an array with `[member, score]` pairs
      def zrangebyscore(key, min, max, withscores: false, with_scores: withscores, limit: nil)
        args = [:zrangebyscore, key, min, max]

        if with_scores
          args << "WITHSCORES"
          block = FloatifyPairs
        end

        if limit
          args << "LIMIT"
          args.concat(limit.map { |l| Integer(l) })
        end

        send_command(args, &block)
      end

      # Return a range of members in a sorted set, by score, with scores ordered
      # from high to low.
      #
      # @example Retrieve members with score `< 100` and `>= 5`
      #   redis.zrevrangebyscore("zset", "(100", "5")
      #     # => ["b", "a"]
      # @example Retrieve the first 2 members with score `<= 0`
      #   redis.zrevrangebyscore("zset", "0", "-inf", :limit => [0, 2])
      #     # => ["b", "a"]
      # @example Retrieve members and their scores with scores `> 5`
      #   redis.zrevrangebyscore("zset", "+inf", "(5", :with_scores => true)
      #     # => [["b", 64.0], ["a", 32.0]]
      #
      # @see #zrangebyscore
      def zrevrangebyscore(key, max, min, withscores: false, with_scores: withscores, limit: nil)
        args = [:zrevrangebyscore, key, max, min]

        if with_scores
          args << "WITHSCORES"
          block = FloatifyPairs
        end

        if limit
          args << "LIMIT"
          args.concat(limit.map { |l| Integer(l) })
        end

        send_command(args, &block)
      end

      # Remove all members in a sorted set within the given scores.
      #
      # @example Remove members with score `>= 5` and `< 100`
      #   redis.zremrangebyscore("zset", "5", "(100")
      #     # => 2
      # @example Remove members with scores `> 5`
      #   redis.zremrangebyscore("zset", "(5", "+inf")
      #     # => 2
      #
      # @param [String] key
      # @param [String] min
      #   - inclusive minimum score is specified verbatim
      #   - exclusive minimum score is specified by prefixing `(`
      # @param [String] max
      #   - inclusive maximum score is specified verbatim
      #   - exclusive maximum score is specified by prefixing `(`
      # @return [Integer] number of members that were removed
      def zremrangebyscore(key, min, max)
        send_command([:zremrangebyscore, key, min, max])
      end

      # Count the members in a sorted set with scores within the given values.
      #
      # @example Count members with score `>= 5` and `< 100`
      #   redis.zcount("zset", "5", "(100")
      #     # => 2
      # @example Count members with scores `> 5`
      #   redis.zcount("zset", "(5", "+inf")
      #     # => 2
      #
      # @param [String] key
      # @param [String] min
      #   - inclusive minimum score is specified verbatim
      #   - exclusive minimum score is specified by prefixing `(`
      # @param [String] max
      #   - inclusive maximum score is specified verbatim
      #   - exclusive maximum score is specified by prefixing `(`
      # @return [Integer] number of members in within the specified range
      def zcount(key, min, max)
        send_command([:zcount, key, min, max])
      end

      # Return the intersection of multiple sorted sets
      #
      # @example Retrieve the intersection of `2*zsetA` and `1*zsetB`
      #   redis.zinter("zsetA", "zsetB", :weights => [2.0, 1.0])
      #     # => ["v1", "v2"]
      # @example Retrieve the intersection of `2*zsetA` and `1*zsetB`, and their scores
      #   redis.zinter("zsetA", "zsetB", :weights => [2.0, 1.0], :with_scores => true)
      #     # => [["v1", 3.0], ["v2", 6.0]]
      #
      # @param [String, Array<String>] keys one or more keys to intersect
      # @param [Hash] options
      #   - `:weights => [Float, Float, ...]`: weights to associate with source
      #   sorted sets
      #   - `:aggregate => String`: aggregate function to use (sum, min, max, ...)
      #   - `:with_scores => true`: include scores in output
      #
      # @return [Array<String>, Array<[String, Float]>]
      #   - when `:with_scores` is not specified, an array of members
      #   - when `:with_scores` is specified, an array with `[member, score]` pairs
      def zinter(*args)
        _zsets_operation(:zinter, *args)
      end
      ruby2_keywords(:zinter) if respond_to?(:ruby2_keywords, true)

      # Intersect multiple sorted sets and store the resulting sorted set in a new
      # key.
      #
      # @example Compute the intersection of `2*zsetA` with `1*zsetB`, summing their scores
      #   redis.zinterstore("zsetC", ["zsetA", "zsetB"], :weights => [2.0, 1.0], :aggregate => "sum")
      #     # => 4
      #
      # @param [String] destination destination key
      # @param [Array<String>] keys source keys
      # @param [Hash] options
      #   - `:weights => [Array<Float>]`: weights to associate with source
      #   sorted sets
      #   - `:aggregate => String`: aggregate function to use (sum, min, max)
      # @return [Integer] number of elements in the resulting sorted set
      def zinterstore(*args)
        _zsets_operation_store(:zinterstore, *args)
      end
      ruby2_keywords(:zinterstore) if respond_to?(:ruby2_keywords, true)

      # Return the union of multiple sorted sets
      #
      # @example Retrieve the union of `2*zsetA` and `1*zsetB`
      #   redis.zunion("zsetA", "zsetB", :weights => [2.0, 1.0])
      #     # => ["v1", "v2"]
      # @example Retrieve the union of `2*zsetA` and `1*zsetB`, and their scores
      #   redis.zunion("zsetA", "zsetB", :weights => [2.0, 1.0], :with_scores => true)
      #     # => [["v1", 3.0], ["v2", 6.0]]
      #
      # @param [String, Array<String>] keys one or more keys to union
      # @param [Hash] options
      #   - `:weights => [Array<Float>]`: weights to associate with source
      #   sorted sets
      #   - `:aggregate => String`: aggregate function to use (sum, min, max)
      #   - `:with_scores => true`: include scores in output
      #
      # @return [Array<String>, Array<[String, Float]>]
      #   - when `:with_scores` is not specified, an array of members
      #   - when `:with_scores` is specified, an array with `[member, score]` pairs
      def zunion(*args)
        _zsets_operation(:zunion, *args)
      end
      ruby2_keywords(:zunion) if respond_to?(:ruby2_keywords, true)

      # Add multiple sorted sets and store the resulting sorted set in a new key.
      #
      # @example Compute the union of `2*zsetA` with `1*zsetB`, summing their scores
      #   redis.zunionstore("zsetC", ["zsetA", "zsetB"], :weights => [2.0, 1.0], :aggregate => "sum")
      #     # => 8
      #
      # @param [String] destination destination key
      # @param [Array<String>] keys source keys
      # @param [Hash] options
      #   - `:weights => [Float, Float, ...]`: weights to associate with source
      #   sorted sets
      #   - `:aggregate => String`: aggregate function to use (sum, min, max, ...)
      # @return [Integer] number of elements in the resulting sorted set
      def zunionstore(*args)
        _zsets_operation_store(:zunionstore, *args)
      end
      ruby2_keywords(:zunionstore) if respond_to?(:ruby2_keywords, true)

      # Return the difference between the first and all successive input sorted sets
      #
      # @example
      #   redis.zadd("zsetA", [[1.0, "v1"], [2.0, "v2"]])
      #   redis.zadd("zsetB", [[3.0, "v2"], [2.0, "v3"]])
      #   redis.zdiff("zsetA", "zsetB")
      #     => ["v1"]
      # @example With scores
      #   redis.zadd("zsetA", [[1.0, "v1"], [2.0, "v2"]])
      #   redis.zadd("zsetB", [[3.0, "v2"], [2.0, "v3"]])
      #   redis.zdiff("zsetA", "zsetB", :with_scores => true)
      #     => [["v1", 1.0]]
      #
      # @param [String, Array<String>] keys one or more keys to compute the difference
      # @param [Hash] options
      #   - `:with_scores => true`: include scores in output
      #
      # @return [Array<String>, Array<[String, Float]>]
      #   - when `:with_scores` is not specified, an array of members
      #   - when `:with_scores` is specified, an array with `[member, score]` pairs
      def zdiff(*keys, with_scores: false)
        _zsets_operation(:zdiff, *keys, with_scores: with_scores)
      end

      # Compute the difference between the first and all successive input sorted sets
      # and store the resulting sorted set in a new key
      #
      # @example
      #   redis.zadd("zsetA", [[1.0, "v1"], [2.0, "v2"]])
      #   redis.zadd("zsetB", [[3.0, "v2"], [2.0, "v3"]])
      #   redis.zdiffstore("zsetA", "zsetB")
      #     # => 1
      #
      # @param [String] destination destination key
      # @param [Array<String>] keys source keys
      # @return [Integer] number of elements in the resulting sorted set
      def zdiffstore(*args)
        _zsets_operation_store(:zdiffstore, *args)
      end
      ruby2_keywords(:zdiffstore) if respond_to?(:ruby2_keywords, true)

      # Scan a sorted set
      #
      # @example Retrieve the first batch of key/value pairs in a hash
      #   redis.zscan("zset", 0)
      #
      # @param [String, Integer] cursor the cursor of the iteration
      # @param [Hash] options
      #   - `:match => String`: only return keys matching the pattern
      #   - `:count => Integer`: return count keys at most per iteration
      #
      # @return [String, Array<[String, Float]>] the next cursor and all found
      #   members and scores
      #
      # See the [Redis Server ZSCAN documentation](https://redis.io/docs/latest/commands/zscan/) for further details
      def zscan(key, cursor, **options)
        _scan(:zscan, cursor, [key], **options) do |reply|
          [reply[0], FloatifyPairs.call(reply[1])]
        end
      end

      # Scan a sorted set
      #
      # @example Retrieve all of the members/scores in a sorted set
      #   redis.zscan_each("zset").to_a
      #   # => [["key70", "70"], ["key80", "80"]]
      #
      # @param [Hash] options
      #   - `:match => String`: only return keys matching the pattern
      #   - `:count => Integer`: return count keys at most per iteration
      #
      # @return [Enumerator] an enumerator for all found scores and members
      #
      # See the [Redis Server ZSCAN documentation](https://redis.io/docs/latest/commands/zscan/) for further details
      def zscan_each(key, **options, &block)
        return to_enum(:zscan_each, key, **options) unless block_given?

        cursor = 0
        loop do
          cursor, values = zscan(key, cursor, **options)
          values.each(&block)
          break if cursor == "0"
        end
      end

      private

      def _zsets_operation(cmd, *keys, weights: nil, aggregate: nil, with_scores: false)
        keys.flatten!(1)
        command = [cmd, keys.size].concat(keys)

        if weights
          command << "WEIGHTS"
          command.concat(weights)
        end

        command << "AGGREGATE" << aggregate if aggregate

        if with_scores
          command << "WITHSCORES"
          block = FloatifyPairs
        end

        send_command(command, &block)
      end

      def _zsets_operation_store(cmd, destination, keys, weights: nil, aggregate: nil)
        keys.flatten!(1)
        command = [cmd, destination, keys.size].concat(keys)

        if weights
          command << "WEIGHTS"
          command.concat(weights)
        end

        command << "AGGREGATE" << aggregate if aggregate

        send_command(command)
      end
    end
  end
end