<?php

declare(strict_types=1);

namespace Doctrine\ORM;

use BackedEnum;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Cache\QueryCacheProfile;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Result;
use Doctrine\ORM\Cache\Logging\CacheLogger;
use Doctrine\ORM\Cache\QueryCacheKey;
use Doctrine\ORM\Cache\TimestampCacheKey;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\MappingException as ORMMappingException;
use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
use Doctrine\ORM\Query\Parameter;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\Persistence\Mapping\MappingException;
use LogicException;
use Psr\Cache\CacheItemPoolInterface;
use Traversable;

use function array_map;
use function array_shift;
use function assert;
use function count;
use function is_array;
use function is_numeric;
use function is_object;
use function is_scalar;
use function is_string;
use function iterator_to_array;
use function ksort;
use function reset;
use function serialize;
use function sha1;

/**
 * Base contract for ORM queries. Base class for Query and NativeQuery.
 *
 * @link    www.doctrine-project.org
 */
abstract class AbstractQuery
{
    /* Hydration mode constants */

    /**
     * Hydrates an object graph. This is the default behavior.
     */
    public const HYDRATE_OBJECT = 1;

    /**
     * Hydrates an array graph.
     */
    public const HYDRATE_ARRAY = 2;

    /**
     * Hydrates a flat, rectangular result set with scalar values.
     */
    public const HYDRATE_SCALAR = 3;

    /**
     * Hydrates a single scalar value.
     */
    public const HYDRATE_SINGLE_SCALAR = 4;

    /**
     * Very simple object hydrator (optimized for performance).
     */
    public const HYDRATE_SIMPLEOBJECT = 5;

    /**
     * Hydrates scalar column value.
     */
    public const HYDRATE_SCALAR_COLUMN = 6;

    /**
     * The parameter map of this query.
     *
     * @var ArrayCollection|Parameter[]
     * @psalm-var ArrayCollection<int, Parameter>
     */
    protected ArrayCollection $parameters;

    /**
     * The user-specified ResultSetMapping to use.
     */
    protected ResultSetMapping|null $resultSetMapping = null;

    /**
     * The map of query hints.
     *
     * @psalm-var array<string, mixed>
     */
    protected array $hints = [];

    /**
     * The hydration mode.
     *
     * @psalm-var string|AbstractQuery::HYDRATE_*
     */
    protected string|int $hydrationMode = self::HYDRATE_OBJECT;

    protected QueryCacheProfile|null $queryCacheProfile = null;

    /**
     * Whether or not expire the result cache.
     */
    protected bool $expireResultCache = false;

    protected QueryCacheProfile|null $hydrationCacheProfile = null;

    /**
     * Whether to use second level cache, if available.
     */
    protected bool $cacheable = false;

    protected bool $hasCache = false;

    /**
     * Second level cache region name.
     */
    protected string|null $cacheRegion = null;

    /**
     * Second level query cache mode.
     *
     * @psalm-var Cache::MODE_*|null
     */
    protected int|null $cacheMode = null;

    protected CacheLogger|null $cacheLogger = null;

    protected int $lifetime = 0;

    /**
     * Initializes a new instance of a class derived from <tt>AbstractQuery</tt>.
     */
    public function __construct(
        /**
         * The entity manager used by this query object.
         */
        protected EntityManagerInterface $em,
    ) {
        $this->parameters = new ArrayCollection();
        $this->hints      = $em->getConfiguration()->getDefaultQueryHints();
        $this->hasCache   = $this->em->getConfiguration()->isSecondLevelCacheEnabled();

        if ($this->hasCache) {
            $this->cacheLogger = $em->getConfiguration()
                ->getSecondLevelCacheConfiguration()
                ->getCacheLogger();
        }
    }

    /**
     * Enable/disable second level query (result) caching for this query.
     *
     * @return $this
     */
    public function setCacheable(bool $cacheable): static
    {
        $this->cacheable = $cacheable;

        return $this;
    }

    /** @return bool TRUE if the query results are enabled for second level cache, FALSE otherwise. */
    public function isCacheable(): bool
    {
        return $this->cacheable;
    }

