<?php

declare(strict_types=1);

namespace Doctrine\ORM;

use Doctrine\Common\Cache\Cache;
use Doctrine\Common\Cache\Psr6\CacheAdapter;
use Doctrine\Common\Cache\Psr6\DoctrineProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Cache\QueryCacheProfile;
use Doctrine\DBAL\LockMode;
use Doctrine\DBAL\Types\Type;
use Doctrine\Deprecations\Deprecation;
use Doctrine\ORM\Internal\Hydration\IterableResult;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\AST\DeleteStatement;
use Doctrine\ORM\Query\AST\SelectStatement;
use Doctrine\ORM\Query\AST\UpdateStatement;
use Doctrine\ORM\Query\Exec\AbstractSqlExecutor;
use Doctrine\ORM\Query\Parameter;
use Doctrine\ORM\Query\ParameterTypeInferer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\ParserResult;
use Doctrine\ORM\Query\QueryException;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Utility\HierarchyDiscriminatorResolver;
use Psr\Cache\CacheItemPoolInterface;

use function array_keys;
use function array_values;
use function assert;
use function count;
use function get_debug_type;
use function in_array;
use function is_int;
use function ksort;
use function md5;
use function method_exists;
use function reset;
use function serialize;
use function sha1;
use function stripos;

/**
 * A Query object represents a DQL query.
 */
final class Query extends AbstractQuery
{
    /**
     * A query object is in CLEAN state when it has NO unparsed/unprocessed DQL parts.
     */
    public const STATE_CLEAN = 1;

    /**
     * A query object is in state DIRTY when it has DQL parts that have not yet been
     * parsed/processed. This is automatically defined as DIRTY when addDqlQueryPart
     * is called.
     */
    public const STATE_DIRTY = 2;

    /* Query HINTS */

    /**
     * The refresh hint turns any query into a refresh query with the result that
     * any local changes in entities are overridden with the fetched values.
     */
    public const HINT_REFRESH = 'doctrine.refresh';

    public const HINT_CACHE_ENABLED = 'doctrine.cache.enabled';

    public const HINT_CACHE_EVICT = 'doctrine.cache.evict';

    /**
     * Internal hint: is set to the proxy entity that is currently triggered for loading
     */
    public const HINT_REFRESH_ENTITY = 'doctrine.refresh.entity';

    /**
     * The forcePartialLoad query hint forces a particular query to return
     * partial objects.
     *
     * @todo Rename: HINT_OPTIMIZE
     */
    public const HINT_FORCE_PARTIAL_LOAD = 'doctrine.forcePartialLoad';

    /**
     * The includeMetaColumns query hint causes meta columns like foreign keys and
     * discriminator columns to be selected and returned as part of the query result.
     *
     * This hint does only apply to non-object queries.
     */
    public const HINT_INCLUDE_META_COLUMNS = 'doctrine.includeMetaColumns';

    /**
     * An array of class names that implement \Doctrine\ORM\Query\TreeWalker and
     * are iterated and executed after the DQL has been parsed into an AST.
     */
    public const HINT_CUSTOM_TREE_WALKERS = 'doctrine.customTreeWalkers';

    /**
     * A string with a class name that implements \Doctrine\ORM\Query\TreeWalker
     * and is used for generating the target SQL from any DQL AST tree.
     */
    public const HINT_CUSTOM_OUTPUT_WALKER = 'doctrine.customOutputWalker';

    /**
     * Marks queries as creating only read only objects.
     *
     * If the object retrieved from the query is already in the identity map
     * then it does not get marked as read only if it wasn't already.
     */
    public const HINT_READ_ONLY = 'doctrine.readOnly';

    public const HINT_INTERNAL_ITERATION = 'doctrine.internal.iteration';

    public const HINT_LOCK_MODE = 'doctrine.lockMode';

    /**
     * The current state of this query.
     *
     * @var int
     * @psalm-var self::STATE_*
     */
    private $_state = self::STATE_DIRTY;

    /**
     * A snapshot of the parameter types the query was parsed with.
     *
     * @var array<string,Type>
     */
    private $parsedTypes = [];

    /**
     * Cached DQL query.
     *
     * @var string|null
     */
    private $dql = null;

    /**
     * The parser result that holds DQL => SQL information.
     *
     * @var ParserResult
     */
    private $parserResult;

    /**
     * The first result to return (the "offset").
     *
     * @var int
     */
    private $firstResult = 0;

