<?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\Cluster;

use PHPUnit\Framework\MockObject\MockObject;
use PredisTestCase;

class PredisStrategyTest extends PredisTestCase
{
    /**
     * @group disconnected
     */
    public function testSupportsKeyTags(): void
    {
        // NOTE: 32 and 64 bits PHP runtimes can produce different hash values.
        $expected = PHP_INT_SIZE == 4 ? -1954026732 : 2340940564;
        $strategy = $this->getClusterStrategy();

        $this->assertSame($expected, $strategy->getSlotByKey('{foo}'));
        $this->assertSame($expected, $strategy->getSlotByKey('{foo}:bar'));
        $this->assertSame($expected, $strategy->getSlotByKey('{foo}:baz'));
        $this->assertSame($expected, $strategy->getSlotByKey('bar:{foo}:baz'));
        $this->assertSame($expected, $strategy->getSlotByKey('bar:{foo}:{baz}'));

        $this->assertSame($expected, $strategy->getSlotByKey('bar:{foo}:baz{}'));
        $this->assertSame(PHP_INT_SIZE == 4 ? -1355751440 : 2939215856, $strategy->getSlotByKey('{}bar:{foo}:baz'));

        $this->assertSame(PHP_INT_SIZE == 4 ? -18873278 : 4276094018, $strategy->getSlotByKey(''));
        $this->assertSame(PHP_INT_SIZE == 4 ? -1574052038 : 2720915258, $strategy->getSlotByKey('{}'));
    }

    /**
     * @group disconnected
     */
    public function testSupportedCommands(): void
    {
        /** @var PredisStrategy */
        $strategy = $this->getClusterStrategy();

        $this->assertSame($this->getExpectedCommands(), $strategy->getSupportedCommands());
    }

    /**
     * @group disconnected
     */
    public function testReturnsNullOnUnsupportedCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $command = $this->getCommandFactory()->create('ping');