    /** @return $this */
    public function setCacheRegion(string $cacheRegion): static
    {
        $this->cacheRegion = $cacheRegion;

        return $this;
    }

    /**
     * Obtain the name of the second level query cache region in which query results will be stored
     *
     * @return string|null The cache region name; NULL indicates the default region.
     */
    public function getCacheRegion(): string|null
    {
        return $this->cacheRegion;
    }

    /** @return bool TRUE if the query cache and second level cache are enabled, FALSE otherwise. */
    protected function isCacheEnabled(): bool
    {
        return $this->cacheable && $this->hasCache;
    }

    public function getLifetime(): int
    {
        return $this->lifetime;
    }

    /**
     * Sets the life-time for this query into second level cache.
     *
     * @return $this
     */
    public function setLifetime(int $lifetime): static
    {
        $this->lifetime = $lifetime;

        return $this;
    }

    /** @psalm-return Cache::MODE_*|null */
    public function getCacheMode(): int|null
    {
        return $this->cacheMode;
    }

    /**
     * @psalm-param Cache::MODE_* $cacheMode
     *
     * @return $this
     */
    public function setCacheMode(int $cacheMode): static
    {
        $this->cacheMode = $cacheMode;

        return $this;
    }

    /**
     * Gets the SQL query that corresponds to this query object.
     * The returned SQL syntax depends on the connection driver that is used
     * by this query object at the time of this method call.
     *
     * @return list<string>|string SQL query
     */
    abstract public function getSQL(): string|array;

    /**
     * Retrieves the associated EntityManager of this Query instance.
     */
    public function getEntityManager(): EntityManagerInterface
    {
        return $this->em;
    }

    /**
     * Frees the resources used by the query object.
     *
     * Resets Parameters, Parameter Types and Query Hints.
     */
    public function free(): void
    {
        $this->parameters = new ArrayCollection();

        $this->hints = $this->em->getConfiguration()->getDefaultQueryHints();
    }

    /**
     * Get all defined parameters.
     *
     * @psalm-return ArrayCollection<int, Parameter>
     */
    public function getParameters(): ArrayCollection
    {
        return $this->parameters;
    }

    /**
     * Gets a query parameter.
     *
     * @param int|string $key The key (index or name) of the bound parameter.
     *
     * @return Parameter|null The value of the bound parameter, or NULL if not available.
     */
    public function getParameter(int|string $key): Parameter|null
    {
        $key = Parameter::normalizeName($key);

        $filteredParameters = $this->parameters->filter(
            static fn (Parameter $parameter): bool => $parameter->getName() === $key
        );

        return ! $filteredParameters->isEmpty() ? $filteredParameters->first() : null;
    }

    /**
     * Sets a collection of query parameters.
     *
     * @param ArrayCollection|mixed[] $parameters
     * @psalm-param ArrayCollection<int, Parameter>|mixed[] $parameters
     *
     * @return $this
     */
    public function setParameters(ArrayCollection|array $parameters): static
    {
        if (is_array($parameters)) {
            /** @psalm-var ArrayCollection<int, Parameter> $parameterCollection */
            $parameterCollection = new ArrayCollection();

            foreach ($parameters as $key => $value) {
                $parameterCollection->add(new Parameter($key, $value));
            }

            $parameters = $parameterCollection;
        }

        $this->parameters = $parameters;

        return $this;
    }

    /**
     * Sets a query parameter.
     *
     * @param string|int                                       $key   The parameter position or name.
     * @param mixed                                            $value The parameter value.
     * @param ParameterType|ArrayParameterType|string|int|null $type  The parameter type. If specified, the given value
     *                                                                will be run through the type conversion of this
     *                                                                type. This is usually not needed for strings and
     *                                                                numeric types.
     *
     * @return $this
     */
    public function setParameter(string|int $key, mixed $value, ParameterType|ArrayParameterType|string|int|null $type = null): static
    {
        $existingParameter = $this->getParameter($key);

        if ($existingParameter !== null) {
            $existingParameter->setValue($value, $type);

            return $this;
        }

        $this->parameters->add(new Parameter($key, $value, $type));

        return $this;
    }

