<?php
/**
 * Copyright 2020 Adobe
 * All Rights Reserved.
 *
 * NOTICE: Adobe permits you to use, modify, and distribute this file in
 * accordance with the terms of the Adobe license agreement accompanying
 * it.
 */
declare(strict_types=1);

namespace Magento\Mray\Unit\CodeStructuralElement\Php\NodeVisitor;

use Closure;
use Magento\Mray\CodeStructuralElement\Php\NodeRuntimeTypeResolver\Factory as TypeResolverFactory;
use Magento\Mray\CodeStructuralElement\Php\NodeVisitor\ContextSwitcher;
use Magento\Mray\CodeStructuralElement\Php\NodeVisitor\ExpressionRuntimeTypeResolver;
use Magento\Mray\CodeStructuralElement\Php\NodeVisitor\PhpDocAst;
use Magento\Mray\CodeStructuralElement\Php\NodeVisitor\StructuralElementsReflector;
use Magento\Mray\CodeStructuralElement\Php\NodeVisitor\VariableDeclarationReflector;
use Magento\Mray\CodeStructuralElement\Php\Parser\Factory;
use Magento\Mray\CodeStructuralElement\Php\Reflection\Context;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\ParserFactory;
use PHPUnit\Framework\TestCase;
use function sprintf;

class VariableDeclarationReflectorTest extends TestCase
{
    /**
     * @param string $code
     *
     * @dataProvider variableAssignments
     */
    public function testVariableIsDefinedWithAssignment(string $code)
    {
        $context = new Context\Global_();

        $parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);
        $traverser = new NodeTraverser();
        $traverser->addVisitor(new VariableDeclarationReflector($context));
        $traverser->traverse($parser->parse($code));

