<?php

/**
 * Credis_Client (a fork of Redisent)
 *
 * Most commands are compatible with phpredis library:
 *   - use "pipeline()" to start a pipeline of commands instead of multi(Redis::PIPELINE)
 *   - any arrays passed as arguments will be flattened automatically
 *   - setOption and getOption are not supported in standalone mode
 *   - order of arguments follows redis-cli instead of phpredis where they differ (lrem)
 *
 * - Uses phpredis library if extension is installed for better performance.
 * - Establishes connection lazily.
 * - Supports tcp and unix sockets.
 * - Reconnects automatically unless a watch or transaction is in progress.
 * - Can set automatic retry connection attempts for iffy Redis connections.
 *
 * @author Colin Mollenhour <colin@mollenhour.com>
 * @copyright 2011 Colin Mollenhour <colin@mollenhour.com>
 * @license http://www.opensource.org/licenses/mit-license.php The MIT License
 * @package Credis_Client
 */


/**
 * Credis-specific errors, wraps native Redis errors
 */
class CredisException extends Exception
{
    const CODE_TIMED_OUT = 1;
    const CODE_DISCONNECTED = 2;

    public function __construct($message, $code = 0, $exception = null)
    {
        if ($exception && get_class($exception) == 'RedisException' && strpos($message, 'read error on connection') === 0) {
            $code = CredisException::CODE_DISCONNECTED;
        }
        parent::__construct($message, $code, $exception);
    }
}

/**
 * Credis_Client, a lightweight Redis PHP standalone client and phpredis wrapper
 *
 * Server/Connection:
 * @method Credis_Client               pipeline()
 * @method Credis_Client               multi()
 * @method Credis_Client|bool          watch(string ...$keys)
 * @method Credis_Client|bool          unwatch()
 * @method array                       exec()
 * @method bool                        discard()
 * @method Credis_Client|bool          flushAll()
 * @method Credis_Client|bool          flushDb()
 * @method array|Credis_Client         info(string $section = null)
 * @method bool|array|Credis_Client    config(string $setGet, string $key, string $value = null)
 * @method array|Credis_Client         role()
 * @method array|Credis_Client         time()
 * @method int|Credis_Client           dbsize()
 *
 * Keys:
 * @method int|Credis_Client           del(string|array ...$keys)
 * @method int|Credis_Client           exists(string $key)
 * @method int|Credis_Client           expire(string $key, int $seconds)
 * @method int|Credis_Client           expireAt(string $key, int $timestamp)
 * @method array|Credis_Client         keys(string $key)
 * @method int|Credis_Client           persist(string $key)
 * @method bool|Credis_Client          rename(string $key, string $newKey)
 * @method bool|Credis_Client          renameNx(string $key, string $newKey)
 * @method array|Credis_Client         sort(string $key, string $arg1, string $valueN = null)
 * @method int|Credis_Client           ttl(string $key)
 * @method string|Credis_Client        type(string $key)
 * @method string|Credis_Client        unlink(string|array ...$keys)
 *
 * Scalars:
 * @method int|Credis_Client           append(string $key, string $value)
 * @method int|Credis_Client           decr(string $key)
 * @method int|Credis_Client           decrBy(string $key, int $decrement)
 * @method false|string|Credis_Client  get(string $key)
 * @method int|Credis_Client           getBit(string $key, int $offset)
 * @method string|Credis_Client        getRange(string $key, int $start, int $end)
 * @method string|Credis_Client        getSet(string $key, string $value)
 * @method int|Credis_Client           incr(string $key)
 * @method int|Credis_Client           incrBy(string $key, int $decrement)
 * @method false|array|Credis_Client   mGet(array $keys)
 * @method bool|Credis_Client          mSet(array $keysValues)
 * @method int|Credis_Client           mSetNx(array $keysValues)
 * @method bool|Credis_Client          set(string $key, string $value, int | array $options = null)
 * @method int|Credis_Client           setBit(string $key, int $offset, int $value)
 * @method bool|Credis_Client          setEx(string $key, int $seconds, string $value)
 * @method int|Credis_Client           setNx(string $key, string $value)
 * @method int |Credis_Client          setRange(string $key, int $offset, int $value)
 * @method int|Credis_Client           strLen(string $key)
 *
 * Sets:
 * @method int|Credis_Client           sAdd(string $key, mixed $value, string $valueN = null)
 * @method int|Credis_Client           sRem(string $key, mixed $value, string $valueN = null)
 * @method array|Credis_Client         sMembers(string $key)
 * @method array|Credis_Client         sUnion(mixed $keyOrArray, string $valueN = null)
 * @method array|Credis_Client         sInter(mixed $keyOrArray, string $valueN = null)
 * @method array |Credis_Client        sDiff(mixed $keyOrArray, string $valueN = null)
 * @method string|Credis_Client        sPop(string $key)
 * @method int|Credis_Client           sCard(string $key)
 * @method int|Credis_Client           sIsMember(string $key, string $member)
 * @method int|Credis_Client           sMove(string $source, string $dest, string $member)
 * @method string|array|Credis_Client  sRandMember(string $key, int $count = null)
 * @method int|Credis_Client           sUnionStore(string $dest, string $key1, string $key2 = null)
 * @method int|Credis_Client           sInterStore(string $dest, string $key1, string $key2 = null)
 * @method int|Credis_Client           sDiffStore(string $dest, string $key1, string $key2 = null)
 *
 * Hashes:
 * @method bool|int|Credis_Client      hSet(string $key, string $field, string $value)
 * @method bool|Credis_Client          hSetNx(string $key, string $field, string $value)
 * @method bool|string|Credis_Client   hGet(string $key, string $field)
 * @method bool|int|Credis_Client      hLen(string $key)
 * @method bool|Credis_Client          hDel(string $key, string $field)
 * @method array|Credis_Client         hKeys(string $key)
 * @method array|Credis_Client         hVals(string $key)
 * @method array|Credis_Client         hGetAll(string $key)
 * @method bool|Credis_Client          hExists(string $key, string $field)
 * @method int|Credis_Client           hIncrBy(string $key, string $field, int $value)
 * @method float|Credis_Client         hIncrByFloat(string $key, string $member, float $value)
 * @method bool|Credis_Client          hMSet(string $key, array $keysValues)
 * @method array|Credis_Client         hMGet(string $key, array $fields)
 *
 * Lists:
 * @method array|null|Credis_Client    blPop(string $keyN, int $timeout)
 * @method array|null|Credis_Client    brPop(string $keyN, int $timeout)
 * @method array|null |Credis_Client   brPoplPush(string $source, string $destination, int $timeout)
 * @method string|null|Credis_Client   lIndex(string $key, int $index)
 * @method int|Credis_Client           lInsert(string $key, string $beforeAfter, string $pivot, string $value)
 * @method int|Credis_Client           lLen(string $key)
 * @method string|null|Credis_Client   lPop(string $key)
 * @method int|Credis_Client           lPush(string $key, mixed $value, mixed $valueN = null)
 * @method int|Credis_Client           lPushX(string $key, mixed $value)
 * @method array|Credis_Client         lRange(string $key, int $start, int $stop)
 * @method int|Credis_Client           lRem(string $key, int $count, mixed $value)
 * @method bool|Credis_Client          lSet(string $key, int $index, mixed $value)
 * @method bool|Credis_Client          lTrim(string $key, int $start, int $stop)
 * @method string|null|Credis_Client   rPop(string $key)
 * @method string|null|Credis_Client   rPoplPush(string $source, string $destination)
 * @method int|Credis_Client           rPush(string $key, mixed $value, mixed $valueN = null)
 * @method int |Credis_Client          rPushX(string $key, mixed $value)
 *
 * Sorted Sets:
 * @method int|Credis_Client           zAdd(string $key, double $score, string $value)
 * @method int|Credis_Client           zCard(string $key)
 * @method int|Credis_Client           zSize(string $key)
 * @method int|Credis_Client           zCount(string $key, mixed $start, mixed $stop)
 * @method int|Credis_Client           zIncrBy(string $key, double $value, string $member)
 * @method array|Credis_Client         zRangeByScore(string $key, mixed $start, mixed $stop, array $args = null)
 * @method array|Credis_Client         zRevRangeByScore(string $key, mixed $start, mixed $stop, array $args = null)
 * @method int|Credis_Client           zRemRangeByScore(string $key, mixed $start, mixed $stop)
 * @method array|Credis_Client         zRange(string $key, mixed $start, mixed $stop, array $args = null)
 * @method array|Credis_Client         zRevRange(string $key, mixed $start, mixed $stop, array $args = null)
 * @method int|Credis_Client           zRank(string $key, string $member)
 * @method int|Credis_Client           zRevRank(string $key, string $member)
 * @method int|Credis_Client           zRem(string $key, string $member)
 * @method int|Credis_Client           zDelete(string $key, string $member)
 * TODO
 *
 * Pub/Sub
 * @method int |Credis_Client          publish(string $channel, string $message)
 * @method int|array|Credis_Client     pubsub(string $subCommand, $arg = null)
 *
 * Scripting:
 * @method string|int|Credis_Client    script(string $command, string $arg1 = null)
 * @method string|int|array|bool|Credis_Client eval(string $script, array|string $keys = null, array|string $args = null)
 * @method string|int|array|bool|Credis_Client evalSha(string $script, array|string $keys = null, array|string $args = null)
 */
