<?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\Command\Redis\Search;

use Predis\Command\Argument\Search\AggregateArguments;
use Predis\Command\Argument\Search\CollectArguments;
use Predis\Command\Argument\Search\CreateArguments;
use Predis\Command\Argument\Search\SchemaFields\AbstractField;
use Predis\Command\Argument\Search\SchemaFields\NumericField;
use Predis\Command\Argument\Search\SchemaFields\TextField;
use Predis\Command\PrefixableCommand;
use Predis\Command\Redis\PredisCommandTestCase;
use Predis\Response\ServerException;

/**
 * @group commands
 * @group realm-stack
 */
class FTAGGREGATE_Test extends PredisCommandTestCase
{
    /**
     * {@inheritDoc}
     */
    protected function getExpectedCommand(): string
    {
        return FTAGGREGATE::class;
    }

    /**
     * {@inheritDoc}
     */
    protected function getExpectedId(): string
    {
        return 'FTAGGREGATE';
    }

    /**
     * @group disconnected
     * @dataProvider argumentsProvider
     * @requires PHP > 7.3
     */
    public function testFilterArguments(array $actualArguments, array $expectedArguments): void
    {
        $command = $this->getCommand();
        $command->setArguments($actualArguments);

        $this->assertEquals($expectedArguments, $command->getArguments());
    }

    /**
     * @group disconnected
     */
    public function testParseResponse(): void
    {
        $this->assertSame(1, $this->getCommand()->parseResponse(1));
    }

    /**
     * @group disconnected
     */
    public function testPrefixKeys(): void
    {
        /** @var PrefixableCommand $command */
        $command = $this->getCommand();
        $actualArguments = ['arg1'];
        $prefix = 'prefix:';
        $expectedArguments = ['prefix:arg1'];

        $command->setRawArguments($actualArguments);
        $command->prefixKeys($prefix);

        $this->assertSame($expectedArguments, $command->getArguments());
    }

    /**
     * @group connected
     * @group relay-resp3
     * @return void
     * @requiresRediSearchVersion >= 1.1.0
     */
    public function testReturnsAggregatedSearchResultWithGivenModifiers(): void
    {
        $redis = $this->getClient();
        $expectedResponse = [
            2,
            [
                'country', 'Ukraine', 'birth', '1995', 'country_birth_Vlad_count', '2',
            ],
            [
                'country', 'Israel', 'birth', '1994', 'country_birth_Vlad_count', '1',
            ],
        ];

        $ftCreateArguments = (new CreateArguments())->prefix(['user:']);
        $schema = [
            new TextField('name'),
            new TextField('country'),
            new NumericField('dob', '', AbstractField::SORTABLE),
        ];

        $this->assertEquals('OK', $redis->ftcreate('idx', $schema, $ftCreateArguments));
        $this->assertSame(
            3,
            $redis->hset('user:0', 'name', 'Vlad', 'country', 'Ukraine', 'dob', 813801600)
        );
        $this->assertSame(
            3,
            $redis->hset('user:1', 'name', 'Vlad', 'country', 'Israel', 'dob', 782265600)
        );
        $this->assertSame(
            3,
            $redis->hset('user:2', 'name', 'Vlad', 'country', 'Ukraine', 'dob', 813801600)
        );

        $ftAggregateArguments = (new AggregateArguments())
            ->apply('year(@dob)', 'birth')
            ->groupBy('@country', '@birth')
            ->reduce('COUNT', true, 'country_birth_Vlad_count')
            ->sortBy(0, '@birth', 'DESC');

        $this->assertSame(
            $expectedResponse,
            $redis->ftaggregate('idx', '@name: "Vlad"', $ftAggregateArguments)
        );
    }

