<?php

declare(strict_types=1);

namespace ProxyManagerTest\Functional;

use Closure;
use Generator;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\Framework\MockObject\MockObject as Mock;
use PHPUnit\Framework\TestCase;
use ProxyManager\Factory\LazyLoadingValueHolderFactory;
use ProxyManager\Proxy\LazyLoadingInterface;
use ProxyManager\Proxy\VirtualProxyInterface;
use ProxyManagerTestAsset\BaseClass;
use ProxyManagerTestAsset\BaseInterface;
use ProxyManagerTestAsset\CallableInterface;
use ProxyManagerTestAsset\ClassWithCounterConstructor;
use ProxyManagerTestAsset\ClassWithDynamicArgumentsMethod;
use ProxyManagerTestAsset\ClassWithMagicMethods;
use ProxyManagerTestAsset\ClassWithMethodWithByRefVariadicFunction;
use ProxyManagerTestAsset\ClassWithMethodWithVariadicFunction;
use ProxyManagerTestAsset\ClassWithParentHint;
use ProxyManagerTestAsset\ClassWithPublicArrayProperty;
use ProxyManagerTestAsset\ClassWithPublicProperties;
use ProxyManagerTestAsset\ClassWithPublicStringNullableTypedProperty;
use ProxyManagerTestAsset\ClassWithPublicStringTypedProperty;
use ProxyManagerTestAsset\ClassWithSelfHint;
use ProxyManagerTestAsset\EmptyClass;
use ProxyManagerTestAsset\NeverCounter;
use ProxyManagerTestAsset\OtherObjectAccessClass;
use ProxyManagerTestAsset\VoidCounter;
use ReflectionClass;
use ReflectionProperty;
use RuntimeException;
use stdClass;

use function array_values;
use function assert;
use function get_class;
use function is_callable;
use function random_int;
use function serialize;
use function ucfirst;
use function uniqid;
use function unserialize;

/**
 * Tests for {@see \ProxyManager\ProxyGenerator\LazyLoadingValueHolderGenerator} produced objects
 *
 * @group Functional
 * @coversNothing
 */
final class LazyLoadingValueHolderFunctionalTest extends TestCase
{
    /**
     * @param mixed[] $params
     * @psalm-param class-string<OriginalClass> $className
     * @psalm-param OriginalClass $instance
     *
     * @dataProvider getProxyMethods
     * @psalm-template OriginalClass
     */
    public function testMethodCalls(string $className, $instance, string $method, array $params, $expectedValue): void
    {
        $proxy = (new LazyLoadingValueHolderFactory())->createProxy(
            $className,
            $this->createInitializer($className, $instance)
        );

        self::assertFalse($proxy->isProxyInitialized());

        $callProxyMethod = [$proxy, $method];
        assert(is_callable($callProxyMethod));
        $parameterValues = array_values($params);

        self::assertSame($expectedValue, $callProxyMethod(...$parameterValues));
        self::assertTrue($proxy->isProxyInitialized());
        self::assertSame($instance, $proxy->getWrappedValueHolderValue());
    }

    /**
     * @param mixed[] $params
     * @psalm-param class-string<OriginalClass> $className
     * @psalm-param OriginalClass $instance
     *
     * @dataProvider getProxyMethods
     * @psalm-template OriginalClass
     */
    public function testMethodCallsAfterUnSerialization(
        string $className,
        $instance,
        string $method,
        array $params,
        $expectedValue
    ): void {
        $proxy = unserialize(serialize((new LazyLoadingValueHolderFactory())->createProxy(
            $className,
            $this->createInitializer($className, $instance)
        )));
        assert($proxy instanceof VirtualProxyInterface);

        self::assertTrue($proxy->isProxyInitialized());

        $callProxyMethod = [$proxy, $method];
        $parameterValues = array_values($params);

        self::assertIsCallable($callProxyMethod);

        self::assertSame($expectedValue, $callProxyMethod(...$parameterValues));
        self::assertEquals($instance, $proxy->getWrappedValueHolderValue());
    }