    /**
     * Processes an individual parameter value.
     *
     * @throws ORMInvalidArgumentException
     */
    public function processParameterValue(mixed $value): mixed
    {
        if (is_scalar($value)) {
            return $value;
        }

        if ($value instanceof Collection) {
            $value = iterator_to_array($value);
        }

        if (is_array($value)) {
            $value = $this->processArrayParameterValue($value);

            return $value;
        }

        if ($value instanceof ClassMetadata) {
            return $value->name;
        }

        if ($value instanceof BackedEnum) {
            return $value->value;
        }

        if (! is_object($value)) {
            return $value;
        }

        try {
            $class = DefaultProxyClassNameResolver::getClass($value);
            $value = $this->em->getUnitOfWork()->getSingleIdentifierValue($value);

            if ($value === null) {
                throw ORMInvalidArgumentException::invalidIdentifierBindingEntity($class);
            }
        } catch (MappingException | ORMMappingException) {
            /* Silence any mapping exceptions. These can occur if the object in
               question is not a mapped entity, in which case we just don't do
               any preparation on the value.
               Depending on MappingDriver, either MappingException or
               ORMMappingException is thrown. */

            $value = $this->potentiallyProcessIterable($value);
        }

        return $value;
    }

    /**
     * If no mapping is detected, trying to resolve the value as a Traversable
     */
    private function potentiallyProcessIterable(mixed $value): mixed
    {
        if ($value instanceof Traversable) {
            $value = iterator_to_array($value);
            $value = $this->processArrayParameterValue($value);
        }

        return $value;
    }

    /**
     * Process a parameter value which was previously identified as an array
     *
     * @param mixed[] $value
     *
     * @return mixed[]
     */
    private function processArrayParameterValue(array $value): array
    {
        foreach ($value as $key => $paramValue) {
            $paramValue  = $this->processParameterValue($paramValue);
            $value[$key] = is_array($paramValue) ? reset($paramValue) : $paramValue;
        }

        return $value;
    }

    /**
     * Sets the ResultSetMapping that should be used for hydration.
     *
     * @return $this
     */
    public function setResultSetMapping(ResultSetMapping $rsm): static
    {
        $this->translateNamespaces($rsm);
        $this->resultSetMapping = $rsm;

        return $this;
    }

    /**
     * Gets the ResultSetMapping used for hydration.
     */
    protected function getResultSetMapping(): ResultSetMapping|null
    {
        return $this->resultSetMapping;
    }

    /**
     * Allows to translate entity namespaces to full qualified names.
     */
    private function translateNamespaces(ResultSetMapping $rsm): void
    {
        $translate = fn ($alias): string => $this->em->getClassMetadata($alias)->getName();

        $rsm->aliasMap         = array_map($translate, $rsm->aliasMap);
        $rsm->declaringClasses = array_map($translate, $rsm->declaringClasses);
    }

    /**
     * Set a cache profile for hydration caching.
     *
     * If no result cache driver is set in the QueryCacheProfile, the default
     * result cache driver is used from the configuration.
     *
     * Important: Hydration caching does NOT register entities in the
     * UnitOfWork when retrieved from the cache. Never use result cached
     * entities for requests that also flush the EntityManager. If you want
     * some form of caching with UnitOfWork registration you should use
     * {@see AbstractQuery::setResultCacheProfile()}.
     *
     * @return $this
     *
     * @example
     * $lifetime = 100;
     * $resultKey = "abc";
     * $query->setHydrationCacheProfile(new QueryCacheProfile());
     * $query->setHydrationCacheProfile(new QueryCacheProfile($lifetime, $resultKey));
     */
    public function setHydrationCacheProfile(QueryCacheProfile|null $profile): static
    {
        if ($profile === null) {
            $this->hydrationCacheProfile = null;

            return $this;
        }

        if (! $profile->getResultCache()) {
            $defaultHydrationCacheImpl = $this->em->getConfiguration()->getHydrationCache();
            if ($defaultHydrationCacheImpl) {
                $profile = $profile->setResultCache($defaultHydrationCacheImpl);
            }
        }

        $this->hydrationCacheProfile = $profile;

        return $this;
    }

