<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\ExpressionLanguage\Tests;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\ExpressionLanguage\Lexer;
use Symfony\Component\ExpressionLanguage\Node;
use Symfony\Component\ExpressionLanguage\Parser;
use Symfony\Component\ExpressionLanguage\SyntaxError;

class ParserTest extends TestCase
{
    public function testParseWithInvalidName()
    {
        $this->expectException(SyntaxError::class);
        $this->expectExceptionMessage('Variable "foo" is not valid around position 1 for expression `foo`.');
        $lexer = new Lexer();
        $parser = new Parser([]);
        $parser->parse($lexer->tokenize('foo'));
    }

    public function testParseWithZeroInNames()
    {
        $this->expectException(SyntaxError::class);
        $this->expectExceptionMessage('Variable "foo" is not valid around position 1 for expression `foo`.');
        $lexer = new Lexer();
        $parser = new Parser([]);
        $parser->parse($lexer->tokenize('foo'), [0]);
    }

    public function testParsePrimaryExpressionWithUnknownFunctionThrows()
    {
        $parser = new Parser([]);
        $stream = (new Lexer())->tokenize('foo()');

        $this->expectException(SyntaxError::class);
        $this->expectExceptionMessage('The function "foo" does not exist around position 1 for expression `foo()`.');

        $parser->parse($stream);
    }

    public function testParseUnknownFunction()
    {
        $parser = new Parser([]);
        $tokenized = (new Lexer())->tokenize('foo()');

        $this->expectException(SyntaxError::class);
        $this->expectExceptionMessage('The function "foo" does not exist around position 1 for expression `foo()`.');

        $parser->parse($tokenized);
    }

    #[DataProvider('getParseData')]
    public function testParse($node, $expression, $names = [])
    {
        $lexer = new Lexer();
        $parser = new Parser([]);
        $this->assertEquals($node, $parser->parse($lexer->tokenize($expression), $names));
    }