    /**
     * @param mixed[] $params
     * @psalm-param class-string<OriginalClass> $className
     * @psalm-param OriginalClass $instance
     *
     * @dataProvider getProxyMethods
     * @psalm-template OriginalClass
     */
    public function testMethodCallsAfterCloning(
        string $className,
        $instance,
        string $method,
        array $params,
        $expectedValue
    ): void {
        $proxy  = (new LazyLoadingValueHolderFactory())->createProxy(
            $className,
            $this->createInitializer($className, $instance)
        );
        $cloned = clone $proxy;

        self::assertTrue($cloned->isProxyInitialized());
        self::assertNotSame($proxy->getWrappedValueHolderValue(), $cloned->getWrappedValueHolderValue());

        $callProxyMethod = [$cloned, $method];
        $parameterValues = array_values($params);

        self::assertIsCallable($callProxyMethod);

        self::assertSame($expectedValue, $callProxyMethod(...$parameterValues));
        self::assertEquals($instance, $cloned->getWrappedValueHolderValue());
    }

    /**
     * @dataProvider getPropertyAccessProxies
     */
    public function testPropertyReadAccess(
        $instance,
        VirtualProxyInterface $proxy,
        string $publicProperty,
        $propertyValue
    ): void {
        self::assertSame($propertyValue, $proxy->$publicProperty);
        self::assertTrue($proxy->isProxyInitialized());
        self::assertEquals($instance, $proxy->getWrappedValueHolderValue());
    }

    /**
     * @dataProvider getPropertyAccessProxies
     */
    public function testPropertyWriteAccess($instance, VirtualProxyInterface $proxy, string $publicProperty): void
    {
        $newValue               = uniqid('', true);
        $proxy->$publicProperty = $newValue;

        self::assertTrue($proxy->isProxyInitialized());
        self::assertSame($newValue, $proxy->$publicProperty);

        $wrappedValue = $proxy->getWrappedValueHolderValue();

        self::assertNotNull($wrappedValue);

        self::assertSame($newValue, $wrappedValue->$publicProperty);
    }

    /**
     * @dataProvider getPropertyAccessProxies
     */
    public function testPropertyExistence($instance, VirtualProxyInterface $proxy, string $publicProperty): void
    {
        self::assertSame(isset($instance->$publicProperty), isset($proxy->$publicProperty));
        self::assertTrue($proxy->isProxyInitialized());
        self::assertEquals($instance, $proxy->getWrappedValueHolderValue());
    }

    /**
     * @dataProvider getPropertyAccessProxies
     */
    public function testPropertyAbsence($instance, VirtualProxyInterface $proxy, string $publicProperty): void
    {
        $propertyType = \PHP_VERSION_ID >= 70400 ? (new ReflectionProperty($instance, $publicProperty))->getType() : null;

        if ($propertyType !== null && ! $propertyType->allowsNull()) {
            self::markTestSkipped('Non-nullable typed properties cannot be removed/unset');
        }

        $instance                  = $proxy->getWrappedValueHolderValue() ?? $instance;
        $instance->$publicProperty = null;
        self::assertFalse(isset($proxy->$publicProperty));
        self::assertTrue($proxy->isProxyInitialized());
    }

    /**
     * @dataProvider getPropertyAccessProxies
     */
    public function testPropertyUnset($instance, VirtualProxyInterface $proxy, string $publicProperty): void
    {
        $instance = $proxy->getWrappedValueHolderValue() ?? $instance;
        unset($proxy->$publicProperty);

        self::assertTrue($proxy->isProxyInitialized());

        self::assertFalse(isset($instance->$publicProperty));
        self::assertFalse(isset($proxy->$publicProperty));
    }

    /**
     * Verifies that accessing a public property containing an array behaves like in a normal context
     */
    public function testCanWriteToArrayKeysInPublicProperty(): void
    {
        $proxy = (new LazyLoadingValueHolderFactory())->createProxy(
            ClassWithPublicArrayProperty::class,
            $this->createInitializer(ClassWithPublicArrayProperty::class, new ClassWithPublicArrayProperty())
        );

        $proxy->arrayProperty['foo'] = 'bar';

        self::assertByRefVariableValueSame('bar', $proxy->arrayProperty['foo']);

        $proxy->arrayProperty = ['tab' => 'taz'];

        self::assertSame(['tab' => 'taz'], $proxy->arrayProperty);
    }