    public function getHydrationCacheProfile(): QueryCacheProfile|null
    {
        return $this->hydrationCacheProfile;
    }

    /**
     * Set a cache profile for the result cache.
     *
     * If no result cache driver is set in the QueryCacheProfile, the default
     * result cache driver is used from the configuration.
     *
     * @return $this
     */
    public function setResultCacheProfile(QueryCacheProfile|null $profile): static
    {
        if ($profile === null) {
            $this->queryCacheProfile = null;

            return $this;
        }

        if (! $profile->getResultCache()) {
            $defaultResultCache = $this->em->getConfiguration()->getResultCache();
            if ($defaultResultCache) {
                $profile = $profile->setResultCache($defaultResultCache);
            }
        }

        $this->queryCacheProfile = $profile;

        return $this;
    }

    /**
     * Defines a cache driver to be used for caching result sets and implicitly enables caching.
     */
    public function setResultCache(CacheItemPoolInterface|null $resultCache): static
    {
        if ($resultCache === null) {
            if ($this->queryCacheProfile) {
                $this->queryCacheProfile = new QueryCacheProfile($this->queryCacheProfile->getLifetime(), $this->queryCacheProfile->getCacheKey());
            }

            return $this;
        }

        $this->queryCacheProfile = $this->queryCacheProfile
            ? $this->queryCacheProfile->setResultCache($resultCache)
            : new QueryCacheProfile(0, null, $resultCache);

        return $this;
    }

    /**
     * Enables caching of the results of this query, for given or default amount of seconds
     * and optionally specifies which ID to use for the cache entry.
     *
     * @param int|null    $lifetime      How long the cache entry is valid, in seconds.
     * @param string|null $resultCacheId ID to use for the cache entry.
     *
     * @return $this
     */
    public function enableResultCache(int|null $lifetime = null, string|null $resultCacheId = null): static
    {
        $this->setResultCacheLifetime($lifetime);
        $this->setResultCacheId($resultCacheId);

        return $this;
    }

    /**
     * Disables caching of the results of this query.
     *
     * @return $this
     */
    public function disableResultCache(): static
    {
        $this->queryCacheProfile = null;

        return $this;
    }

    /**
     * Defines how long the result cache will be active before expire.
     *
     * @param int|null $lifetime How long the cache entry is valid, in seconds.
     *
     * @return $this
     */
    public function setResultCacheLifetime(int|null $lifetime): static
    {
        $lifetime = (int) $lifetime;

        if ($this->queryCacheProfile) {
            $this->queryCacheProfile = $this->queryCacheProfile->setLifetime($lifetime);

            return $this;
        }

        $this->queryCacheProfile = new QueryCacheProfile($lifetime);

        $cache = $this->em->getConfiguration()->getResultCache();
        if (! $cache) {
            return $this;
        }

        $this->queryCacheProfile = $this->queryCacheProfile->setResultCache($cache);

        return $this;
    }

    /**
     * Defines if the result cache is active or not.
     *
     * @param bool $expire Whether or not to force resultset cache expiration.
     *
     * @return $this
     */
    public function expireResultCache(bool $expire = true): static
    {
        $this->expireResultCache = $expire;

        return $this;
    }

    /**
     * Retrieves if the resultset cache is active or not.
     */
    public function getExpireResultCache(): bool
    {
        return $this->expireResultCache;
    }

    public function getQueryCacheProfile(): QueryCacheProfile|null
    {
        return $this->queryCacheProfile;
    }

    /**
     * Change the default fetch mode of an association for this query.
     *
     * @param class-string $class
     * @psalm-param Mapping\ClassMetadata::FETCH_EAGER|Mapping\ClassMetadata::FETCH_LAZY $fetchMode
     */
    public function setFetchMode(string $class, string $assocName, int $fetchMode): static
    {
        $this->hints['fetchMode'][$class][$assocName] = $fetchMode;

        return $this;
    }