    /**
     * @group connected
     * @return void
     * @requiresRediSearchVersion >= 2.8.0
     */
    public function testReturnsAggregatedSearchResultWithGivenModifiersResp3(): void
    {
        $redis = $this->getResp3Client();

        $ftCreateArguments = (new CreateArguments())->prefix(['user:']);
        $schema = [
            new TextField('name'),
            new TextField('country'),
            new NumericField('dob', '', AbstractField::SORTABLE),
        ];

        $this->assertEquals('OK', $redis->ftcreate('idx', $schema, $ftCreateArguments));
        $this->assertSame(
            3,
            $redis->hset('user:0', 'name', 'Vlad', 'country', 'Ukraine', 'dob', 813801600)
        );
        $this->assertSame(
            3,
            $redis->hset('user:1', 'name', 'Vlad', 'country', 'Israel', 'dob', 782265600)
        );
        $this->assertSame(
            3,
            $redis->hset('user:2', 'name', 'Vlad', 'country', 'Ukraine', 'dob', 813801600)
        );

        $ftAggregateArguments = (new AggregateArguments())
            ->apply('year(@dob)', 'birth')
            ->groupBy('@country', '@birth')
            ->reduce('COUNT', true, 'country_birth_Vlad_count')
            ->sortBy(0, '@birth', 'DESC');

        $this->assertNotEmpty(
            $redis->ftaggregate('idx', '@name: "Vlad"', $ftAggregateArguments)
        );
    }

    /**
     * @group connected
     * @group relay-resp3
     * @return void
     * @requiresRedisVersion >= 8.9.0
     */
    public function testAggregatesWithCollectReducer(): void
    {
        $redis = $this->getClient();
        $this->enableUnstableSearchFeatures($redis);

        $this->createFruitsIndex($redis);

        $ftAggregateArguments = (new AggregateArguments())
            ->groupBy('@color')
            ->reduceCollect(
                (new CollectArguments())
                    ->fields('@name', '@sweetness')
                    ->sortBy(['@sweetness' => CollectArguments::SORT_DESC])
                    ->limit(0, 2),
                'fruits'
            );

        $response = $redis->ftaggregate('idx', '*', $ftAggregateArguments);

        // Top-level group order is not guaranteed; key the collected columns by
        // color. Within a group SORTBY makes the entry order deterministic.
        $collectedByColor = $this->indexResp2CollectByColor($response);

        $this->assertEquals(
            [
                ['name', 'apple', 'sweetness', '4'],
                ['name', 'strawberry', 'sweetness', '3'],
            ],
            $collectedByColor['red']
        );
        $this->assertEquals(
            [
                ['name', 'banana', 'sweetness', '4'],
                ['name', 'lemon', 'sweetness', '2'],
            ],
            $collectedByColor['yellow']
        );
    }

    /**
     * @group connected
     * @return void
     * @requiresRedisVersion >= 8.9.0
     */
    public function testAggregatesWithCollectReducerResp3(): void
    {
        $redis = $this->getResp3Client();
        $this->enableUnstableSearchFeatures($redis);

        $this->createFruitsIndex($redis);

        $ftAggregateArguments = (new AggregateArguments())
            ->groupBy('@color')
            ->reduceCollect(
                (new CollectArguments())
                    ->fields('@name', '@sweetness')
                    ->sortBy(['@sweetness' => CollectArguments::SORT_DESC])
                    ->limit(0, 2),
                'fruits'
            );

        $response = $redis->ftaggregate('idx', '*', $ftAggregateArguments);

        // In RESP3 each entry is a map; key the collected columns by color.
        $collectedByColor = [];
        foreach ($response['results'] as $result) {
            $attributes = $result['extra_attributes'];
            $collectedByColor[$attributes['color']] = $attributes['fruits'];
        }

        $this->assertEquals(
            [
                ['name' => 'apple', 'sweetness' => '4'],
                ['name' => 'strawberry', 'sweetness' => '3'],
            ],
            $collectedByColor['red']
        );
        $this->assertEquals(
            [
                ['name' => 'banana', 'sweetness' => '4'],
                ['name' => 'lemon', 'sweetness' => '2'],
            ],
            $collectedByColor['yellow']
        );
    }

    /**
     * @group connected
     * @group relay-resp3
     * @return void
     * @requiresRedisVersion >= 8.9.0
     */
    public function testCollectReducerProjectsAllFields(): void
    {
        $redis = $this->getClient();
        $this->enableUnstableSearchFeatures($redis);

        $this->createFruitsIndex($redis);

        // FIELDS * projects the fields materialized in the pipeline at this stage.
        $ftAggregateArguments = (new AggregateArguments())
            ->load('@name', '@color', '@sweetness')
            ->groupBy('@color')
            ->reduceCollect(
                (new CollectArguments())->allFields()->sortBy(['@sweetness' => CollectArguments::SORT_DESC]),
                'entries'
            );

        $response = $redis->ftaggregate('idx', '*', $ftAggregateArguments);
        $collectedByColor = $this->indexResp2CollectByColor($response, 'entries');

        $this->assertCount(2, $collectedByColor['red']);
        $this->assertCount(2, $collectedByColor['yellow']);

        // Each entry carries the projected fields as a flat key/value array.
        foreach ($collectedByColor['red'] as $entry) {
            $this->assertContains('name', $entry);
            $this->assertContains('sweetness', $entry);
        }
    }