    /**
     * Verifies that public properties retrieved via `__get` don't get modified in the object itself
     */
    public function testWillNotModifyRetrievedPublicProperties(): void
    {
        $proxy    = (new LazyLoadingValueHolderFactory())->createProxy(
            ClassWithPublicProperties::class,
            $this->createInitializer(ClassWithPublicProperties::class, new ClassWithPublicProperties())
        );
        $variable = $proxy->property0;

        self::assertByRefVariableValueSame('property0', $variable);

        $variable = 'foo';

        self::assertSame('property0', $proxy->property0);
        self::assertByRefVariableValueSame('foo', $variable);
    }

    /**
     * Verifies that public properties references retrieved via `__get` modify in the object state
     */
    public function testWillModifyByRefRetrievedPublicProperties(): void
    {
        $proxy    = (new LazyLoadingValueHolderFactory())->createProxy(
            ClassWithPublicProperties::class,
            $this->createInitializer(ClassWithPublicProperties::class, new ClassWithPublicProperties())
        );
        $variable = & $proxy->property0;

        self::assertByRefVariableValueSame('property0', $variable);

        $variable = 'foo';

        self::assertSame('foo', $proxy->property0);
        self::assertByRefVariableValueSame('foo', $variable);
    }

    /**
     * @group 16
     *
     * Verifies that initialization of a value holder proxy may happen multiple times
     */
    public function testWillAllowMultipleProxyInitialization(): void
    {
        $counter = 0;

        $proxy = (new LazyLoadingValueHolderFactory())->createProxy(
            BaseClass::class,
            static function (& $wrappedInstance) use (& $counter): bool {
                $wrappedInstance = new BaseClass();

                // sometimes, we need to declare `/** @var type $var */` to declare a by-ref variable type - phpcs can't understand it
                // phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.NoAssignment
                /** @var int $counter */
                $wrappedInstance->publicProperty = (string) ($counter += 1);

                return true;
            }
        );

        self::assertSame('1', $proxy->publicProperty);
        self::assertSame('2', $proxy->publicProperty);
        self::assertSame('3', $proxy->publicProperty);
    }

    /**
     * @group 115
     * @group 175
     */
    public function testWillBehaveLikeObjectWithNormalConstructor(): void
    {
        $instance = new ClassWithCounterConstructor(10);

        self::assertSame(10, $instance->amount, 'Verifying that test asset works as expected');
        self::assertSame(10, $instance->getAmount(), 'Verifying that test asset works as expected');
        $instance->__construct(3);
        self::assertSame(13, $instance->amount, 'Verifying that test asset works as expected');
        self::assertSame(13, $instance->getAmount(), 'Verifying that test asset works as expected');

        $proxyName = get_class(
            (new LazyLoadingValueHolderFactory())
                ->createProxy(
                    ClassWithCounterConstructor::class,
                    static function (): bool {
                        return true;
                    }
                )
        );

        /** @psalm-suppress UnsafeInstantiation it is allowed (by design) to instantiate these proxies */
        $proxy = new $proxyName(15);

        self::assertSame(15, $proxy->amount, 'Verifying that the proxy constructor works as expected');
        self::assertSame(15, $proxy->getAmount(), 'Verifying that the proxy constructor works as expected');
        $proxy->__construct(5);
        self::assertSame(20, $proxy->amount, 'Verifying that the proxy constructor works as expected');
        self::assertSame(20, $proxy->getAmount(), 'Verifying that the proxy constructor works as expected');
    }