    /**
     * Defines the processing mode to be used during hydration / result set transformation.
     *
     * @param string|int $hydrationMode Doctrine processing mode to be used during hydration process.
     *                                  One of the Query::HYDRATE_* constants.
     * @psalm-param string|AbstractQuery::HYDRATE_* $hydrationMode
     *
     * @return $this
     */
    public function setHydrationMode(string|int $hydrationMode): static
    {
        $this->hydrationMode = $hydrationMode;

        return $this;
    }

    /**
     * Gets the hydration mode currently used by the query.
     *
     * @psalm-return string|AbstractQuery::HYDRATE_*
     */
    public function getHydrationMode(): string|int
    {
        return $this->hydrationMode;
    }

    /**
     * Gets the list of results for the query.
     *
     * Alias for execute(null, $hydrationMode = HYDRATE_OBJECT).
     *
     * @psalm-param string|AbstractQuery::HYDRATE_* $hydrationMode
     */
    public function getResult(string|int $hydrationMode = self::HYDRATE_OBJECT): mixed
    {
        return $this->execute(null, $hydrationMode);
    }

    /**
     * Gets the array of results for the query.
     *
     * Alias for execute(null, HYDRATE_ARRAY).
     *
     * @return mixed[]
     */
    public function getArrayResult(): array
    {
        return $this->execute(null, self::HYDRATE_ARRAY);
    }

    /**
     * Gets one-dimensional array of results for the query.
     *
     * Alias for execute(null, HYDRATE_SCALAR_COLUMN).
     *
     * @return mixed[]
     */
    public function getSingleColumnResult(): array
    {
        return $this->execute(null, self::HYDRATE_SCALAR_COLUMN);
    }

    /**
     * Gets the scalar results for the query.
     *
     * Alias for execute(null, HYDRATE_SCALAR).
     *
     * @return mixed[]
     */
    public function getScalarResult(): array
    {
        return $this->execute(null, self::HYDRATE_SCALAR);
    }

    /**
     * Get exactly one result or null.
     *
     * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode
     *
     * @throws NonUniqueResultException
     */
    public function getOneOrNullResult(string|int|null $hydrationMode = null): mixed
    {
        try {
            $result = $this->execute(null, $hydrationMode);
        } catch (NoResultException) {
            return null;
        }

        if ($this->hydrationMode !== self::HYDRATE_SINGLE_SCALAR && ! $result) {
            return null;
        }

        if (! is_array($result)) {
            return $result;
        }

        if (count($result) > 1) {
            throw new NonUniqueResultException();
        }

        return array_shift($result);
    }

    /**
     * Gets the single result of the query.
     *
     * Enforces the presence as well as the uniqueness of the result.
     *
     * If the result is not unique, a NonUniqueResultException is thrown.
     * If there is no result, a NoResultException is thrown.
     *
     * @psalm-param string|AbstractQuery::HYDRATE_*|null $hydrationMode
     *
     * @throws NonUniqueResultException If the query result is not unique.
     * @throws NoResultException        If the query returned no result.
     */
    public function getSingleResult(string|int|null $hydrationMode = null): mixed
    {
        $result = $this->execute(null, $hydrationMode);

        if ($this->hydrationMode !== self::HYDRATE_SINGLE_SCALAR && ! $result) {
            throw new NoResultException();
        }

        if (! is_array($result)) {
            return $result;
        }

        if (count($result) > 1) {
            throw new NonUniqueResultException();
        }

        return array_shift($result);
    }

    /**
     * Gets the single scalar result of the query.
     *
     * Alias for getSingleResult(HYDRATE_SINGLE_SCALAR).
     *
     * @return bool|float|int|string|null The scalar result.
     *
     * @throws NoResultException        If the query returned no result.
     * @throws NonUniqueResultException If the query result is not unique.
     */
    public function getSingleScalarResult(): mixed
    {
        return $this->getSingleResult(self::HYDRATE_SINGLE_SCALAR);
    }

    /**
     * Sets a query hint. If the hint name is not recognized, it is silently ignored.
     *
     * @return $this
     */
    public function setHint(string $name, mixed $value): static
    {
        $this->hints[$name] = $value;

        return $this;
    }

    /**
     * Gets the value of a query hint. If the hint name is not recognized, FALSE is returned.
     *
     * @return mixed The value of the hint or FALSE, if the hint name is not recognized.
     */
    public function getHint(string $name): mixed
    {
        return $this->hints[$name] ?? false;
    }