    /**
     * The maximum number of results to return (the "limit").
     *
     * @var int|null
     */
    private $maxResults = null;

    /**
     * The cache driver used for caching queries.
     *
     * @var CacheItemPoolInterface|null
     */
    private $queryCache;

    /**
     * Whether or not expire the query cache.
     *
     * @var bool
     */
    private $expireQueryCache = false;

    /**
     * The query cache lifetime.
     *
     * @var int|null
     */
    private $queryCacheTTL;

    /**
     * Whether to use a query cache, if available. Defaults to TRUE.
     *
     * @var bool
     */
    private $useQueryCache = true;

    /**
     * Gets the SQL query/queries that correspond to this DQL query.
     *
     * @return list<string>|string The built sql query or an array of all sql queries.
     */
    public function getSQL()
    {
        return $this->parse()->getSqlExecutor()->getSqlStatements();
    }

    /**
     * Returns the corresponding AST for this DQL query.
     *
     * @return SelectStatement|UpdateStatement|DeleteStatement
     */
    public function getAST()
    {
        $parser = new Parser($this);

        return $parser->getAST();
    }

    /**
     * {@inheritdoc}
     *
     * @return ResultSetMapping
     */
    protected function getResultSetMapping()
    {
        // parse query or load from cache
        if ($this->_resultSetMapping === null) {
            $this->_resultSetMapping = $this->parse()->getResultSetMapping();
        }

        return $this->_resultSetMapping;
    }

    /**
     * Parses the DQL query, if necessary, and stores the parser result.
     *
     * Note: Populates $this->_parserResult as a side-effect.
     */
    private function parse(): ParserResult
    {
        $types = [];

        foreach ($this->parameters as $parameter) {
            /** @var Query\Parameter $parameter */
            $types[$parameter->getName()] = $parameter->getType();
        }

        // Return previous parser result if the query and the filter collection are both clean
        if ($this->_state === self::STATE_CLEAN && $this->parsedTypes === $types && $this->_em->isFiltersStateClean()) {
            return $this->parserResult;
        }

        $this->_state      = self::STATE_CLEAN;
        $this->parsedTypes = $types;

        $queryCache = $this->queryCache ?? $this->_em->getConfiguration()->getQueryCache();
        // Check query cache.
        if (! ($this->useQueryCache && $queryCache)) {
            $parser = new Parser($this);

            $this->parserResult = $parser->parse();

            return $this->parserResult;
        }

        $cacheItem = $queryCache->getItem($this->getQueryCacheId());

        if (! $this->expireQueryCache && $cacheItem->isHit()) {
            $cached = $cacheItem->get();
            if ($cached instanceof ParserResult) {
                // Cache hit.
                $this->parserResult = $cached;

                return $this->parserResult;
            }
        }

        // Cache miss.
        $parser = new Parser($this);

        $this->parserResult = $parser->parse();

        $queryCache->save($cacheItem->set($this->parserResult)->expiresAfter($this->queryCacheTTL));

        return $this->parserResult;
    }

    /**
     * {@inheritdoc}
     */
    protected function _doExecute()
    {
        $executor = $this->parse()->getSqlExecutor();

        if ($this->_queryCacheProfile) {
            $executor->setQueryCacheProfile($this->_queryCacheProfile);
        } else {
            $executor->removeQueryCacheProfile();
        }

        if ($this->_resultSetMapping === null) {
            $this->_resultSetMapping = $this->parserResult->getResultSetMapping();
        }

        // Prepare parameters
        $paramMappings = $this->parserResult->getParameterMappings();
        $paramCount    = count($this->parameters);
        $mappingCount  = count($paramMappings);

        if ($paramCount > $mappingCount) {
            throw QueryException::tooManyParameters($mappingCount, $paramCount);
        }

        if ($paramCount < $mappingCount) {
            throw QueryException::tooFewParameters($mappingCount, $paramCount);
        }

        // evict all cache for the entity region
        if ($this->hasCache && isset($this->_hints[self::HINT_CACHE_EVICT]) && $this->_hints[self::HINT_CACHE_EVICT]) {
            $this->evictEntityCacheRegion();
        }

        [$sqlParams, $types] = $this->processParameterMappings($paramMappings);

        $this->evictResultSetCache(
            $executor,
            $sqlParams,
            $types,
            $this->_em->getConnection()->getParams()
        );

        return $executor->execute($this->_em->getConnection(), $sqlParams, $types);
    }

