<?php

declare(strict_types=1);

namespace Doctrine\ORM\Proxy;

use Closure;
use Doctrine\Deprecations\Deprecation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\ORMInvalidArgumentException;
use Doctrine\ORM\Persisters\Entity\EntityPersister;
use Doctrine\ORM\UnitOfWork;
use Doctrine\ORM\Utility\IdentifierFlattener;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\Proxy;
use LogicException;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\VarExporter\ProxyHelper;

use function array_combine;
use function array_flip;
use function array_keys;
use function assert;
use function bin2hex;
use function chmod;
use function class_exists;
use function count;
use function dirname;
use function file_exists;
use function file_put_contents;
use function filemtime;
use function func_num_args;
use function is_bool;
use function is_dir;
use function is_int;
use function is_writable;
use function ltrim;
use function mkdir;
use function preg_match_all;
use function random_bytes;
use function rename;
use function rtrim;
use function sprintf;
use function str_replace;
use function strpos;
use function strrpos;
use function strtr;
use function substr;
use function ucfirst;

use const DIRECTORY_SEPARATOR;
use const PHP_VERSION_ID;

/**
 * This factory is used to create proxy objects for entities at runtime.
 */
class ProxyFactory
{
    /**
     * Never autogenerate a proxy and rely that it was generated by some
     * process before deployment.
     */
    public const AUTOGENERATE_NEVER = 0;

    /**
     * Always generates a new proxy in every request.
     *
     * This is only sane during development.
     */
    public const AUTOGENERATE_ALWAYS = 1;

    /**
     * Autogenerate the proxy class when the proxy file does not exist.
     *
     * This strategy causes a file_exists() call whenever any proxy is used the
     * first time in a request.
     */
    public const AUTOGENERATE_FILE_NOT_EXISTS = 2;

    /**
     * Generate the proxy classes using eval().
     *
     * This strategy is only sane for development, and even then it gives me
     * the creeps a little.
     */
    public const AUTOGENERATE_EVAL = 3;

    /**
     * Autogenerate the proxy class when the proxy file does not exist or
     * when the proxied file changed.
     *
     * This strategy causes a file_exists() call whenever any proxy is used the
     * first time in a request. When the proxied file is changed, the proxy will
     * be updated.
     */
    public const AUTOGENERATE_FILE_NOT_EXISTS_OR_CHANGED = 4;

    private const PROXY_CLASS_TEMPLATE = <<<'EOPHP'
<?php

namespace <namespace>;

/**
 * DO NOT EDIT THIS FILE - IT WAS CREATED BY DOCTRINE'S PROXY GENERATOR
 */
class <proxyShortClassName> extends \<className> implements \<baseProxyInterface>
{
    <useLazyGhostTrait>

    public function __isInitialized(): bool
    {
        return isset($this->lazyObjectState) && $this->isLazyObjectInitialized();
    }

    public function __serialize(): array
    {
        <serializeImpl>
    }
}

EOPHP;

    /** The UnitOfWork this factory uses to retrieve persisters */
    private readonly UnitOfWork $uow;

    /** @var self::AUTOGENERATE_* */
    private $autoGenerate;

    /** The IdentifierFlattener used for manipulating identifiers */
    private readonly IdentifierFlattener $identifierFlattener;

    /** @var array<class-string, Closure> */
    private array $proxyFactories = [];

    private readonly string $proxyDir;
    private readonly string $proxyNs;