    /**
     * @group 265
     */
    public function testWillForwardVariadicByRefArguments(): void
    {
        $object = (new LazyLoadingValueHolderFactory())->createProxy(
            ClassWithMethodWithByRefVariadicFunction::class,
            static function (& $wrappedInstance): bool {
                $wrappedInstance = new ClassWithMethodWithByRefVariadicFunction();

                return true;
            }
        );

        $parameters = ['a', 'b', 'c'];

        // first, testing normal variadic behavior (verifying we didn't screw up in the test asset)
        self::assertSame(['a', 'changed', 'c'], (new ClassWithMethodWithByRefVariadicFunction())->tuz(...$parameters));
        self::assertSame(['a', 'changed', 'c'], $object->tuz(...$parameters));
        self::assertSame(['a', 'changed', 'c'], $parameters, 'by-ref variadic parameter was changed');
    }

    /**
     * This test documents a known limitation: `func_get_args()` (and similars) don't work in proxied APIs.
     * If you manage to make this test pass, then please do send a patch
     *
     * @group 265
     */
    public function testWillNotForwardDynamicArguments(): void
    {
        $object = (new LazyLoadingValueHolderFactory())->createProxy(
            ClassWithDynamicArgumentsMethod::class,
            static function (& $wrappedInstance): bool {
                $wrappedInstance = new ClassWithDynamicArgumentsMethod();

                return true;
            }
        );

        self::assertSame(['a', 'b'], (new ClassWithDynamicArgumentsMethod())->dynamicArgumentsMethod('a', 'b'));

        $this->expectException(ExpectationFailedException::class);

        self::assertSame(['a', 'b'], $object->dynamicArgumentsMethod('a', 'b'));
    }

    /**
     * @psalm-param (CallableInterface&Mock)|null $initializerMatcher
     * @psalm-param class-string<TClass> $className
     * @psalm-param TClass $realInstance
     *
     * @return Closure(
     *  TClass|null,
     *  VirtualProxyInterface,
     *  string,
     *  array,
     *  ?Closure
     * ) : bool
     *
     * @template TClass
     */
    private function createInitializer(string $className, $realInstance, ?Mock $initializerMatcher = null): Closure
    {
        if (! $initializerMatcher) {
            $initializerMatcher = $this->createMock(CallableInterface::class);

            $initializerMatcher
                ->expects(self::once())
                ->method('__invoke')
                ->with(
                    self::logicalAnd(
                        self::isInstanceOf(VirtualProxyInterface::class),
                        self::isInstanceOf($className)
                    ),
                    $realInstance
                );
        }

        return /**
                * @psalm-param ?TClass $wrappedObject
                */
        static function (
            & $wrappedObject,
            VirtualProxyInterface $proxy,
            string $method,
            array $params,
            ?Closure & $initializer
        ) use (
            $initializerMatcher,
            $realInstance
        ): bool {
            $initializer = null;

            $wrappedObject = $realInstance;

            $initializerMatcher->__invoke($proxy, $wrappedObject, $method, $params);

            return true;
        };
    }

    /**
     * Generates a list of object, invoked method, parameters, expected result
     *
     * @return string[][]|object[][]|bool[][]|mixed[][][]
     */
    public function getProxyMethods(): array
    {
        $selfHintParam = new ClassWithSelfHint();
        $empty         = new EmptyClass();

        return [
            [
                BaseClass::class,
                new BaseClass(),
                'publicMethod',
                [],
                'publicMethodDefault',
            ],
            [
                BaseClass::class,
                new BaseClass(),
                'publicTypeHintedMethod',
                [new stdClass()],
                'publicTypeHintedMethodDefault',
            ],
            [
                BaseClass::class,
                new BaseClass(),
                'publicByReferenceMethod',
                [],
                'publicByReferenceMethodDefault',
            ],
            [
                BaseInterface::class,
                new BaseClass(),
                'publicMethod',
                [],
                'publicMethodDefault',
            ],
            [
                ClassWithSelfHint::class,
                new ClassWithSelfHint(),
                'selfHintMethod',
                ['parameter' => $selfHintParam],
                $selfHintParam,
            ],
            [
                ClassWithParentHint::class,
                new ClassWithParentHint(),
                'parentHintMethod',
                ['parameter' => $empty],
                $empty,
            ],
            [
                ClassWithMethodWithVariadicFunction::class,
                new ClassWithMethodWithVariadicFunction(),
                'buz',
                ['Ocramius', 'Malukenho'],
                ['Ocramius', 'Malukenho'],
            ],
            [
                ClassWithMethodWithByRefVariadicFunction::class,
                new ClassWithMethodWithByRefVariadicFunction(),
                'tuz',
                ['Ocramius', 'Malukenho'],
                ['Ocramius', 'changed'],
            ],
            [
                ClassWithMagicMethods::class,
                new ClassWithMagicMethods(),
                '__get',
                ['parameterName'],
                'parameterName',
            ],
            [
                ClassWithMagicMethods::class,
                new ClassWithMagicMethods(),
                '__set',
                ['foo', 'bar'],
                ['foo' => 'bar'],
            ],
            [
                ClassWithMagicMethods::class,
                new ClassWithMagicMethods(),
                '__isset',
                ['example'],
                true,
            ],
            [
                ClassWithMagicMethods::class,
                new ClassWithMagicMethods(),
                '__isset',
                [''],
                false,
            ],
            [
                ClassWithMagicMethods::class,
                new ClassWithMagicMethods(),
                '__unset',
                ['example'],
                true,
            ],
        ];
    }