    /**
     * @param array<string,mixed> $sqlParams
     * @param array<string,Type>  $types
     * @param array<string,mixed> $connectionParams
     */
    private function evictResultSetCache(
        AbstractSqlExecutor $executor,
        array $sqlParams,
        array $types,
        array $connectionParams
    ): void {
        if ($this->_queryCacheProfile === null || ! $this->getExpireResultCache()) {
            return;
        }

        $cache = method_exists(QueryCacheProfile::class, 'getResultCache')
            ? $this->_queryCacheProfile->getResultCache()
            : $this->_queryCacheProfile->getResultCacheDriver();

        assert($cache !== null);

        $statements = (array) $executor->getSqlStatements(); // Type casted since it can either be a string or an array

        foreach ($statements as $statement) {
            $cacheKeys = $this->_queryCacheProfile->generateCacheKeys($statement, $sqlParams, $types, $connectionParams);

            $cache instanceof CacheItemPoolInterface
                ? $cache->deleteItem(reset($cacheKeys))
                : $cache->delete(reset($cacheKeys));
        }
    }

    /**
     * Evict entity cache region
     */
    private function evictEntityCacheRegion(): void
    {
        $AST = $this->getAST();

        if ($AST instanceof SelectStatement) {
            throw new QueryException('The hint "HINT_CACHE_EVICT" is not valid for select statements.');
        }

        $className = $AST instanceof DeleteStatement
            ? $AST->deleteClause->abstractSchemaName
            : $AST->updateClause->abstractSchemaName;

        $this->_em->getCache()->evictEntityRegion($className);
    }

    /**
     * Processes query parameter mappings.
     *
     * @param array<list<int>> $paramMappings
     *
     * @return mixed[][]
     * @psalm-return array{0: list<mixed>, 1: array}
     *
     * @throws Query\QueryException
     */
    private function processParameterMappings(array $paramMappings): array
    {
        $sqlParams = [];
        $types     = [];

        foreach ($this->parameters as $parameter) {
            $key = $parameter->getName();

            if (! isset($paramMappings[$key])) {
                throw QueryException::unknownParameter($key);
            }

            [$value, $type] = $this->resolveParameterValue($parameter);

            foreach ($paramMappings[$key] as $position) {
                $types[$position] = $type;
            }

            $sqlPositions = $paramMappings[$key];

            // optimized multi value sql positions away for now,
            // they are not allowed in DQL anyways.
            $value      = [$value];
            $countValue = count($value);

            for ($i = 0, $l = count($sqlPositions); $i < $l; $i++) {
                $sqlParams[$sqlPositions[$i]] = $value[$i % $countValue];
            }
        }

        if (count($sqlParams) !== count($types)) {
            throw QueryException::parameterTypeMismatch();
        }

        if ($sqlParams) {
            ksort($sqlParams);
            $sqlParams = array_values($sqlParams);

            ksort($types);
            $types = array_values($types);
        }

        return [$sqlParams, $types];
    }

    /**
     * @return mixed[] tuple of (value, type)
     * @psalm-return array{0: mixed, 1: mixed}
     */
    private function resolveParameterValue(Parameter $parameter): array
    {
        if ($parameter->typeWasSpecified()) {
            return [$parameter->getValue(), $parameter->getType()];
        }

        $key           = $parameter->getName();
        $originalValue = $parameter->getValue();
        $value         = $originalValue;
        $rsm           = $this->getResultSetMapping();

        if ($value instanceof ClassMetadata && isset($rsm->metadataParameterMapping[$key])) {
            $value = $value->getMetadataValue($rsm->metadataParameterMapping[$key]);
        }

        if ($value instanceof ClassMetadata && isset($rsm->discriminatorParameters[$key])) {
            $value = array_keys(HierarchyDiscriminatorResolver::resolveDiscriminatorsForClass($value, $this->_em));
        }

        $processedValue = $this->processParameterValue($value);

        return [
            $processedValue,
            $originalValue === $processedValue
                ? $parameter->getType()
                : ParameterTypeInferer::inferType($processedValue),
        ];
    }

    /**
     * Defines a cache driver to be used for caching queries.
     *
     * @deprecated Call {@see setQueryCache()} instead.
     *
     * @param Cache|null $queryCache Cache driver.
     *
     * @return $this
     */
    public function setQueryCacheDriver($queryCache): self
    {
        Deprecation::trigger(
            'doctrine/orm',
            'https://github.com/doctrine/orm/pull/9004',
            '%s is deprecated and will be removed in Doctrine 3.0. Use setQueryCache() instead.',
            __METHOD__
        );

        $this->queryCache = $queryCache ? CacheAdapter::wrap($queryCache) : null;

        return $this;
    }