    /**
     * @group connected
     * @group relay-resp3
     * @return void
     * @requiresRedisVersion >= 1.1.0
     */
    public function testThrowsExceptionOnNonExistingIndex(): void
    {
        $redis = $this->getClient();

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

        $redis->ftaggregate('index', 'query');
    }

    /**
     * @var \Predis\ClientInterface|null Client used to toggle the unstable-features gate, kept for teardown restoration.
     */
    private $unstableFeaturesClient;

    /**
     * @var string|null Original value of the unstable-features gate, restored on teardown.
     */
    private $originalUnstableFeatures;

    /**
     * Restores any global server state mutated by the connected tests.
     */
    protected function tearDown(): void
    {
        if ($this->unstableFeaturesClient !== null && $this->originalUnstableFeatures !== null) {
            $this->unstableFeaturesClient->config(
                'set',
                'search-enable-unstable-features',
                $this->originalUnstableFeatures
            );

            $this->unstableFeaturesClient = null;
            $this->originalUnstableFeatures = null;
        }

        parent::tearDown();
    }

    /**
     * Enables the unstable-features gate that COLLECT is currently behind.
     *
     * The previous value is captured and restored on teardown so the connected
     * tests do not leave the gate flipped on the shared server.
     */
    private function enableUnstableSearchFeatures($redis): void
    {
        $config = $redis->config('get', 'search-enable-unstable-features');
        $this->originalUnstableFeatures = $config['search-enable-unstable-features'] ?? array_pop($config);
        $this->unstableFeaturesClient = $redis;

        $redis->config('set', 'search-enable-unstable-features', 'yes');
    }

    /**
     * Creates a fruits HASH index with name, color and a sortable sweetness field.
     */
    private function createFruitsIndex($redis): void
    {
        $schema = [
            new TextField('name'),
            new TextField('color'),
            new NumericField('sweetness', '', AbstractField::SORTABLE),
        ];

        $this->assertEquals(
            'OK',
            $redis->ftcreate('idx', $schema, (new CreateArguments())->prefix(['fruit:']))
        );

        $this->assertSame(3, $redis->hset('fruit:1', 'name', 'apple', 'color', 'red', 'sweetness', 4));
        $this->assertSame(3, $redis->hset('fruit:2', 'name', 'strawberry', 'color', 'red', 'sweetness', 3));
        $this->assertSame(3, $redis->hset('fruit:3', 'name', 'banana', 'color', 'yellow', 'sweetness', 4));
        $this->assertSame(3, $redis->hset('fruit:4', 'name', 'lemon', 'color', 'yellow', 'sweetness', 2));

        // Give the index a moment to catch up with the written documents.
        usleep(20000);
    }

    /**
     * Reduces a RESP2 FT.AGGREGATE reply to a map of group value => collected column.
     *
     * The reply is [count, row, row, ...] where each row is a flat key/value
     * array holding the GROUPBY key ("color") and the collect alias column.
     */
    private function indexResp2CollectByColor(array $response, string $alias = 'fruits'): array
    {
        $collectedByColor = [];

        for ($i = 1, $max = count($response); $i < $max; $i++) {
            $row = $response[$i];
            $color = null;
            $collected = null;

            for ($j = 0, $rowMax = count($row); $j < $rowMax; $j += 2) {
                if ($row[$j] === 'color') {
                    $color = $row[$j + 1];
                } elseif ($row[$j] === $alias) {
                    $collected = $row[$j + 1];
                }
            }

            $collectedByColor[$color] = $collected;
        }

        return $collectedByColor;
    }

