<?php

/*
 * This file is part of the Predis package.
 *
 * (c) 2009-2020 Daniele Alessandri
 * (c) 2021-2026 Till Krüss
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Predis;

use Exception;
use Iterator;
use PHPUnit\Framework\MockObject\MockObject;
use Predis\Command\Factory as CommandFactory;
use Predis\Command\Processor\KeyPrefixProcessor;
use Predis\Command\RawCommand;
use Predis\Connection\Cluster\RedisCluster;
use Predis\Connection\Factory;
use Predis\Connection\NodeConnectionInterface;
use Predis\Connection\Parameters;
use Predis\Connection\ParametersInterface;
use Predis\Connection\Replication\MasterSlaveReplication;
use Predis\Connection\Replication\SentinelReplication;
use Predis\Connection\Resource\StreamFactoryInterface;
use Predis\Connection\StreamConnection;
use Predis\Retry\Retry;
use Predis\Retry\Strategy\ExponentialBackoff;
use PredisTestCase;
use Psr\Http\Message\StreamInterface;
use ReflectionProperty;
use RuntimeException;
use stdClass;

class ClientTest extends PredisTestCase
{
    /**
     * @group disconnected
     */
    public function testConstructorWithoutArguments(): void
    {
        $client = new Client();

        /** @var NodeConnectionInterface */
        $connection = $client->getConnection();
        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);

        $parameters = $connection->getParameters();
        $this->assertSame($parameters->host, '127.0.0.1');
        $this->assertSame($parameters->port, 6379);

        $options = $client->getOptions();
        $this->assertSame($options->commands, $client->getCommandFactory());

        $this->assertFalse($client->isConnected());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithNullArgument(): void
    {
        $client = new Client(null);

        /** @var NodeConnectionInterface */
        $connection = $client->getConnection();
        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);

        $parameters = $connection->getParameters();
        $this->assertSame($parameters->host, '127.0.0.1');
        $this->assertSame($parameters->port, 6379);

        $options = $client->getOptions();
        $this->assertSame($options->commands, $client->getCommandFactory());

        $this->assertFalse($client->isConnected());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithNullAndNullArguments(): void
    {
        $client = new Client(null, null);

        /** @var NodeConnectionInterface */
        $connection = $client->getConnection();
        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);

        $parameters = $connection->getParameters();
        $this->assertSame($parameters->host, '127.0.0.1');
        $this->assertSame($parameters->port, 6379);

        $options = $client->getOptions();
        $this->assertSame($options->commands, $client->getCommandFactory());

        $this->assertFalse($client->isConnected());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithArrayArgument(): void
    {
        $client = new Client($arg1 = ['host' => 'localhost', 'port' => 7000]);

        /** @var NodeConnectionInterface */
        $connection = $client->getConnection();
        $parameters = $connection->getParameters();

        $this->assertSame($parameters->host, $arg1['host']);
        $this->assertSame($parameters->port, $arg1['port']);
    }

    /**
     * @group disconnected
     */
    public function testConstructorThrowsExceptionWithArrayOfParametersArgumentAndMissingOption(): void
    {
        $this->expectException('InvalidArgumentException');
        $this->expectExceptionMessage('Array of connection parameters requires `cluster`, `replication` or `aggregate` client option');

        $arg1 = [
            ['host' => 'localhost', 'port' => 7000],
            ['host' => 'localhost', 'port' => 7001],
        ];

        $client = new Client($arg1);
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithArrayOfArrayArgumentAndClusterOption(): void
    {
        $arg1 = [
            ['host' => 'localhost', 'port' => 7000],
            ['host' => 'localhost', 'port' => 7001],
        ];

        $client = new Client($arg1, [
            'aggregate' => $this->getAggregateInitializer($arg1),
        ]);

        $this->assertInstanceOf('Predis\Connection\AggregateConnectionInterface', $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithStringArgument(): void
    {
        $client = new Client('tcp://localhost:7000');

        /** @var NodeConnectionInterface */
        $connection = $client->getConnection();
        $parameters = $connection->getParameters();

        $this->assertSame($parameters->host, 'localhost');
        $this->assertSame($parameters->port, 7000);
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithArrayOfStringArgument(): void
    {
        $arg1 = ['tcp://localhost:7000', 'tcp://localhost:7001'];

        $client = new Client($arg1, [
            'aggregate' => $this->getAggregateInitializer($arg1),
        ]);

        $this->assertInstanceOf('Predis\Connection\AggregateConnectionInterface', $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithArrayOfConnectionsArgument(): void
    {
        $arg1 = [
            $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock(),
            $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock(),
        ];

        $client = new Client($arg1, [
            'aggregate' => $this->getAggregateInitializer($arg1),
        ]);

        $this->assertInstanceOf('Predis\Connection\AggregateConnectionInterface', $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithConnectionArgument(): void
    {
        $factory = new Factory();
        $connection = $factory->create('tcp://localhost:7000');

        $client = new Client($connection);

        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $client->getConnection());
        $this->assertSame($connection, $client->getConnection());

        /** @var NodeConnectionInterface */
        $connection = $client->getConnection();
        $parameters = $connection->getParameters();

        $this->assertSame($parameters->host, 'localhost');
        $this->assertSame($parameters->port, 7000);
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithClusterArgument(): void
    {
        $cluster = new Connection\Cluster\PredisCluster(new Parameters());

        $factory = new Factory();
        $cluster->add($factory->create('tcp://localhost:7000'));
        $cluster->add($factory->create('tcp://localhost:7001'));

        $client = new Client($cluster);

        $this->assertInstanceOf('Predis\Connection\Cluster\ClusterInterface', $client->getConnection());
        $this->assertSame($cluster, $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithReplicationArgument(): void
    {
        $replication = new MasterSlaveReplication();

        $factory = new Factory();
        $replication->add($factory->create('tcp://host1?alias=master'));
        $replication->add($factory->create('tcp://host2?alias=slave'));

        $client = new Client($replication);

        $this->assertInstanceOf('Predis\Connection\Replication\ReplicationInterface', $client->getConnection());
        $this->assertSame($replication, $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithCallableArgument(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();

        $callable = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $callable
            ->expects($this->once())
            ->method('__invoke')
            ->with($this->isInstanceOf('Predis\Configuration\OptionsInterface'))
            ->willReturn($connection);

        $client = new Client($callable);

        $this->assertSame($connection, $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithCallableConnectionInitializerThrowsExceptionOnInvalidReturnType(): void
    {
        $this->expectException('InvalidArgumentException');
        $this->expectExceptionMessage('Callable parameters must return a valid connection');

        $wrongType = $this->getMockBuilder('stdClass')->getMock();

        $callable = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $callable
            ->expects($this->once())
            ->method('__invoke')
            ->with($this->isInstanceOf('Predis\Configuration\OptionsInterface'))
            ->willReturn($wrongType);

        new Client($callable);
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithNullAndArrayArgument(): void
    {
        $connections = $this->getMockBuilder('Predis\Connection\FactoryInterface')->getMock();

        $arg2 = ['prefix' => 'prefix:', 'connections' => $connections];
        $client = new Client(null, $arg2);

        /** @var CommandFactory */
        $commands = $client->getCommandFactory();
        $this->assertInstanceOf('Predis\Command\FactoryInterface', $commands);

        /** @var KeyPrefixProcessor */
        $processor = $commands->getProcessor();
        $this->assertInstanceOf('Predis\Command\Processor\KeyPrefixProcessor', $processor);
        $this->assertSame('prefix:', $processor->getPrefix());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithArrayAndOptionReplication(): void
    {
        $arg1 = ['tcp://127.0.0.1:6379?role=master', 'tcp://127.0.0.1:6380?role=slave'];
        $arg2 = ['replication' => 'predis'];
        $client = new Client($arg1, $arg2);

        /** @var MasterSlaveReplication */
        $connection = $client->getConnection();

        $this->assertInstanceOf('Predis\Connection\Replication\ReplicationInterface', $connection);
        $this->assertSame('127.0.0.1:6379', (string) $connection->getConnectionByRole('master'));
        $this->assertSame('127.0.0.1:6380', (string) $connection->getConnectionByRole('slave'));
    }

    /**
     * @group disconnected
     */
    public function testClusterOptionHasPrecedenceOverReplicationOptionAndAggregateOption(): void
    {
        $arg1 = ['tcp://host1', 'tcp://host2'];

        $connection = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')->getMock();

        $fncluster = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $fncluster
            ->expects($this->once())
            ->method('__invoke')
            ->with(
                $arg1,
                $this->isInstanceOf('Predis\Configuration\OptionsInterface'),
                $this->isInstanceOf('Predis\Configuration\OptionInterface')
            )
            ->willReturn($connection);

        $fnreplication = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $fnreplication
            ->expects($this->never())
            ->method('__invoke');

        $fnaggregate = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $fnaggregate
            ->expects($this->never())
            ->method('__invoke');

        $arg2 = [
            'cluster' => $fncluster,
            'replication' => $fnreplication,
            'aggregate' => $fnaggregate,
        ];

        $client = new Client($arg1, $arg2);

        $this->assertSame($connection, $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testReplicationOptionHasPrecedenceOverAggregateOption(): void
    {
        $arg1 = ['tcp://host1', 'tcp://host2'];

        $connection = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')->getMock();

        $fnreplication = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $fnreplication
            ->expects($this->once())
            ->method('__invoke')
            ->with(
                $arg1,
                $this->isInstanceOf('Predis\Configuration\OptionsInterface'),
                $this->isInstanceOf('Predis\Configuration\OptionInterface')
            )
            ->willReturn($connection);

        $fnaggregate = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $fnaggregate
            ->expects($this->never())
            ->method('__invoke');

        $arg2 = [
            'replication' => $fnreplication,
            'aggregate' => $fnaggregate,
        ];

        $client = new Client($arg1, $arg2);
    }

    /**
     * @group disconnected
     */
    public function testAggregateOptionDoesNotTriggerAggregationInClient(): void
    {
        $arg1 = ['tcp://host1', 'tcp://host2'];

        $connections = $this->getMockBuilder('Predis\Connection\FactoryInterface')->getMock();
        $connections
            ->expects($this->never())
            ->method('create');

        $connection = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')
            ->getMock();
        $connection
            ->expects($this->never())
            ->method('add');

        $fnaggregate = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $fnaggregate
            ->expects($this->once())
            ->method('__invoke')
            ->with(
                $arg1,
                $this->isInstanceOf('Predis\Configuration\OptionsInterface'),
                $this->isInstanceOf('Predis\Configuration\OptionInterface')
            )
            ->willReturn($connection);

        $arg2 = ['aggregate' => $fnaggregate, 'connections' => $connections];

        $client = new Client($arg1, $arg2);

        $this->assertSame($connection, $client->getConnection());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithInvalidArgumentType(): void
    {
        $this->expectException('InvalidArgumentException');
        $this->expectExceptionMessage('Invalid type for connection parameters');

        $client = new Client(new stdClass());
    }

    /**
     * @group disconnected
     */
    public function testConstructorWithInvalidOptionType(): void
    {
        $this->expectException('InvalidArgumentException');
        $this->expectExceptionMessage('Invalid type for client options');

        $client = new Client('tcp://host1', new stdClass());
    }

    /**
     * @group disconnected
     */
    public function testConnectAndDisconnect(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('connect');
        $connection
            ->expects($this->once())
            ->method('disconnect');

        $client = new Client($connection);
        $client->connect();
        $client->disconnect();
    }

    /**
     * @group disconnected
     */
    public function testIsConnectedChecksConnectionState(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('isConnected');

        $client = new Client($connection);
        $client->isConnected();
    }

    /**
     * @group disconnected
     */
    public function testQuitIsAliasForDisconnect(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('disconnect');

        $client = new Client($connection);
        $client->quit();
    }

    /**
     * @group disconnected
     */
    public function testCreatesNewCommandUsingSpecifiedCommandFactory(): void
    {
        $ping = $this->getCommandFactory()->create('ping', []);

        $commands = $this->getMockBuilder('Predis\Command\FactoryInterface')->getMock();
        $commands
            ->expects($this->once())
            ->method('create')
            ->with('ping', [])
            ->willReturn($ping);

        $client = new Client(null, ['commands' => $commands]);
        $this->assertSame($ping, $client->createCommand('ping', []));
    }

    /**
     * @group disconnected
     */
    public function testExecuteCommandReturnsParsedResponses(): void
    {
        $commands = $this->getCommandFactory();

        $ping = $commands->create('ping', []);
        $hgetall = $commands->create('hgetall', ['metavars', 'foo', 'hoge']);

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->exactly(2))
            ->method('executeCommand')
            ->withConsecutive(
                [$ping],
                [$hgetall]
            )
            ->willReturnOnConsecutiveCalls(
                new Response\Status('PONG'),
                ['foo', 'bar', 'hoge', 'piyo']
            );

        $connection
            ->expects($this->exactly(2))
            ->method('getParameters')
            ->willReturn(new Parameters(['protocol' => 2]));

        $client = new Client($connection);

        $this->assertEquals('PONG', $client->executeCommand($ping));
        $this->assertSame(['foo' => 'bar', 'hoge' => 'piyo'], $client->executeCommand($hgetall));
    }

    /**
     * @group disconnected
     */
    public function testExecuteCommandReturnsResp3ParsedResponses(): void
    {
        $commands = $this->getCommandFactory();

        $ping = $commands->create('ping', []);
        $get = $commands->create('get', []);

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->exactly(2))
            ->method('executeCommand')
            ->withConsecutive(
                [$ping],
                [$get]
            )
            ->willReturnOnConsecutiveCalls(
                new Response\Status('PONG'),
                []
            );

        $connection
            ->expects($this->exactly(2))
            ->method('getParameters')
            ->willReturn(new Parameters(['protocol' => 3]));

        $client = new Client($connection);

        $this->assertEquals('PONG', $client->executeCommand($ping));
        $this->assertSame([], $client->executeCommand($get));
    }

    /**
     * @group disconnected
     */
    public function testExecuteCommandThrowsExceptionOnRedisError(): void
    {
        $this->expectException('Predis\Response\ServerException');
        $this->expectExceptionMessage('Operation against a key holding the wrong kind of value');

        $ping = $this->getCommandFactory()->create('ping', []);
        $expectedResponse = new Response\Error('ERR Operation against a key holding the wrong kind of value');

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('executeCommand')
            ->willReturn($expectedResponse);
        $connection
            ->expects($this->once())
            ->method('getParameters')
            ->willReturn(new Parameters());

        $client = new Client($connection);
        $client->executeCommand($ping);
    }

    /**
     * @group disconnected
     */
    public function testExecuteCommandReturnsErrorResponseOnRedisError(): void
    {
        $ping = $this->getCommandFactory()->create('ping', []);
        $expectedResponse = new Response\Error('ERR Operation against a key holding the wrong kind of value');

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('executeCommand')
            ->willReturn($expectedResponse);
        $connection
            ->expects($this->once())
            ->method('getParameters')
            ->willReturn(new Parameters());

        $client = new Client($connection, ['exceptions' => false]);
        $response = $client->executeCommand($ping);

        $this->assertSame($response, $expectedResponse);
    }

    /**
     * @group disconnected
     */
    public function testCallingRedisCommandExecutesInstanceOfCommand(): void
    {
        $ping = $this->getCommandFactory()->create('ping', []);

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('executeCommand')
            ->with($this->isInstanceOf('Predis\Command\Redis\PING'))
            ->willReturn('PONG');

        $connection
            ->expects($this->once())
            ->method('getParameters')
            ->willReturn(new Parameters(['protocol' => 2]));

        $commands = $this->getMockBuilder('Predis\Command\FactoryInterface')->getMock();
        $commands
            ->expects($this->once())
            ->method('create')
            ->with('ping', [])
            ->willReturn($ping);

        $options = ['commands' => $commands];

        /** @var ClientInterface */
        $client = $this->getMockBuilder('Predis\Client')
            ->onlyMethods([])
            ->setConstructorArgs([$connection, $options])
            ->getMock();

        $this->assertEquals('PONG', $client->ping());
    }

    /**
     * @group disconnected
     */
    public function testCallingRedisCommandThrowsExceptionOnServerError(): void
    {
        $this->expectException('Predis\Response\ServerException');
        $this->expectExceptionMessage('Operation against a key holding the wrong kind of value');

        $expectedResponse = new Response\Error('ERR Operation against a key holding the wrong kind of value');

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('executeCommand')
            ->with($this->isRedisCommand('PING'))
            ->willReturn($expectedResponse);

        $connection
            ->expects($this->once())
            ->method('getParameters')
            ->willReturn(new Parameters());

        $client = new Client($connection);
        $client->ping();
    }

    /**
     * @group disconnected
     */
    public function testCallingRedisCommandReturnsErrorResponseOnRedisError(): void
    {
        $expectedResponse = new Response\Error('ERR Operation against a key holding the wrong kind of value');

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('executeCommand')
            ->with($this->isRedisCommand('PING'))
            ->willReturn($expectedResponse);
        $connection
            ->expects($this->once())
            ->method('getParameters')
            ->willReturn(new Parameters());

        $client = new Client($connection, ['exceptions' => false]);
        $response = $client->ping();

        $this->assertSame($response, $expectedResponse);
    }

    /**
     * @group disconnected
     */
    public function testRawCommand(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->exactly(3))
            ->method('executeCommand')
            ->withConsecutive(
                [$this->isRedisCommand('SET', ['foo', 'bar'])],
                [$this->isRedisCommand('GET', ['foo'])],
                [$this->isRedisCommand('PING')]
            )
            ->willReturnOnConsecutiveCalls(
                new Response\Status('OK'),
                'bar',
                'PONG'
            );

        $client = new Client($connection);

        $this->assertSame('OK', $client->executeRaw(['SET', 'foo', 'bar']));
        $this->assertSame('bar', $client->executeRaw(['GET', 'foo']));

        $error = true;  // $error is always populated by reference.
        $this->assertSame('PONG', $client->executeRaw(['PING'], $error));
        $this->assertFalse($error);
    }

    /**
     * @group disconnected
     */
    public function testRawCommandNeverAppliesPrefix(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->exactly(2))
            ->method('executeCommand')
            ->withConsecutive(
                [$this->isRedisCommand('SET', ['foo', 'bar'])],
                [$this->isRedisCommand('GET', ['foo'])]
            )
            ->willReturnOnConsecutiveCalls(
                new Response\Status('OK'),
                'bar'
            );

        $client = new Client($connection, ['prefix' => 'predis:']);

        $this->assertSame('OK', $client->executeRaw(['SET', 'foo', 'bar']));
        $this->assertSame('bar', $client->executeRaw(['GET', 'foo']));
    }

    /**
     * @group disconnected
     */
    public function testRawCommandNeverThrowsExceptions(): void
    {
        $message = 'ERR Mock error response';
        $response = new Response\Error($message);

        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('executeCommand')
            ->with($this->isRedisCommand('PING'))
            ->willReturn($response);

        $client = new Client($connection, ['exceptions' => true]);

        $this->assertSame($message, $client->executeRaw(['PING'], $error));
        $this->assertTrue($error);
    }

    /**
     * @group disconnected
     */
    public function testThrowsExceptionOnNonRegisteredRedisCommand(): void
    {
        $this->expectException('Predis\ClientException');
        $this->expectExceptionMessage('Command `INVALIDCOMMAND` is not a registered Redis command');

        $client = new Client();
        $client->invalidCommand();
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodReturnsInstanceOfSubclass(): void
    {
        /** @var Client */
        $client = $this->getMockBuilder('Predis\Client')
            ->onlyMethods([])
            ->setConstructorArgs([
                ['tcp://host1?alias=node01', 'tcp://host2?alias=node02'],
                ['cluster' => 'predis'],
            ])
            ->setMockClassName('SubclassedClient')
            ->getMock();

        $this->assertInstanceOf('SubclassedClient', $client->getClientBy('alias', 'node02'));
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodSupportsSelectingConnectionById(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();

        $aggregate = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')
            ->onlyMethods(['getConnectionById'])
            ->getMockForAbstractClass();
        $aggregate
            ->expects($this->once())
            ->method('getConnectionById')
            ->with('127.0.0.1:6379')
            ->willReturn($connection);

        $client = new Client($aggregate);
        $nodeClient = $client->getClientBy('id', '127.0.0.1:6379');

        $this->assertSame($connection, $nodeClient->getConnection());
        $this->assertSame($client->getOptions(), $nodeClient->getOptions());
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodThrowsExceptionSelectingConnectionByUnknownId(): void
    {
        $this->expectException('InvalidArgumentException');
        $this->expectExceptionMessage('Cannot find a connection by id matching `127.0.0.1:7000`');

        $aggregate = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')->getMock();
        $aggregate
            ->expects($this->once())
            ->method('getConnectionById')
            ->with('127.0.0.1:7000')
            ->willReturn(null);

        $client = new Client($aggregate);
        $client->getClientBy('id', '127.0.0.1:7000');
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodSupportsSelectingConnectionByAlias(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();

        $aggregate = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')
            ->addMethods(['getConnectionByAlias'])
            ->getMockForAbstractClass();
        $aggregate
            ->expects($this->once())
            ->method('getConnectionByAlias')
            ->with('myalias')
            ->willReturn($connection);

        $client = new Client($aggregate);
        $nodeClient = $client->getClientBy('alias', 'myalias');

        $this->assertSame($connection, $nodeClient->getConnection());
        $this->assertSame($client->getOptions(), $nodeClient->getOptions());
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodSupportsSelectingConnectionByKey(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();

        $aggregate = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')
            ->addMethods(['getConnectionByKey'])
            ->getMockForAbstractClass();
        $aggregate
            ->expects($this->once())
            ->method('getConnectionByKey')
            ->with('key:1')
            ->willReturn($connection);

        $client = new Client($aggregate);
        $nodeClient = $client->getClientBy('key', 'key:1');

        $this->assertSame($connection, $nodeClient->getConnection());
        $this->assertSame($client->getOptions(), $nodeClient->getOptions());
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodSupportsSelectingConnectionBySlot(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();

        $aggregate = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')
            ->addMethods(['getConnectionBySlot'])
            ->getMockForAbstractClass();
        $aggregate
            ->expects($this->once())
            ->method('getConnectionBySlot')
            ->with(5460)
            ->willReturn($connection);

        $client = new Client($aggregate);
        $nodeClient = $client->getClientBy('slot', 5460);

        $this->assertSame($connection, $nodeClient->getConnection());
        $this->assertSame($client->getOptions(), $nodeClient->getOptions());
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodSupportsSelectingConnectionByRole(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();

        $aggregate = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')
            ->addMethods(['getConnectionByRole'])
            ->getMockForAbstractClass();
        $aggregate
            ->expects($this->once())
            ->method('getConnectionByRole')
            ->with('master')
            ->willReturn($connection);

        $client = new Client($aggregate);
        $nodeClient = $client->getClientBy('role', 'master');

        $this->assertSame($connection, $nodeClient->getConnection());
        $this->assertSame($client->getOptions(), $nodeClient->getOptions());
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodSupportsSelectingConnectionByCommand(): void
    {
        $command = RawCommand::create('GET', 'key');
        $connection = $this->getMockBuilder('Predis\Connection\ConnectionInterface')->getMock();

        $aggregate = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')
            ->onlyMethods(['getConnectionByCommand'])
            ->getMockForAbstractClass();
        $aggregate
            ->expects($this->once())
            ->method('getConnectionByCommand')
            ->with($command)
            ->willReturn($connection);

        $client = new Client($aggregate);
        $nodeClient = $client->getClientBy('command', $command);

        $this->assertSame($connection, $nodeClient->getConnection());
        $this->assertSame($client->getOptions(), $nodeClient->getOptions());
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodThrowsExceptionWhenSelectingConnectionByUnknownType(): void
    {
        $this->expectException('InvalidArgumentException');
        $this->expectExceptionMessage('Invalid selector type: `unknown`');

        $client = new Client('tcp://127.0.0.1?alias=node01');

        $client->getClientBy('unknown', 'test');
    }

    /**
     * @group disconnected
     */
    public function testGetClientByMethodThrowsExceptionWhenConnectionDoesNotSupportSelectorType(): void
    {
        $this->expectException('InvalidArgumentException');
        $this->expectExceptionMessage('Selecting connection by id is not supported by Predis\Connection\StreamConnection');

        $client = new Client('tcp://127.0.0.1?alias=node01');

        $client->getClientBy('id', 'node01');
    }

    /**
     * @group disconnected
     */
    public function testPipelineWithoutArgumentsReturnsPipeline(): void
    {
        $client = new Client();

        $this->assertInstanceOf('Predis\Pipeline\Pipeline', $client->pipeline());
    }

    /**
     * @group disconnected
     */
    public function testPipelineWithArrayReturnsPipeline(): void
    {
        $client = new Client();

        $this->assertInstanceOf('Predis\Pipeline\Pipeline', $client->pipeline([]));
        $this->assertInstanceOf('Predis\Pipeline\Atomic', $client->pipeline(['atomic' => true]));
        $this->assertInstanceOf('Predis\Pipeline\FireAndForget', $client->pipeline(['fire-and-forget' => true]));
    }

    /**
     * @group disconnected
     */
    public function testPipelineWithCallableExecutesPipeline(): void
    {
        $callable = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $callable
            ->expects($this->once())
            ->method('__invoke')
            ->with($this->isInstanceOf('Predis\Pipeline\Pipeline'));

        $client = new Client();
        $client->pipeline($callable);
    }

    /**
     * @group disconnected
     */
    public function testPubSubLoopWithoutArgumentsReturnsPubSubConsumer(): void
    {
        $client = new Client();

        $this->assertInstanceOf('Predis\Consumer\PubSub\Consumer', $client->pubSubLoop());
    }

    /**
     * @group disconnected
     */
    public function testPubSubLoopWithArrayReturnsPubSubConsumerWithOptions(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock();
        $options = ['subscribe' => 'channel'];

        $client = new Client($connection);

        $this->assertInstanceOf('Predis\Consumer\PubSub\Consumer', $pubsub = $client->pubSubLoop($options));

        $reflection = new ReflectionProperty($pubsub, 'options');
        $reflection->setAccessible(true);

        $this->assertSame($options, $reflection->getValue($pubsub));
    }

    /**
     * @group disconnected
     */
    public function testPubSubLoopWithArrayAndCallableExecutesPubSub(): void
    {
        // NOTE: we use a subscribe count of 0 in the message payload to trick
        //       the context and forcing it to be closed since there are no more
        //       active subscriptions.
        $connection = $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('read')
            ->willReturn(['subscribe', 'channel', 0]);

        $callable = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $callable
            ->expects($this->once())
            ->method('__invoke');

        $client = new Client($connection);
        $this->assertNull($client->pubSubLoop(['subscribe' => 'channel'], $callable));
    }

    /**
     * @group disconnected
     */
    public function testPubSubLoopWithCallableReturningFalseStopsPubSubConsumer(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock();
        $connection
            ->expects($this->exactly(2))
            ->method('read')
            ->willReturnOnConsecutiveCalls(
                ['subscribe', 'channel', 1],
                ['unsubscribe', 'channel', 0]
            );
        $connection
            ->expects($this->exactly(2))
            ->method('writeRequest')
            ->withConsecutive(
                [$this->isRedisCommand('SUBSCRIBE')],
                [$this->isRedisCommand('UNSUBSCRIBE')]
            );

        $callable = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $callable
            ->expects($this->exactly(2))
            ->method('__invoke')
            ->withConsecutive(
                [
                    $this->isInstanceOf('Predis\Consumer\PubSub\Consumer'),
                    (object) ['kind' => 'subscribe', 'channel' => 'channel', 'payload' => 1],
                ],
                [
                    $this->isInstanceOf('Predis\Consumer\PubSub\Consumer'),
                    (object) ['kind' => 'unsubscribe', 'channel' => 'channel', 'payload' => 0],
                ]
            )
            ->willReturnOnConsecutiveCalls(
                false,
                null // <-- this value would be ignored as it is the callback to UNSUBSCRIBE
            );

        $client = new Client($connection);

        $this->assertNull($client->pubSubLoop(['subscribe' => 'channel'], $callable));
    }

    /**
     * @group disconnected
     */
    public function testTransactionWithoutArgumentsReturnsMultiExec(): void
    {
        $client = new Client();

        $this->assertInstanceOf('Predis\Transaction\MultiExec', $client->transaction());
    }

    /**
     * @group disconnected
     */
    public function testTransactionWithArrayReturnsMultiExecTransactionWithOptions(): void
    {
        $options = ['cas' => true, 'retry' => 3];

        $client = new Client();

        $this->assertInstanceOf('Predis\Transaction\MultiExec', $tx = $client->transaction($options));

        // I hate this part but reflection is the easiest way in this case.
        $property = new ReflectionProperty($tx, 'modeCAS');
        $property->setAccessible(true);
        $this->assertSame($options['cas'], $property->getValue($tx));

        $property = new ReflectionProperty($tx, 'attempts');
        $property->setAccessible(true);
        $this->assertSame($options['retry'], $property->getValue($tx));
    }

    /**
     * @group disconnected
     */
    public function testTransactionWithArrayAndCallableExecutesMultiExec(): void
    {
        // We use CAS here as we don't care about the actual MULTI/EXEC context.
        $options = ['cas' => true, 'retry' => 3];

        $connection = $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('executeCommand')
            ->willReturn(new Response\Status('QUEUED'));
        $connection
            ->expects($this->any())
            ->method('getParameters')
            ->willReturn(new Parameters());

        $callable = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $callable
            ->expects($this->once())
            ->method('__invoke')
            ->willReturnCallback(static function ($tx) { $tx->ping(); });

        $client = new Client($connection);
        $client->transaction($options, $callable);
    }

    /**
     * @group disconnected
     */
    public function testMonitorReturnsMonitorConsumer(): void
    {
        $connection = $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock();
        $connection
            ->expects($this->once())
            ->method('getParameters')
            ->willReturn(new Parameters(['protocol' => 2]));

        $client = new Client($connection);

        $this->assertInstanceOf('Predis\Monitor\Consumer', $monitor = $client->monitor());
    }

    /**
     * @group disconnected
     */
    public function testClientResendScriptCommandUsingEvalOnNoScriptErrors(): void
    {
        $luaScriptBody = 'return redis.call(\'exists\', KEYS[1])';

        $command = $this->getMockForAbstractClass('Predis\Command\ScriptCommand', [], '', true, true, true, ['parseResponse']);
        $command
            ->expects($this->once())
            ->method('getScript')
            ->willReturn($luaScriptBody);
        $command
            ->expects($this->once())
            ->method('parseResponse')
            ->with('OK')
            ->willReturn(true);

        $connection = $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock();
        $connection
            ->expects($this->exactly(2))
            ->method('executeCommand')
            ->withConsecutive(
                [$command],
                [$this->isRedisCommand('EVAL', [$luaScriptBody])]
            )
            ->willReturnOnConsecutiveCalls(
                new Response\Error('NOSCRIPT'),
                'OK'
            );

        $connection
            ->expects($this->exactly(2))
            ->method('getParameters')
            ->willReturn(new Parameters(['protocol' => 2]));

        $client = new Client($connection);

        $this->assertTrue($client->executeCommand($command));
    }

    /**
     * @group disconnected
     */
    public function testGetIteratorWithTraversableConnections(): void
    {
        $connection1 = $this->getMockConnection('tcp://127.0.0.1:6381');
        $connection2 = $this->getMockConnection('tcp://127.0.0.1:6382');
        $connection3 = $this->getMockConnection('tcp://127.0.0.1:6383');

        $aggregate = new Connection\Cluster\PredisCluster(new Parameters());

        $aggregate->add($connection1);
        $aggregate->add($connection2);
        $aggregate->add($connection3);

        $client = new Client($aggregate);

        /** @var Iterator */
        $iterator = $client->getIterator();

        $this->assertInstanceOf('\Predis\Client', $nodeClient = $iterator->current());
        $this->assertSame($connection1, $nodeClient->getConnection());
        $this->assertSame('127.0.0.1:6381', $iterator->key());

        $iterator->next();

        $this->assertInstanceOf('\Predis\Client', $nodeClient = $iterator->current());
        $this->assertSame($connection2, $nodeClient->getConnection());
        $this->assertSame('127.0.0.1:6382', $iterator->key());

        $iterator->next();

        $this->assertInstanceOf('\Predis\Client', $nodeClient = $iterator->current());
        $this->assertSame($connection3, $nodeClient->getConnection());
        $this->assertSame('127.0.0.1:6383', $iterator->key());
    }

    /**
     * @group disconnected
     */
    public function testGetIteratorWithNonTraversableConnectionNoException(): void
    {
        $connection = $this->getMockConnection('tcp://127.0.0.1:6381');
        $client = new Client($connection);

        /** @var Iterator */
        $iterator = $client->getIterator();

        $this->assertInstanceOf('\Predis\Client', $nodeClient = $iterator->current());
        $this->assertSame($connection, $nodeClient->getConnection());
        $this->assertSame('127.0.0.1:6381', $iterator->key());
    }

    /**
     * @group disconnected
     */
    public function testExecuteCommandRetryCommandOnRetryableException()
    {
        $mockStream = $this->getMockBuilder(StreamInterface::class)->getMock();
        $mockStreamFactory = $this->getMockBuilder(StreamFactoryInterface::class)->getMock();
        $parameters = new Parameters([
            'retry' => new Retry(new ExponentialBackoff(1000, 10000), 3),
        ]);

        $mockStream
            ->expects($this->atLeast(3))
            ->method('close')
            ->withAnyParameters();

        $mockStream
            ->expects($this->exactly(4))
            ->method('write')
            ->withAnyParameters()
            ->willReturnOnConsecutiveCalls(
                $this->throwException(new RuntimeException('', 2)),
                $this->throwException(new RuntimeException('', 2)),
                $this->throwException(new RuntimeException('', 2)),
                1000
            );

        $mockStream
            ->expects($this->once())
            ->method('read')
            ->withAnyParameters()
            ->willReturn("+PONG\r\n");

        $mockStreamFactory
            ->expects($this->exactly(4))
            ->method('createStream')
            ->withAnyParameters()
            ->willReturn($mockStream);

        $connection = new StreamConnection($parameters, $mockStreamFactory);
        $client = new Client($connection);
        $this->assertEquals('PONG', $client->ping());
    }

    /**
     * @group connected
     * @group relay-incompatible
     * @requiresRedisVersion >= 7.2.0
     */
    public function testSetClientInfoOnConnection(): void
    {
        $client = new Client($this->getParameters());
        $libName = $client->client('LIST')[0]['lib-name'];
        $libVer = $client->client('LIST')[0]['lib-ver'];

        $this->assertSame('predis', $libName);
        $this->assertSame(Client::VERSION, $libVer);
    }

    /**
     * @group connected
     * @requiresRedisVersion >= 5.0.0
     */
    public function testClientsCreateDifferentPersistentConnections(): void
    {
        $client1 = new Client($this->getParameters(['database' => 14, 'persistent' => true, 'conn_uid' => 1]));
        $client2 = new Client($this->getParameters(['database' => 15, 'persistent' => true, 'conn_uid' => 2]));

        $client1->set('foo', 'bar');
        $client2->set('foo', 'baz');

        $this->assertSame('bar', $client1->get('foo'));
        $this->assertSame('baz', $client2->get('foo'));
        $this->assertNotSame($client1->client('ID'), $client2->client('ID'));
        $client1->disconnect();
        $client2->disconnect();
    }

    /**
     * @group connected
     * @requiresRedisVersion >= 5.0.0
     */
    public function testClientsCreateSamePersistentConnections(): void
    {
        $client1 = new Client($this->getParameters(['persistent' => true]));
        $client2 = new Client($this->getParameters(['persistent' => true]));

        $client1->set('foo', 'bar');
        $client2->set('foo', 'baz');

        $this->assertSame('baz', $client2->get('foo'));
        $this->assertSame($client1->client('ID'), $client2->client('ID'));
        $client1->disconnect();
    }

    /**
     * @group connected
     * @group cluster
     * @requiresRedisVersion >= 2.0.0
     */
    public function testClusterClientsCreateDifferentPersistentConnections(): void
    {
        $client1 = new Client(
            $this->getDefaultParametersArray(),
            ['cluster' => 'redis', 'parameters' => ['persistent' => true, 'conn_uid' => 1]]
        );
        $client2 = new Client(
            $this->getDefaultParametersArray(),
            ['cluster' => 'redis', 'parameters' => ['persistent' => true, 'conn_uid' => 2]]
        );

        $client1->set('{shard1}foo', 'bar');
        $client2->set('{shard2}foo', 'baz');

        $this->assertSame('bar', $client1->get('{shard1}foo'));
        $this->assertSame('baz', $client2->get('{shard2}foo'));
        $client1->disconnect();
        $client2->disconnect();
    }

    /**
     * @group connected
     * @group cluster
     * @requiresRedisVersion >= 2.0.0
     * @requires PHP >= 7.4
     * @return void
     */
    public function testCreatesClusterWithRelayConnection(): void
    {
        $client = new Client($this->getDefaultParametersArray(), ['cluster' => 'redis', 'connections' => 'relay']);

        $this->assertEquals('OK', $client->set('key', 'value'));
        $this->assertSame('value', $client->get('key'));
    }

    /**
     * @group connected
     * @group unprotected
     * @requiresRedisVersion >= 2.0.0
     * @return void
     */
    public function testClientAuthenticationAgainstUnprotectedServer(): void
    {
        $client = new Client($this->getParameters());

        $this->assertEquals('OK', $client->set('key', 'value'));
        $this->assertSame('value', $client->get('key'));

        // AUTH doesn't throw exception if no authentication requires.
        $clientWithPassword = new Client($this->getParameters(
            ['password' => getenv('REDIS_PASSWORD') ?: constant('REDIS_PASSWORD')])
        );
        $this->assertEquals('OK', $clientWithPassword->set('key', 'value'));
        $this->assertSame('value', $clientWithPassword->get('key'));

        $this->assertEquals(
            'OK',
            $client->acl->setUser(
                'test_user',
                'on',
                '>foobar',
                'allcommands',
                'allkeys'
            )
        );

        $clientTestUser = new Client($this->getParameters(
            ['username' => 'test_user', 'password' => 'foobar'])
        );
        $this->assertEquals('test_user', $clientTestUser->acl->whoami());
        $this->assertEquals('OK', $clientTestUser->set('key', 'value'));
        $this->assertSame('value', $clientTestUser->get('key'));
        $this->assertEquals(1, $clientTestUser->acl->delUser('test_user'));
    }

    /**
     * @group connected
     * @return void
     * @requiresRedisVersion >= 7.0.0
     */
    public function testStandaloneNodeRetryCommandExecutionOnTimeoutException(): void
    {
        $retries = 0;
        $mockDisconnect = static function () use (&$retries) {
            $streamConnection = new StreamConnection(new Parameters([
                'retry' => new Retry(new ExponentialBackoff(1000, 10000), 3),
            ]));
            $disconnectFunc = [$streamConnection, 'disconnect'];
            ++$retries;
            $disconnectFunc();
        };

        $stubConnection = $this->getMockBuilder(StreamConnection::class)
            ->setConstructorArgs([new Parameters([
                'retry' => new Retry(new ExponentialBackoff(100, 1000), 3),
                'read_write_timeout' => 0.1,
            ])])
            ->onlyMethods(['disconnect'])
            ->getMock();

        $stubConnection
            ->expects($this->exactly(7))
            ->method('disconnect')
            ->willReturnCallback($mockDisconnect);

        $stubConnection->addConnectCommand(new RawCommand('auth', ['foobar']));

        $client = new Client($stubConnection);

        $this->expectException(TimeoutException::class);

        $client->blmpop(3, ['random_key']);
        $this->assertEquals(3, $retries);
    }

    /**
     * @group connected
     * @group relay-incompatible
     * @return void
     * @requiresRedisVersion >= 7.0.0
     */
    public function testStandaloneNodeRetryCommandExecutionOnTimeoutExceptionIntegration(): void
    {
        // Retry used to wrap callback around, so we can count retries
        $retry = new Retry(new ExponentialBackoff(100, 1000), 3);
        $retriesCount = 0;
        $retryWrapperFunc = static function (callable $do, ?callable $fail = null) use ($retry, &$retriesCount) {
            $failWrapperFunc = static function (Exception $e) use (&$retriesCount, $fail) {
                ++$retriesCount;
                $fail($e);
            };

            return $retry->callWithRetry($do, $failWrapperFunc);
        };

        $mockRetry = $this->getMockBuilder(Retry::class)
            ->setConstructorArgs([new ExponentialBackoff(100, 1000), 3])
            ->onlyMethods(['callWithRetry'])
            ->getMock();

        $mockRetry
            ->expects($this->any())
            ->method('callWithRetry')
            ->willReturnCallback($retryWrapperFunc);

        // Create a real connection with mocked retry and short read_write_timeout
        $client = $this->createClient([
            'retry' => $mockRetry,
            'read_write_timeout' => 0.1,
        ]);

        $this->expectException(TimeoutException::class);

        try {
            // blmpop with 3 second timeout will exceed the 0.1 second read_write_timeout
            // causing TimeoutException to be thrown and retried 3 times before failing
            $client->blmpop(3, ['random_key_that_does_not_exist']);
        } finally {
            $this->assertGreaterThanOrEqual(3, $retriesCount);
        }
    }

    /**
     * @group connected
     * @group cluster
     * @return void
     * @requiresRedisVersion >= 2.0.0
     */
    public function testClusterRetryCommandExecutionOnTimeoutException(): void
    {
        $defaultParams = $this->getDefaultParametersArray();
        $parsedParams = [];

        foreach ($defaultParams as $param) {
            $parsedParam = Parameters::parse($param);
            $parsedParam['retry'] = new Retry(new ExponentialBackoff(1000, 10000), 3);
            $parsedParam['read_write_timeout'] = 0.1;
            $parsedParams[] = Parameters::create($parsedParam);
        }

        $retries = 0;
        $mockDisconnect = static function () use (&$retries) {
            $streamConnection = new StreamConnection(new Parameters([
                'retry' => new Retry(new ExponentialBackoff(1000, 10000), 3),
            ]));
            $disconnectFunc = [$streamConnection, 'disconnect'];
            ++$retries;
            $disconnectFunc();
        };

        $stubConnection1 = $this->getMockBuilder(StreamConnection::class)
            ->setConstructorArgs([$parsedParams[0]])
            ->onlyMethods(['disconnect'])
            ->getMock();

        $stubConnection1
            ->expects($this->any())
            ->method('disconnect')
            ->willReturnCallback($mockDisconnect);

        $stubConnection1->addConnectCommand(new RawCommand('auth', [$parsedParams[0]->password]));

        $stubConnection2 = $this->getMockBuilder(StreamConnection::class)
            ->setConstructorArgs([$parsedParams[1]])
            ->onlyMethods(['disconnect'])
            ->getMock();

        $stubConnection2
            ->expects($this->any())
            ->method('disconnect')
            ->willReturnCallback($mockDisconnect);

        $stubConnection2->addConnectCommand(new RawCommand('auth', [$parsedParams[1]->password]));

        $stubConnection3 = $this->getMockBuilder(StreamConnection::class)
            ->setConstructorArgs([$parsedParams[2]])
            ->onlyMethods(['disconnect'])
            ->getMock();

        $stubConnection3
            ->expects($this->any())
            ->method('disconnect')
            ->willReturnCallback($mockDisconnect);

        $stubConnection3->addConnectCommand(new RawCommand('auth', [$parsedParams[2]->password]));

        $mockFactory = $this->getMockBuilder(Factory::class)->getMock();
        $clusterConnection = new RedisCluster($mockFactory, $parsedParams[0]);

        $clusterConnection->add($stubConnection1);
        $clusterConnection->add($stubConnection2);
        $clusterConnection->add($stubConnection3);

        $client = new Client($clusterConnection);

        $this->expectException(TimeoutException::class);

        $client->blpop(['random_key'], 3);
        $this->assertEquals(3, $retries);
    }

    /**
     * @group connected
     * @group sentinel
     * @group relay-incompatible
     * @requiresRedisVersion >= 7.0.0
     */
    public function testConnectToSentinelAndExecuteCommands(): void
    {
        $client = $this->createClient();

        $this->assertEquals('PONG', $client->ping());

        $client->set('sentinel_test_key', 'sentinel_test_value');
        $this->assertEquals('sentinel_test_value', $client->get('sentinel_test_key'));
    }

    /**
     * @group connected
     * @group sentinel
     * @group relay-incompatible
     * @requiresRedisVersion >= 7.0.0
     */
    public function testSentinelReplicationConnection(): void
    {
        $client = $this->createClient();
        $connection = $client->getConnection();

        $this->assertInstanceOf(SentinelReplication::class, $connection);

        // Verify we can get master connection
        $master = $connection->getMaster();
        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $master);

        // Verify master is connected
        $this->assertTrue($master->isConnected());
    }

    /**
     * @group connected
     * @group sentinel
     * @group relay-incompatible
     * @requiresRedisVersion >= 7.0.0
     */
    public function testSentinelReplicationConnectionWithResp3(): void
    {
        $client = $this->createClient(['protocol' => 3]);
        $connection = $client->getConnection();

        $this->assertInstanceOf(SentinelReplication::class, $connection);

        // SENTINEL slaves returns a map under RESP3 - calling getSlaves()
        // exercises the parsing path that previously read fields by
        // positional index. We do not assert on cardinality because the
        // CI topology may not include replicas; we only require that the
        // call completes without protocol-incompatibility errors.
        $slaves = $connection->getSlaves();
        $this->assertIsArray($slaves);

        foreach ($slaves as $slave) {
            $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $slave);
        }

        $client->set('sentinel_resp3_test_key', 'sentinel_resp3_test_value');
        $this->assertEquals('sentinel_resp3_test_value', $client->get('sentinel_resp3_test_key'));
    }

    // ******************************************************************** //
    // ---- HELPER METHODS ------------------------------------------------ //
    // ******************************************************************** //

    /**
     * Returns an URI string representation of the specified connection parameters.
     *
     * @param array $parameters Array of connection parameters.
     *
     * @return string URI string.
     */
    protected function getParametersString(array $parameters): string
    {
        $defaults = $this->getDefaultParametersArray();

        $scheme = $parameters['scheme'] ?? $defaults['scheme'];
        $host = $parameters['host'] ?? $defaults['host'];
        $port = $parameters['port'] ?? $defaults['port'];

        unset($parameters['scheme'], $parameters['host'], $parameters['port']);
        $uriString = "$scheme://$host:$port/?";

        foreach ($parameters as $k => $v) {
            $uriString .= "$k=$v&";
        }

        return $uriString;
    }

    /**
     * Returns a mock object simulating an aggregate connection initializer.
     *
     * @param ParametersInterface|array|string $parameters Expected connection parameters
     *
     * @return callable|MockObject
     */
    protected function getAggregateInitializer($parameters)
    {
        $connection = $this->getMockBuilder('Predis\Connection\AggregateConnectionInterface')->getMock();

        $callable = $this->getMockBuilder('stdClass')
            ->addMethods(['__invoke'])
            ->getMock();
        $callable
            ->expects($this->once())
            ->method('__invoke')
            ->with(
                $parameters,
                $this->isInstanceOf('Predis\Configuration\OptionsInterface'),
                $this->isInstanceOf('Predis\Configuration\OptionInterface')
            )
            ->willReturn($connection);

        return $callable;
    }
}
