<?php
declare(strict_types=1);

/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @since         4.3.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\Test\TestCase\Database\Expression;

use Cake\Chronos\Chronos;
use Cake\Chronos\ChronosDate;
use Cake\Database\Expression\CaseStatementExpression;
use Cake\Database\Expression\ComparisonExpression;
use Cake\Database\Expression\FunctionExpression;
use Cake\Database\Expression\IdentifierExpression;
use Cake\Database\Expression\QueryExpression;
use Cake\Database\Expression\WhenThenExpression;
use Cake\Database\TypeFactory;
use Cake\Database\TypeMap;
use Cake\Database\ValueBinder;
use Cake\Datasource\ConnectionManager;
use Cake\I18n\Date;
use Cake\I18n\DateTime;
use Cake\Test\test_app\TestApp\Database\Expression\CustomWhenThenExpression;
use Cake\Test\test_app\TestApp\Stub\CaseStatementExpressionStub;
use Cake\Test\test_app\TestApp\Stub\WhenThenExpressionStub;
use Cake\TestSuite\TestCase;
use InvalidArgumentException;
use LogicException;
use PHPUnit\Framework\Attributes\DataProvider;
use stdClass;
use TestApp\Database\Type\CustomExpressionType;
use TestApp\View\Object\TestObjectWithToString;
use TypeError;

class CaseStatementExpressionTest extends TestCase
{
    // region Type handling