    /**
     * Generates proxies and instances with a public property to feed to the property accessor methods
     *
     * @return array<int, array<int, object|VirtualProxyInterface|string>>
     */
    public function getPropertyAccessProxies(): array
    {
        $instance1             = new BaseClass();
        $instance2             = new BaseClass();

        $factory    = new LazyLoadingValueHolderFactory();
        $serialized = unserialize(serialize($factory->createProxy(
            BaseClass::class,
            $this->createInitializer(BaseClass::class, $instance2)
        )));
        assert($serialized instanceof VirtualProxyInterface);

        $tests = [
            [
                $instance1,
                $factory->createProxy(
                    BaseClass::class,
                    $this->createInitializer(BaseClass::class, $instance1)
                ),
                'publicProperty',
                'publicPropertyDefault',
            ],
            [
                $instance2,
                $serialized,
                'publicProperty',
                'publicPropertyDefault',
            ],
        ];

        if (\PHP_VERSION_ID < 70400) {
            return $tests;
        }

        $typedProperty         = new ClassWithPublicStringTypedProperty();
        $typedNullableProperty = new ClassWithPublicStringNullableTypedProperty();

        $typedProperty->typedProperty                 = 'Typed property initialized value';
        $typedNullableProperty->typedNullableProperty = 'Typed nullable property initialized value';

        return array_merge($tests, [
            [
                $typedProperty,
                $factory->createProxy(
                    ClassWithPublicStringTypedProperty::class,
                    $this->createInitializer(ClassWithPublicStringTypedProperty::class, $typedProperty)
                ),
                'typedProperty',
                'Typed property initialized value',
            ],
            [
                $typedNullableProperty,
                $factory->createProxy(
                    ClassWithPublicStringNullableTypedProperty::class,
                    $this->createInitializer(ClassWithPublicStringNullableTypedProperty::class, $typedNullableProperty)
                ),
                'typedNullableProperty',
                'Typed nullable property initialized value',
            ],
        ]);
    }

    /**
     * @group 276
     * @dataProvider getMethodsThatAccessPropertiesOnOtherObjectsInTheSameScope
     */
    public function testWillLazyLoadMembersOfOtherProxiesWithTheSamePrivateScope(
        $callerObject,
        $realInstance,
        string $method,
        string $expectedValue
    ): void {
        $className = get_class($realInstance);
        $proxy     = (new LazyLoadingValueHolderFactory())->createProxy(
            $className,
            $this->createInitializer($className, $realInstance)
        );

        $accessor = [$callerObject, $method];

        self::assertIsCallable($accessor);
        self::assertFalse($proxy->isProxyInitialized());
        self::assertSame($expectedValue, $accessor($proxy));
        self::assertTrue($proxy->isProxyInitialized());
    }