    /**
     * Defines a cache driver to be used for caching queries.
     *
     * @return $this
     */
    public function setQueryCache(?CacheItemPoolInterface $queryCache): self
    {
        $this->queryCache = $queryCache;

        return $this;
    }

    /**
     * Defines whether the query should make use of a query cache, if available.
     *
     * @param bool $bool
     *
     * @return $this
     */
    public function useQueryCache($bool): self
    {
        $this->useQueryCache = $bool;

        return $this;
    }

    /**
     * Returns the cache driver used for query caching.
     *
     * @deprecated
     *
     * @return Cache|null The cache driver used for query caching or NULL, if
     * this Query does not use query caching.
     */
    public function getQueryCacheDriver(): ?Cache
    {
        Deprecation::trigger(
            'doctrine/orm',
            'https://github.com/doctrine/orm/pull/9004',
            '%s is deprecated and will be removed in Doctrine 3.0 without replacement.',
            __METHOD__
        );

        $queryCache = $this->queryCache ?? $this->_em->getConfiguration()->getQueryCache();

        return $queryCache ? DoctrineProvider::wrap($queryCache) : null;
    }

    /**
     * Defines how long the query cache will be active before expire.
     *
     * @param int|null $timeToLive How long the cache entry is valid.
     *
     * @return $this
     */
    public function setQueryCacheLifetime($timeToLive): self
    {
        if ($timeToLive !== null) {
            $timeToLive = (int) $timeToLive;
        }

        $this->queryCacheTTL = $timeToLive;

        return $this;
    }

    /**
     * Retrieves the lifetime of resultset cache.
     */
    public function getQueryCacheLifetime(): ?int
    {
        return $this->queryCacheTTL;
    }

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