        $var = $context->getVariable('var');
        $this->assertTrue($var->isDefined(), 'Variable $var should be defined.');
    }

    /**
     * @param string $code
     *
     * @dataProvider variableAssignments
     */
    public function testTypedVariableIsDefinedWithAssignment(string $code)
    {
        $context = $this->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'var', 'int');
    }

    public function variableAssignments(): array
    {
        return [
            'Assign' => ['<?php $var = 1;'],
            'Assign ref' => ['<?php $otherVar = 2; $var =& $otherVar;'],
        ];
    }

    public function testVariableDefinedWithAnnotation()
    {

        $code = <<<'CODE'
        <?php
        /** @var \DateTime $date */
        CODE;

        $context = $this->parse($code);
        $name = 'date';
        $expectedType = '\DateTime';
        $var = $context->getVariable($name);
        $type = (string)$var->getType();
        // variable defined with annotation is not defined as not exists in a code and cannot
        // be used for fetch operations
        $this->assertFalse($var->isDefined(), sprintf('Variable $%s should not be defined.', $name));
        // however type of variable defined with annotation is known
        $this->assertEquals(
            $expectedType,
            $type,
            sprintf('Variable $%s should be of type %s, but %s given.', $name, $expectedType, $type)
        );
    }

    /**
     * @param string $code
     * @dataProvider variableDefinitionsExtendedWithAnnotation
     */
    public function testVariableDefinitionExtendedWithAnnotation(string $code)
    {
        $context = $this->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'date', '\DateTime');
    }

    public function variableDefinitionsExtendedWithAnnotation(): array
    {
        return [
            'Before variable expression' => [
                <<<'CODE'
                <?php
                /** @var \DateTime $date */
                $date = date_create();
                CODE
            ],
            'Before variable expression with code between' => [
                <<<'CODE'
                <?php
                /** @var \DateTime $date */
                $time = 'now';
                $date = date_create($time);
                CODE
            ],
            'After variable expression' => [
                <<<'CODE'
                <?php
                $date = date_create();
                /** @var \DateTime $date */
                CODE
            ],
        ];
    }

    /**
     * @param string $code
     * @param array $expectedVars
     *
     * @dataProvider conditionalVarDeclarations
     */
    public function testConditionalVarDeclaration(string $code, array $expectedVars)
    {
        // todo: implement conditional variables definition
        $this->markTestSkipped('Conditional variables definition is not implemented');

        $context = $this->parse($code);
        foreach ($expectedVars as $varName => $expectedType) {
            $this->assertContextHasDefinedVariableOfType($context, $varName, $expectedType);
        }
    }

    public function conditionalVarDeclarations(): array
    {
        return [
            'if/else' => [
                <<<'CODE'
                <?php
                if (condition()) {
                    $var = 65;
                } else {
                    $var = 'A';
                }
                CODE,
                [
                    'var' => 'int|string'
                ]
            ],
            'if' => [
                <<<'CODE'
                <?php
                if (condition()) {
                    $var = 65;
                }
                CODE,
                [
                    'var' => 'int|null'
                ]
            ],
            'if overwrite' => [
                <<<'CODE'
                <?php
                $var = 'A';
                if (condition()) {
                    $var = 65;
                }
                CODE,
                [
                    'var' => 'string|int'
                ]
            ],
            // todo: add checks for variables defined in if conditional expression
            // todo: add checks for variables defined in elseif conditional expression
            // todo: add other conditional logic examples (switch, loops, goto, ternary operator, etc.)
        ];
    }

    /**
     * @param string $code
     * @dataProvider functionParameterDeclarations
     */
    public function testParameterDeclaration(string $code)
    {
        $contexts = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_ ||
                $node instanceof Node\Stmt\ClassMethod ||
                $node instanceof Node\Expr\Closure ||
                $node instanceof Node\Expr\ArrowFunction;
        });

        $context = $contexts[0];
        $functionContext = $contexts[count($contexts) - 1];

        $this->assertFalse(
            $context->getVariable('o')->isDefined(),
            'Function parameter should not be visible for global context'
        );
        $this->assertFalse(
            $context->getVariable('var')->isDefined(),
            'Variable defined inside function should not be visible for global context'
        );
        $this->assertTrue(
            $functionContext->getVariable('o')->isDefined(),
            'Function parameter should be visible inside function as variable'
        );
        $this->assertTrue(
            $functionContext->getVariable('var')->isDefined(),
            'Variable defined inside function should be detected'
        );
        $expectedType = '\stdClass';
        $type = (string)$functionContext->getVariable('var')->getType();
        $this->assertEquals(
            $expectedType,
            $type,
            sprintf('Variable $var should be of type %s, but %s given.', $expectedType, $type)
        );
    }

    public function functionParameterDeclarations(): array
    {
        return [
            'Function With Type Hint' => [
                <<<'CODE'
                <?php
                function f(\stdClass $o) {
                    $var = $o;
                }
                CODE,
            ],
            'Function With Annotation' => [
                <<<'CODE'
                <?php

                /**
                 * @param \stdClass $o
                 */
                function f($o) {
                    $var = $o;
                }
                CODE,
            ],
            'Static Class Method With Type Hint' => [
                <<<'CODE'
                <?php
                class C
                {
                    public static function f(\stdClass $o) {
                        $var = $o;
                    }
                }
                CODE,
            ],
            'Static Class Method With Annotation' => [
                <<<'CODE'
                <?php
                class C
                {
                    /**
                     * @param \stdClass $o
                     */
                    public static function f($o) {
                        $var = $o;
                    }
                }
                CODE,
            ],
            'Class Method With Type Hint' => [
                <<<'CODE'
                <?php
                class C
                {
                    public function f(\stdClass $o) {
                        $var = $o;
                    }
                }
                CODE,
            ],
            'Class Method With Annotation' => [
                <<<'CODE'
                <?php
                class C
                {
                    /**
                     * @param \stdClass $o
                     */
                    public function f($o) {
                        $var = $o;
                    }
                }
                CODE,
            ],
            'Closure With Type Hint' => [
                <<<'CODE'
                <?php
                $f = function (\stdClass $o) {
                    $var = $o;
                };
                CODE,
            ],
            'Assigned Closure With Annotation' => [
                <<<'CODE'
                <?php

                /**
                 * @param \stdClass $o
                 */
                $f = function ($o) {
                    $var = $o;
                };
                CODE,
            ],
            'Callback Closures With Annotation' => [
                <<<'CODE'
                <?php

                callbacks_invoker(
                    /**
                     * @param \stdClass $o
                     */
                    $f = function ($o) {
                        $var = $o;
                    }
                );
                CODE,
            ],
            'Arrow Function With Type Hint' => [
                <<<'CODE'
                <?php
                $f = (fn (\stdClass $o) => ($var = $o));
                CODE,
            ],
            'Arrow Function With Annotation' => [
                <<<'CODE'
                <?php

                /**
                 * @param \stdClass $o
                 */
                $f = (fn ($o) => ($var = $o));
                CODE,
            ],
            'Callback Arrow Functions With Annotation' => [
                <<<'CODE'
                <?php

                callbacks_invoker(
                    /**
                     * @param \stdClass $o
                     */
                    fn ($o) => ($var = $o)
                );
                CODE,
            ],
            'Inherited annotation' => [
                // test implementation require correct order of class declarations
                // if order cannot be guaranteed code should be processed in 2 stages: declarations read,
                // code types resolution
                <<<'CODE'
                <?php
                
                class C1
                {
                    /**
                     * @param \stdClass $o
                     */
                    public function m($o)
                    {
                    }
                }

                class C2 extends C1
                {
                    /**
                     * @inheritDoc
                     */
                    public function m($o) {
                        $var = $o;
                    }
                }
                CODE,
            ],
            // todo: implement advanced resolution of conflicts for type hints and annotations
        ];
    }

    /**
     * @param string $code
     * @param string $expectedType
     * @dataProvider casesWhenAnnotationPreciseType
     */
    public function testAnnotationPreciseTypeHint(string $code, string $expectedType)
    {
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p', $expectedType);
    }

    public function casesWhenAnnotationPreciseType(): array
    {
        return [
            'Missed Type Hint' => [
                <<<'CODE'
                <?php
                /**
                  * @param int $p
                  */
                function f($p) {}                   
                CODE,
                'int'
            ],
            'Object Type' => [
                <<<'CODE'
                <?php
                /**
                  * @param \stdClass $p
                  */
                function f(object $p) {}                   
                CODE,
                '\stdClass'
            ],
            'Invalid Annotation for Object Type' => [
                <<<'CODE'
                <?php
                /**
                  * @param int $p
                  */
                function f(object $p) {}                   
                CODE,
                'object'
            ],
            'Object Type Annotation for Scalar' => [
                <<<'CODE'
                <?php
                /**
                  * @param \stdClass $p
                  */
                function f(int $p) {}                   
                CODE,
                'int'
            ],
            'Typed Array' => [
                <<<'CODE'
                <?php
                /**
                  * @param int[] $p
                  */
                function f(array $p) {}                   
                CODE,
                'int[]'
            ],
            'Invalid Annotation for Array' => [
                <<<'CODE'
                <?php
                /**
                  * @param int $p
                  */
                function f(array $p) {}                   
                CODE,
                'array'
            ],
            'Array Type Annotation for Scalar' => [
                <<<'CODE'
                <?php
                /**
                  * @param int[] $p
                  */
                function f(int $p) {}                   
                CODE,
                'int'
            ],
        ];
    }

    public function testMultipleParametersDeclaration()
    {
        $code = <<<'CODE'
        <?php
        function f(int $p1, string $p2) {}
        CODE;
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p1', 'int');
        $this->assertContextHasDefinedVariableOfType($functionContext, 'p2', 'string');
    }

    public function testNotFullParametersAnnotationDeclaration()
    {
        $code = <<<'CODE'
        <?php
        /**
         * @param string $p2
         */
        function f(int $p1, $p2) {}
        CODE;
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p1', 'int');
        $this->assertContextHasDefinedVariableOfType($functionContext, 'p2', 'string');
    }

    public function testAnnotationOnlyParameterDeclaration()
    {
        $code = <<<'CODE'
        <?php
        /**
         * @param string $p2
         */
        function f(int $p1) {}
        CODE;
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p1', 'int');
        $this->assertVariableIsNotDefinedInContext($functionContext, 'p2');
    }

    public function testVariadicParameterDeclaration()
    {
        $code = <<<'CODE'
        <?php
        function f(...$p1) {}
        CODE;
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p1', 'array');
    }

    public function testTypedVariadicParameterDeclaration()
    {
        $code = <<<'CODE'
        <?php
        function f(int ...$p1) {}
        CODE;
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p1', 'int[]');
    }

    public function testNullableParameterDeclaration()
    {
        $code = <<<'CODE'
        <?php
        function f(?int $p1) {}
        CODE;
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p1', 'int|null');
    }

    public function testNullableVariadicParameterDeclaration()
    {
        $code = <<<'CODE'
        <?php
        function f(?int ...$p1) {}
        CODE;
        list(, $functionContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_;
        });

        $this->assertContextHasDefinedVariableOfType($functionContext, 'p1', '(int|null)[]');
    }

    public function testThisVariableAvailableInInstanceMethod()
    {
        $code = <<<'CODE'
        <?php
        class C
        {
            public function m() {}
        }
        CODE;
        list(, $methodContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\ClassMethod;
        });

        $this->assertContextHasDefinedVariableOfType($methodContext, 'this', '\C');
    }

    public function testThisVariableIsNotDefinedInStaticMethods()
    {
        $code = <<<'CODE'
        <?php
        class C
        {
            public static function m() {}
        }
        CODE;
        list(, $methodContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\ClassMethod;
        });

        $this->assertVariableIsNotDefinedInContext($methodContext, 'this');
    }

    /**
     * @param string $code
     * @param Closure $assert
     *
     * @dataProvider variableScopeCases
     */
    public function testVariableScope(string $code, Closure $assert)
    {
        $contexts = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\Function_ ||
                $node instanceof Node\Stmt\ClassMethod ||
                $node instanceof Node\Expr\Closure ||
                $node instanceof Node\Expr\ArrowFunction;
        });
        $assert(...$contexts);
    }

    public function variableScopeCases(): array
    {
        return [
            'Global variable inside function' => [
                <<<'CODE'
                <?php
                $var = 1;
                function f() {}
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertVariableIsNotDefinedInContext($function, 'var');
                }
            ],
            'Global variable and parameter with same name' => [
                <<<'CODE'
                <?php
                $var = 1;
                function f(string $var) {}
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'string');
                }
            ],
            // todo: read superglobals from https://github.com/JetBrains/phpstorm-stubs
            'Superglobal variable inside function' => [
                <<<'CODE'
                <?php
                /**
                 * @xglobal array $_ENV
                 */
                $_ENV = [];

                function f() {
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, '_ENV', 'array');
                    $this->assertContextHasDefinedVariableOfType($function, '_ENV', 'array');
                }
            ],
            'Injected global variable inside function' => [
                <<<'CODE'
                <?php
                $var = 1;
                function f() {
                    global $var;
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'int');
                }
            ],
            'Global variable inside instance method' => [
                <<<'CODE'
                <?php
                $var = 1;
                class C {
                    public function m() {}
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertVariableIsNotDefinedInContext($function, 'var');
                }
            ],
            'Superglobal variable inside instance method' => [
                <<<'CODE'
                <?php
                /**
                 * @xglobal array $_SERVER
                 */
                $_SERVER = [];
                class C {
                    protected function m() {}
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, '_SERVER', 'array');
                    $this->assertContextHasDefinedVariableOfType($function, '_SERVER', 'array');
                }
            ],
            'Injected global variable inside instance method' => [
                <<<'CODE'
                <?php
                $var = 1;
                class C {
                    private function f() {
                        global $var;
                    }
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'int');
                }
            ],
            'Global variable inside static method' => [
                <<<'CODE'
                <?php
                $var = 1;
                class C {
                    public static function m() {}
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertVariableIsNotDefinedInContext($function, 'var');
                }
            ],
            'Superglobal variable inside static method' => [
                <<<'CODE'
                <?php
                /**
                 * @xglobal array $_GET
                 */
                $_GET = [];
                class C {
                    private static function m() {}
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, '_GET', 'array');
                    $this->assertContextHasDefinedVariableOfType($function, '_GET', 'array');
                }
            ],
            'Injected global variable inside static method' => [
                <<<'CODE'
                <?php
                $var = 1;
                class C {
                    protected static function f() {
                        global $var;
                    }
                }
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'int');
                }
            ],

            'Outer variable inside closure' => [
                <<<'CODE'
                <?php
                $var = 1;
                $f = function () {};
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertVariableIsNotDefinedInContext($function, 'var');
                }
            ],
            'Outer injected inside closure' => [
                <<<'CODE'
                <?php
                $var = 1;
                $f = function () use ($var) {};
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'int');
                }
            ],
            'Outer variable and parameter collision in closure' => [
                <<<'CODE'
                <?php
                $var = 1;
                $f = function ($var) {};
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'mixed');
                }
            ],
            'This inside closure defined in instance method' => [
                <<<'CODE'
                <?php
                class C {
                    public function m() {
                        $f = function () {};
                    }
                }
                CODE,
                function ($global, $closure) {
                    $this->assertContextHasDefinedVariableOfType($closure, 'this', '\C');
                }
            ],
            'This inside closure defined in static method' => [
                <<<'CODE'
                <?php
                class C {
                    public static function m() {
                        $f = function () {};
                    }
                }
                CODE,
                function ($global, $closure) {
                    $this->assertVariableIsNotDefinedInContext($closure, 'this');
                }
            ],

            'Outer variable inside function arrow' => [
                <<<'CODE'
                <?php
                $var = 1;
                $f = fn () => $var * 2;
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'int');
                }
            ],
            'Outer variable and parameter collision in function arrow' => [
                <<<'CODE'
                <?php
                $var = 1;
                $f = fn (string $var) => 'Hello ' . $var;
                CODE,
                function ($global, $function) {
                    $this->assertContextHasDefinedVariableOfType($global, 'var', 'int');
                    $this->assertContextHasDefinedVariableOfType($function, 'var', 'string');
                }
            ],
            'This inside arrow function defined in instance method' => [
                <<<'CODE'
                <?php
                class C {
                    public function m() {
                        $f = fn () => true;
                    }
                }
                CODE,
                function ($global, $closure) {
                    $this->assertContextHasDefinedVariableOfType($closure, 'this', '\C');
                }
            ],
            'This inside arrow function defined in static method' => [
                <<<'CODE'
                <?php
                class C {
                    public static function m() {
                        $f = fn () => true;
                    }
                }
                CODE,
                function ($global, $closure) {
                    $this->assertVariableIsNotDefinedInContext($closure, 'this');
                }
            ],
        ];
    }

    /**
     * @param string $code
     *
     * @dataProvider containersWithKnownValues
     */
    public function testContainerKnownValue(string $code)
    {
        $context = $this->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'var', 'int');
    }

    public function containersWithKnownValues(): array
    {
        return [
            'Array defined with key' => [
                <<<'CODE'
                <?php
                $container = ['int' => 1, 'str' => 'abc'];
                $var = $container['int'];
                CODE
            ],
            'Nested array defined with key' => [
                <<<'CODE'
                <?php
                $container = [];
                $container['arr'] = ['int' => 1, 'str' => 'abc'];
                $var = $container['arr']['int'];
                CODE
            ],
            'Array with appended key' => [
                <<<'CODE'
                <?php
                $container = [];
                $container['dim1.1']['dim2.1'] = 1;
                $container['dim1.1']['dim2.2'] = 'foo';
                $container['dim1.2'] = 'bar';
                $var = $container['dim1.1']['dim2.1'];
                CODE
            ],
            'Implicit array with appended key' => [
                <<<'CODE'
                <?php
                $container['key'] = 1;
                $var = $container['key'];
                CODE
            ],
            'Object' => [
                <<<'CODE'
                <?php
                $container = new stdClass();
                $container->key = 1;
                $var = $container->key;
                CODE
            ],
            'Implicit object' => [
                <<<'CODE'
                <?php
                $container->key = 1;
                $var = $container->key;
                CODE
            ],
        ];
    }

    /**
     * @param string $code
     * @param string $expectedType
     *
     * @dataProvider classPropertiesDeclarations
     */
    public function testObjectProperty(string $code, string $expectedType)
    {
        list(, $methodContext) = $this->parseWithContextsSwitch($code, function (Node $node) {
            return $node instanceof Node\Stmt\ClassMethod;
        });

        $this->assertContextHasDefinedVariableOfType($methodContext, 'var', $expectedType);
    }

    public function classPropertiesDeclarations(): array
    {
        return [
            'Untyped' => [
                <<<'CODE'
                <?php
                class C {
                    private $p;
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'mixed'
            ],
            'Type hint' => [
                <<<'CODE'
                <?php
                class C {
                    private int $p;
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'int'
            ],
            'Type hint for multiple properties' => [
                <<<'CODE'
                <?php
                class C {
                    private string $p1, $p2;
                    public function m() {
                        $var = $this->p1;
                    }
                }
                CODE,
                'string'
            ],
            'Type annotation' => [
                <<<'CODE'
                <?php
                class C {
                    /**
                     * @var float
                     */
                    private $p;
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'float'
            ],
            'Type annotation for multiple properties' => [
                <<<'CODE'
                <?php
                class C {
                    /**
                     * @var bool
                     */
                    private $p1, $p2;
                    public function m() {
                        $var = $this->p1;
                    }
                }
                CODE,
                'bool'
            ],
            'Class annotation with property' => [
                <<<'CODE'
                <?php
                /**
                 * @property string $p
                 */
                class C {
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'string'
            ],
            'Class annotation with read-only property' => [
                <<<'CODE'
                <?php
                /**
                 * @property-read string $p
                 */
                class C {
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'string'
            ],
            'Class annotation with write-only property' => [
                <<<'CODE'
                <?php
                /**
                 * @property-write string $p
                 */
                class C {
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'string'
            ],
            'Conflicts resolution' => [
                <<<'CODE'
                <?php
                /**
                 * @property string $p
                 */
                class C {
                    /** @var int[] */
                    private array $p;
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'int[]' // class annotation overwritten, property annotation more precise than type hint
            ],
            'Obsolete type hint' => [
                <<<'CODE'
                <?php
                class C {
                    /** @var int */
                    private string $p;
                    public function m() {
                        $var = $this->p;
                    }
                }
                CODE,
                'string' // annotation does not match implementation
            ],
            'Static property' => [
                <<<'CODE'
                <?php
                class C {
                    private static string $p;
                    public function m() {
                        $var = self::$p;
                    }
                }
                CODE,
                'string'
            ]
        ];
    }

    /**
     * @param string $code
     * @dataProvider typesDefinedByMethod
     */
    public function testTypeFromMethodCall(string $code)
    {
        $context = new Context\Pointer(new Context\Global_());
        $parser = Factory::create($context);
        $parser->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'var', 'string');
    }

    /**
     * @return string[][]
     */
    public function typesDefinedByMethod(): array
    {
        return [
            [
                <<<'CODE'
                <?php
                
                namespace ns;
                
                /** @var I $i */
                $var = $i->getVar();
                
                interface I 
                {
                    public function getVar(): string;
                }
                CODE
            ],
            [
                <<<'CODE'
                <?php
                
                namespace ns;
                $c = new C2();
                $var = $c->m();
                
                class C1 {
                    public function m() {
                        return 'string';
                    }
                }
                
                class C2 extends C1 {
                    public function m() {
                        return parent::m();
                    }
                }
                CODE
            ],
            [
                <<<'CODE'
                <?php
                
                namespace ns;
                $c = new C2();
                $var = $c->m();
                
                class C1 {
                    public function m() {
                        return 'string';
                    }
                }
                
                class C2 extends C1 {
                    
                }
                CODE
            ],
        ];
    }

    /**
     * @param string $code
     * @param string $expectedType
     * @dataProvider typesFromClassConstant
     */
    public function testTypeFromClassConstant(string $code, string $expectedType)
    {
        $context = new Context\Pointer(new Context\Global_());
        $parser = Factory::create($context);
        $parser->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'var', $expectedType);
    }

    /**
     * @return string[][]
     */
    public function typesFromClassConstant(): array
    {
        return [
            [
                <<<'CODE'
                <?php
                namespace ns;
                $var = C::CNST;
                class C {
                    const CNST = 1;
                }
                CODE,
                'int'
            ],
            [
                <<<'CODE'
                <?php
                
                namespace ns;
                $var = C::class;
                CODE,
                'string'
            ],
        ];
    }

    public function testTypeFromGlobalConstant()
    {
        $code = <<<'CODE'
        <?php
        namespace ns;
        const CNST = 1;
        $var = CNST;
        CODE;
        $context = new Context\Pointer(new Context\Global_());
        $parser = Factory::create($context);
        $parser->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'var', 'int');
    }

    public function testTypeFromFunction()
    {
        $code = <<<'CODE'
        <?php
        namespace ns;
        function func () {
            return 'str';
        }
        $var = func();
        CODE;
        $context = new Context\Pointer(new Context\Global_());
        $parser = Factory::create($context);
        $parser->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'var', 'string');
    }

    public function testTypeFromRecursiveFunction()
    {
        $code = <<<'CODE'
        <?php
        namespace ns;
        function func1 ($c) {
            return func2();
        }
        function func2 () {
            return func1();
        }
        $var = func1();
        CODE;
        $context = new Context\Pointer(new Context\Global_());
        $parser = Factory::create($context);
        $parser->parse($code);
        $this->assertContextHasDefinedVariableOfType($context, 'var', 'mixed');
    }

    private function parse(string $code): Context
    {
        $context = new Context\Global_();

        $parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);
        $traverser = new NodeTraverser();
        $traverser->addVisitor(new PhpDocAst());
        $traverser->addVisitor(new VariableDeclarationReflector($context));
        $traverser->addVisitor(new ExpressionRuntimeTypeResolver(TypeResolverFactory::default()->create(), $context));

        $traverser->traverse($parser->parse($code));

        return $context;
    }

    private function parseWithContextsSwitch(string $code, Closure $captureContextDecision): array
    {
        $context = new Context\Pointer(new Context\Global_());
        require_once __DIR__ . '/NodeContextSpy.php';
        $contextSpy = new NodeContextSpy($context, $captureContextDecision);

        $parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);

        $traverser = new NodeTraverser();
        $traverser->addVisitor($contextSpy);
        $traverser->addVisitor(new NameResolver());
        $traverser->addVisitor(new PhpDocAst());
        $traverser->addVisitor(new ContextSwitcher($context));
        $traverser->addVisitor(new StructuralElementsReflector($context));
        $traverser->addVisitor(new VariableDeclarationReflector($context));
        $traverser->addVisitor(new ExpressionRuntimeTypeResolver(TypeResolverFactory::default()->create(), $context));

        $traverser->traverse($parser->parse($code));
        return $contextSpy->getCaptured();
    }

    private function assertContextHasDefinedVariableOfType(Context $context, string $name, string $expectedType)
    {
        $var = $context->getVariable($name);
        $type = (string)$var->getType();
        $this->assertTrue($var->isDefined(), sprintf('Variable $%s should be defined.', $name));
        $this->assertEquals(
            $expectedType,
            $type,
            sprintf('Variable $%s should be of type %s, but %s given.', $name, $expectedType, $type)
        );
    }

    private function assertVariableIsNotDefinedInContext(Context $context, string $name)
    {
        $var = $context->getVariable($name);
        $this->assertFalse($var->isDefined(), sprintf('Variable $%s should not be defined.', $name));
    }
}