    /**
     * Initializes a new instance of the <tt>ProxyFactory</tt> class that is
     * connected to the given <tt>EntityManager</tt>.
     *
     * @param EntityManagerInterface    $em           The EntityManager the new factory works for.
     * @param string                    $proxyDir     The directory to use for the proxy classes. It must exist.
     * @param string                    $proxyNs      The namespace to use for the proxy classes.
     * @param bool|self::AUTOGENERATE_* $autoGenerate The strategy for automatically generating proxy classes.
     */
    public function __construct(
        private readonly EntityManagerInterface $em,
        string|null $proxyDir = null,
        string|null $proxyNs = null,
        bool|int $autoGenerate = self::AUTOGENERATE_NEVER,
    ) {
        if (! $em->getConfiguration()->isNativeLazyObjectsEnabled()) {
            if (PHP_VERSION_ID >= 80400) {
                Deprecation::trigger(
                    'doctrine/orm',
                    'https://github.com/doctrine/orm/pull/12005',
                    'Not enabling native lazy objects is deprecated and will be impossible in Doctrine ORM 4.0.',
                );
            }

            if (! $proxyDir) {
                throw ORMInvalidArgumentException::proxyDirectoryRequired();
            }

            if (! $proxyNs) {
                throw ORMInvalidArgumentException::proxyNamespaceRequired();
            }
        } elseif (PHP_VERSION_ID >= 80400 && func_num_args() > 1) {
            Deprecation::trigger(
                'doctrine/orm',
                'https://github.com/doctrine/orm/pull/12005',
                'Passing more than just the EntityManager to the %s is deprecated and will not be possible in Doctrine ORM 4.0.',
                __METHOD__,
            );
        }

        if (is_int($autoGenerate) ? $autoGenerate < 0 || $autoGenerate > 4 : ! is_bool($autoGenerate)) {
            throw ORMInvalidArgumentException::invalidAutoGenerateMode($autoGenerate);
        }

        if ($proxyDir === null && $em->getConfiguration()->isNativeLazyObjectsEnabled()) {
            $proxyDir = '';
        }

        if ($proxyNs === null && $em->getConfiguration()->isNativeLazyObjectsEnabled()) {
            $proxyNs = '';
        }

        $this->proxyDir = $proxyDir;
        $this->proxyNs  = $proxyNs;

        $this->uow                 = $em->getUnitOfWork();
        $this->autoGenerate        = (int) $autoGenerate;
        $this->identifierFlattener = new IdentifierFlattener($this->uow, $em->getMetadataFactory());
    }

    /**
     * @param class-string $className
     * @param array<mixed> $identifier
     */
    public function getProxy(string $className, array $identifier): object
    {
        if ($this->em->getConfiguration()->isNativeLazyObjectsEnabled()) {
            $classMetadata       = $this->em->getClassMetadata($className);
            $entityPersister     = $this->uow->getEntityPersister($className);
            $identifierFlattener = $this->identifierFlattener;

            $proxy = $classMetadata->reflClass->newLazyGhost(static function (object $object) use (
                $identifier,
                $entityPersister,
                $identifierFlattener,
                $classMetadata,
            ): void {
                $original = $entityPersister->loadById($identifier, $object);
                if ($original === null) {
                    throw EntityNotFoundException::fromClassNameAndIdentifier(
                        $classMetadata->getName(),
                        $identifierFlattener->flattenIdentifier($classMetadata, $identifier),
                    );
                }
            }, ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE);

            foreach ($identifier as $idField => $value) {
                $classMetadata->propertyAccessors[$idField]->setValue($proxy, $value);
            }

            return $proxy;
        }

        $proxyFactory = $this->proxyFactories[$className] ?? $this->getProxyFactory($className);

        return $proxyFactory($identifier);
    }

    /**
     * Generates proxy classes for all given classes.
     *
     * @param ClassMetadata[] $classes  The classes (ClassMetadata instances) for which to generate proxies.
     * @param string|null     $proxyDir The target directory of the proxy classes. If not specified, the
     *                                  directory configured on the Configuration of the EntityManager used
     *                                  by this factory is used.
     *
     * @return int Number of generated proxies.
     */
    public function generateProxyClasses(array $classes, string|null $proxyDir = null): int
    {
        if ($this->em->getConfiguration()->isNativeLazyObjectsEnabled()) {
            return 0;
        }

        $generated = 0;

        foreach ($classes as $class) {
            if ($this->skipClass($class)) {
                continue;
            }

            $proxyFileName  = $this->getProxyFileName($class->getName(), $proxyDir ?: $this->proxyDir);
            $proxyClassName = self::generateProxyClassName($class->getName(), $this->proxyNs);

            $this->generateProxyClass($class, $proxyFileName, $proxyClassName);

            ++$generated;
        }

        return $generated;
    }

    protected function skipClass(ClassMetadata $metadata): bool
    {
        return $metadata->isMappedSuperclass
            || $metadata->isEmbeddedClass
            || $metadata->getReflectionClass()->isAbstract();
    }