class Credis_Client
{
    const TYPE_STRING = 'string';
    const TYPE_LIST = 'list';
    const TYPE_SET = 'set';
    const TYPE_ZSET = 'zset';
    const TYPE_HASH = 'hash';
    const TYPE_NONE = 'none';

    /**
     * Socket connection to the Redis server or Redis library instance
     * @var resource|Redis
     */
    protected $redis;
    protected $redisMulti;

    /**
     * Host of the Redis server
     * @var string
     */
    protected $host;

    /**
     * Scheme of the Redis server (tcp, tls, tlsv1.2, unix)
     * @var string|null
     */
    protected $scheme;

    /**
     * SSL Meta information
     * @var array|null
     */
    protected $sslMeta;

    /**
     * Port on which the Redis server is running
     * @var int|null
     */
    protected $port;

    /**
     * Timeout for connecting to Redis server
     * @var float|null
     */
    protected $timeout;

    /**
     * Timeout for reading response from Redis server
     * @var float|null
     */
    protected $readTimeout;

    /**
     * Unique identifier for persistent connections
     * @var string
     */
    protected $persistent;

    /**
     * @var bool
     */
    protected $closeOnDestruct = true;

    /**
     * @var bool
     */
    protected $connected = false;

    /**
     * @var bool
     */
    protected $standalone;

    /**
     * @var int
     */
    protected $maxConnectRetries = 0;

    /**
     * @var int
     */
    protected $connectFailures = 0;

    /**
     * @var bool
     */
    protected $usePipeline = false;

    /**
     * @var array
     */
    protected $commandNames;

    /**
     * @var string
     */
    protected $commands;

    /**
     * @var bool
     */
    protected $isMulti = false;

    /**
     * @var bool
     */
    protected $isWatching = false;

    /**
     * @var string|null
     */
    protected $authUsername;

    /**
     * @var string|null
     */
    protected $authPassword;

    /**
     * @var int
     */
    protected $selectedDb = 0;

    /**
     * Aliases for backwards compatibility with phpredis
     * @var array
     */
    protected $wrapperMethods = array('delete' => 'del', 'getkeys' => 'keys', 'sremove' => 'srem');

    /**
     * @var array<string,string>|callable|null
     */
    protected $renamedCommands;

    /**
     * @var int
     */
    protected $requests = 0;

    /**
     * @var bool
     */
    protected $subscribed = false;

    /** @var bool */
    protected $oldPhpRedis = false;

    /** @var array */
    protected $tlsOptions = [];


    /**
     * @var bool
     */
    protected $isTls = false;

    /**
     * Gets Useful Meta debug information about the SSL
     *
     * @return array|null
     */
    public function getSslMeta()
    {
        return $this->sslMeta;
    }