    public function argumentsProvider(): array
    {
        return [
            'with default arguments' => [
                ['index', 'query'],
                ['index', 'query', 'DIALECT', 2],
            ],
            'with VERBATIM modifier' => [
                ['index', 'query', (new AggregateArguments())->verbatim()],
                ['index', 'query', 'VERBATIM', 'DIALECT', 2],
            ],
            'with LOAD modifier - specified fields' => [
                ['index', 'query', (new AggregateArguments())->load('field1', 'field2')],
                ['index', 'query', 'LOAD', 2, 'field1', 'field2', 'DIALECT', 2],
            ],
            'with LOAD modifier - all fields' => [
                ['index', 'query', (new AggregateArguments())->load('*')],
                ['index', 'query', 'LOAD', '*', 'DIALECT', 2],
            ],
            'with TIMEOUT modifier' => [
                ['index', 'query', (new AggregateArguments())->timeout(2)],
                ['index', 'query', 'TIMEOUT', 2, 'DIALECT', 2],
            ],
            'with GROUPBY modifier' => [
                ['index', 'query', (new AggregateArguments())->groupBy('property1', 'property2')],
                ['index', 'query', 'GROUPBY', 2, 'property1', 'property2', 'DIALECT', 2],
            ],
            'with REDUCE modifier' => [
                ['index', 'query', (new AggregateArguments())->reduce('function', 'arg1', true, 'alias1', 'arg2')],
                ['index', 'query', 'REDUCE', 'function', 2, 'arg1', 'AS', 'alias1', 'arg2', 'DIALECT', 2],
            ],
            'with SORTBY modifier' => [
                ['index', 'query', (new AggregateArguments())->sortBy(2, 'property1', 'ASC', 'property2', 'DESC')],
                ['index', 'query', 'SORTBY', 4, 'property1', 'ASC', 'property2', 'DESC', 'MAX', 2, 'DIALECT', 2],
            ],
            'with REDUCE COLLECT modifier' => [
                [
                    'index', 'query',
                    (new AggregateArguments())
                        ->groupBy('@color')
                        ->reduceCollect(
                            (new CollectArguments())
                                ->fields('@name', '@sweetness')
                                ->sortBy(['@sweetness' => CollectArguments::SORT_DESC])
                                ->limit(0, 2),
                            'fruits'
                        ),
                ],
                [
                    'index', 'query', 'GROUPBY', 1, '@color', 'REDUCE', 'COLLECT', 11, 'FIELDS', 2, '@name',
                    '@sweetness', 'SORTBY', 2, '@sweetness', 'DESC', 'LIMIT', 0, 2, 'AS', 'fruits', 'DIALECT', 2,
                ],
            ],
            'with APPLY modifier' => [
                ['index', 'query', (new AggregateArguments())->apply('expression', 'name')],
                ['index', 'query', 'APPLY', 'expression', 'AS', 'name', 'DIALECT', 2],
            ],
            'with LIMIT modifier' => [
                ['index', 'query', (new AggregateArguments())->limit(2, 3)],
                ['index', 'query', 'LIMIT', 2, 3, 'DIALECT', 2],
            ],
            'with FILTER modifier' => [
                ['index', 'query', (new AggregateArguments())->filter('filter')],
                ['index', 'query', 'FILTER', 'filter', 'DIALECT', 2],
            ],
            'with WITHCURSOR modifier' => [
                ['index', 'query', (new AggregateArguments())->withCursor(10, 20)],
                ['index', 'query', 'WITHCURSOR', 'COUNT', 10, 'MAXIDLE', 20, 'DIALECT', 2],
            ],
            'with PARAMS modifier' => [
                ['index', 'query', (new AggregateArguments())->params(['name1', 'value1', 'name2', 'value2'])],
                ['index', 'query', 'PARAMS', 4, 'name1', 'value1', 'name2', 'value2', 'DIALECT', 2],
            ],
            'with DIALECT modifier' => [
                ['index', 'query', (new AggregateArguments())->dialect('dialect')],
                ['index', 'query', 'DIALECT', 'dialect'],
            ],
            'with chain of arguments' => [
                [
                    'index',
                    '@name: "test"',
                    (new AggregateArguments())
                        ->apply('year(@dob)', 'birth')
                        ->groupBy('@birth', '@country')
                        ->reduce('COUNT', true, 'num_visits')
                        ->sortBy(0, '@day'),
                ],
                [
                    'index', '@name: "test"', 'APPLY', 'year(@dob)', 'AS', 'birth', 'GROUPBY', 2, '@birth', '@country',
                    'REDUCE', 'COUNT', 0, 'AS', 'num_visits', 'SORTBY', 1, '@day', 'DIALECT', 2,
                ],
            ],
        ];
    }
}
