<?php

/*
 * This file is part of Twig.
 *
 * (c) Fabien Potencier
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Twig\Tests\Extension;

/*
 * This file is part of Twig.
 *
 * (c) Fabien Potencier
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extension\CoreExtension;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Markup;
use Twig\Sandbox\SecurityError;
use Twig\Sandbox\SecurityPolicy;

class CoreTest extends TestCase
{
    use ExpectDeprecationTrait;

    /**
     * @dataProvider provideCycleCases
     */
    #[DataProvider('provideCycleCases')]
    public function testCycleFunction($values, $position, $expected)
    {
        $this->assertSame($expected, CoreExtension::cycle($values, $position));
    }

    public static function provideCycleCases()
    {
        return [
            [[1, 2, 3], 0, 1],
            [[1, 2, 3], 1, 2],
            [[1, 2, 3], 2, 3],
            [[1, 2, 3], 3, 1],
            [[false, 0, null], 0, false],
            [[false, 0, null], 1, 0],
            [[false, 0, null], 2, null],

            [[['a', 'b'], ['c', 'd']], 3, ['c', 'd']],
        ];
    }

    /**
     * @dataProvider provideCycleInvalidCases
     */
    #[DataProvider('provideCycleInvalidCases')]
    public function testCycleFunctionThrowRuntimeError($values, mixed $position = null)
    {
        $this->expectException(RuntimeError::class);
        CoreExtension::cycle($values, $position ?? 0);
    }

    public static function provideCycleInvalidCases()
    {
        return [
            'empty' => [[]],
            'non-countable' => [new class extends \ArrayObject {
            }],
        ];
    }

    /**
     * @dataProvider getRandomFunctionTestData
     */
    #[DataProvider('getRandomFunctionTestData')]
    public function testRandomFunction(array $expectedInArray, $value1, $value2 = null)
    {
        for ($i = 0; $i < 100; ++$i) {
            $this->assertTrue(\in_array(CoreExtension::random('UTF-8', $value1, $value2), $expectedInArray, true)); // assertContains() would not consider the type
        }
    }

    public static function getRandomFunctionTestData()
    {
        return [
            'array' => [
                ['apple', 'orange', 'citrus'],
                ['apple', 'orange', 'citrus'],
            ],
            'Traversable' => [
                ['apple', 'orange', 'citrus'],
                new \ArrayObject(['apple', 'orange', 'citrus']),
            ],
            'unicode string' => [
                ['Ä', '€', 'é'],
                'Ä€é',
            ],
            'numeric but string' => [
                ['1', '2', '3'],
                '123',
            ],
            'integer' => [
                range(0, 5, 1),
                5,
            ],
            'float' => [
                range(0, 5, 1),
                5.9,
            ],
            'negative' => [
                [0, -1, -2],
                -2,
            ],
            'min max int' => [
                range(50, 100),
                50,
                100,
            ],
            'min max float' => [
                range(-10, 10),
                -9.5,
                9.5,
            ],
            'min null' => [
                range(0, 100),
                null,
                100,
            ],
        ];
    }

    public function testRandomFunctionWithoutParameter()
    {
        $max = mt_getrandmax();

        for ($i = 0; $i < 100; ++$i) {
            $val = CoreExtension::random('UTF-8');
            $this->assertTrue(\is_int($val) && $val >= 0 && $val <= $max);
        }
    }

    public function testRandomFunctionReturnsAsIs()
    {
        $this->assertSame('', CoreExtension::random('UTF-8', ''));

        $instance = new \stdClass();
        $this->assertSame($instance, CoreExtension::random('UTF-8', $instance));
    }

    public function testRandomFunctionOfEmptyArrayThrowsException()
    {
        $this->expectException(RuntimeError::class);
        CoreExtension::random('UTF-8', []);
    }

    public function testRandomFunctionOnNonUTF8String()
    {
        $text = iconv('UTF-8', 'ISO-8859-1', 'Äé');
        for ($i = 0; $i < 30; ++$i) {
            $rand = CoreExtension::random('ISO-8859-1', $text);
            $this->assertTrue(\in_array(iconv('ISO-8859-1', 'UTF-8', $rand), ['Ä', 'é'], true));
        }
    }

    public function testReverseFilterOnNonUTF8String()
    {
        $input = iconv('UTF-8', 'ISO-8859-1', 'Äé');
        $output = iconv('ISO-8859-1', 'UTF-8', CoreExtension::reverse('ISO-8859-1', $input));

        $this->assertEquals($output, 'éÄ');
    }

    /**
     * @dataProvider provideTwigFirstCases
     */
    #[DataProvider('provideTwigFirstCases')]
    public function testTwigFirst($expected, $input)
    {
        $this->assertSame($expected, CoreExtension::first('UTF-8', $input));
    }

    public static function provideTwigFirstCases()
    {
        $i = [1 => 'a', 2 => 'b', 3 => 'c'];

        return [
            ['a', 'abc'],
            [1, [1, 2, 3]],
            ['', null],
            ['', ''],
            ['a', new CoreTestIterator($i, array_keys($i), true, 3)],
        ];
    }

    /**
     * @dataProvider provideTwigLastCases
     */
    #[DataProvider('provideTwigLastCases')]
    public function testTwigLast($expected, $input)
    {
        $this->assertSame($expected, CoreExtension::last('UTF-8', $input));
    }

    public static function provideTwigLastCases()
    {
        $i = [1 => 'a', 2 => 'b', 3 => 'c'];

        return [
            ['c', 'abc'],
            [3, [1, 2, 3]],
            ['', null],
            ['', ''],
            ['c', new CoreTestIterator($i, array_keys($i), true)],
        ];
    }

    /**
     * @dataProvider provideArrayKeyCases
     */
    #[DataProvider('provideArrayKeyCases')]
    public function testArrayKeysFilter(array $expected, $input)
    {
        $this->assertSame($expected, CoreExtension::keys($input));
    }

    public static function provideArrayKeyCases()
    {
        $array = ['a' => 'a1', 'b' => 'b1', 'c' => 'c1'];
        $keys = array_keys($array);

        return [
            [$keys, $array],
            [$keys, new CoreTestIterator($array, $keys)],
            [$keys, new CoreTestIteratorAggregate($array, $keys)],
            [$keys, new CoreTestIteratorAggregateAggregate($array, $keys)],
            [[], null],
            [['a'], new \SimpleXMLElement('<xml><a></a></xml>')],
        ];
    }

    /**
     * @dataProvider provideInFilterCases
     */
    #[DataProvider('provideInFilterCases')]
    public function testInFilter($expected, $value, $compare)
    {
        $this->assertSame($expected, CoreExtension::inFilter($value, $compare));
    }

    public static function provideInFilterCases()
    {
        $array = [1, 2, 'a' => 3, 5, 6, 7];
        $keys = array_keys($array);

        return [
            [true, 1, $array],
            [true, '3', $array],
            [true, '3', 'abc3def'],
            [true, 1, new CoreTestIterator($array, $keys, true, 1)],
            [true, '3', new CoreTestIterator($array, $keys, true, 3)],
            [true, '3', new CoreTestIteratorAggregateAggregate($array, $keys, true, 3)],
            [false, 4, $array],
            [false, 4, new CoreTestIterator($array, $keys, true)],
            [false, 4, new CoreTestIteratorAggregateAggregate($array, $keys, true)],
            [false, 1, 1],
            [true, 'b', new \SimpleXMLElement('<xml><a>b</a></xml>')],
        ];
    }

    /**
     * @dataProvider provideSliceFilterCases
     */
    #[DataProvider('provideSliceFilterCases')]
    public function testSliceFilter($expected, $input, $start, $length = null, $preserveKeys = false)
    {
        $this->assertSame($expected, CoreExtension::slice('UTF-8', $input, $start, $length, $preserveKeys));
    }

    public static function provideSliceFilterCases()
    {
        $i = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4];
        $keys = array_keys($i);

        return [
            [['a' => 1], $i, 0, 1, true],
            [['a' => 1], $i, 0, 1, false],
            [['b' => 2, 'c' => 3], $i, 1, 2],
            [[1], [1, 2, 3, 4], 0, 1],
            [[2, 3], [1, 2, 3, 4], 1, 2],
            [[2, 3], new CoreTestIterator($i, $keys, true), 1, 2],
            [['c' => 3, 'd' => 4], new CoreTestIteratorAggregate($i, $keys, true), 2, null, true],
            [$i, new CoreTestIterator($i, $keys, true), 0, \count($keys) + 10, true],
            [[], new CoreTestIterator($i, $keys, true), \count($keys) + 10],
            ['de', 'abcdef', 3, 2],
            [[], new \SimpleXMLElement('<items><item>1</item><item>2</item></items>'), 3],
            [[], new \ArrayIterator([1, 2]), 3],
        ];
    }

    /**
     * @dataProvider provideCompareCases
     */
    #[DataProvider('provideCompareCases')]
    public function testCompare($expected, $a, $b)
    {
        $this->assertSame($expected, CoreExtension::compare($a, $b));
        $this->assertSame($expected, -CoreExtension::compare($b, $a));
    }

    public function testCompareNAN()
    {
        $this->assertSame(1, CoreExtension::compare(\NAN, 'NAN'));
        $this->assertSame(1, CoreExtension::compare('NAN', \NAN));
        $this->assertSame(1, CoreExtension::compare(\NAN, 'foo'));
        $this->assertSame(1, CoreExtension::compare('foo', \NAN));
    }

    public static function provideCompareCases()
    {
        return [
            [0, 'a', 'a'],

            // from https://wiki.php.net/rfc/string_to_number_comparison
            [0, 0, '0'],
            [0, 0, '0.0'],

            [-1, 0, 'foo'],
            [1, 0, ''],
            [0, 42, '   42'],
            [-1, 42, '42foo'],

            [0, '0', '0'],
            [0, '0', '0.0'],
            [-1, '0', 'foo'],
            [1, '0', ''],
            [0, '42', '   42'],
            [-1, '42', '42foo'],

            [0, 42, '000042'],
            [0, 42, '42.0'],
            [0, 42.0, '+42.0E0'],
            [0, 0, '0e214987142012'],

            [0, '42', '000042'],
            [0, '42', '42.0'],
            [0, '42.0', '+42.0E0'],
            [0, '0', '0e214987142012'],

            [0, 42, '   42'],
            [0, 42, '42   '],
            [-1, 42, '42abc'],
            [-1, 42, 'abc42'],
            [-1, 0, 'abc42'],

            [0, 42.0, '   42.0'],
            [0, 42.0, '42.0   '],
            [-1, 42.0, '42.0abc'],
            [-1, 42.0, 'abc42.0'],
            [-1, 0.0, 'abc42.0'],

            [0, \INF, 'INF'],
            [0, -\INF, '-INF'],
            [0, \INF, '1e1000'],
            [0, -\INF, '-1e1000'],

            [-1, 10, 20],
            [-1, '10', 20],
            [-1, 10, '20'],

            [1, 42, ' foo'],
            [0, 42, "42\f"],
            [1, 42, "\x00\x34\x32"],
        ];
    }

    public function testSandboxedInclude()
    {
        $twig = new Environment(new ArrayLoader([
            'index' => '{{ include("included", sandboxed: true) }}',
            'included' => '{{ "included"|e }}',
        ]));
        $policy = new SecurityPolicy(allowedFunctions: ['include']);
        $sandbox = new SandboxExtension($policy, false);
        $twig->addExtension($sandbox);

        // We expect a compile error
        $this->expectException(SecurityError::class);
        $twig->render('index');
    }

    public function testSandboxedIncludeWithPreloadedTemplate()
    {
        $twig = new Environment(new ArrayLoader([
            'index' => '{{ include("included", sandboxed: true) }}',
            'included' => '{{ "included"|e }}',
        ]));
        $policy = new SecurityPolicy(allowedFunctions: ['include']);
        $sandbox = new SandboxExtension($policy, false);
        $twig->addExtension($sandbox);

        // The template is loaded without the sandbox enabled
        // so, no compile error
        $twig->load('included');

        // We expect a runtime error
        $this->expectException(SecurityError::class);
        $twig->render('index');
    }

    public function testSandboxedIncludeResultStaysEscapedWhenAssigned()
    {
        $twig = new Environment(new ArrayLoader([
            'index' => "{% set body = include('included', sandboxed: true) %}[{{ body }}]",
            'included' => '{{ evil }}',
        ]), ['autoescape' => 'html']);
        $twig->addExtension(new SandboxExtension(new SecurityPolicy([], ['escape'], [], [], ['include']), false));

        $this->assertSame('[&lt;script&gt;]', $twig->render('index', ['evil' => '<script>']));
    }

    public function testIncludeReturnsMarkupForRenderedContent()
    {
        $twig = new Environment(new ArrayLoader(['included' => 'content']));

        $result = CoreExtension::include($twig, [], 'included');

        $this->assertInstanceOf(Markup::class, $result);
        $this->assertSame('content', (string) $result);
    }

    public function testIncludeReturnsAnEmptyStringForEmptyContent()
    {
        $twig = new Environment(new ArrayLoader(['included' => '']));

        $this->assertSame('', CoreExtension::include($twig, [], 'included'));
    }

    public function testIncludeReturnsAnEmptyStringForIgnoredMissingTemplate()
    {
        $twig = new Environment(new ArrayLoader([]));

        $this->assertSame('', CoreExtension::include($twig, [], 'missing', ignoreMissing: true));
    }

    public function testLastModified()
    {
        $this->assertGreaterThan(1000000000, (new CoreExtension())->getLastModified());
    }

    /**
     * @group legacy
     */
    #[Group('legacy')]
    public function testCycleWithArrayAccessAndTraversableButNotCountable()
    {
        $this->expectDeprecation('Since twig/twig 3.12: Passing a non-countable sequence of values to "Twig\Extension\CoreExtension::cycle()" is deprecated.');

        $seq = new class implements \ArrayAccess, \IteratorAggregate {
            public function offsetExists($offset): bool
            {
                return true;
            }

            public function offsetGet($offset): mixed
            {
                return 'val';
            }

            public function offsetSet($offset, $value): void
            {
            }

            public function offsetUnset($offset): void
            {
            }

            public function getIterator(): \Traversable
            {
                yield 'odd';
                yield 'even';
            }
        };

        $result = CoreExtension::cycle($seq, 0);

        $this->assertEquals('odd', $result, 'cycle should return the first item from the traversable sequence, not the sequence itself.');
    }
}