    /**
     * Creates a connection to the Redis server on host {@link $host} and port {@link $port}.
     * $host may also be a path to a unix socket or a string in the form of tcp://[hostname]:[port] or unix://[path]
     *
     * @param string $host The hostname of the Redis server
     * @param int|null $port The port number of the Redis server
     * @param float|null $timeout Timeout period in seconds
     * @param string $persistent Flag to establish persistent connection
     * @param int $db The selected database of the Redis server
     * @param string|null $password The authentication password of the Redis server
     * @param string|null $username The authentication username of the Redis server
     * @param array|null $tlsOptions The TLS/SSL context options. See https://www.php.net/manual/en/context.ssl.php for details
     * @throws CredisException
     */
    public function __construct($host = '127.0.0.1', $port = 6379, $timeout = null, $persistent = '', $db = 0, $password = null, $username = null, $tlsOptions = null)
    {
        $this->host = (string)$host;
        if ($port !== null) {
            $this->port = (int)$port;
        }
        $this->scheme = null;
        $this->timeout = $timeout;
        $this->persistent = (string)$persistent;
        $this->standalone = !extension_loaded('redis');
        $this->authPassword = $password;
        $this->authUsername = $username;
        $this->selectedDb = (int)$db;
        $this->convertHost();
        if (is_array($tlsOptions) && count($tlsOptions) !== 0) {
            $this->setTlsOptions($tlsOptions);
        }
        // PHP Redis extension support TLS/ACL AUTH since 5.3.0
        $this->oldPhpRedis = (bool)version_compare(phpversion('redis'), '5.3.0', '<');
        if ((
            $this->isTls
            || $this->authUsername !== null
        )
        && !$this->standalone && $this->oldPhpRedis) {
            $this->standalone = true;
        }
    }

    public function __destruct()
    {
        if ($this->closeOnDestruct) {
            $this->close();
        }
    }

    /**
     * @return bool
     */
    public function isSubscribed()
    {
        return $this->subscribed;
    }

    /**
     * Return the host of the Redis instance
     * @return string
     */
    public function getHost()
    {
        return $this->host;
    }

    /**
     * Return the port of the Redis instance
     * @return int|null
     */
    public function getPort()
    {
        return $this->port;
    }

    /**
     * @return bool
     */
    public function isTls()
    {
        return $this->isTls;
    }

    /**
     * Return the selected database
     * @return int
     */
    public function getSelectedDb()
    {
        return $this->selectedDb;
    }

    /**
     * @return string
     */
    public function getPersistence()
    {
        return $this->persistent;
    }

    /**
     * @return Credis_Client
     * @throws CredisException
     */
    public function forceStandalone()
    {
        if ($this->standalone) {
            return $this;
        }
        if ($this->connected) {
            throw new CredisException('Cannot force Credis_Client to use standalone PHP driver after a connection has already been established.');
        }
        $this->standalone = true;
        return $this;
    }

    /**
     * @param int $retries
     * @return Credis_Client
     */
    public function setMaxConnectRetries($retries)
    {
        $this->maxConnectRetries = $retries;
        return $this;
    }

    /**
     * @param bool $flag
     * @return Credis_Client
     */
    public function setCloseOnDestruct($flag)
    {
        $this->closeOnDestruct = $flag;
        return $this;
    }

    /**
     * @throws CredisException
     */
    public function setTlsOptions(array $tlsOptions)
    {
        if ($this->connected) {
            throw new CredisException('Cannot change TLS options after a connection has already been established.');
        }
        $this->tlsOptions = $tlsOptions;
    }

    /**
     * @throws CredisException
     */
    protected function convertHost()
    {
        if (preg_match('#^(tcp|tls|ssl|tlsv\d(?:\.\d)?|unix)://(.+)$#', $this->host, $matches)) {
            $this->isTls = strpos($matches[1], 'tls') === 0 || strpos($matches[1], 'ssl') === 0;
            if ($this->isTls || $matches[1] === 'tcp') {
                $this->scheme = $matches[1];
                if (!preg_match('#^([^:]+)(:([0-9]+))?(/(.+))?$#', $matches[2], $matches)) {
                    throw new CredisException('Invalid host format; expected ' . $this->scheme . '://host[:port][/persistence_identifier]');
                }
                $this->host = $matches[1];
                $this->port = (int)(isset($matches[3]) ? $matches[3] : $this->port);
                $this->persistent = isset($matches[5]) ? $matches[5] : $this->persistent;
            } else {
                $this->host = $matches[2];
                $this->port = null;
                $this->scheme = 'unix';
                if (substr($this->host, 0, 1) != '/') {
                    throw new CredisException('Invalid unix socket format; expected unix:///path/to/redis.sock');
                }
            }
        }
        if ($this->port !== null && substr($this->host, 0, 1) == '/') {
            $this->port = null;
            $this->scheme = 'unix';
        }
        if (!$this->scheme) {
            $this->scheme = 'tcp';
        }
    }

    /**
     * @return Credis_Client
     * @throws CredisException
     */
    public function connect()
    {
        if ($this->connected) {
            return $this;
        }
        $this->close(true);
        $tlsOptions = $this->isTls ? $this->tlsOptions : [];
        if ($this->standalone) {
            $flags = STREAM_CLIENT_CONNECT;
            $remote_socket = $this->port === null
                ? $this->scheme . '://' . $this->host
                : $this->scheme . '://' . $this->host . ':' . $this->port;
            if ($this->persistent && $this->port !== null) {
                // Persistent connections to UNIX sockets are not supported
                $remote_socket .= '/' . $this->persistent;
                $flags = $flags | STREAM_CLIENT_PERSISTENT;
            }
            if ($this->isTls) {
                $tlsOptions = array_merge($tlsOptions, [
                    'capture_peer_cert' => true,
                    'capture_peer_cert_chain' => true,
                    'capture_session_meta' => true,
                ]);
            }

            // passing $context as null errors before php 8.0
            $context = stream_context_create(['ssl' => $tlsOptions]);

            $result = $this->redis = @stream_socket_client($remote_socket, $errno, $errstr, $this->timeout !== null ? $this->timeout : 2.5, $flags, $context);

            if ($result && $this->isTls) {
                $this->sslMeta = stream_context_get_options($context);
            }
        } else {
            if (!$this->redis) {
                $this->redis = new Redis();
            }
            $socketTimeout = $this->timeout ?: 0.0;
            try {
                if ($this->oldPhpRedis) {
                    $result = $this->persistent
                        ? $this->redis->pconnect($this->host, (int)$this->port, $socketTimeout, $this->persistent)
                        : $this->redis->connect($this->host, (int)$this->port, $socketTimeout);
                } else {
                    // 7th argument is non-documented TLS options. But it only exists on the newer versions of phpredis
                    if ($tlsOptions) {
                        $context = ['stream' => $tlsOptions];
                    } else {
                        $context = [];
                    }
                    /** @noinspection PhpMethodParametersCountMismatchInspection */
                    $result = $this->persistent
                        ? $this->redis->pconnect($this->scheme . '://' . $this->host, (int)$this->port, $socketTimeout, $this->persistent, 0, 0.0, $context)
                        : $this->redis->connect($this->scheme . '://' . $this->host, (int)$this->port, $socketTimeout, null, 0, 0.0, $context);
                }
            } catch (Exception $e) {
                // Some applications will capture the php error that phpredis can sometimes generate and throw it as an Exception
                $result = false;
                $errno = 1;
                $errstr = $e->getMessage();
            }
        }

        // Use recursion for connection retries
        if (!$result) {
            $this->connectFailures++;
            if ($this->connectFailures <= $this->maxConnectRetries) {
                return $this->connect();
            }
            $failures = $this->connectFailures;
            $this->connectFailures = 0;
            throw new CredisException(sprintf(
                "Connection to Redis%s %s://%s failed after %s failures.%s",
                $this->standalone ? ' standalone' : '',
                $this->scheme,
                $this->host . ($this->port ? ':' . $this->port : ''),
                $failures,
                (isset($errno) && isset($errstr) ? "Last Error : ({$errno}) {$errstr}" : "")
            ));
        }

        $this->connectFailures = 0;
        $this->connected = true;

        // Set read timeout
        if ($this->readTimeout) {
            $this->setReadTimeout($this->readTimeout);
        }
        if ($this->authPassword) {
            $this->auth($this->authPassword, $this->authUsername);
        }
        if ($this->selectedDb !== 0) {
            $this->select($this->selectedDb);
        }
        return $this;
    }

