<?php

declare(strict_types=1);

namespace Atk4\Data\Tests\Persistence\Sql;

use Atk4\Core\Phpunit\TestCase;
use Atk4\Data\Persistence\Sql\Connection;
use Atk4\Data\Persistence\Sql\Exception;
use Atk4\Data\Persistence\Sql\Expression;
use Atk4\Data\Persistence\Sql\Expressionable;
use Atk4\Data\Persistence\Sql\Mysql\Connection as MysqlConnection;
use Atk4\Data\Persistence\Sql\Mysql\Expression as MysqlExpression;
use Atk4\Data\Persistence\Sql\Sqlite\Connection as SqliteConnection;
use Atk4\Data\Persistence\Sql\Sqlite\Expression as SqliteExpression;
use PHPUnit\Framework\Attributes\DataProvider;

class ExpressionTest extends TestCase
{
    /**
     * @param string|array<string, mixed> $template
     * @param array<int|string, mixed>    $arguments
     */
    protected function e($template = [], array $arguments = []): Expression
    {
        return new class($template, $arguments) extends Expression {
            protected string $identifierEscapeChar = '"';

            #[\Override]
            protected function escapeStringLiteral(string $value): string
            {
                return null; // @phpstan-ignore return.type
            }
        };
    }

    /**
     * @param mixed ...$args
     *
     * @return mixed
     */
    private function callProtected(object $obj, string $name, ...$args)
    {
        return \Closure::bind(static fn () => $obj->{$name}(...$args), null, $obj)();
    }

    public function testConstructorNoTemplateException(): void
    {
        $this->expectException(Exception::class);
        $this->expectExceptionMessage('Template is not defined');
        $this->e()->render();
    }

    public function testConstructorEmptyTemplate(): void
    {
        self::assertSame(
            '',
            $this->e('')->render()[0]
        );
    }

    /**
     * Testing simple template patterns without arguments.
     * Testing different ways how to pass template to constructor.
     */
    public function testConstructor2(): void
    {
        // pass as string
        self::assertSame(
            'now()',
            $this->e('now()')->render()[0]
        );
        // pass as array with template key
        self::assertSame(
            'now()',
            $this->e(['template' => 'now()'])->render()[0]
        );
        // pass as array with template key
        self::assertSame(
            ':a Name',
            $this->e(['template' => '[] Name'], ['Last'])->render()[0]
        );
    }

    /**
     * Testing template with simple arguments.
     */
    public function testConstructor3(): void
    {
        $e = $this->e('hello, [who]', ['who' => 'world']);
        self::assertSame([
            'hello, :a',
            [':a' => 'world'],
        ], $e->render());

        $e = $this->e('hello, {who}', ['who' => 'world']);
        self::assertSame([
            'hello, "world"',
            [],
        ], $e->render());
    }

    /**
     * Testing template with complex arguments.
     */
    public function testConstructor4(): void
    {
        // argument = Expression
        self::assertSame(
            'hello, world',
            $this->e('hello, [who]', ['who' => $this->e('world')])->render()[0]
        );

        // multiple arguments = Expression
        self::assertSame(
            'hello, world',
            $this->e(
                '[what], [who]',
                [
                    'what' => $this->e('hello'),
                    'who' => $this->e('world'),
                ]
            )->render()[0]
        );

        // numeric argument = Expression
        self::assertSame(
            'hello, world',
            $this->e(
                '[]',
                [
                    $this->e(
                        '[what], [who]',
                        [
                            'what' => $this->e('hello'),
                            'who' => $this->e('world'),
                        ]
                    ),
                ]
            )->render()[0]
        );

        // pass template as array
        self::assertSame(
            'hello, world',
            $this->e(
                ['template' => 'hello, [who]'],
                ['who' => $this->e('world')]
            )->render()[0]
        );
    }

    /**
     * @param array<int|string, mixed> $exprArguments
     *
     * @dataProvider provideNoTemplatingInSqlStringCases
     */
    #[DataProvider('provideNoTemplatingInSqlStringCases')]
    public function testNoTemplatingInSqlString(string $expectedStr, string $exprTemplate, array $exprArguments): void
    {
        self::assertSame($expectedStr, $this->e($exprTemplate, $exprArguments)->render()[0]);
    }

    /**
     * @return iterable<list<mixed>>
     */
    public static function provideNoTemplatingInSqlStringCases(): iterable
    {
        $testStrs = [];
        foreach (['\'', '"', '`'] as $enclosureChar) {
            foreach ([
                '\'[]\'',
                '\'{}\'',
                '\'{{}}\'',
                '\'[a]\'',
                '\'\[]\'',
                '\'\\\[]\'',
                '\'[\'\']\'',
                '\'\'\'[]\'',
                '\'[]\'\'\'',
                '--[]' . "\n",
                '-- select [a]' . "\n",
                '/*[]*/',
                '/* select [a] */',
            ] as $testStr) {
                $testStr = str_replace('\'', $enclosureChar, $testStr);

                yield [rtrim($testStr, "\n"), $testStr, []];

                $testStrs[] = $testStr;
            }
        }

        $fullStr = implode('', $testStrs);
        yield [$fullStr, $fullStr, []];
        yield [$fullStr . ' :a', $fullStr . ' []', ['foo']];

        $fullStr = implode(' ', $testStrs);
        yield [$fullStr, $fullStr, []];

        $fullStr = implode('x', $testStrs);
        yield [$fullStr, $fullStr, []];
    }