    public function hasHint(string $name): bool
    {
        return isset($this->hints[$name]);
    }

    /**
     * Return the key value map of query hints that are currently set.
     *
     * @return array<string,mixed>
     */
    public function getHints(): array
    {
        return $this->hints;
    }

    /**
     * Executes the query and returns an iterable that can be used to incrementally
     * iterate over the result.
     *
     * @psalm-param ArrayCollection<int, Parameter>|mixed[] $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null    $hydrationMode
     *
     * @return iterable<mixed>
     */
    public function toIterable(
        ArrayCollection|array $parameters = [],
        string|int|null $hydrationMode = null,
    ): iterable {
        if ($hydrationMode !== null) {
            $this->setHydrationMode($hydrationMode);
        }

        if (count($parameters) !== 0) {
            $this->setParameters($parameters);
        }

        $rsm = $this->getResultSetMapping();
        if ($rsm === null) {
            throw new LogicException('Uninitialized result set mapping.');
        }

        if ($rsm->isMixed && count($rsm->scalarMappings) > 0) {
            throw QueryException::iterateWithMixedResultNotAllowed();
        }

        $stmt = $this->_doExecute();

        return $this->em->newHydrator($this->hydrationMode)->toIterable($stmt, $rsm, $this->hints);
    }

    /**
     * Executes the query.
     *
     * @psalm-param ArrayCollection<int, Parameter>|mixed[]|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null         $hydrationMode
     */
    public function execute(
        ArrayCollection|array|null $parameters = null,
        string|int|null $hydrationMode = null,
    ): mixed {
        if ($this->cacheable && $this->isCacheEnabled()) {
            return $this->executeUsingQueryCache($parameters, $hydrationMode);
        }

        return $this->executeIgnoreQueryCache($parameters, $hydrationMode);
    }

    /**
     * Execute query ignoring second level cache.
     *
     * @psalm-param ArrayCollection<int, Parameter>|mixed[]|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null         $hydrationMode
     */
    private function executeIgnoreQueryCache(
        ArrayCollection|array|null $parameters = null,
        string|int|null $hydrationMode = null,
    ): mixed {
        if ($hydrationMode !== null) {
            $this->setHydrationMode($hydrationMode);
        }

        if (! empty($parameters)) {
            $this->setParameters($parameters);
        }

        $setCacheEntry = static function ($data): void {
        };

        if ($this->hydrationCacheProfile !== null) {
            [$cacheKey, $realCacheKey] = $this->getHydrationCacheId();

            $cache     = $this->getHydrationCache();
            $cacheItem = $cache->getItem($cacheKey);
            $result    = $cacheItem->isHit() ? $cacheItem->get() : [];

            if (isset($result[$realCacheKey])) {
                return $result[$realCacheKey];
            }

            if (! $result) {
                $result = [];
            }

            $setCacheEntry = static function ($data) use ($cache, $result, $cacheItem, $realCacheKey): void {
                $cache->save($cacheItem->set($result + [$realCacheKey => $data]));
            };
        }

        $stmt = $this->_doExecute();

        if (is_numeric($stmt)) {
            $setCacheEntry($stmt);

            return $stmt;
        }

        $rsm = $this->getResultSetMapping();
        if ($rsm === null) {
            throw new LogicException('Uninitialized result set mapping.');
        }

        $data = $this->em->newHydrator($this->hydrationMode)->hydrateAll($stmt, $rsm, $this->hints);

        $setCacheEntry($data);

        return $data;
    }

    private function getHydrationCache(): CacheItemPoolInterface
    {
        assert($this->hydrationCacheProfile !== null);

        $cache = $this->hydrationCacheProfile->getResultCache();
        assert($cache !== null);

        return $cache;
    }