    /**
     * @return bool
     */
    public function isConnected()
    {
        return $this->connected;
    }

    /**
     * Set the read timeout for the connection. Use 0 to disable timeouts entirely (or use a very long timeout
     * if not supported).
     *
     * @param float $timeout 0 (or -1) for no timeout, otherwise number of seconds
     * @return Credis_Client
     * @throws CredisException
     */
    public function setReadTimeout($timeout)
    {
        if ($timeout < -1) {
            throw new CredisException('Timeout values less than -1 are not accepted.');
        }
        $this->readTimeout = $timeout;
        if ($this->isConnected()) {
            if ($this->standalone) {
                $timeout = $timeout <= 0 ? 315360000 : $timeout; // Ten-year timeout
                stream_set_blocking($this->redis, true);
                stream_set_timeout($this->redis, (int)floor($timeout), ($timeout - floor($timeout)) * 1000000);
            } elseif (defined('Redis::OPT_READ_TIMEOUT')) {
                // supported in phpredis 2.2.3
                // a timeout value of -1 means reads will not time out
                $timeout = $timeout == 0 ? -1 : $timeout;
                try {
                    $this->redis->setOption(Redis::OPT_READ_TIMEOUT, $timeout);
                } catch (RedisException $e) {
                    throw new CredisException($e->getMessage(), $e->getCode(), $e);
                }
            }
        }
        return $this;
    }

    /**
     * @return bool
     */
    public function close($force = false)
    {
        $result = true;
        if ($this->redis && ($force || $this->connected && !$this->persistent)) {
            try {
                if (is_callable(array($this->redis, 'close'))) {
                    $this->redis->close();
                } else {
                    @fclose($this->redis);
                    $this->redis = null;
                }
            } catch (Exception $e) {
                // Ignore exceptions on close
                $result = false;
            }
            $this->connected = $this->usePipeline = $this->isMulti = $this->isWatching = false;
        }
        return $result;
    }

    /**
     * Enabled command renaming and provide mapping method. Supported methods are:
     *
     * 1. renameCommand('foo') // Salted md5 hash for all commands -> md5('foo'.$command)
     * 2. renameCommand(function($command){ return 'my'.$command; }); // Callable
     * 3. renameCommand('get', 'foo') // Single command -> alias
     * 4. renameCommand(['get' => 'foo', 'set' => 'bar']) // Full map of [command -> alias]
     *
     * @param string|callable|array $command
     * @param string|null $alias
     * @return $this
     * @throws CredisException
     */
    public function renameCommand($command, $alias = null)
    {
        if (!$this->standalone) {
            $this->forceStandalone();
        }
        if ($alias === null) {
            $this->renamedCommands = $command;
        } else {
            if (!$this->renamedCommands) {
                $this->renamedCommands = array();
            }
            $this->renamedCommands[$command] = $alias;
        }
        return $this;
    }

    /**
     * @param $command
     * @return string
     */
    public function getRenamedCommand($command)
    {
        static $map;

        // Command renaming not enabled
        if ($this->renamedCommands === null) {
            return $command;
        }

        // Initialize command map
        if ($map === null) {
            if (is_array($this->renamedCommands)) {
                $map = $this->renamedCommands;
            } else {
                $map = array();
            }
        }

        // Generate and return cached result
        if (!isset($map[$command])) {
            // String means all commands are hashed with salted md5
            if (is_string($this->renamedCommands)) {
                $map[$command] = md5($this->renamedCommands . $command);
            } // Would already be set in $map if it was intended to be renamed
            elseif (is_array($this->renamedCommands)) {
                return $command;
            } // User-supplied function
            elseif (is_callable($this->renamedCommands)) {
                $map[$command] = call_user_func($this->renamedCommands, $command);
            }
        }
        return $map[$command];
    }

    /**
     * @param string $password
     * @param string|null $username
     * @return bool
     * @throws CredisException
     */
    public function auth($password, $username = null)
    {
        if ($username !== null) {
            $response = $this->__call('auth', array($username, $password));
            $this->authUsername = $username;
        } else {
            $response = $this->__call('auth', array($password));
        }
        $this->authPassword = $password;
        return $response;
    }

    /**
     * @param int $index
     * @return bool
     * @throws CredisException
     */
    public function select($index)
    {
        $response = $this->__call('select', array($index));
        $this->selectedDb = (int)$index;
        return $response;
    }

    /**
     * @param string $caller
     * @return void
     * @throws CredisException
     */
    protected function assertNotPipelineOrMulti($caller)
    {
        if ($this->standalone && ($this->isMulti || $this->usePipeline) ||
            // phpredis triggers a php fatal error, so do the check before
            !$this->standalone && ($this->redis->getMode() === Redis::MULTI || $this->redis->getMode() === Redis::PIPELINE)) {
            throw new CredisException('multi()/pipeline() mode can not be used with '.$caller);
        }
    }

    /**
     * @param string|array ...$args
     * @return array
     * @throws CredisException
     */
    public function pUnsubscribe(...$args)
    {
        list($command, $channel, $subscribedChannels) = $this->__call('punsubscribe', $args);
        $this->subscribed = $subscribedChannels > 0;
        return array($command, $channel, $subscribedChannels);
    }