    /**
     * Creates a closure capable of initializing a proxy
     *
     * @return Closure(InternalProxy, array):void
     *
     * @throws EntityNotFoundException
     */
    private function createLazyInitializer(ClassMetadata $classMetadata, EntityPersister $entityPersister, IdentifierFlattener $identifierFlattener): Closure
    {
        return static function (InternalProxy $proxy, array $identifier) use ($entityPersister, $classMetadata, $identifierFlattener): void {
            $original = $entityPersister->loadById($identifier);

            if ($original === null) {
                throw EntityNotFoundException::fromClassNameAndIdentifier(
                    $classMetadata->getName(),
                    $identifierFlattener->flattenIdentifier($classMetadata, $identifier),
                );
            }

            if ($proxy === $original) {
                return;
            }

            $class = $entityPersister->getClassMetadata();

            foreach ($class->getPropertyAccessors() as $name => $property) {
                if (isset($identifier[$name])) {
                    continue;
                }

                $property->setValue($proxy, $property->getValue($original));
            }
        };
    }

    private function getProxyFileName(string $className, string $baseDirectory): string
    {
        $baseDirectory = $baseDirectory ?: $this->proxyDir;

        return rtrim($baseDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . InternalProxy::MARKER
            . str_replace('\\', '', $className) . '.php';
    }

    private function getProxyFactory(string $className): Closure
    {
        $skippedProperties = [];
        $class             = $this->em->getClassMetadata($className);
        $identifiers       = array_flip($class->getIdentifierFieldNames());
        $filter            = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE;
        $reflector         = $class->getReflectionClass();

        while ($reflector) {
            foreach ($reflector->getProperties($filter) as $property) {
                $name = $property->name;

                if (PHP_VERSION_ID >= 80400 && count($property->getHooks()) > 0) {
                    throw new LogicException(sprintf(
                        'Doctrine ORM does not support property hook on %s::%s without using native lazy objects. Check https://github.com/doctrine/orm/issues/11624 for details of versions that support property hooks.',
                        $property->getDeclaringClass()->getName(),
                        $property->getName(),
                    ));
                }

                if ($property->isStatic() || ! isset($identifiers[$name])) {
                    continue;
                }

                $prefix = $property->isPrivate() ? "\0" . $property->class . "\0" : ($property->isProtected() ? "\0*\0" : '');

                $skippedProperties[$prefix . $name] = true;
            }

            $filter    = ReflectionProperty::IS_PRIVATE;
            $reflector = $reflector->getParentClass();
        }

        $className        = $class->getName(); // aliases and case sensitivity
        $entityPersister  = $this->uow->getEntityPersister($className);
        $initializer      = $this->createLazyInitializer($class, $entityPersister, $this->identifierFlattener);
        $proxyClassName   = $this->loadProxyClass($class);
        $identifierFields = [];

        foreach (array_keys($identifiers) as $identifier) {
            $identifierFields[$identifier] = $class->getPropertyAccessor($identifier);
        }

        $proxyFactory = Closure::bind(static function (array $identifier) use ($initializer, $skippedProperties, $identifierFields, $className): InternalProxy {
            $proxy = self::createLazyGhost(static function (InternalProxy $object) use ($initializer, $identifier): void {
                $initializer($object, $identifier);
            }, $skippedProperties);

            foreach ($identifierFields as $idField => $reflector) {
                if (! isset($identifier[$idField])) {
                    throw ORMInvalidArgumentException::missingPrimaryKeyValue($className, $idField);
                }

                assert($reflector !== null);
                $reflector->setValue($proxy, $identifier[$idField]);
            }

            return $proxy;
        }, null, $proxyClassName);

        return $this->proxyFactories[$className] = $proxyFactory;
    }

    private function loadProxyClass(ClassMetadata $class): string
    {
        $proxyClassName = self::generateProxyClassName($class->getName(), $this->proxyNs);

        if (class_exists($proxyClassName, false)) {
            return $proxyClassName;
        }

        if ($this->autoGenerate === self::AUTOGENERATE_EVAL) {
            $this->generateProxyClass($class, null, $proxyClassName);

            return $proxyClassName;
        }

        $fileName = $this->getProxyFileName($class->getName(), $this->proxyDir);

        switch ($this->autoGenerate) {
            case self::AUTOGENERATE_FILE_NOT_EXISTS_OR_CHANGED:
                if (file_exists($fileName) && filemtime($fileName) >= filemtime($class->getReflectionClass()->getFileName())) {
                    break;
                }
                // no break
            case self::AUTOGENERATE_FILE_NOT_EXISTS:
                if (file_exists($fileName)) {
                    break;
                }
                // no break
            case self::AUTOGENERATE_ALWAYS:
                $this->generateProxyClass($class, $fileName, $proxyClassName);
                break;
        }

        require $fileName;

        return $proxyClassName;
    }

    private function generateProxyClass(ClassMetadata $class, string|null $fileName, string $proxyClassName): void
    {
        $i            = strrpos($proxyClassName, '\\');
        $placeholders = [
            '<className>' => $class->getName(),
            '<namespace>' => substr($proxyClassName, 0, $i),
            '<proxyShortClassName>' => substr($proxyClassName, 1 + $i),
            '<baseProxyInterface>' => InternalProxy::class,
        ];

        preg_match_all('(<([a-zA-Z]+)>)', self::PROXY_CLASS_TEMPLATE, $placeholderMatches);

        foreach (array_combine($placeholderMatches[0], $placeholderMatches[1]) as $placeholder => $name) {
            $placeholders[$placeholder] ?? $placeholders[$placeholder] = $this->{'generate' . ucfirst($name)}($class);
        }

        $proxyCode = strtr(self::PROXY_CLASS_TEMPLATE, $placeholders);

        if (! $fileName) {
            if (! class_exists($proxyClassName)) {
                eval(substr($proxyCode, 5));
            }

            return;
        }

        $parentDirectory = dirname($fileName);

        if (! is_dir($parentDirectory) && ! @mkdir($parentDirectory, 0775, true)) {
            throw ORMInvalidArgumentException::proxyDirectoryNotWritable($this->proxyDir);
        }

        if (! is_writable($parentDirectory)) {
            throw ORMInvalidArgumentException::proxyDirectoryNotWritable($this->proxyDir);
        }

        $tmpFileName = $fileName . '.' . bin2hex(random_bytes(12));

        file_put_contents($tmpFileName, $proxyCode);
        @chmod($tmpFileName, 0664);
        rename($tmpFileName, $fileName);
    }

    private function generateUseLazyGhostTrait(ClassMetadata $class): string
    {
        // @phpstan-ignore staticMethod.deprecated (Because we support Symfony < 7.3)
        $code = ProxyHelper::generateLazyGhost($class->getReflectionClass());
        $code = substr($code, 7 + (int) strpos($code, "\n{"));
        $code = substr($code, 0, (int) strpos($code, "\n}"));
        $code = str_replace('LazyGhostTrait;', str_replace("\n    ", "\n", 'LazyGhostTrait {
            initializeLazyObject as private;
            setLazyObjectAsInitialized as public __setInitialized;
            isLazyObjectInitialized as private;
            createLazyGhost as private;
            resetLazyObject as private;
        }

        public function __load(): void
        {
            $this->initializeLazyObject();
        }
        '), $code);

        return $code;
    }

    private function generateSerializeImpl(ClassMetadata $class): string
    {
        $reflector  = $class->getReflectionClass();
        $properties = $reflector->hasMethod('__serialize') ? 'parent::__serialize()' : '(array) $this';

        $code = '$properties = ' . $properties . ';
        unset($properties["\0" . self::class . "\0lazyObjectState"]);

        ';

        if ($reflector->hasMethod('__serialize') || ! $reflector->hasMethod('__sleep')) {
            return $code . 'return $properties;';
        }

        return $code . '$data = [];

        foreach (parent::__sleep() as $name) {
            $value = $properties[$k = $name] ?? $properties[$k = "\0*\0$name"] ?? $properties[$k = "\0' . $reflector->name . '\0$name"] ?? $k = null;

            if (null === $k) {
                trigger_error(sprintf(\'serialize(): "%s" returned as member variable from __sleep() but does not exist\', $name), \E_USER_NOTICE);
            } else {
                $data[$k] = $value;
            }
        }

        return $data;';
    }

    private static function generateProxyClassName(string $className, string $proxyNamespace): string
    {
        return rtrim($proxyNamespace, '\\') . '\\' . Proxy::MARKER . '\\' . ltrim($className, '\\');
    }
}