    /**
     * @group 276
     * @dataProvider getMethodsThatAccessPropertiesOnOtherObjectsInTheSameScope
     */
    public function testWillFetchMembersOfOtherDeSerializedProxiesWithTheSamePrivateScope(
        $callerObject,
        $realInstance,
        string $method,
        string $expectedValue
    ): void {
        $className = get_class($realInstance);
        $proxy     = unserialize(serialize((new LazyLoadingValueHolderFactory())->createProxy(
            $className,
            $this->createInitializer($className, $realInstance)
        )));
        assert($proxy instanceof LazyLoadingInterface);

        $accessor = [$callerObject, $method];
        assert(is_callable($accessor));

        self::assertTrue($proxy->isProxyInitialized());
        self::assertSame($expectedValue, $accessor($proxy));
    }

    /**
     * @group 276
     * @dataProvider getMethodsThatAccessPropertiesOnOtherObjectsInTheSameScope
     */
    public function testWillFetchMembersOfOtherClonedProxiesWithTheSamePrivateScope(
        $callerObject,
        $realInstance,
        string $method,
        string $expectedValue
    ): void {
        $className = get_class($realInstance);
        $proxy     = clone (new LazyLoadingValueHolderFactory())->createProxy(
            $className,
            $this->createInitializer($className, $realInstance)
        );

        $accessor = [$callerObject, $method];
        assert(is_callable($accessor));

        self::assertTrue($proxy->isProxyInitialized());
        self::assertSame($expectedValue, $accessor($proxy));
    }

    /**
     * @group 327
     */
    public function testWillExecuteLogicInAVoidMethod(): void
    {
        $proxy = (new LazyLoadingValueHolderFactory())->createProxy(
            VoidCounter::class,
            $this->createInitializer(VoidCounter::class, new VoidCounter())
        );

        $increment = random_int(100, 1000);

        $proxy->increment($increment);

        self::assertSame($increment, $proxy->counter);
    }

    /**
     * @requires PHP 8.1
     *
     * @psalm-suppress UndefinedClass Class, interface or enum named never does not exist
     */
    public function testWillExecuteLogicInANeverMethod(): void
    {
        $proxy = (new LazyLoadingValueHolderFactory())->createProxy(
            NeverCounter::class,
            $this->createInitializer(NeverCounter::class, new NeverCounter())
        );

        $increment = random_int(100, 1000);

        try {
            $proxy->increment($increment);
            $this->fail('RuntimeException expected');
        } catch (RuntimeException $e) {
            // Do nothing
        }

        self::assertSame($increment, $proxy->counter);
    }

    public function getMethodsThatAccessPropertiesOnOtherObjectsInTheSameScope(): Generator
    {
        foreach ((new ReflectionClass(OtherObjectAccessClass::class))->getProperties() as $property) {
            $propertyName  = $property->getName();
            $expectedValue = uniqid('', true);

            // callee is an actual object
            yield OtherObjectAccessClass::class . '#$' . $propertyName => [
                new OtherObjectAccessClass(),
                $this->buildInstanceWithValues(new OtherObjectAccessClass(), [$propertyName => $expectedValue]),
                'get' . ucfirst($propertyName),
                $expectedValue,
            ];

            $expectedValue = uniqid('', true);

            // callee is a proxy (not to be lazy-loaded!)
            yield '(proxy) ' . OtherObjectAccessClass::class . '#$' . $propertyName => [
                (new LazyLoadingValueHolderFactory())->createProxy(
                    OtherObjectAccessClass::class,
                    $this->createInitializer(
                        OtherObjectAccessClass::class,
                        new OtherObjectAccessClass()
                    )
                ),
                $this->buildInstanceWithValues(new OtherObjectAccessClass(), [$propertyName => $expectedValue]),
                'get' . ucfirst($propertyName),
                $expectedValue,
            ];
        }
    }

    /** @param array<string, string> $values */
    private function buildInstanceWithValues($instance, array $values)
    {
        foreach ($values as $property => $value) {
            $property = new ReflectionProperty($instance, $property);

            $property->setAccessible(true);

            $property->setValue($instance, $value);
        }

        return $instance;
    }

    private static function assertByRefVariableValueSame($expected, & $actual): void
    {
        self::assertSame($expected, $actual);
    }
}