    /**
     * @param ?int $Iterator
     * @param string $pattern
     * @param int $count
     * @return bool|array
     * @throws CredisException
     */
    public function scan(&$Iterator, $pattern = null, $count = null)
    {
        $this->assertNotPipelineOrMulti(__METHOD__);
        return $this->__call('scan', array(&$Iterator, $pattern, $count));
    }

    /**
     * @param ?int $Iterator
     * @param string $field
     * @param string $pattern
     * @param int $count
     * @return bool|array
     * @throws CredisException
     */
    public function hscan(&$Iterator, $field, $pattern = null, $count = null)
    {
        $this->assertNotPipelineOrMulti(__METHOD__);
        return $this->__call('hscan', array($field, &$Iterator, $pattern, $count));
    }

    /**
     * @param ?int $Iterator
     * @param string $field
     * @param string $pattern
     * @param ?int $count
     * @return bool|array
     * @throws CredisException
     */
    public function sscan(&$Iterator, $field, $pattern = null, $count = null)
    {
        $this->assertNotPipelineOrMulti(__METHOD__);
        return $this->__call('sscan', array($field, &$Iterator, $pattern, $count));
    }

    /**
     * @param ?int $Iterator
     * @param string $field
     * @param string $pattern
     * @param ?int $count
     * @return bool|array
     * @throws CredisException
     */
    public function zscan(&$Iterator, $field, $pattern = null, $count = null)
    {
        $this->assertNotPipelineOrMulti(__METHOD__);
        return $this->__call('zscan', array($field, &$Iterator, $pattern, $count));
    }

    /**
     * @param string|array $patterns
     * @param $callback
     * @return $this|array|bool|Credis_Client|mixed|null|string
     * @throws CredisException
     */
    public function pSubscribe($patterns, $callback)
    {
        if (!$this->standalone) {
            return $this->__call('pSubscribe', array((array)$patterns, $callback));
        }

        // Standalone mode: use infinite loop to subscribe until timeout
        $patternCount = is_array($patterns) ? count($patterns) : 1;
        while ($patternCount--) {
            if (isset($status)) {
                list($command, $pattern, $status) = $this->read_reply();
            } else {
                list($command, $pattern, $status) = $this->__call('psubscribe', array($patterns));
            }
            $this->subscribed = $status > 0;
            if (!$status) {
                throw new CredisException('Invalid pSubscribe response.');
            }
        }
        while ($this->subscribed) {
            list($type, $pattern, $channel, $message) = $this->read_reply();
            if ($type != 'pmessage') {
                throw new CredisException('Received non-pmessage reply.');
            }
            $callback($this, $pattern, $channel, $message);
        }
        return null;
    }

    /**
     * @param string|array ...$args
     * @return array
     * @throws CredisException
     */
    public function unsubscribe(...$args)
    {
        list($command, $channel, $subscribedChannels) = $this->__call('unsubscribe', $args);
        $this->subscribed = $subscribedChannels > 0;
        return array($command, $channel, $subscribedChannels);
    }

    /**
     * @param string|array $channels
     * @param $callback
     * @return $this|array|bool|Credis_Client|mixed|null|string
     * @throws CredisException
     */
    public function subscribe($channels, $callback)
    {
        if (!$this->standalone) {
            return $this->__call('subscribe', array((array)$channels, $callback));
        }

        // Standalone mode: use infinite loop to subscribe until timeout
        $channelCount = is_array($channels) ? count($channels) : 1;
        while ($channelCount--) {
            if (isset($status)) {
                list($command, $channel, $status) = $this->read_reply();
            } else {
                list($command, $channel, $status) = $this->__call('subscribe', array($channels));
            }
            $this->subscribed = $status > 0;
            if (!$status) {
                throw new CredisException('Invalid subscribe response.');
            }
        }
        while ($this->subscribed) {
            list($type, $channel, $message) = $this->read_reply();
            if ($type != 'message') {
                throw new CredisException('Received non-message reply.');
            }
            $callback($this, $channel, $message);
        }
        return null;
    }

    /**
     * @param string|null $name
     * @return string|Credis_Client
     * @throws CredisException
     */
    public function ping($name = null)
    {
        return $this->__call('ping', $name ? array($name) : array());
    }

    /**
     * @param string $command
     * @param array $args
     *
     * @return array|Credis_Client
     * @throws CredisException
     */
    public function rawCommand($command, array $args)
    {
        if ($this->standalone) {
            return $this->__call($command, $args);
        } else {
            \array_unshift($args, $command);
            return $this->__call('rawCommand', $args);
        }
    }