        $this->assertNull($strategy->getSlot($command));
    }

    /**
     * @group disconnected
     */
    public function testFirstKeyCommands(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['key', 10];

        foreach ($this->getExpectedCommands('keys-first') as $commandID) {
            if ($commandID === 'ARLASTITEMS') {
                $arguments[] = 10;
            }

            if ($commandID === 'ARSCAN') {
                $arguments[] = 11;
            }

            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testAllKeysCommands(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['{key}:1', '{key}:2', '{key}:3', '{key}:4'];

        foreach ($this->getExpectedCommands('keys-all') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testInterleavedKeysCommands(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['{key}:1', 'value1', '{key}:2', 'value2'];

        foreach ($this->getExpectedCommands('keys-interleaved') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testFakeKeyCommandsWithOneKey(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['1', '2'];

        foreach ($this->getExpectedCommands('keys-fake') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForSortCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['{key}:1', 'value1', '{key}:2', 'value2'];

        $commandID = 'SORT';

        $command = $commands->create($commandID, ['{key}:1']);
        $this->assertNotNull($strategy->getSlot($command), $commandID);

        $command = $commands->create($commandID, ['{key}:1', ['STORE' => '{key}:2']]);
        $this->assertNotNull($strategy->getSlot($command), $commandID);
    }

    /**
     * @group disconnected
     */
    public function testKeysForBlockingListCommands(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['{key}:1', '{key}:2', 10];

        foreach ($this->getExpectedCommands('keys-blockinglist') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForFirstTwoKeysCommands(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['{key}:source', '{key}:destination', 'LEFT', 'RIGHT'];

        foreach ($this->getExpectedCommands('keys-first-two') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testReturnsNullOnFirstTwoKeysCommandsWithDifferentSlots(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['key:source', 'key:destination', 'LEFT', 'RIGHT'];

        foreach ($this->getExpectedCommands('keys-first-two') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForZsetAggregationCommands(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['{key}:destination', ['{key}:1', '{key}:1'], [], 'sum'];

        foreach ($this->getExpectedCommands('keys-zaggregated') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForBitOpCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['AND', '{key}:destination', '{key}:src:1', '{key}:src:2'];

        foreach ($this->getExpectedCommands('keys-bitop') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

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

        // These commands drop or reshape their arguments unless all of them are given.
        $arguments = [
            'XACKDEL' => ['key', 'group', 'KEEPREF', ['1-1']],
            'XCLAIM' => ['key', 'group', 'consumer', 0, ['1-1']],
            'XDELEX' => ['key', 'KEEPREF', ['1-1']],
            'XNACK' => ['key', 'group', 'silent', ['1-1']],
            'XPENDING' => ['key', 'group'],
        ];

        foreach ($this->getExpectedCommands('keys-stream') as $commandID) {
            $command = $commands->create($commandID, $arguments[$commandID]);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

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

        // The key of these commands follows the subcommand.
        $arguments = [
            'XGROUP' => ['CREATE', 'key', 'group', '$'],
            'XINFO' => ['GROUPS', 'key'],
        ];

        foreach ($this->getExpectedCommands('keys-stream-subcommand') as $commandID) {
            $command = $commands->create($commandID, $arguments[$commandID]);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

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

        foreach ($this->getExpectedCommands('keys-stream-subcommand') as $commandID) {
            $command = $commands->create($commandID, ['HELP']);
            $this->assertNull($strategy->getSlot($command), $commandID);
        }
    }

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

        // The keys follow the STREAMS token and are trailed by the same number of IDs.
        $arguments = [
            'XREAD' => [null, null, ['{key}:1', '{key}:2'], '0', '0'],
            'XREADGROUP' => ['group', 'consumer', null, null, false, '{key}:1', '{key}:2', '0', '0'],
        ];

        foreach ($this->getExpectedCommands('keys-stream-read') as $commandID) {
            $command = $commands->create($commandID, $arguments[$commandID]);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

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

        // A group or a consumer may be named after the token that separates the keys.
        $command = $commands->create('XREADGROUP', ['streams', 'streams', null, null, false, 'key', '0']);
        $this->assertSame($strategy->getSlotByKey('key'), $strategy->getSlot($command));
    }

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

        $arguments = [
            'XREAD' => [null, null, ['key:1', 'key:2'], '0', '0'],
            'XREADGROUP' => ['group', 'consumer', null, null, false, 'key:1', 'key:2', '0', '0'],
        ];

        foreach ($this->getExpectedCommands('keys-stream-read') as $commandID) {
            $command = $commands->create($commandID, $arguments[$commandID]);
            $this->assertNull($strategy->getSlot($command), $commandID);
        }
    }

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

        $arguments = [
            'VADD' => ['key', [1.0, 2.0], 'element'],
            'VSIM' => ['key', [1.0, 2.0]],
        ];

        foreach ($this->getExpectedCommands('keys-vector') as $commandID) {
            $command = $commands->create($commandID, $arguments[$commandID]);
            $this->assertSame($strategy->getSlotByKey('key'), $strategy->getSlot($command), $commandID);
        }
    }

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

        // HIMPORT SET is routed by the hash slot of its key (at position 1).
        $command = $commands->create('HIMPORT', ['SET', '{key}:1', 'fieldset', 'v1', 'v2']);
        $this->assertNotNull($strategy->getSlot($command));

        // PREPARE/DISCARD/DISCARDALL have no key and are not slot-routable.
        $command = $commands->create('HIMPORT', ['PREPARE', 'fieldset', 'f1', 'f2']);
        $this->assertNull($strategy->getSlot($command));

        $command = $commands->create('HIMPORT', ['DISCARD', 'fieldset']);
        $this->assertNull($strategy->getSlot($command));

        $command = $commands->create('HIMPORT', ['DISCARDALL']);
        $this->assertNull($strategy->getSlot($command));
    }

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

        $arguments = [
            'HEXPIRE' => ['key', 60, ['field']],
            'HEXPIREAT' => ['key', 1893456000, ['field']],
            'HPERSIST' => ['key', ['field']],
            'HPEXPIRE' => ['key', 60000, ['field']],
            'HPEXPIREAT' => ['key', 1893456000000, ['field']],
            'HTTL' => ['key', ['field']],
            'HPTTL' => ['key', ['field']],
            'HEXPIRETIME' => ['key', ['field']],
            'HPEXPIRETIME' => ['key', ['field']],
            'HGETEX' => ['key', ['field']],
            'HGETDEL' => ['key', ['field']],
        ];

        foreach ($this->getExpectedCommands('keys-hash-field') as $commandID) {
            $command = $commands->create($commandID, $arguments[$commandID]);
            $this->assertSame($strategy->getSlotByKey('key'), $strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForObjectSubcommand(): void
    {
        $strategy = $this->getClusterStrategy();

        $command = $this->getCommandFactory()->create('OBJECT', ['ENCODING', 'key']);

        $this->assertSame($strategy->getSlotByKey('key'), $strategy->getSlot($command));
    }

    /**
     * @group disconnected
     */
    public function testReturnsNullOnObjectSubcommandWithoutKey(): void
    {
        $strategy = $this->getClusterStrategy();
        $command = $this->getCommandFactory()->create('OBJECT', ['HELP']);

        $this->assertNull($strategy->getSlot($command));
    }

    /**
     * @group disconnected
     */
    public function testKeysForGeoradiusCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $commandID = 'GEORADIUS';

        $command = $commands->create($commandID, ['{key}:1', 10, 10, 1, 'km']);
        $this->assertNotNull($strategy->getSlot($command), $commandID);

        $command = $commands->create($commandID, ['{key}:1', 10, 10, 1, 'km', 'store', '{key}:2', 'storedist', '{key}:3']);
        $this->assertNotNull($strategy->getSlot($command), $commandID);
    }

    /**
     * @group disconnected
     */
    public function testKeysForGeoradiusByMemberCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $commandID = 'GEORADIUSBYMEMBER';

        $command = $commands->create($commandID, ['{key}:1', 'member', 1, 'km']);
        $this->assertNotNull($strategy->getSlot($command), $commandID);

        $command = $commands->create($commandID, ['{key}:1', 'member', 1, 'km', 'store', '{key}:2', 'storedist', '{key}:3']);
        $this->assertNotNull($strategy->getSlot($command), $commandID);
    }

    /**
     * @group disconnected
     */
    public function testKeysForEvalCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['%SCRIPT%', 2, '{key}:1', '{key}:2', 'value1', 'value2'];

        foreach ($this->getExpectedCommands('keys-script') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForSUnsubscribeCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = [];

        foreach ($this->getExpectedCommands('keys-sunsubscribe') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForEvalReadOnlyCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $commands = $this->getCommandFactory();
        $arguments = ['%SCRIPT%', ['{key}:1', '{key}:2'], 'value1', 'value2'];

        foreach ($this->getExpectedCommands('keys-script-ro') as $commandID) {
            $command = $commands->create($commandID, $arguments);
            $this->assertNotNull($strategy->getSlot($command), $commandID);
        }
    }

    /**
     * @group disconnected
     */
    public function testKeysForScriptCommand(): void
    {
        $strategy = $this->getClusterStrategy();
        $arguments = ['{key}:1', '{key}:2', 'value1', 'value2'];

        /** @var \Predis\Command\CommandInterface|MockObject */
        $command = $this->getMockBuilder('Predis\Command\ScriptCommand')
            ->onlyMethods(['getScript', 'getKeysCount'])
            ->getMock();
        $command
            ->expects($this->once())
            ->method('getScript')
            ->willReturn('return true');
        $command
            ->expects($this->exactly(2))
            ->method('getKeysCount')
            ->willReturn(2);
        $command->setArguments($arguments);

        $this->assertNotNull($strategy->getSlot($command), "Script Command [{$command->getId()}]");
    }

    /**
     * @group disconnected
     */
    public function testUnsettingCommandHandler(): void
    {
        /** @var PredisStrategy */
        $strategy = $this->getClusterStrategy();
        $strategy->setCommandHandler('set');
        $strategy->setCommandHandler('get', null);

        $commands = $this->getCommandFactory();

        $command = $commands->create('set', ['key', 'value']);
        $this->assertNull($strategy->getSlot($command));

        $command = $commands->create('get', ['key']);
        $this->assertNull($strategy->getSlot($command));
    }

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

        /** @var PredisStrategy */
        $strategy = $this->getClusterStrategy();
        $strategy->setCommandHandler('get', $callable);

        $commands = $this->getCommandFactory();
        $command = $commands->create('get', ['key']);

        $this->assertNotNull($strategy->getSlot($command));
    }

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

    /**
     * Creates the default cluster strategy object.
     *
     * @return StrategyInterface
     */
    protected function getClusterStrategy(): StrategyInterface
    {
        $strategy = new PredisStrategy();

        $connection = $this->getMockBuilder('Predis\Connection\NodeConnectionInterface')->getMock();
        $strategy->getDistributor()->add($connection);

        return $strategy;
    }

    /**
     * Returns the list of expected supported commands.
     *
     * @param ?string $type Optional type of command (based on its keys)
     *
     * @return array
     */
    protected function getExpectedCommands(?string $type = null): array
    {
        $commands = [
            /* commands operating on the key space */
            'EXISTS' => 'keys-all',
            'DEL' => 'keys-all',
            'UNLINK' => 'keys-all',
            'TYPE' => 'keys-first',
            'EXPIRE' => 'keys-first',
            'EXPIREAT' => 'keys-first',
            'PERSIST' => 'keys-first',
            'PEXPIRE' => 'keys-first',
            'PEXPIREAT' => 'keys-first',
            'TTL' => 'keys-first',
            'PTTL' => 'keys-first',
            'SORT' => 'variable',
            'DUMP' => 'keys-first',
            'RESTORE' => 'keys-first',
            'OBJECT' => 'keys-object-subcommand',
            'FLUSHDB' => 'keys-fake',

            /* commands operating on string values */
            'APPEND' => 'keys-first',
            'DECR' => 'keys-first',
            'DECRBY' => 'keys-first',
            'GET' => 'keys-first',
            'GETBIT' => 'keys-first',
            'MGET' => 'keys-all',
            'SET' => 'keys-first',
            'GETRANGE' => 'keys-first',
            'GETSET' => 'keys-first',
            'INCR' => 'keys-first',
            'INCRBY' => 'keys-first',
            'INCRBYFLOAT' => 'keys-first',
            'INCREX' => 'keys-first',
            'SETBIT' => 'keys-first',
            'SETEX' => 'keys-first',
            'MSET' => 'keys-interleaved',
            'MSETNX' => 'keys-interleaved',
            'SETNX' => 'keys-first',
            'SETRANGE' => 'keys-first',
            'STRLEN' => 'keys-first',
            'SUBSTR' => 'keys-first',
            'BITOP' => 'keys-bitop',
            'BITCOUNT' => 'keys-first',
            'BITFIELD' => 'keys-first',

            /* commands operating on lists */
            'LINSERT' => 'keys-first',
            'LINDEX' => 'keys-first',
            'LLEN' => 'keys-first',
            'LMOVEM' => 'keys-first-two',
            'LPOP' => 'keys-first',
            'RPOP' => 'keys-first',
            'RPOPLPUSH' => 'keys-all',
            'BLMOVEM' => 'keys-first-two',
            'BLPOP' => 'keys-blockinglist',
            'BRPOP' => 'keys-blockinglist',
            'BRPOPLPUSH' => 'keys-blockinglist',
            'LPUSH' => 'keys-first',
            'LPUSHX' => 'keys-first',
            'RPUSH' => 'keys-first',
            'RPUSHX' => 'keys-first',
            'LRANGE' => 'keys-first',
            'LREM' => 'keys-first',
            'LSET' => 'keys-first',
            'LTRIM' => 'keys-first',

            /* commands operating on arrays */
            'ARCOUNT' => 'keys-first',
            'ARDEL' => 'keys-first',
            'ARDELRANGE' => 'keys-first',
            'ARGET' => 'keys-first',
            'ARGETRANGE' => 'keys-first',
            'ARGREP' => 'keys-first',
            'ARINFO' => 'keys-first',
            'ARINSERT' => 'keys-first',
            'ARLASTITEMS' => 'keys-first',
            'ARLEN' => 'keys-first',
            'ARMGET' => 'keys-first',
            'ARMSET' => 'keys-first',
            'ARNEXT' => 'keys-first',
            'AROP' => 'keys-first',
            'ARRING' => 'keys-first',
            'ARSCAN' => 'keys-first',
            'ARSEEK' => 'keys-first',
            'ARSET' => 'keys-first',

            /* commands operating on sets */
            'SADD' => 'keys-first',
            'SCARD' => 'keys-first',
            'SDIFF' => 'keys-all',
            'SDIFFSTORE' => 'keys-all',
            'SINTER' => 'keys-all',
            'SINTERSTORE' => 'keys-all',
            'SUNION' => 'keys-all',
            'SUNIONSTORE' => 'keys-all',
            'SISMEMBER' => 'keys-first',
            'SMEMBERS' => 'keys-first',
            'SSCAN' => 'keys-first',
            'SPOP' => 'keys-first',
            'SRANDMEMBER' => 'keys-first',
            'SREM' => 'keys-first',

            /* commands operating on sorted sets */
            'ZADD' => 'keys-first',
            'ZCARD' => 'keys-first',
            'ZCOUNT' => 'keys-first',
            'ZINCRBY' => 'keys-first',
            'ZINTERSTORE' => 'keys-zaggregated',
            'ZRANGE' => 'keys-first',
            'ZRANGEBYSCORE' => 'keys-first',
            'ZRANK' => 'keys-first',
            'ZREM' => 'keys-first',
            'ZREMRANGEBYRANK' => 'keys-first',
            'ZREMRANGEBYSCORE' => 'keys-first',
            'ZREVRANGE' => 'keys-first',
            'ZREVRANGEBYSCORE' => 'keys-first',
            'ZREVRANK' => 'keys-first',
            'ZSCORE' => 'keys-first',
            'ZMSCORE' => 'keys-first',
            'ZUNIONSTORE' => 'keys-zaggregated',
            'ZSCAN' => 'keys-first',
            'ZLEXCOUNT' => 'keys-first',
            'ZRANGEBYLEX' => 'keys-first',
            'ZREMRANGEBYLEX' => 'keys-first',
            'ZREVRANGEBYLEX' => 'keys-first',

            /* commands operating on hashes */
            'HDEL' => 'keys-first',
            'HEXISTS' => 'keys-first',
            'HGET' => 'keys-first',
            'HGETALL' => 'keys-first',
            'HMGET' => 'keys-first',
            'HMSET' => 'keys-first',
            'HINCRBY' => 'keys-first',
            'HINCRBYFLOAT' => 'keys-first',
            'HKEYS' => 'keys-first',
            'HLEN' => 'keys-first',
            'HSET' => 'keys-first',
            'HSETNX' => 'keys-first',
            'HVALS' => 'keys-first',
            'HSCAN' => 'keys-first',
            'HSTRLEN' => 'keys-first',
            'HIMPORT' => 'keys-himport',
            'HEXPIRE' => 'keys-hash-field',
            'HEXPIREAT' => 'keys-hash-field',
            'HPERSIST' => 'keys-hash-field',
            'HPEXPIRE' => 'keys-hash-field',
            'HPEXPIREAT' => 'keys-hash-field',
            'HTTL' => 'keys-hash-field',
            'HPTTL' => 'keys-hash-field',
            'HEXPIRETIME' => 'keys-hash-field',
            'HPEXPIRETIME' => 'keys-hash-field',
            'HGETEX' => 'keys-hash-field',
            'HGETDEL' => 'keys-hash-field',

            /* commands operating on streams */
            'XACK' => 'keys-first',
            'XACKDEL' => 'keys-stream',
            'XADD' => 'keys-first',
            'XAUTOCLAIM' => 'keys-first',
            'XCFGSET' => 'keys-first',
            'XCLAIM' => 'keys-stream',
            'XDEL' => 'keys-first',
            'XDELEX' => 'keys-stream',
            'XGROUP' => 'keys-stream-subcommand',
            'XINFO' => 'keys-stream-subcommand',
            'XLEN' => 'keys-first',
            'XNACK' => 'keys-stream',
            'XPENDING' => 'keys-stream',
            'XRANGE' => 'keys-first',
            'XREAD' => 'keys-stream-read',
            'XREADGROUP' => 'keys-stream-read',
            'XREVRANGE' => 'keys-first',
            'XSETID' => 'keys-first',
            'XTRIM' => 'keys-first',

            /* commands operating on vector sets */
            'VADD' => 'keys-vector',
            'VCARD' => 'keys-first',
            'VDIM' => 'keys-first',
            'VEMB' => 'keys-first',
            'VGETATTR' => 'keys-first',
            'VINFO' => 'keys-first',
            'VLINKS' => 'keys-first',
            'VRANDMEMBER' => 'keys-first',
            'VRANGE' => 'keys-first',
            'VREM' => 'keys-first',
            'VSETATTR' => 'keys-first',
            'VSIM' => 'keys-vector',

            /* commands operating on time series */
            'TS.READ' => 'keys-first',

            /* commands operating on HyperLogLog */
            'PFADD' => 'keys-first',
            'PFCOUNT' => 'keys-all',
            'PFMERGE' => 'keys-all',

            /* scripting */
            'EVAL' => 'keys-script',
            'EVALSHA' => 'keys-script',
            'EVAL_RO' => 'keys-script-ro',
            'EVALSHA_RO' => 'keys-script-ro',

            /* server */
            'INFO' => 'keys-fake',

            /* commands performing geospatial operations */
            'GEOADD' => 'keys-first',
            'GEOHASH' => 'keys-first',
            'GEOPOS' => 'keys-first',
            'GEODIST' => 'keys-first',
            'GEORADIUS' => 'keys-georadius',
            'GEORADIUSBYMEMBER' => 'keys-georadius',

            /* sharded pubsub */
            'SSUBSCRIBE' => 'keys-all',
            'SUNSUBSCRIBE' => 'keys-sunsubscribe',
            'SPUBLISH' => 'keys-first',

            /* cluster */
            'CLUSTER' => 'keys-fake',

            /* control */
            'ACL' => 'keys-fake',
        ];

        if (isset($type)) {
            $commands = array_filter($commands, static function (string $expectedType) use ($type) {
                return $expectedType === $type;
            });
        }

        return array_keys($commands);
    }
}