final class CoreTestIteratorAggregate implements \IteratorAggregate
{
    private $iterator;

    public function __construct(array $array, array $keys, $allowAccess = false, $maxPosition = false)
    {
        $this->iterator = new CoreTestIterator($array, $keys, $allowAccess, $maxPosition);
    }

    public function getIterator(): \Traversable
    {
        return $this->iterator;
    }
}

final class CoreTestIteratorAggregateAggregate implements \IteratorAggregate
{
    private $iterator;

    public function __construct(array $array, array $keys, $allowValueAccess = false, $maxPosition = false)
    {
        $this->iterator = new CoreTestIteratorAggregate($array, $keys, $allowValueAccess, $maxPosition);
    }

    public function getIterator(): \Traversable
    {
        return $this->iterator;
    }
}

final class CoreTestIterator implements \Iterator
{
    private $position;
    private $array;
    private $arrayKeys;
    private $allowValueAccess;
    private $maxPosition;

    public function __construct(array $values, array $keys, $allowValueAccess = false, $maxPosition = false)
    {
        $this->array = $values;
        $this->arrayKeys = $keys;
        $this->position = 0;
        $this->allowValueAccess = $allowValueAccess;
        $this->maxPosition = false === $maxPosition ? \count($values) + 1 : $maxPosition;
    }

    public function rewind(): void
    {
        $this->position = 0;
    }

    #[\ReturnTypeWillChange]
    public function current()
    {
        if ($this->allowValueAccess) {
            return $this->array[$this->key()];
        }

        throw new \LogicException('Code should only use the keys, not the values provided by iterator.');
    }

    #[\ReturnTypeWillChange]
    public function key()
    {
        return $this->arrayKeys[$this->position];
    }

    public function next(): void
    {
        ++$this->position;
        if ($this->position === $this->maxPosition) {
            throw new \LogicException(\sprintf('Code should not iterate beyond %d.', $this->maxPosition));
        }
    }

    public function valid(): bool
    {
        return isset($this->arrayKeys[$this->position]);
    }
}