    /**
     * @throws CredisException
     */
    public function __call($name, $args)
    {
        // Lazy connection
        $this->connect();

        $name = strtolower($name);

        // Send request via native PHP
        if ($this->standalone) {
            // Early returns should verify how phpredis behaves!
            $trackedArgs = array();
            switch ($name) {
                case 'eval':
                case 'evalsha':
                    $script = array_shift($args);
                    $keys = (array)array_shift($args);
                    $eArgs = (array)array_shift($args);
                    $args = array($script, count($keys), $keys, $eArgs);
                    break;
                case 'zinterstore':
                case 'zunionstore':
                    $dest = array_shift($args);
                    $keys = (array)array_shift($args);
                    $weights = array_shift($args);
                    $aggregate = array_shift($args);
                    $args = array($dest, count($keys), $keys);
                    if ($weights) {
                        $args[] = (array)$weights;
                    }
                    if ($aggregate) {
                        $args[] = $aggregate;
                    }
                    break;
                case 'set':
                    // The php redis module has different behaviour with ttl
                    // https://github.com/phpredis/phpredis#set
                    if (count($args) === 3 && is_int($args[2])) {
                        $args = array($args[0], $args[1], array('EX', $args[2]));
                    } elseif (count($args) === 3 && is_array($args[2])) {
                        $tmp_args = $args;
                        $args = array($tmp_args[0], $tmp_args[1]);
                        foreach ($tmp_args[2] as $k => $v) {
                            if (is_string($k)) {
                                $args[] = array($k, $v);
                            } elseif (is_int($k)) {
                                $args[] = $v;
                            }
                        }
                        unset($tmp_args);
                    }
                    break;
                case 'scan':
                    $trackedArgs = array(&$args[0]);
                    if ($trackedArgs[0] === null) {
                        $trackedArgs[0] = 0;
                    } elseif ($trackedArgs[0] === 0) {
                        return false;
                    }
                    $eArgs = array($trackedArgs[0]);
                    if (!empty($args[1])) {
                        $eArgs[] = 'MATCH';
                        $eArgs[] = $args[1];
                    }
                    if (!empty($args[2])) {
                        $eArgs[] = 'COUNT';
                        $eArgs[] = $args[2];
                    }
                    $args = $eArgs;
                    break;
                case 'sscan':
                case 'zscan':
                case 'hscan':
                    $trackedArgs = array(&$args[1]);
                    if ($trackedArgs[0] === null) {
                        $trackedArgs[0] = 0;
                    } elseif ($trackedArgs[0] === 0) {
                        return false;
                    }
                    $eArgs = array($args[0], $trackedArgs[0]);
                    if (!empty($args[2])) {
                        $eArgs[] = 'MATCH';
                        $eArgs[] = $args[2];
                    }
                    if (!empty($args[3])) {
                        $eArgs[] = 'COUNT';
                        $eArgs[] = $args[3];
                    }
                    $args = $eArgs;
                    break;
                case 'zrangebyscore':
                case 'zrevrangebyscore':
                case 'zrange':
                case 'zrevrange':
                    if (isset($args[3]) && is_array($args[3])) {
                        // map options
                        $cArgs = array();
                        if (!empty($args[3]['withscores'])) {
                            $cArgs[] = 'withscores';
                        }
                        if (($name == 'zrangebyscore' || $name == 'zrevrangebyscore') && array_key_exists('limit', $args[3])) {
                            $cArgs[] = array('limit' => $args[3]['limit']);
                        }
                        $args[3] = $cArgs;
                        $trackedArgs = $cArgs;
                    }
                    break;
                case 'mget':
                    if (isset($args[0]) && is_array($args[0])) {
                        $args = array_values($args[0]);
                    }
                    if (is_array($args) && count($args) === 0) {
                        return ($this->isMulti || $this->usePipeline) ? $this : false;
                    }
                    break;
                case 'hmset':
                    if (isset($args[1]) && is_array($args[1])) {
                        $cArgs = array();
                        foreach ($args[1] as $id => $value) {
                            $cArgs[] = $id;
                            $cArgs[] = $value;
                        }
                        $args[1] = $cArgs;
                    }
                    break;
                case 'zsize':
                    $name = 'zcard';
                    break;
                case 'zdelete':
                    $name = 'zrem';
                    break;
                case 'hmget':
                    // hmget needs to track the keys for rehydrating the results
                    if (isset($args[1])) {
                        $trackedArgs = $args[1];
                    }
                    break;
                case 'multi':
                    // calling multi() multiple times is a no-op
                    if ($this->isMulti) {
                        return $this;
                    }
                    break;
            }
            // Flatten arguments
            $args = self::_flattenArguments($args);

            // In pipeline mode
            if ($this->usePipeline) {
                if ($name === 'pipeline') {
                    throw new CredisException('A pipeline is already in use and only one pipeline is supported.');
                } elseif ($name === 'exec') {
                    if ($this->isMulti) {
                        $this->commandNames[] = array($name, $trackedArgs, true);
                        $this->commands .= self::_prepare_command(array($this->getRenamedCommand($name)));
                    }

                    try {
                        // Write request
                        if ($this->commands) {
                            $this->write_command($this->commands);
                        }

                        // Read response
                        $queuedResponses = array();
                        $response = array();
                        foreach ($this->commandNames as $command) {
                            list($name, $arguments, $requireDispatch) = $command;
                            if (!$requireDispatch) {
                                $queuedResponses[] = $command;
                                continue;
                            }
                            $result = $this->read_reply($name, true);
                            if ($result !== null) {
                                if ($name === 'multi') {
                                    continue;
                                }
                                $result = $this->decode_reply($name, $result, $arguments);
                                $response[] = $result;
                            } else {
                                $queuedResponses[] = $command;
                            }
                        }

                        if ($this->isMulti) {
                            $execResponse = array_pop($response);
                            if (!empty($execResponse)) {
                                foreach ($queuedResponses as $key => $command) {
                                    list($name, $arguments) = $command;
                                    $response[] = $this->decode_reply($name, $execResponse[$key], $arguments);
                                }
                            }
                        }
                    } catch (CredisException $e) {
                        // the connection on redis's side is likely in a bad state, force it closed to abort the pipeline/transaction
                        $this->close(true);
                        throw $e;
                    } finally {
                        $this->commands = $this->commandNames = null;
                        $this->isMulti = $this->usePipeline = false;
                    }
                    return $response;
                } elseif ($name === 'discard') {
                    $this->commands = null;
                    $this->commandNames = null;
                    $this->usePipeline = $this->isMulti = false;
                } else {
                    if ($name === 'multi') {
                        $this->isMulti = true;
                    }
                    array_unshift($args, $this->getRenamedCommand($name));
                    $this->commandNames[] = array($name, $trackedArgs, true);
                    $this->commands .= self::_prepare_command($args);
                    return $this;
                }
            }

            // Start pipeline mode
            if ($name === 'pipeline') {
                $this->usePipeline = true;
                if (!$this->isMulti) {
                    $this->commandNames = [];
                }
                $this->commands = '';
                return $this;
            }

            // If unwatching, allow reconnect with no error thrown
            if ($name === 'unwatch') {
                $this->isWatching = false;
            }

            // Non-pipeline mode
            array_unshift($args, $this->getRenamedCommand($name));
            $command = self::_prepare_command($args);
            // transaction mode needs to track commands
            if ($this->isMulti) {
                try {
                    if ($name === 'exec' || $name === 'discard') {
                        try {
                            $this->write_command($command);
                            $response = $this->read_reply($name);
                            $response = $this->decode_reply($name, $response, $trackedArgs);
                        } finally {
                            $this->isMulti = false;
                            $this->commandNames = [];
                        }
                    } else {
                        $this->commandNames[] = array($name, $trackedArgs, false);
                        $this->write_command($command);
                        $response = $this->read_reply($name);
                    }
                } catch (CredisException $e) {
                    // the connection on redis's side is likely in a bad state, force it closed to abort the transaction
                    $this->isMulti = false;
                    $this->commandNames = [];
                    $this->close(true);
                    throw $e;
                }
            } else {
                $this->write_command($command);
                $response = $this->read_reply($name);
                $response = $this->decode_reply($name, $response, $trackedArgs);
            }

            // Watch mode disables reconnect so error is thrown
            if ($name === 'watch') {
                $this->isWatching = true;
            } // Started transaction
            elseif ($this->isMulti || $name === 'multi') {
                $this->isMulti = true;
                $response = $this;
            }
        } // Send request via phpredis client
        else {
            // Tweak arguments
            switch ($name) {
                case 'get':   // optimize common cases
                case 'set':
                case 'hget':
                case 'hset':
                case 'setex':
                case 'mset':
                case 'msetnx':
                case 'hmset':
                case 'hmget':
                case 'zrangebyscore':
                case 'zrevrangebyscore':
                    break;
                case 'zrange':
                case 'zrevrange':
                    if (isset($args[3]) && is_array($args[3])) {
                        $cArgs = $args[3];
                        $args[3] = !empty($cArgs['withscores']);
                    }
                    $args = self::_flattenArguments($args);
                    break;
                case 'zinterstore':
                case 'zunionstore':
                    $cArgs = array();
                    $cArgs[] = array_shift($args); // destination
                    $cArgs[] = array_shift($args); // keys
                    if (isset($args[0]) and isset($args[0]['weights'])) {
                        $cArgs[] = (array)$args[0]['weights'];
                    } else {
                        $cArgs[] = null;
                    }
                    if (isset($args[0]) and isset($args[0]['aggregate'])) {
                        $cArgs[] = strtoupper($args[0]['aggregate']);
                    }
                    $args = $cArgs;
                    break;
                case 'mget':
                    if (isset($args[0]) && !is_array($args[0])) {
                        $args = array($args);
                    }
                    break;
                case 'lrem':
                    $args = array($args[0], $args[2], $args[1]);
                    break;
                case 'eval':
                case 'evalsha':
                    if (isset($args[1]) && is_array($args[1])) {
                        $cKeys = $args[1];
                    } elseif (isset($args[1]) && is_string($args[1])) {
                        $cKeys = array($args[1]);
                    } else {
                        $cKeys = array();
                    }
                    if (isset($args[2]) && is_array($args[2])) {
                        $cArgs = $args[2];
                    } elseif (isset($args[2]) && is_string($args[2])) {
                        $cArgs = array($args[2]);
                    } else {
                        $cArgs = array();
                    }
                    $args = array($args[0], array_merge($cKeys, $cArgs), count($cKeys));
                    break;
                case 'subscribe':
                case 'psubscribe':
                    break;
                case 'scan':
                case 'sscan':
                case 'hscan':
                case 'zscan':
                    // allow phpredis to see the caller's reference
                    //$param_ref =& $args[0];
                    break;
                case 'auth':
                    // For phpredis pre-v5.3, the type signature is string, not array|string
                    $args = $this->oldPhpRedis ? $args : array($args);
                    break;
                default:
                    // Flatten arguments
                    $args = self::_flattenArguments($args);
            }

            try {
                // Proxy pipeline mode to the phpredis library
                if ($name == 'pipeline' || $name == 'multi') {
                    if (!$this->isMulti) {
                        $this->isMulti = true;
                        $this->redisMulti = call_user_func_array(array($this->redis, $name), $args);
                    }
                    return $this;
                } elseif ($name == 'exec' || $name == 'discard') {
                    $this->isMulti = false;
                    $response = $this->redisMulti->$name();
                    $this->redisMulti = null;
                    return $response;
                }

                // Use aliases to be compatible with phpredis wrapper
                if (isset($this->wrapperMethods[$name])) {
                    $name = $this->wrapperMethods[$name];
                }

                // Multi and pipeline return self for chaining
                if ($this->isMulti) {
                    call_user_func_array(array($this->redisMulti, $name), $args);
                    return $this;
                }


                // Send request, retry one time when using persistent connections on the first request only
                $this->requests++;
                try {
                    $response = call_user_func_array(array($this->redis, $name), $args);
                } catch (RedisException $e) {
                    if ($this->persistent && $this->requests == 1 && $e->getMessage() == 'read error on connection') {
                        $this->close(true);
                        $this->connect();
                        $response = call_user_func_array(array($this->redis, $name), $args);
                    } else {
                        throw $e;
                    }
                }
            } // Wrap exceptions
            catch (RedisException $e) {
                $code = 0;
                try {
                    if (!($result = $this->redis->IsConnected())) {
                        $this->close(true);
                        $code = CredisException::CODE_DISCONNECTED;
                    }
                } catch (RedisException $e2) {
                    throw new CredisException($e2->getMessage(), $e2->getCode(), $e2);
                }
                throw new CredisException($e->getMessage(), $code, $e);
            }

            #echo "> $name : ".substr(print_r($response, TRUE),0,100)."\n";

            // change return values where it is too difficult to minim in standalone mode
            try {
                switch ($name) {
                    case 'type':
                        $typeMap = array(
                            self::TYPE_NONE,
                            self::TYPE_STRING,
                            self::TYPE_SET,
                            self::TYPE_LIST,
                            self::TYPE_ZSET,
                            self::TYPE_HASH,
                        );
                        $response = $typeMap[$response];
                        break;

                    case 'eval':
                    case 'evalsha':
                    case 'script':
                        $error = $this->redis->getLastError();
                        $this->redis->clearLastError();
                        if ($error && substr($error, 0, 8) == 'NOSCRIPT') {
                            $response = null;
                        } elseif ($error) {
                            throw new CredisException($error);
                        }
                        break;
                    case 'exists':
                        // smooth over phpredis-v4 vs earlier difference to match documented credis return results
                        $response = (int)$response;
                        break;
                    case 'ping':
                        if ($response) {
                            if ($response === true) {
                                $response = isset($args[0]) ? $args[0] : "PONG";
                            } elseif ($response[0] === '+') {
                                $response = substr($response, 1);
                            }
                        }
                        break;
                    case 'auth':
                        if (is_bool($response) && $response === true) {
                            $this->redis->clearLastError();
                        }
                        // no break
                    default:
                        $error = $this->redis->getLastError();
                        $this->redis->clearLastError();
                        if ($error) {
                            throw new CredisException(rtrim($error));
                        }
                        break;
                }
            } catch (RedisException $e) {
                throw new CredisException($e->getMessage(), $e->getCode(), $e);
            }
        }

        return $response;
    }