    /**
     * Load from second level cache or executes the query and put into cache.
     *
     * @psalm-param ArrayCollection<int, Parameter>|mixed[]|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null         $hydrationMode
     */
    private function executeUsingQueryCache(
        ArrayCollection|array|null $parameters = null,
        string|int|null $hydrationMode = null,
    ): mixed {
        $rsm = $this->getResultSetMapping();
        if ($rsm === null) {
            throw new LogicException('Uninitialized result set mapping.');
        }

        $queryCache = $this->em->getCache()->getQueryCache($this->cacheRegion);
        $queryKey   = new QueryCacheKey(
            $this->getHash(),
            $this->lifetime,
            $this->cacheMode ?: Cache::MODE_NORMAL,
            $this->getTimestampKey(),
        );

        $result = $queryCache->get($queryKey, $rsm, $this->hints);

        if ($result !== null) {
            if ($this->cacheLogger) {
                $this->cacheLogger->queryCacheHit($queryCache->getRegion()->getName(), $queryKey);
            }

            return $result;
        }

        $result = $this->executeIgnoreQueryCache($parameters, $hydrationMode);
        $cached = $queryCache->put($queryKey, $rsm, $result, $this->hints);

        if ($this->cacheLogger) {
            $this->cacheLogger->queryCacheMiss($queryCache->getRegion()->getName(), $queryKey);

            if ($cached) {
                $this->cacheLogger->queryCachePut($queryCache->getRegion()->getName(), $queryKey);
            }
        }

        return $result;
    }

    private function getTimestampKey(): TimestampCacheKey|null
    {
        assert($this->resultSetMapping !== null);
        $entityName = reset($this->resultSetMapping->aliasMap);

        if (empty($entityName)) {
            return null;
        }

        $metadata = $this->em->getClassMetadata($entityName);

        return new TimestampCacheKey($metadata->rootEntityName);
    }

    /**
     * Get the result cache id to use to store the result set cache entry.
     * Will return the configured id if it exists otherwise a hash will be
     * automatically generated for you.
     *
     * @return string[] ($key, $hash)
     * @psalm-return array{string, string} ($key, $hash)
     */
    protected function getHydrationCacheId(): array
    {
        $parameters = [];
        $types      = [];

        foreach ($this->getParameters() as $parameter) {
            $parameters[$parameter->getName()] = $this->processParameterValue($parameter->getValue());
            $types[$parameter->getName()]      = $parameter->getType();
        }

        $sql = $this->getSQL();
        assert(is_string($sql));
        $queryCacheProfile      = $this->getHydrationCacheProfile();
        $hints                  = $this->getHints();
        $hints['hydrationMode'] = $this->getHydrationMode();

        ksort($hints);
        assert($queryCacheProfile !== null);

        return $queryCacheProfile->generateCacheKeys($sql, $parameters, $types, $hints);
    }

    /**
     * Set the result cache id to use to store the result set cache entry.
     * If this is not explicitly set by the developer then a hash is automatically
     * generated for you.
     */
    public function setResultCacheId(string|null $id): static
    {
        if (! $this->queryCacheProfile) {
            return $this->setResultCacheProfile(new QueryCacheProfile(0, $id));
        }

        $this->queryCacheProfile = $this->queryCacheProfile->setCacheKey($id);

        return $this;
    }

    /**
     * Executes the query and returns a the resulting Statement object.
     *
     * @return Result|int The executed database statement that holds
     *                    the results, or an integer indicating how
     *                    many rows were affected.
     */
    abstract protected function _doExecute(): Result|int;

    /**
     * Cleanup Query resource when clone is called.
     */
    public function __clone()
    {
        $this->parameters = new ArrayCollection();

        $this->hints = [];
        $this->hints = $this->em->getConfiguration()->getDefaultQueryHints();
    }

    /**
     * Generates a string of currently query to use for the cache second level cache.
     */
    protected function getHash(): string
    {
        $query = $this->getSQL();
        assert(is_string($query));
        $hints  = $this->getHints();
        $params = array_map(function (Parameter $parameter) {
            $value = $parameter->getValue();

            // Small optimization
            // Does not invoke processParameterValue for scalar value
            if (is_scalar($value)) {
                return $value;
            }

            return $this->processParameterValue($value);
        }, $this->parameters->getValues());

        ksort($hints);

        return sha1($query . '-' . serialize($params) . '-' . serialize($hints));
    }
}