    public function testNestedParams(): void
    {
        $e1 = $this->e('[] and []', [
            $this->e('++[]', [1]),
            $this->e('**[]', [2]),
        ]);

        self::assertSame('++:a and **:b', $e1->render()[0]);

        $e2 = $this->e('=== [foo] ===', ['foo' => $e1]);

        self::assertSame('=== ++:a and **:b ===', $e2->render()[0]);

        self::assertSame('++:a and **:b', $e1->render()[0]);
    }

    /**
     * Tests where one expression with parameter is used within several other expressions.
     */
    public function testNestedExpressions(): void
    {
        $e1 = $this->e('Hello [who]', ['who' => 'world']);

        $e2 = $this->e('[greeting]! How are you.', ['greeting' => $e1]);
        $e3 = $this->e('It is me again. [greeting]', ['greeting' => $e1]);

        self::assertSame('Hello :a! How are you.', $e2->render()[0]);
        self::assertSame('It is me again. Hello :a', $e3->render()[0]);

        $e4 = $this->e('[] and good night', [$e1]);
        self::assertSame('Hello :a and good night', $e4->render()[0]);
    }

    public function testExpr(): void
    {
        self::assertInstanceOf(Expression::class, $this->e('foo'));

        $connection = \Closure::bind(static fn () => new MysqlConnection(), null, Connection::class)();
        $e = new MysqlExpression(['connection' => $connection]);
        self::assertSame(MysqlExpression::class, get_class($e->expr('foo')));
        self::assertSame($connection, $e->expr('foo')->connection);
    }

    public function testEscapeStringLiteral(): void
    {
        $escapeStringLiteralFx = \Closure::bind(static function ($value, $expressionClass = SqliteExpression::class) {
            $e = new $expressionClass();

            return $e->escapeStringLiteral($value);
        }, null, Expression::class);

        self::assertSame('\'\'', $escapeStringLiteralFx(''));
        self::assertSame('\'foo\'', $escapeStringLiteralFx('foo'));
        self::assertSame('concat(\'\', x\'00\')', $escapeStringLiteralFx("\0"));
        self::assertSame('concat(\'a\', x\'0000\')', $escapeStringLiteralFx("a\0\0"));
        self::assertSame('concat(\'a\', x\'' . str_repeat('00', 10_000) . '\')', $escapeStringLiteralFx('a' . str_repeat("\0", 10_000)));
        self::assertSame('concat(\'a\', x\'006200\', \'c\')', $escapeStringLiteralFx("a\0b\0c"));
        self::assertSame(
            'concat(\'a\', x\'00' . str_repeat('62', 100) . '00\', \'c\')',
            $escapeStringLiteralFx("a\0" . str_repeat('b', 100) . "\0c")
        );
        self::assertSame(
            'concat(\'a\', x\'00\', \'' . str_repeat('b', 101) . '\', x\'00\', \'c\')',
            $escapeStringLiteralFx("a\0" . str_repeat('b', 101) . "\0c")
        );

        self::assertSame('\'foo\'', $escapeStringLiteralFx('foo', MysqlExpression::class));
        self::assertSame('x\'00\'', $escapeStringLiteralFx("\0", MysqlExpression::class));
        self::assertSame(
            'concat(\'a\', x\'00' . str_repeat('62', 100) . '00\', \'c\')',
            $escapeStringLiteralFx("a\0" . str_repeat('b', 100) . "\0c", MysqlExpression::class)
        );
        self::assertSame(
            'concat(\'a\', x\'00\', \'' . str_repeat('b', 101) . '\', x\'00\', \'c\')',
            $escapeStringLiteralFx("a\0" . str_repeat('b', 101) . "\0c", MysqlExpression::class)
        );
    }

    public function testEscapeIdentifier(): void
    {
        // escaping expressions
        self::assertSame(
            '"first_name"',
            $this->callProtected($this->e(), 'escapeIdentifier', 'first_name')
        );
        self::assertSame(
            '"123"',
            $this->callProtected($this->e(), 'escapeIdentifier', '123')
        );
        self::assertSame(
            '"he""llo"',
            $this->callProtected($this->e(), 'escapeIdentifier', 'he"llo')
        );

        // should not escape expressions
        self::assertSame(
            '*',
            $this->callProtected($this->e(), 'escapeIdentifierSoft', '*')
        );
        self::assertSame(
            '"*"',
            $this->callProtected($this->e(), 'escapeIdentifier', '*')
        );
        self::assertSame(
            '(2 + 2) age',
            $this->callProtected($this->e(), 'escapeIdentifierSoft', '(2 + 2) age')
        );
        self::assertSame(
            '"(2 + 2) age"',
            $this->callProtected($this->e(), 'escapeIdentifier', '(2 + 2) age')
        );
        self::assertSame(
            '"users"."first_name"',
            $this->callProtected($this->e(), 'escapeIdentifierSoft', 'users.first_name')
        );
        self::assertSame(
            '"users".*',
            $this->callProtected($this->e(), 'escapeIdentifierSoft', 'users.*')
        );
    }