    public function testExpressionTypeCastingSimpleCase(): void
    {
        TypeFactory::map('custom', CustomExpressionType::class);

        $expression = (new CaseStatementExpression(1, 'custom'))
            ->when(1, 'custom')
            ->then(2, 'custom')
            ->else(3, 'custom');

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE CUSTOM(:param0) WHEN CUSTOM(:param1) THEN CUSTOM(:param2) ELSE CUSTOM(:param3) END',
            $sql,
        );
    }

    public function testExpressionTypeCastingNullValues(): void
    {
        TypeFactory::map('custom', CustomExpressionType::class);

        $expression = (new CaseStatementExpression(null, 'custom'))
            ->when(1, 'custom')
            ->then(null, 'custom')
            ->else(null, 'custom');

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE CUSTOM(:param0) WHEN CUSTOM(:param1) THEN CUSTOM(:param2) ELSE CUSTOM(:param3) END',
            $sql,
        );
    }

    public function testExpressionTypeCastingSearchedCase(): void
    {
        TypeFactory::map('custom', CustomExpressionType::class);

        $expression = (new CaseStatementExpression())
            ->when(['Table.column' => true], ['Table.column' => 'custom'])
            ->then(1, 'custom')
            ->else(2, 'custom');

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE WHEN Table.column = (CUSTOM(:param0)) THEN CUSTOM(:param1) ELSE CUSTOM(:param2) END',
            $sql,
        );
    }

    public function testGetReturnType(): void
    {
        // all provided `then` and `else` types are the same, return
        // type can be inferred
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(1, 'integer')
            ->when(['Table.column_b' => true])
            ->then(2, 'integer')
            ->else(3, 'integer');
        $this->assertSame('integer', $expression->getReturnType());

        // all provided `then` an `else` types are the same, one `then`
        // type is `null`, return type can be inferred
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(1)
            ->when(['Table.column_b' => true])
            ->then(2, 'integer')
            ->else(3, 'integer');
        $this->assertSame('integer', $expression->getReturnType());

        // all `then` types are null, an `else` type was provided,
        // return type can be inferred
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(1)
            ->when(['Table.column_b' => true])
            ->then(2)
            ->else(3, 'integer');
        $this->assertSame('integer', $expression->getReturnType());

        // all provided `then` types are the same, the `else` type is
        // `null`, return type can be inferred
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(1, 'integer')
            ->when(['Table.column_b' => true])
            ->then(2, 'integer')
            ->else(3);
        $this->assertSame('integer', $expression->getReturnType());

        // no `then` or `else` types were provided, they are all `null`,
        // and will be derived from the passed value, return type can be
        // inferred
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(1)
            ->when(['Table.column_b' => true])
            ->then(2)
            ->else(3);
        $this->assertSame('integer', $expression->getReturnType());

        // all `then` and `else` point to columns of the same type,
        // return type can be inferred
        $typeMap = new TypeMap([
            'Table.column_a' => 'boolean',
            'Table.column_b' => 'boolean',
            'Table.column_c' => 'boolean',
        ]);
        $expression = (new CaseStatementExpression())
            ->setTypeMap($typeMap)
            ->when(['Table.column_a' => true])
            ->then(new IdentifierExpression('Table.column_a'))
            ->when(['Table.column_b' => true])
            ->then(new IdentifierExpression('Table.column_b'))
            ->else(new IdentifierExpression('Table.column_c'));
        $this->assertSame('boolean', $expression->getReturnType());

        // all `then` and `else` use the same custom type, return type
        // can be inferred
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(1, 'custom')
            ->when(['Table.column_b' => true])
            ->then(2, 'custom')
            ->else(3, 'custom');
        $this->assertSame('custom', $expression->getReturnType());

        // all `then` and `else` types were provided, but an explicit
        // return type was set, return type will be overwritten
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(1, 'integer')
            ->when(['Table.column_b' => true])
            ->then(2, 'integer')
            ->else(3, 'integer')
            ->setReturnType('string');
        $this->assertSame('string', $expression->getReturnType());

        // all `then` and `else` types are different, return type
        // cannot be inferred
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->then(true)
            ->when(['Table.column_b' => true])
            ->then(1)
            ->else(null);
        $this->assertSame('string', $expression->getReturnType());
    }

    public function testSetReturnType(): void
    {
        $expression = (new CaseStatementExpression())->else('1');
        $this->assertSame('string', $expression->getReturnType());

        $expression->setReturnType('float');
        $this->assertSame('float', $expression->getReturnType());
    }

    public static function valueTypeInferenceDataProvider(): array
    {
        return [
            // Values that should have their type inferred because
            // they will be bound by the case expression.
            ['1', 'string'],
            [1, 'integer'],
            [1.0, 'float'],
            [true, 'boolean'],
            [ChronosDate::now(), 'date'],
            [Chronos::now(), 'datetime'],

            // Values that should not have a type inferred, either
            // because they are not bound by the case expression,
            // and/or because their type is obtained differently
            // (for example from a type map).
            [new IdentifierExpression('Table.column'), null],
            [new FunctionExpression('SUM', ['Table.column' => 'literal'], [], 'integer'), null],
            [new stdClass(), null],
            [null, null],
        ];
    }

    /**
     * @param mixed $value The value from which to infer the type.
     * @param string|null $type The expected type.
     */
    #[DataProvider('valueTypeInferenceDataProvider')]
    public function testInferValueType($value, ?string $type): void
    {
        $expression = new CaseStatementExpressionStub();

        $this->assertNull($expression->getValueType());

        $expression = (new CaseStatementExpressionStub($value))
            ->setTypeMap(new TypeMap(['Table.column' => 'boolean']))
            ->when(1)
            ->then(2);

        $this->assertSame($type, $expression->getValueType());
    }

    public static function whenTypeInferenceDataProvider(): array
    {
        return [
            // Values that should have their type inferred because
            // they will be bound by the case expression.
            ['1', 'string'],
            [1, 'integer'],
            [1.0, 'float'],
            [true, 'boolean'],
            [ChronosDate::now(), 'date'],
            [Chronos::now(), 'datetime'],

            // Values that should not have a type inferred, either
            // because they are not bound by the case expression,
            // and/or because their type is obtained differently
            // (for example from a type map).
            [new IdentifierExpression('Table.column'), null],
            [new FunctionExpression('SUM', ['Table.column' => 'literal'], [], 'integer'), null],
            [['Table.column' => true], null],
            [new stdClass(), null],
        ];
    }

    /**
     * @param mixed $value The value from which to infer the type.
     * @param string|null $type The expected type.
     */
    #[DataProvider('whenTypeInferenceDataProvider')]
    public function testInferWhenType($value, ?string $type): void
    {
        $expression = (new CaseStatementExpressionStub())
            ->setTypeMap(new TypeMap(['Table.column' => 'boolean']));
        $expression->when(new WhenThenExpressionStub($expression->getTypeMap()));

        $this->assertNull($expression->clause('when')[0]->getWhenType());

        $expression->clause('when')[0]
            ->when($value)
            ->then(1);

        $this->assertSame($type, $expression->clause('when')[0]->getWhenType());
    }

    public static function resultTypeInferenceDataProvider(): array
    {
        return [
            // Unless a result type has been set manually, values
            // should have their type inferred when possible.
            ['1', 'string'],
            [1, 'integer'],
            [1.0, 'float'],
            [true, 'boolean'],
            [ChronosDate::now(), 'date'],
            [Chronos::now(), 'datetime'],
            [new IdentifierExpression('Table.column'), 'boolean'],
            [new FunctionExpression('SUM', ['Table.column' => 'literal'], [], 'integer'), 'integer'],
            [new stdClass(), null],
            [null, null],
        ];
    }

    /**
     * @param mixed $value The value from which to infer the type.
     * @param string|null $type The expected type.
     */
    #[DataProvider('resultTypeInferenceDataProvider')]
    public function testInferResultType($value, ?string $type): void
    {
        $expression = (new CaseStatementExpressionStub())
            ->setTypeMap(new TypeMap(['Table.column' => 'boolean']))
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen;
            });

        $this->assertNull($expression->clause('when')[0]->getResultType());

        $expression->clause('when')[0]
            ->when(['Table.column' => true])
            ->then($value);

        $this->assertSame($type, $expression->clause('when')[0]->getResultType());
    }

    /**
     * @param mixed $value The value from which to infer the type.
     * @param string|null $type The expected type.
     */
    #[DataProvider('resultTypeInferenceDataProvider')]
    public function testInferElseType($value, ?string $type): void
    {
        $expression = new CaseStatementExpressionStub();

        $this->assertNull($expression->getElseType());

        $expression = (new CaseStatementExpressionStub())
            ->setTypeMap(new TypeMap(['Table.column' => 'boolean']));

        $this->assertNull($expression->getElseType());

        $expression->else($value);

        $this->assertSame($type, $expression->getElseType());
    }

    public function testWhenArrayValueInheritTypeMap(): void
    {
        $typeMap = new TypeMap([
            'Table.column_a' => 'boolean',
            'Table.column_b' => 'string',
        ]);

        $expression = (new CaseStatementExpression())
            ->setTypeMap($typeMap)
            ->when(['Table.column_a' => true])
            ->then(1)
            ->when(['Table.column_b' => 'foo'])
            ->then(2)
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => true,
                    'type' => 'boolean',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 'foo',
                    'type' => 'string',
                    'placeholder' => 'c2',
                ],
                ':c3' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c3',
                ],
                ':c4' => [
                    'value' => 3,
                    'type' => 'integer',
                    'placeholder' => 'c4',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testWhenArrayValueWithExplicitTypes(): void
    {
        $typeMap = new TypeMap([
            'Table.column_a' => 'boolean',
            'Table.column_b' => 'string',
        ]);

        $expression = (new CaseStatementExpression())
            ->setTypeMap($typeMap)
            ->when(['Table.column_a' => 123], ['Table.column_a' => 'integer'])
            ->then(1)
            ->when(['Table.column_b' => 'foo'])
            ->then(2)
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => 123,
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 'foo',
                    'type' => 'string',
                    'placeholder' => 'c2',
                ],
                ':c3' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c3',
                ],
                ':c4' => [
                    'value' => 3,
                    'type' => 'integer',
                    'placeholder' => 'c4',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testWhenCallableArrayValueInheritTypeMap(): void
    {
        $typeMap = new TypeMap([
            'Table.column_a' => 'boolean',
            'Table.column_b' => 'string',
        ]);

        $expression = (new CaseStatementExpression())
            ->setTypeMap($typeMap)
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen
                    ->when(['Table.column_a' => true])
                    ->then(1);
            })
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen
                    ->when(['Table.column_b' => 'foo'])
                    ->then(2);
            })
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => true,
                    'type' => 'boolean',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 'foo',
                    'type' => 'string',
                    'placeholder' => 'c2',
                ],
                ':c3' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c3',
                ],
                ':c4' => [
                    'value' => 3,
                    'type' => 'integer',
                    'placeholder' => 'c4',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testWhenCallableArrayValueWithExplicitTypes(): void
    {
        $typeMap = new TypeMap([
            'Table.column_a' => 'boolean',
            'Table.column_b' => 'string',
        ]);

        $expression = (new CaseStatementExpression())
            ->setTypeMap($typeMap)
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen
                    ->when(['Table.column_a' => 123], ['Table.column_a' => 'integer'])
                    ->then(1);
            })
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen
                    ->when(['Table.column_b' => 'foo'])
                    ->then(2);
            })
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => 123,
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 'foo',
                    'type' => 'string',
                    'placeholder' => 'c2',
                ],
                ':c3' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c3',
                ],
                ':c4' => [
                    'value' => 3,
                    'type' => 'integer',
                    'placeholder' => 'c4',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testWhenArrayValueRequiresArrayTypeValue(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'When using an array for the `$when` argument, the `$type` ' .
            'argument must be an array too, `string` given.',
        );

        (new CaseStatementExpression())
            ->when(['Table.column' => 123], 'integer')
            ->then(1);
    }

    public function testWhenNonArrayValueRequiresStringTypeValue(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'When using a non-array value for the `$when` argument, ' .
            'the `$type` argument must be a string, `array` given.',
        );

        (new CaseStatementExpression())
            ->when(123, ['Table.column' => 'integer'])
            ->then(1);
    }

    public function testInternalTypeMapChangesAreNonPersistent(): void
    {
        $typeMap = new TypeMap([
            'Table.column' => 'integer',
        ]);

        $expression = (new CaseStatementExpression())
            ->setTypeMap($typeMap)
            ->when(['Table.column' => 123])
            ->then(1)
            ->when(['Table.column' => 'foo'], ['Table.column' => 'string'])
            ->then('bar')
            ->when(['Table.column' => 456])
            ->then(2);

        $valueBinder = new ValueBinder();
        $expression->sql($valueBinder);
        $this->assertSame(
            [
                ':c0' => [
                    'value' => 123,
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 'foo',
                    'type' => 'string',
                    'placeholder' => 'c2',
                ],
                ':c3' => [
                    'value' => 'bar',
                    'type' => 'string',
                    'placeholder' => 'c3',
                ],
                ':c4' => [
                    'value' => 456,
                    'type' => 'integer',
                    'placeholder' => 'c4',
                ],
                ':c5' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c5',
                ],
            ],
            $valueBinder->bindings(),
        );

        $this->assertSame($typeMap, $expression->getTypeMap());
    }

    // endregion

    // region SQL injections

    public function testSqlInjectionViaTypedCaseValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression('1 THEN 1 END; DELETE * FROM foo; --', 'integer'))
            ->when(1)
            ->then(2);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c2',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testSqlInjectionViaUntypedCaseValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression('1 THEN 1 END; DELETE * FROM foo; --'))
            ->when(1)
            ->then(2);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'string',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c2',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testSqlInjectionViaTypedWhenValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression())
            ->when('1 THEN 1 END; DELETE * FROM foo; --', 'integer')
            ->then(1);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE WHEN :c0 THEN :c1 ELSE NULL END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testSqlInjectionViaTypedWhenArrayValueIsNotPossible(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'When using an array for the `$when` argument, the `$type` ' .
            'argument must be an array too, `string` given.',
        );

        (new CaseStatementExpression())
            ->when(['1 THEN 1 END; DELETE * FROM foo; --' => '123'], 'integer')
            ->then(1);
    }

    public function testSqlInjectionViaUntypedWhenValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression())
            ->when('1 THEN 1 END; DELETE * FROM foo; --')
            ->then(1);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE WHEN :c0 THEN :c1 ELSE NULL END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'string',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testSqlInjectionViaTypedThenValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression(1))
            ->when(2)
            ->then('1 THEN 1 END; DELETE * FROM foo; --', 'integer');

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'integer',
                    'placeholder' => 'c2',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testSqlInjectionViaUntypedThenValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression(1))
            ->when(2)
            ->then('1 THEN 1 END; DELETE * FROM foo; --');

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'string',
                    'placeholder' => 'c2',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testSqlInjectionViaTypedElseValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression(1))
            ->when(2)
            ->then(3)
            ->else('1 THEN 1 END; DELETE * FROM foo; --', 'integer');

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE :c0 WHEN :c1 THEN :c2 ELSE :c3 END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 3,
                    'type' => 'integer',
                    'placeholder' => 'c2',
                ],
                ':c3' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'integer',
                    'placeholder' => 'c3',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    public function testSqlInjectionViaUntypedElseValueIsNotPossible(): void
    {
        $expression = (new CaseStatementExpression(1))
            ->when(2)
            ->then(3)
            ->else('1 THEN 1 END; DELETE * FROM foo; --');

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE :c0 WHEN :c1 THEN :c2 ELSE :c3 END',
            $sql,
        );
        $this->assertSame(
            [
                ':c0' => [
                    'value' => 1,
                    'type' => 'integer',
                    'placeholder' => 'c0',
                ],
                ':c1' => [
                    'value' => 2,
                    'type' => 'integer',
                    'placeholder' => 'c1',
                ],
                ':c2' => [
                    'value' => 3,
                    'type' => 'integer',
                    'placeholder' => 'c2',
                ],
                ':c3' => [
                    'value' => '1 THEN 1 END; DELETE * FROM foo; --',
                    'type' => 'string',
                    'placeholder' => 'c3',
                ],
            ],
            $valueBinder->bindings(),
        );
    }

    // endregion

    // region Getters

    public function testGetInvalidCaseExpressionClause(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'The `$clause` argument must be one of `value`, `when`, `else`, the given value `invalid` is invalid.',
        );

        (new CaseStatementExpression())->clause('invalid');
    }

    public function testGetInvalidWhenThenExpressionClause(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'The `$clause` argument must be one of `when`, `then`, the given value `invalid` is invalid.',
        );

        (new WhenThenExpression())->clause('invalid');
    }

    public function testGetValueClause(): void
    {
        $expression = new CaseStatementExpression();

        $this->assertNull($expression->clause('value'));

        $expression = (new CaseStatementExpression(1))
            ->when(1)
            ->then(2);

        $this->assertSame(1, $expression->clause('value'));
    }

    public function testGetWhenClause(): void
    {
        $when = ['Table.column' => true];

        $expression = new CaseStatementExpression();
        $this->assertSame([], $expression->clause('when'));

        $expression
            ->when($when)
            ->then(1);

        $this->assertCount(1, $expression->clause('when'));
        $this->assertInstanceOf(WhenThenExpression::class, $expression->clause('when')[0]);
    }

    public function testWhenArrayValueGetWhenClause(): void
    {
        $when = ['Table.column' => true];

        $expression = new CaseStatementExpression();
        $this->assertSame([], $expression->clause('when'));

        $expression
            ->when($when)
            ->then(1);

        $this->assertEquals(
            new QueryExpression($when),
            $expression->clause('when')[0]->clause('when'),
        );
    }

    public function testWhenNonArrayValueGetWhenClause(): void
    {
        $expression = new CaseStatementExpression();
        $this->assertSame([], $expression->clause('when'));

        $expression
            ->when(1)
            ->then(2);

        $this->assertSame(1, $expression->clause('when')[0]->clause('when'));
    }

    public function testWhenGetThenClause(): void
    {
        $expression = (new CaseStatementExpression())
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen;
            });

        $this->assertNull($expression->clause('when')[0]->clause('then'));

        $expression->clause('when')[0]->then(1);

        $this->assertSame(1, $expression->clause('when')[0]->clause('then'));
    }

    public function testGetElseClause(): void
    {
        $expression = new CaseStatementExpression();

        $this->assertNull($expression->clause('else'));

        $expression
            ->when(['Table.column' => true])
            ->then(1)
            ->else(2);

        $this->assertSame(2, $expression->clause('else'));
    }

    // endregion

    // region Order based syntax

    public function testWhenThenElse(): void
    {
        $expression = (new CaseStatementExpression())
            ->when(['Table.column_a' => true, 'Table.column_b IS' => null])
            ->then(1)
            ->when(['Table.column_c' => true, 'Table.column_d IS NOT' => null])
            ->then(2)
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE ' .
            'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' .
            'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' .
            'ELSE :c4 ' .
            'END',
            $sql,
        );
    }

    public function testWhenBeforeClosingThenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Cannot call `when()` between `when()` and `then()`.');

        (new CaseStatementExpression())
            ->when(['Table.column_a' => true])
            ->when(['Table.column_b' => true]);
    }

    public function testElseBeforeClosingThenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Cannot call `else()` between `when()` and `then()`.');

        (new CaseStatementExpression())
            ->when(['Table.column' => true])
            ->else(1);
    }

    public function testThenBeforeOpeningWhenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Cannot call `then()` before `when()`.');

        (new CaseStatementExpression())
            ->then(1);
    }

    // endregion

    // region Callable syntax

    public function testWhenCallables(): void
    {
        $expression = (new CaseStatementExpression())
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen
                    ->when([
                        'Table.column_a' => true,
                        'Table.column_b IS' => null,
                    ])
                    ->then(1);
            })
            ->when(function (WhenThenExpression $whenThen) {
                return $whenThen
                    ->when([
                        'Table.column_c' => true,
                        'Table.column_d IS NOT' => null,
                    ])
                    ->then(2);
            })
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE ' .
            'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' .
            'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' .
            'ELSE :c4 ' .
            'END',
            $sql,
        );
    }

    public function testWhenCallablesWithCustomWhenThenExpressions(): void
    {
        $expression = (new CaseStatementExpression())
            ->when(function () {
                return (new CustomWhenThenExpression())
                    ->when([
                        'Table.column_a' => true,
                        'Table.column_b IS' => null,
                    ])
                    ->then(1);
            })
            ->when(function () {
                return (new CustomWhenThenExpression())
                    ->when([
                        'Table.column_c' => true,
                        'Table.column_d IS NOT' => null,
                    ])
                    ->then(2);
            })
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE ' .
            'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' .
            'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' .
            'ELSE :c4 ' .
            'END',
            $sql,
        );
    }

    public function testWhenCallablesWithInvalidReturnTypeFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage(
            '`when()` callables must return an instance of ' .
            '`\Cake\Database\Expression\WhenThenExpression`, `null` given.',
        );

        $this->deprecated(function (): void {
            (new CaseStatementExpression())
                ->when(function () {
                    return null;
                });
        });
    }

    // endregion

    // region Self-contained values

    public function testSelfContainedWhenThenExpressions(): void
    {
        $expression = (new CaseStatementExpression())
            ->when(
                (new WhenThenExpression())
                    ->when([
                        'Table.column_a' => true,
                        'Table.column_b IS' => null,
                    ])
                    ->then(1),
            )
            ->when(
                (new WhenThenExpression())
                    ->when([
                        'Table.column_c' => true,
                        'Table.column_d IS NOT' => null,
                    ])
                    ->then(2),
            )
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE ' .
            'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' .
            'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' .
            'ELSE :c4 ' .
            'END',
            $sql,
        );
    }

    public function testSelfContainedCustomWhenThenExpressions(): void
    {
        $expression = (new CaseStatementExpression())
            ->when(
                (new CustomWhenThenExpression())
                    ->when([
                        'Table.column_a' => true,
                        'Table.column_b IS' => null,
                    ])
                    ->then(1),
            )
            ->when(
                (new CustomWhenThenExpression())
                    ->when([
                        'Table.column_c' => true,
                        'Table.column_d IS NOT' => null,
                    ])
                    ->then(2),
            )
            ->else(3);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);
        $this->assertSame(
            'CASE ' .
            'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' .
            'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' .
            'ELSE :c4 ' .
            'END',
            $sql,
        );
    }

    // endregion

    // region Incomplete states

    public function testCompilingEmptyCaseExpressionFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Case expression must have at least one when statement.');

        $this->deprecated(function (): void {
            (new CaseStatementExpression())->sql(new ValueBinder());
        });
    }

    public function testCompilingNonClosedWhenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');

        $this->deprecated(function (): void {
            (new CaseStatementExpression())
                ->when(['Table.column' => true])
                ->sql(new ValueBinder());
        });
    }

    public function testCompilingWhenThenExpressionWithMissingWhenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `when()`.');

        $this->deprecated(function (): void {
            (new CaseStatementExpression())
                ->when(function (WhenThenExpression $whenThen) {
                    return $whenThen->then(1);
                })
                ->sql(new ValueBinder());
        });
    }

    public function testCompilingWhenThenExpressionWithMissingThenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');

        $this->deprecated(function (): void {
            (new CaseStatementExpression())
                ->when(function (WhenThenExpression $whenThen) {
                    return $whenThen->when(1);
                })
                ->sql(new ValueBinder());
        });
    }

    // endregion

    // region Valid values

    public static function validCaseValuesDataProvider(): array
    {
        return [
            [null, 'NULL', null],
            ['0', null, 'string'],
            [0, null, 'integer'],
            [0.0, null, 'float'],
            ['foo', null, 'string'],
            [true, null, 'boolean'],
            [Date::now(), null, 'date'],
            [ChronosDate::now(), null, 'date'],
            [DateTime::now(), null, 'datetime'],
            [Chronos::now(), null, 'datetime'],
            [new IdentifierExpression('Table.column'), 'Table.column', null],
            [new QueryExpression('Table.column'), 'Table.column', null],
            [ConnectionManager::get('test')->selectQuery('a'), '(SELECT a)', null],
            [new TestObjectWithToString(), null, 'string'],
            [new stdClass(), null, null],
        ];
    }

    /**
     * @param mixed $value The case value.
     * @param string|null $sqlValue The expected SQL string value.
     * @param string|null $type The expected bound type.
     */
    #[DataProvider('validCaseValuesDataProvider')]
    public function testValidCaseValue($value, ?string $sqlValue, ?string $type): void
    {
        $expression = (new CaseStatementExpression($value))
            ->when(1)
            ->then(2);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);

        if ($sqlValue) {
            $this->assertEqualsSql(
                "CASE {$sqlValue} WHEN :c0 THEN :c1 ELSE NULL END",
                $sql,
            );

            $this->assertSame(
                [
                    ':c0' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                ],
                $valueBinder->bindings(),
            );
        } else {
            $this->assertEqualsSql(
                'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END',
                $sql,
            );

            $this->assertSame(
                [
                    ':c0' => [
                        'value' => $value,
                        'type' => $type,
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                    ':c2' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c2',
                    ],
                ],
                $valueBinder->bindings(),
            );
        }
    }

    public static function validWhenValuesSimpleCaseDataProvider(): array
    {
        return [
            ['0', null, 'string'],
            [0, null, 'integer'],
            [0.0, null, 'float'],
            ['foo', null, 'string'],
            [true, null, 'boolean'],
            [new stdClass(), null, null],
            [new TestObjectWithToString(), null, 'string'],
            [Date::now(), null, 'date'],
            [ChronosDate::now(), null, 'date'],
            [DateTime::now(), null, 'datetime'],
            [Chronos::now(), null, 'datetime'],
            [
                new IdentifierExpression('Table.column'),
                'CASE :c0 WHEN Table.column THEN :c1 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => true,
                        'type' => 'boolean',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                ],
            ],
            [
                new QueryExpression('Table.column'),
                'CASE :c0 WHEN Table.column THEN :c1 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => true,
                        'type' => 'boolean',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                ],
            ],
            [
                ConnectionManager::get('test')->selectQuery('a'),
                'CASE :c0 WHEN (SELECT a) THEN :c1 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => true,
                        'type' => 'boolean',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                ],
            ],
            [
                [
                    'Table.column_a' => 1,
                    'Table.column_b' => 'foo',
                ],
                'CASE :c0 WHEN (Table.column_a = :c1 AND Table.column_b = :c2) THEN :c3 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => true,
                        'type' => 'boolean',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                    ':c2' => [
                        'value' => 'foo',
                        'type' => 'string',
                        'placeholder' => 'c2',
                    ],
                    ':c3' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c3',
                    ],
                ],
            ],
        ];
    }

    /**
     * @param mixed $value The when value.
     * @param string|null $expectedSql The expected SQL string.
     * @param array|string|null $typeOrBindings The expected bound type(s).
     */
    #[DataProvider('validWhenValuesSimpleCaseDataProvider')]
    public function testValidWhenValueSimpleCase($value, ?string $expectedSql, $typeOrBindings = null): void
    {
        $typeMap = new TypeMap([
            'Table.column_a' => 'integer',
            'Table.column_b' => 'string',
        ]);
        $expression = (new CaseStatementExpression(true))
            ->setTypeMap($typeMap)
            ->when($value)
            ->then(2);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);

        if ($expectedSql) {
            $this->assertEqualsSql($expectedSql, $sql);
            $this->assertSame($typeOrBindings, $valueBinder->bindings());
        } else {
            $this->assertEqualsSql('CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', $sql);
            $this->assertSame(
                [
                    ':c0' => [
                        'value' => true,
                        'type' => 'boolean',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => $value,
                        'type' => $typeOrBindings,
                        'placeholder' => 'c1',
                    ],
                    ':c2' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c2',
                    ],
                ],
                $valueBinder->bindings(),
            );
        }
    }

    public static function validWhenValuesSearchedCaseDataProvider(): array
    {
        return [
            ['0', null, 'string'],
            [0, null, 'integer'],
            [0.0, null, 'float'],
            ['foo', null, 'string'],
            [true, null, 'boolean'],
            [new stdClass(), null, null],
            [new TestObjectWithToString(), null, 'string'],
            [Date::now(), null, 'date'],
            [ChronosDate::now(), null, 'date'],
            [DateTime::now(), null, 'datetime'],
            [Chronos::now(), null, 'datetime'],
            [
                new IdentifierExpression('Table.column'),
                'CASE WHEN Table.column THEN :c0 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                ],
            ],
            [
                new QueryExpression('Table.column'),
                'CASE WHEN Table.column THEN :c0 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                ],
            ],
            [
                ConnectionManager::get('test')->selectQuery('a'),
                'CASE WHEN (SELECT a) THEN :c0 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                ],
            ],
            [
                [
                    'Table.column_a' => 1,
                    'Table.column_b' => 'foo',
                ],
                'CASE WHEN (Table.column_a = :c0 AND Table.column_b = :c1) THEN :c2 ELSE NULL END',
                [
                    ':c0' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 'foo',
                        'type' => 'string',
                        'placeholder' => 'c1',
                    ],
                    ':c2' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c2',
                    ],
                ],
            ],
        ];
    }

    /**
     * @param mixed $value The when value.
     * @param string|null $expectedSql The expected SQL string.
     * @param array|string|null $typeOrBindings The expected bound type(s).
     */
    #[DataProvider('validWhenValuesSearchedCaseDataProvider')]
    public function testValidWhenValueSearchedCase($value, ?string $expectedSql, $typeOrBindings = null): void
    {
        $typeMap = new TypeMap([
            'Table.column_a' => 'integer',
            'Table.column_b' => 'string',
        ]);
        $expression = (new CaseStatementExpression())
            ->setTypeMap($typeMap)
            ->when($value)
            ->then(2);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);

        if ($expectedSql) {
            $this->assertEqualsSql($expectedSql, $sql);
            $this->assertSame($typeOrBindings, $valueBinder->bindings());
        } else {
            $this->assertEqualsSql('CASE WHEN :c0 THEN :c1 ELSE NULL END', $sql);
            $this->assertSame(
                [
                    ':c0' => [
                        'value' => $value,
                        'type' => $typeOrBindings,
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                ],
                $valueBinder->bindings(),
            );
        }
    }

    public static function validThenValuesDataProvider(): array
    {
        return [
            [null, 'NULL', null],
            ['0', null, 'string'],
            [0, null, 'integer'],
            [0.0, null, 'float'],
            ['foo', null, 'string'],
            [true, null, 'boolean'],
            [Date::now(), null, 'date'],
            [ChronosDate::now(), null, 'date'],
            [DateTime::now(), null, 'datetime'],
            [Chronos::now(), null, 'datetime'],
            [new IdentifierExpression('Table.column'), 'Table.column', null],
            [new QueryExpression('Table.column'), 'Table.column', null],
            [ConnectionManager::get('test')->selectQuery('a'), '(SELECT a)', null],
            [new TestObjectWithToString(), null, 'string'],
            [new stdClass(), null, null],
        ];
    }

    /**
     * @param mixed $value The then value.
     * @param string|null $sqlValue The expected SQL string value.
     * @param string|null $type The expected bound type.
     */
    #[DataProvider('validThenValuesDataProvider')]
    public function testValidThenValue($value, ?string $sqlValue, ?string $type): void
    {
        $expression = (new CaseStatementExpression())
            ->when(1)
            ->then($value);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);

        if ($sqlValue) {
            $this->assertEqualsSql(
                "CASE WHEN :c0 THEN {$sqlValue} ELSE NULL END",
                $sql,
            );

            $this->assertSame(
                [
                    ':c0' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                ],
                $valueBinder->bindings(),
            );
        } else {
            $this->assertEqualsSql(
                'CASE WHEN :c0 THEN :c1 ELSE NULL END',
                $sql,
            );

            $this->assertSame(
                [
                    ':c0' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => $value,
                        'type' => $type,
                        'placeholder' => 'c1',
                    ],
                ],
                $valueBinder->bindings(),
            );
        }
    }

    public static function validElseValuesDataProvider(): array
    {
        return [
            [null, 'NULL', null],
            ['0', null, 'string'],
            [0, null, 'integer'],
            [0.0, null, 'float'],
            ['foo', null, 'string'],
            [true, null, 'boolean'],
            [Date::now(), null, 'date'],
            [ChronosDate::now(), null, 'date'],
            [DateTime::now(), null, 'datetime'],
            [Chronos::now(), null, 'datetime'],
            [new IdentifierExpression('Table.column'), 'Table.column', null],
            [new QueryExpression('Table.column'), 'Table.column', null],
            [ConnectionManager::get('test')->selectQuery('a'), '(SELECT a)', null],
            [new TestObjectWithToString(), null, 'string'],
            [new stdClass(), null, null],
        ];
    }

    /**
     * @param mixed $value The else value.
     * @param string|null $sqlValue The expected SQL string value.
     * @param string|null $type The expected bound type.
     */
    #[DataProvider('validElseValuesDataProvider')]
    public function testValidElseValue($value, ?string $sqlValue, ?string $type): void
    {
        $expression = (new CaseStatementExpression())
            ->when(1)
            ->then(2)
            ->else($value);

        $valueBinder = new ValueBinder();
        $sql = $expression->sql($valueBinder);

        if ($sqlValue) {
            $this->assertEqualsSql(
                "CASE WHEN :c0 THEN :c1 ELSE {$sqlValue} END",
                $sql,
            );

            $this->assertSame(
                [
                    ':c0' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                ],
                $valueBinder->bindings(),
            );
        } else {
            $this->assertEqualsSql(
                'CASE WHEN :c0 THEN :c1 ELSE :c2 END',
                $sql,
            );

            $this->assertSame(
                [
                    ':c0' => [
                        'value' => 1,
                        'type' => 'integer',
                        'placeholder' => 'c0',
                    ],
                    ':c1' => [
                        'value' => 2,
                        'type' => 'integer',
                        'placeholder' => 'c1',
                    ],
                    ':c2' => [
                        'value' => $value,
                        'type' => $type,
                        'placeholder' => 'c2',
                    ],
                ],
                $valueBinder->bindings(),
            );
        }
    }

    // endregion

    // region Invalid values

    public static function invalidCaseValuesDataProvider(): array
    {
        $res = fopen('data:text/plain,123', 'rb');
        fclose($res);

        return [
            [[], 'array'],
            [
                function (): void {
                },
                'Closure',
            ],
            [$res, 'resource (closed)'],
        ];
    }

    /**
     * @param mixed $value The case value.
     * @param string $typeName The expected error type name.
     */
    #[DataProvider('invalidCaseValuesDataProvider')]
    public function testInvalidCaseValue($value, string $typeName): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'The `$value` argument must be either `null`, a scalar value, an object, ' .
            "or an instance of `\\Cake\\Database\\ExpressionInterface`, `{$typeName}` given.",
        );

        new CaseStatementExpression($value);
    }

    public function testInvalidWhenValue(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'The `$when` argument must be a non-empty array',
        );

        (new CaseStatementExpression())
            ->when([])
            ->then(1);
    }

    public static function invalidThenValueDataProvider(): array
    {
        $res = fopen('data:text/plain,123', 'rb');
        fclose($res);

        return [
            [[], 'array'],
            [
                function (): void {
                },
                'Closure',
            ],
            [$res, 'resource (closed)'],
        ];
    }

    /**
     * @param mixed $value The then value.
     * @param string $typeName The expected error type name.
     */
    #[DataProvider('invalidThenValueDataProvider')]
    public function testInvalidThenValue($value, string $typeName): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'The `$result` argument must be either `null`, a scalar value, an object, ' .
            "or an instance of `\\Cake\\Database\\ExpressionInterface`, `{$typeName}` given.",
        );

        (new CaseStatementExpression())
            ->when(1)
            ->then($value);
    }

    public static function invalidThenTypeDataProvider(): array
    {
        $res = fopen('data:text/plain,123', 'rb');
        fclose($res);

        return [
            [1],
            [1.0],
            [new stdClass()],
            [
                function (): void {
                },
            ],
            [$res], // resource (closed)
        ];
    }

    /**
     * @param mixed $type The then type.
     */
    #[DataProvider('invalidThenTypeDataProvider')]
    public function testInvalidThenType($type): void
    {
        $this->expectException(TypeError::class);

        (new CaseStatementExpression())
            ->when(1)
            ->then(1, $type);
    }

    public static function invalidElseValueDataProvider(): array
    {
        $res = fopen('data:text/plain,123', 'rb');
        fclose($res);

        return [
            [[], 'array'],
            [
                function (): void {
                },
                'Closure',
            ],
            [$res, 'resource (closed)'],
        ];
    }

    /**
     * @param mixed $value The else value.
     * @param string $typeName The expected error type name.
     */
    #[DataProvider('invalidElseValueDataProvider')]
    public function testInvalidElseValue($value, string $typeName): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage(
            'The `$result` argument must be either `null`, a scalar value, an object, ' .
            "or an instance of `\\Cake\\Database\\ExpressionInterface`, `{$typeName}` given.",
        );

        (new CaseStatementExpression())
            ->when(1)
            ->then(1)
            ->else($value);
    }

    public static function invalidElseTypeDataProvider(): array
    {
        $res = fopen('data:text/plain,123', 'rb');
        fclose($res);

        return [
            [1],
            [1.0],
            [new stdClass()],
            [
                // Closure
                function (): void {
                },
            ],
            [
                $res, //resource (closed)
            ],
        ];
    }

    /**
     * @param mixed $type The else type.
     */
    #[DataProvider('invalidElseTypeDataProvider')]
    public function testInvalidElseType($type): void
    {
        $this->expectException(TypeError::class);

        (new CaseStatementExpression())
            ->when(1)
            ->then(1)
            ->else(1, $type);
    }

    // endregion

    // region Traversal

    public function testTraverse(): void
    {
        $value = new IdentifierExpression('Table.column');
        $conditionsA = ['Table.column_a' => true, 'Table.column_b IS' => null];
        $resultA = new QueryExpression('1');
        $conditionsB = ['Table.column_c' => true, 'Table.column_d IS NOT' => null];
        $resultB = new QueryExpression('2');
        $else = new QueryExpression('3');

        $expression = (new CaseStatementExpression($value))
            ->when($conditionsA)
            ->then($resultA)
            ->when($conditionsB)
            ->then($resultB)
            ->else($else);

        $expressions = [];
        $expression->traverse(function ($expression) use (&$expressions): void {
            $expressions[] = $expression;
        });

        $this->assertCount(14, $expressions);
        $this->assertInstanceOf(IdentifierExpression::class, $expressions[0]);
        $this->assertSame($value, $expressions[0]);
        $this->assertInstanceOf(WhenThenExpression::class, $expressions[1]);
        $this->assertEquals(new QueryExpression($conditionsA), $expressions[2]);
        $this->assertEquals(new ComparisonExpression('Table.column_a', true), $expressions[3]);
        $this->assertSame($resultA, $expressions[6]);
        $this->assertInstanceOf(WhenThenExpression::class, $expressions[7]);
        $this->assertEquals(new QueryExpression($conditionsB), $expressions[8]);
        $this->assertEquals(new ComparisonExpression('Table.column_c', true), $expressions[9]);
        $this->assertSame($resultB, $expressions[12]);
        $this->assertSame($else, $expressions[13]);
    }

    public function testTraverseBeforeClosingThenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');

        $this->deprecated(function (): void {
            $expression = (new CaseStatementExpression())
                ->when(['Table.column' => true]);

            $expression->traverse(
                function (): void {
                },
            );
        });
    }

    // endregion

    // region Cloning

    public function testClone(): void
    {
        $value = new IdentifierExpression('Table.column');
        $conditionsA = ['Table.column_a' => true, 'Table.column_b IS' => null];
        $resultA = new QueryExpression('1');
        $conditionsB = ['Table.column_c' => true, 'Table.column_d IS NOT' => null];
        $resultB = new QueryExpression('2');
        $else = new QueryExpression('3');

        $expression = (new CaseStatementExpression($value))
            ->when($conditionsA)
            ->then($resultA)
            ->when($conditionsB)
            ->then($resultB)
            ->else($else);
        $clone = clone $expression;

        $this->assertEquals($clone, $expression);
        $this->assertNotSame($clone, $expression);
    }

    public function testCloneBeforeClosingThenFails(): void
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');

        $this->deprecated(function (): void {
            $expression = (new CaseStatementExpression())
                ->when(['Table.column' => true]);

            clone $expression;
        });
    }

    // endregion
}