        return $this;
    }

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

    public function free(): void
    {
        parent::free();

        $this->dql    = null;
        $this->_state = self::STATE_CLEAN;
    }

    /**
     * Sets a DQL query string.
     *
     * @param string|null $dqlQuery DQL Query.
     */
    public function setDQL($dqlQuery): self
    {
        if ($dqlQuery === null) {
            Deprecation::trigger(
                'doctrine/orm',
                'https://github.com/doctrine/orm/pull/9784',
                'Calling %s with null is deprecated and will result in a TypeError in Doctrine 3.0',
                __METHOD__
            );

            return $this;
        }

        $this->dql    = $dqlQuery;
        $this->_state = self::STATE_DIRTY;

        return $this;
    }

    /**
     * Returns the DQL query that is represented by this query object.
     */
    public function getDQL(): ?string
    {
        return $this->dql;
    }

    /**
     * Returns the state of this query object
     * By default the type is Doctrine_ORM_Query_Abstract::STATE_CLEAN but if it appears any unprocessed DQL
     * part, it is switched to Doctrine_ORM_Query_Abstract::STATE_DIRTY.
     *
     * @see AbstractQuery::STATE_CLEAN
     * @see AbstractQuery::STATE_DIRTY
     *
     * @return int The query state.
     * @psalm-return self::STATE_* The query state.
     */
    public function getState(): int
    {
        return $this->_state;
    }

    /**
     * Method to check if an arbitrary piece of DQL exists
     *
     * @param string $dql Arbitrary piece of DQL to check for.
     */
    public function contains($dql): bool
    {
        return stripos($this->getDQL(), $dql) !== false;
    }

    /**
     * Sets the position of the first result to retrieve (the "offset").
     *
     * @param int|null $firstResult The first result to return.
     *
     * @return $this
     */
    public function setFirstResult($firstResult): self
    {
        if (! is_int($firstResult)) {
            Deprecation::trigger(
                'doctrine/orm',
                'https://github.com/doctrine/orm/pull/9809',
                'Calling %s with %s is deprecated and will result in a TypeError in Doctrine 3.0. Pass an integer.',
                __METHOD__,
                get_debug_type($firstResult)
            );

            $firstResult = (int) $firstResult;
        }

        $this->firstResult = $firstResult;
        $this->_state      = self::STATE_DIRTY;

        return $this;
    }

    /**
     * Gets the position of the first result the query object was set to retrieve (the "offset").
     * Returns NULL if {@link setFirstResult} was not applied to this query.
     *
     * @return int|null The position of the first result.
     */
    public function getFirstResult(): ?int
    {
        return $this->firstResult;
    }

    /**
     * Sets the maximum number of results to retrieve (the "limit").
     *
     * @param int|null $maxResults
     *
     * @return $this
     */
    public function setMaxResults($maxResults): self
    {
        if ($maxResults !== null) {
            $maxResults = (int) $maxResults;
        }

        $this->maxResults = $maxResults;
        $this->_state     = self::STATE_DIRTY;

        return $this;
    }

    /**
     * Gets the maximum number of results the query object was set to retrieve (the "limit").
     * Returns NULL if {@link setMaxResults} was not applied to this query.
     *
     * @return int|null Maximum number of results.
     */
    public function getMaxResults(): ?int
    {
        return $this->maxResults;
    }

    /**
     * Executes the query and returns an IterableResult that can be used to incrementally
     * iterated over the result.
     *
     * @deprecated
     *
     * @param ArrayCollection|mixed[]|null $parameters    The query parameters.
     * @param string|int                   $hydrationMode The hydration mode to use.
     * @psalm-param ArrayCollection<int, Parameter>|array<string, mixed>|null $parameters
     * @psalm-param string|AbstractQuery::HYDRATE_*|null                      $hydrationMode
     */
    public function iterate($parameters = null, $hydrationMode = self::HYDRATE_OBJECT): IterableResult
    {
        $this->setHint(self::HINT_INTERNAL_ITERATION, true);

        return parent::iterate($parameters, $hydrationMode);
    }

    /** {@inheritDoc} */
    public function toIterable(iterable $parameters = [], $hydrationMode = self::HYDRATE_OBJECT): iterable
    {
        $this->setHint(self::HINT_INTERNAL_ITERATION, true);

        return parent::toIterable($parameters, $hydrationMode);
    }

    /**
     * {@inheritdoc}
     */
    public function setHint($name, $value): self
    {
        $this->_state = self::STATE_DIRTY;

        return parent::setHint($name, $value);
    }

    /**
     * {@inheritdoc}
     */
    public function setHydrationMode($hydrationMode): self
    {
        $this->_state = self::STATE_DIRTY;

        return parent::setHydrationMode($hydrationMode);
    }

    /**
     * Set the lock mode for this Query.
     *
     * @see \Doctrine\DBAL\LockMode
     *
     * @param int $lockMode
     * @psalm-param LockMode::* $lockMode
     *
     * @return $this
     *
     * @throws TransactionRequiredException
     */
    public function setLockMode($lockMode): self
    {
        if (in_array($lockMode, [LockMode::NONE, LockMode::PESSIMISTIC_READ, LockMode::PESSIMISTIC_WRITE], true)) {
            if (! $this->_em->getConnection()->isTransactionActive()) {
                throw TransactionRequiredException::transactionRequired();
            }
        }

        $this->setHint(self::HINT_LOCK_MODE, $lockMode);

        return $this;
    }

    /**
     * Get the current lock mode for this query.
     *
     * @return int|null The current lock mode of this query or NULL if no specific lock mode is set.
     */
    public function getLockMode(): ?int
    {
        $lockMode = $this->getHint(self::HINT_LOCK_MODE);

        if ($lockMode === false) {
            return null;
        }

        return $lockMode;
    }

    /**
     * Generate a cache id for the query cache - reusing the Result-Cache-Id generator.
     */
    protected function getQueryCacheId(): string
    {
        ksort($this->_hints);

        return md5(
            $this->getDQL() . serialize($this->_hints) .
            '&platform=' . get_debug_type($this->getEntityManager()->getConnection()->getDatabasePlatform()) .
            ($this->_em->hasFilters() ? $this->_em->getFilters()->getHash() : '') .
            '&firstResult=' . $this->firstResult . '&maxResult=' . $this->maxResults .
            '&hydrationMode=' . $this->_hydrationMode . '&types=' . serialize($this->parsedTypes) . 'DOCTRINE_QUERY_CACHE_SALT'
        );
    }

    protected function getHash(): string
    {
        return sha1(parent::getHash() . '-' . $this->firstResult . '-' . $this->maxResults);
    }

    /**
     * Cleanup Query resource when clone is called.
     */
    public function __clone()
    {
        parent::__clone();

        $this->_state = self::STATE_DIRTY;
    }
}