    /**
     * @throws CredisException
     */
    protected function write_command($command)
    {
        // Reconnect on lost connection (Redis server "timeout" exceeded since last command)
        if (feof($this->redis)) {
            // If a watch or transaction was in progress and connection was lost, throw error rather than reconnect
            // since transaction/watch state will be lost.
            if (($this->isMulti && !$this->usePipeline) || $this->isWatching) {
                $this->close(true);
                throw new CredisException('Lost connection to Redis server during watch or transaction.');
            }
            $this->close(true);
            $this->connect();
            if ($this->authPassword) {
                $this->auth($this->authPassword);
            }
            if ($this->selectedDb != 0) {
                $this->select($this->selectedDb);
            }
        }

        $commandLen = strlen($command);
        $lastFailed = false;
        for ($written = 0; $written < $commandLen; $written += $fwrite) {
            $fwrite = fwrite($this->redis, substr($command, $written));
            if ($fwrite === false || ($fwrite == 0 && $lastFailed)) {
                $this->close(true);
                throw new CredisException('Failed to write entire command to stream');
            }
            $lastFailed = $fwrite == 0;
        }
    }

    /**
     * @throws CredisException
     */
    protected function read_reply($name = '', $returnQueued = false)
    {
        $reply = fgets($this->redis);
        if ($reply === false) {
            $info = stream_get_meta_data($this->redis);
            $this->close(true);
            if ($info['timed_out']) {
                throw new CredisException('Read operation timed out.', CredisException::CODE_TIMED_OUT);
            } else {
                throw new CredisException('Lost connection to Redis server.', CredisException::CODE_DISCONNECTED);
            }
        }
        $reply = rtrim($reply, "\r\n");
        #echo "> $name: $reply\n";
        $replyType = substr($reply, 0, 1);
        switch ($replyType) {
            /* Error reply */
            case '-':
                if ($this->isMulti || $this->usePipeline) {
                    $response = false;
                } elseif ($name == 'evalsha' && substr($reply, 0, 9) == '-NOSCRIPT') {
                    $response = null;
                } else {
                    throw new CredisException(substr($reply, 0, 4) == '-ERR' ? 'ERR ' . substr($reply, 5) : substr($reply, 1));
                }
                break;
                /* Inline reply */
            case '+':
                $response = substr($reply, 1);
                if ($response == 'OK') {
                    return true;
                }
                if ($response == 'QUEUED') {
                    return $returnQueued ? null : true;
                }
                break;
                /* Bulk reply */
            case '$':
                if ($reply == '$-1') {
                    return false;
                }
                $size = (int)substr($reply, 1);
                $response = stream_get_contents($this->redis, $size + 2);
                if (!$response) {
                    $this->close(true);
                    throw new CredisException('Error reading reply.');
                }
                $response = substr($response, 0, $size);
                break;
                /* Multi-bulk reply */
            case '*':
                $count = substr($reply, 1);
                if ($count == '-1') {
                    return false;
                }

                $response = array();
                for ($i = 0; $i < $count; $i++) {
                    $response[] = $this->read_reply();
                }
                break;
                /* Integer reply */
            case ':':
                $response = intval(substr($reply, 1));
                break;
            default:
                throw new CredisException('Invalid response: ' . print_r($reply, true));
        }

        return $response;
    }