    public static function getParseData()
    {
        $arguments = new Node\ArgumentsNode();
        $arguments->addElement(new Node\ConstantNode('arg1'));
        $arguments->addElement(new Node\ConstantNode(2));
        $arguments->addElement(new Node\ConstantNode(true));

        $arrayNode = new Node\ArrayNode();
        $arrayNode->addElement(new Node\NameNode('bar'));

        return [
            [
                new Node\NameNode('a'),
                'a',
                ['a'],
            ],
            [
                new Node\ConstantNode('a'),
                '"a"',
            ],
            [
                new Node\ConstantNode(3),
                '3',
            ],
            [
                new Node\ConstantNode(false),
                'false',
            ],
            [
                new Node\ConstantNode(true),
                'true',
            ],
            [
                new Node\ConstantNode(null),
                'null',
            ],
            [
                new Node\UnaryNode('-', new Node\ConstantNode(3)),
                '-3',
            ],
            [
                new Node\BinaryNode('-', new Node\ConstantNode(3), new Node\ConstantNode(3)),
                '3 - 3',
            ],
            [
                new Node\BinaryNode('*',
                    new Node\BinaryNode('-', new Node\ConstantNode(3), new Node\ConstantNode(3)),
                    new Node\ConstantNode(2)
                ),
                '(3 - 3) * 2',
            ],
            [
                new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL),
                'foo.bar',
                ['foo'],
            ],
            [
                new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL),
                'foo.bar()',
                ['foo'],
            ],
            [
                new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('not', true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL),
                'foo.not()',
                ['foo'],
            ],
            [
                new Node\GetAttrNode(
                    new Node\NameNode('foo'),
                    new Node\ConstantNode('bar', true),
                    $arguments,
                    Node\GetAttrNode::METHOD_CALL
                ),
                'foo.bar("arg1", 2, true)',
                ['foo'],
            ],
            [
                new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode(3), new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL),
                'foo[3]',
                ['foo'],
            ],
            [
                new Node\ConditionalNode(new Node\ConstantNode(true), new Node\ConstantNode(true), new Node\ConstantNode(false)),
                'true ? true : false',
            ],
            [
                new Node\BinaryNode('matches', new Node\ConstantNode('foo'), new Node\ConstantNode('/foo/')),
                '"foo" matches "/foo/"',
            ],
            [
                new Node\BinaryNode('starts with', new Node\ConstantNode('foo'), new Node\ConstantNode('f')),
                '"foo" starts with "f"',
            ],
            [
                new Node\BinaryNode('ends with', new Node\ConstantNode('foo'), new Node\ConstantNode('f')),
                '"foo" ends with "f"',
            ],
            [
                new Node\BinaryNode('contains', new Node\ConstantNode('foo'), new Node\ConstantNode('f')),
                '"foo" contains "f"',
            ],
            [
                new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL),
                'foo?.bar',
                ['foo'],
            ],
            [
                new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL),
                'foo?.bar()',
                ['foo'],
            ],
            [
                new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('not', true, true), new Node\ArgumentsNode(), Node\GetAttrNode::METHOD_CALL),
                'foo?.not()',
                ['foo'],
            ],
            [
                new Node\NullCoalesceNode(new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar', true), new Node\ArgumentsNode(), Node\GetAttrNode::PROPERTY_CALL), new Node\ConstantNode('default')),
                'foo.bar ?? "default"',
                ['foo'],
            ],
            [
                new Node\NullCoalesceNode(new Node\GetAttrNode(new Node\NameNode('foo'), new Node\ConstantNode('bar'), new Node\ArgumentsNode(), Node\GetAttrNode::ARRAY_CALL), new Node\ConstantNode('default')),
                'foo["bar"] ?? "default"',
                ['foo'],
            ],

            // chained calls
            [
                self::createGetAttrNode(
                    self::createGetAttrNode(
                        self::createGetAttrNode(
                            self::createGetAttrNode(new Node\NameNode('foo'), 'bar', Node\GetAttrNode::METHOD_CALL),
                            'foo', Node\GetAttrNode::METHOD_CALL),
                        'baz', Node\GetAttrNode::PROPERTY_CALL),
                    '3', Node\GetAttrNode::ARRAY_CALL),
                'foo.bar().foo().baz[3]',
                ['foo'],
            ],

            [
                new Node\NameNode('foo'),
                'bar',
                ['foo' => 'bar'],
            ],

            // Operators collisions
            [
                new Node\BinaryNode(
                    'in',
                    new Node\GetAttrNode(
                        new Node\NameNode('foo'),
                        new Node\ConstantNode('not', true),
                        new Node\ArgumentsNode(),
                        Node\GetAttrNode::PROPERTY_CALL
                    ),
                    $arrayNode
                ),
                'foo.not in [bar]',
                ['foo', 'bar'],
            ],
            [
                new Node\BinaryNode(
                    'or',
                    new Node\UnaryNode('not', new Node\NameNode('foo')),
                    new Node\GetAttrNode(
                        new Node\NameNode('foo'),
                        new Node\ConstantNode('not', true),
                        new Node\ArgumentsNode(),
                        Node\GetAttrNode::PROPERTY_CALL
                    )
                ),
                'not foo or foo.not',
                ['foo'],
            ],
            [
                new Node\BinaryNode(
                    'xor',
                    new Node\NameNode('foo'),
                    new Node\NameNode('bar'),
                ),
                'foo xor bar',
                ['foo', 'bar'],
            ],
            [
                new Node\BinaryNode('..', new Node\ConstantNode(0), new Node\ConstantNode(3)),
                '0..3',
            ],
            [
                new Node\BinaryNode('+', new Node\ConstantNode(0), new Node\ConstantNode(0.1)),
                '0+.1',
            ],
        ];
    }

    private static function createGetAttrNode($node, $item, $type)
    {
        return new Node\GetAttrNode($node, new Node\ConstantNode($item, Node\GetAttrNode::ARRAY_CALL !== $type), new Node\ArgumentsNode(), $type);
    }

    #[DataProvider('getInvalidPostfixData')]
    public function testParseWithInvalidPostfixData($expr, $names = [])
    {
        $this->expectException(SyntaxError::class);
        $lexer = new Lexer();
        $parser = new Parser([]);
        $parser->parse($lexer->tokenize($expr), $names);
    }

    public static function getInvalidPostfixData()
    {
        return [
            [
                'foo."#"',
                ['foo'],
            ],
            [
                'foo."bar"',
                ['foo'],
            ],
            [
                'foo.**',
                ['foo'],
            ],
            [
                'foo.123',
                ['foo'],
            ],
        ];
    }

    public function testNameProposal()
    {
        $this->expectException(SyntaxError::class);
        $this->expectExceptionMessage('Did you mean "baz"?');
        $lexer = new Lexer();
        $parser = new Parser([]);

        $parser->parse($lexer->tokenize('foo > bar'), ['foo', 'baz']);
    }

    #[DataProvider('getLintData')]
    public function testLint($expression, $names, int $checks = 0, ?string $exception = null)
    {
        if ($exception) {
            $this->expectException(SyntaxError::class);
            $this->expectExceptionMessage($exception);
        }

        $lexer = new Lexer();
        $parser = new Parser([]);
        $parser->lint($lexer->tokenize($expression), $names, $checks);

        // Parser doesn't return anything when the correct expression is passed
        $this->expectNotToPerformAssertions();
    }

    public static function getLintData(): array
    {
        return [
            'valid expression' => [
                'expression' => 'foo["some_key"].callFunction(a ? b)',
                'names' => ['foo', 'a', 'b'],
            ],
            'valid expression with null safety' => [
                'expression' => 'foo["some_key"]?.callFunction(a ? b)',
                'names' => ['foo', 'a', 'b'],
            ],
            'allow expression with unknown names' => [
                'expression' => 'foo.bar',
                'names' => [],
                'checks' => Parser::IGNORE_UNKNOWN_VARIABLES,
            ],
            'allow expression with unknown functions' => [
                'expression' => 'foo()',
                'names' => [],
                'checks' => Parser::IGNORE_UNKNOWN_FUNCTIONS,
            ],
            'allow expression with unknown functions and names' => [
                'expression' => 'foo(bar)',
                'names' => [],
                'checks' => Parser::IGNORE_UNKNOWN_FUNCTIONS | Parser::IGNORE_UNKNOWN_VARIABLES,
            ],
            'array with trailing comma' => [
                'expression' => '[value1, value2, value3,]',
                'names' => ['value1', 'value2', 'value3'],
            ],
            'hashmap with trailing comma' => [
                'expression' => '{val1: value1, val2: value2, val3: value3,}',
                'names' => ['value1', 'value2', 'value3'],
            ],
            'disallow expression with unknown names by default' => [
                'expression' => 'foo.bar',
                'names' => [],
                'checks' => 0,
                'exception' => 'Variable "foo" is not valid around position 1 for expression `foo.bar',
            ],
            'disallow expression with unknown functions by default' => [
                'expression' => 'foo()',
                'names' => [],
                'checks' => 0,
                'exception' => 'The function "foo" does not exist around position 1 for expression `foo()',
            ],
            'operator collisions' => [
                'expression' => 'foo.not in [bar]',
                'names' => ['foo', 'bar'],
            ],
            'incorrect expression ending' => [
                'expression' => 'foo["a"] foo["b"]',
                'names' => ['foo'],
                'checks' => 0,
                'exception' => 'Unexpected token "name" of value "foo" '.
                    'around position 10 for expression `foo["a"] foo["b"]`.',
            ],
            'incorrect operator' => [
                'expression' => 'foo["some_key"] // 2',
                'names' => ['foo'],
                'checks' => 0,
                'exception' => 'Unexpected token "operator" of value "/" '.
                    'around position 18 for expression `foo["some_key"] // 2`.',
            ],
            'incorrect array' => [
                'expression' => '[value1, value2 value3]',
                'names' => ['value1', 'value2', 'value3'],
                'checks' => 0,
                'exception' => 'An array element must be followed by a comma. '.
                    'Unexpected token "name" of value "value3" ("punctuation" expected with value ",") '.
                    'around position 17 for expression `[value1, value2 value3]`.',
            ],
            'incorrect array element' => [
                'expression' => 'foo["some_key")',
                'names' => ['foo'],
                'checks' => 0,
                'exception' => 'Unclosed "[" around position 3 for expression `foo["some_key")`.',
            ],
            'incorrect hash key' => [
                'expression' => '{+: value1}',
                'names' => ['value1'],
                'checks' => 0,
                'exception' => 'A hash key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "operator" of value "+" around position 2 for expression `{+: value1}`.',
            ],
            'missed array key' => [
                'expression' => 'foo[]',
                'names' => ['foo'],
                'checks' => 0,
                'exception' => 'Unexpected token "punctuation" of value "]" around position 5 for expression `foo[]`.',
            ],
            'missed closing bracket in sub expression' => [
                'expression' => 'foo[(bar ? bar : "default"]',
                'names' => ['foo', 'bar'],
                'checks' => 0,
                'exception' => 'Unclosed "(" around position 4 for expression `foo[(bar ? bar : "default"]`.',
            ],
            'incorrect hash following' => [
                'expression' => '{key: foo key2: bar}',
                'names' => ['foo', 'bar'],
                'checks' => 0,
                'exception' => 'A hash value must be followed by a comma. '.
                    'Unexpected token "name" of value "key2" ("punctuation" expected with value ",") '.
                    'around position 11 for expression `{key: foo key2: bar}`.',
            ],
            'incorrect hash assign' => [
                'expression' => '{key => foo}',
                'names' => ['foo'],
                'checks' => 0,
                'exception' => 'Unexpected character "=" around position 5 for expression `{key => foo}`.',
            ],
            'incorrect array as hash using' => [
                'expression' => '[foo: foo]',
                'names' => ['foo'],
                'checks' => 0,
                'exception' => 'An array element must be followed by a comma. '.
                    'Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") '.
                    'around position 5 for expression `[foo: foo]`.',
            ],
        ];
    }
}