    public function testEscapeParam(): void
    {
        $e = $this->e('hello, [who]', ['who' => 'world']);
        self::assertSame([
            'hello, :a',
            [':a' => 'world'],
        ], $e->render());
    }

    public function testConsume(): void
    {
        $constants = (new \ReflectionClass(Expression::class))->getConstants();

        self::assertSame(
            '"123"',
            $this->callProtected($this->e(), 'consume', '123', $constants['ESCAPE_IDENTIFIER'])
        );
        self::assertSame(
            ':x',
            $this->callProtected($this->e(['renderParamBase' => 'x']), 'consume', 123, $constants['ESCAPE_PARAM'])
        );
        self::assertSame(
            '123',
            $this->callProtected($this->e(), 'consume', '123', $constants['ESCAPE_NONE'])
        );

        $myField = new class implements Expressionable {
            #[\Override]
            public function getDsqlExpression(Expression $expr): Expression
            {
                return $expr->expr('"myfield"');
            }
        };
        $e = $this->e('hello, []', [$myField]);
        $e->connection = \Closure::bind(static fn () => new SqliteConnection(), null, Connection::class)();
        self::assertSame(
            'hello, "myfield"',
            $e->render()[0]
        );
    }

    public function testConsumeUnsupportedEscapeModeException(): void
    {
        $this->expectException(Exception::class);
        $this->expectExceptionMessage('Unexpected escape mode');
        $this->callProtected($this->e(), 'consume', 123, 'blahblah');
    }

    public function testConsumeNotExpressionableObjectException(): void
    {
        $this->expectException(\Error::class);
        $this->callProtected($this->e(), 'consume', new \stdClass());
    }

    public function testConsumeDifferentConnectionException(): void
    {
        $c1 = Connection::connect('sqlite::memory:');
        $c2 = Connection::connect('sqlite::memory:');

        $c1->expr('[]', [$c1->expr('foo')])->render();

        $this->expectException(Exception::class);
        $this->expectExceptionMessage('Connection instance does not match');
        $c1->expr('[]', [$c2->expr('foo')])->render();
    }

    public function testRenderNoTagException(): void
    {
        $e = $this->e('hello, [world]');

        $this->expectException(Exception::class);
        $this->expectExceptionMessage('Expression could not render tag');
        try {
            $e->render();
        } catch (Exception $e) {
            self::assertSame('world', $e->getParams()['tag']);

            throw $e;
        }
    }

    public function testArrayAccess(): void
    {
        $e = $this->e('', ['parrot' => 'red', 'blue']);

        // offsetGet
        self::assertSame('red', $e['parrot']);
        self::assertSame('blue', $e[0]);

        // offsetSet
        $e['cat'] = 'black';
        self::assertSame('black', $e['cat']);
        $e['cat'] = 'white';
        self::assertSame('white', $e['cat']);

        // offsetExists, offsetUnset
        self::assertTrue(isset($e['cat']));
        unset($e['cat']);
        self::assertFalse(isset($e['cat']));

        // testing absence of specific key in assignment
        $e = $this->e('[], []');
        $e[] = 'Hello';
        $e[] = 'World';
        self::assertSame(':a, :b', $e->render()[0]);

        // real-life example
        $age = $this->e('coalesce([age], [default_age])');
        $age['age'] = $this->e('year(now()) - year(birth_date)');
        $age['default_age'] = 18;
        self::assertSame('coalesce(year(now()) - year(birth_date), :a)', $age->render()[0]);
    }

    public function testEscapeParamCustom(): void
    {
        $e = new class('hello, [who]', ['who' => ['world']]) extends SqliteExpression {
            #[\Override]
            public function escapeParam($value): string
            {
                return json_encode($value);
            }
        };

        self::assertSame([
            'hello, ["world"]',
            [],
        ], $e->render());
    }

    public function testVarDumpBasic(): void
    {
        self::assertSame(
            'test',
            $this->e('test')->__debugInfo()['R']
        );
    }

    public function testVarDumpException(): void
    {
        self::assertSame(
            Exception::class . ': Expression could not render tag',
            $this->e('test [noSuchTag] ')->__debugInfo()['R']
        );
    }

    public function testReset(): void
    {
        // reset everything
        $e = $this->e('hello, [name] [surname]', ['name' => 'John', 'surname' => 'Doe']);
        $e->reset();
        self::assertSame(['custom' => []], $e->args);

        // reset particular custom/tag
        $e = $this->e('hello, [name] [surname]', ['name' => 'John', 'surname' => 'Doe']);
        $e->reset('surname');
        self::assertSame(['custom' => ['name' => 'John']], $e->args);
    }
}