    /**
     * @throws CredisException
     */
    protected function decode_reply($name, $response, array &$arguments = array())
    {
        // Smooth over differences between phpredis and standalone response
        switch ($name) {
            case '': // Minor optimization for multi-bulk replies
                break;
            case 'config':
            case 'hgetall':
                $keys = $values = array();
                while ($response) {
                    $keys[] = array_shift($response);
                    $values[] = array_shift($response);
                }
                $response = count($keys) ? array_combine($keys, $values) : array();
                break;
            case 'info':
                $lines = explode("\r\n", trim($response, "\r\n"));
                $response = array();
                foreach ($lines as $line) {
                    if (!$line || substr($line, 0, 1) == '#') {
                        continue;
                    }
                    list($key, $value) = explode(':', $line, 2);
                    $response[$key] = $value;
                }
                break;
            case 'ttl':
                if ($response === -1) {
                    $response = false;
                }
                break;
            case 'hmget':
                if (count($arguments) != count($response)) {
                    throw new CredisException(
                        'hmget arguments and response do not match: ' . print_r($arguments, true) . ' ' . print_r(
                            $response,
                            true
                        )
                    );
                }
                // rehydrate results into key => value form
                $response = array_combine($arguments, $response);
                break;

            case 'scan':
            case 'sscan':
                $arguments[0] = intval(array_shift($response));
                $response = empty($response[0]) ? array() : $response[0];
                break;
            case 'hscan':
            case 'zscan':
                $arguments[0] = intval(array_shift($response));
                $response = empty($response[0]) ? array() : $response[0];
                if (!empty($response) && is_array($response)) {
                    $count = count($response);
                    $out = array();
                    for ($i = 0; $i < $count; $i += 2) {
                        $out[$response[$i]] = $response[$i + 1];
                    }
                    $response = $out;
                }
                break;
            case 'zrangebyscore':
            case 'zrevrangebyscore':
            case 'zrange':
            case 'zrevrange':
                if (in_array('withscores', $arguments, true)) {
                    // Map array of values into key=>score list like phpRedis does
                    $item = null;
                    $out = array();
                    foreach ($response as $value) {
                        if ($item == null) {
                            $item = $value;
                        } else {
                            // 2nd value is the score
                            $out[$item] = (float)$value;
                            $item = null;
                        }
                    }
                    $response = $out;
                }
                break;
        }

        return $response;
    }

    /**
     * Build the Redis unified protocol command
     *
     * @param array $args
     * @return string
     */
    private static function _prepare_command($args)
    {
        return sprintf('*%d%s%s%s', count($args), "\r\n", implode("\r\n", array_map([static::class, '_map'], $args)), "\r\n");
    }

    private static function _map($arg)
    {
        return sprintf('$%d%s%s', strlen((string)$arg), "\r\n", $arg);
    }

    /**
     * Flatten arguments
     *
     * If an argument is an array, the key is inserted as argument followed by the array values
     *  array('zrangebyscore', '-inf', 123, array('limit' => array('0', '1')))
     * becomes
     *  array('zrangebyscore', '-inf', 123, 'limit', '0', '1')
     *
     * @param array $arguments
     * @param array $out
     * @return array
     */
    private static function _flattenArguments(array $arguments, &$out = array())
    {
        foreach ($arguments as $key => $arg) {
            if (!is_int($key)) {
                $out[] = $key;
            }

            if (is_array($arg)) {
                self::_flattenArguments($arg, $out);
            } else {
                $out[] = $arg;
            }
        }

        return $out;
    }
}
