<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Utility;

use Doctrine\DBAL\Exception as DoctrineException;
use Doctrine\DBAL\Platforms\TrimMode;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Exception\Page\BrokenRootLineException;
use TYPO3\CMS\Core\Exception\Page\CircularRootLineException;
use TYPO3\CMS\Core\Exception\Page\MountPointsDisabledException;
use TYPO3\CMS\Core\Exception\Page\PageNotFoundException;
use TYPO3\CMS\Core\Exception\Page\PagePropertyRelationNotFoundException;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Versioning\VersionState;

/**
 * A utility resolving and Caching the Rootline generation
 */
class RootlineUtility
{
    // Note that having a nesting depth of 100 is quite high, but defined to be more on a "safe" side here. Main goal
    // is to mitigate unforeseen recursion which are not covered by the ancestor guard (checking page uid in path).
    private const int MAX_CTE_TRAVERSAL_LEVELS = 100;

    /** @internal */
    public const RUNTIME_CACHE_TAG = 'rootline-utility';

    protected int $pageUid;

    protected string $mountPointParameter;

    /** @var int[] */
    protected array $parsedMountPointParameters = [];

    protected int $languageUid = 0;

    protected int $workspaceUid = 0;

    protected FrontendInterface $cache;
    protected FrontendInterface $runtimeCache;

    protected PageRepository $pageRepository;
    protected Context $context;

    protected string $cacheIdentifier;

    /**
     * @throws MountPointsDisabledException
     */
    public function __construct(int $uid, string $mountPointParameter = '', ?Context $context = null)
    {
        $this->mountPointParameter = $this->sanitizeMountPointParameter($mountPointParameter);
        $this->context = $context ?? GeneralUtility::makeInstance(Context::class);
        $this->pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context);
        $this->languageUid = $this->context->getPropertyFromAspect('language', 'id', 0);
        $this->workspaceUid = (int)$this->context->getPropertyFromAspect('workspace', 'id', 0);
        if ($this->mountPointParameter !== '') {
            if (!($GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids'] ?? false)) {
                throw new MountPointsDisabledException('Mount-Point Pages are disabled for this installation. Cannot resolve a Rootline for a page with Mount-Points', 1343462896);
            }
            $this->parseMountPointParameter();
        }
        $this->pageUid = $this->resolvePageId($uid);
        $this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('rootline');
        $this->runtimeCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime');
        $this->cacheIdentifier = $this->getCacheIdentifier();
    }

    /**
     * Returns the actual rootline without the tree root (uid=0), including the page with $this->pageUid
     *
     * @throws BrokenRootLineException
     * @throws CircularRootLineException
     * @throws PageNotFoundException
     * @throws DoctrineException
     */
    public function get(): array
    {
        if ($this->pageUid === 0) {
            // pageUid 0 has no root line, return empty array right away
            return [];
        }
        if (!$this->runtimeCache->has('rootline-localcache-' . $this->cacheIdentifier)) {
            $entry = $this->cache->get($this->cacheIdentifier);
            if (!$entry) {
                $this->generateRootlineCache();
            } else {
                $this->runtimeCache->set('rootline-localcache-' . $this->cacheIdentifier, $entry, [self::RUNTIME_CACHE_TAG]);
                $depth = count($entry);
                // Populate the root-lines for parent pages as well
                // since they are part of the current root-line
                while ($depth > 1) {
                    --$depth;
                    $parentCacheIdentifier = $this->getCacheIdentifier($entry[$depth - 1]['uid']);
                    // Abort if the root-line of the parent page is
                    // already in the local cache data
                    if ($this->runtimeCache->has('rootline-localcache-' . $parentCacheIdentifier)) {
                        break;
                    }
                    // Behaves similar to array_shift(), but preserves
                    // the array keys - which contain the page ids here
                    $entry = array_slice($entry, 1, null, true);
                    $this->runtimeCache->set('rootline-localcache-' . $parentCacheIdentifier, $entry, [self::RUNTIME_CACHE_TAG]);
                }
            }
        }
        return $this->runtimeCache->get('rootline-localcache-' . $this->cacheIdentifier);
    }

    protected function getCacheIdentifier(?int $otherUid = null): string
    {
        $mountPointParameter = $this->mountPointParameter;
        if ($mountPointParameter !== '' && str_contains($mountPointParameter, ',')) {
            $mountPointParameter = str_replace(',', '__', $mountPointParameter);
        }
        return implode('_', [
            $otherUid ?? $this->pageUid,
            $mountPointParameter,
            $this->languageUid,
            $this->workspaceUid,
            $this->context->getAspect('visibility')->includeHiddenContent() ? '1' : '0',
            $this->context->getAspect('visibility')->includeHiddenPages() ? '1' : '0',
        ]);
    }

    /**
     * Queries the database for the page record and returns it.
     *
     * @param int $uid Page id
     * @throws PageNotFoundException
     * @return array<string, string|int|float|null>
     */
    protected function getRecordArray(int $uid): array
    {
        $currentCacheIdentifier = $this->getCacheIdentifier($uid);
        if (!$this->runtimeCache->has('rootline-recordcache-' . $currentCacheIdentifier)) {
            $row = $this->getWorkspaceResolvedPageRecord($uid, $this->workspaceUid);
            if (is_array($row)) {
                $row = $this->enrichPageRecordArray($row, $uid);
                $this->runtimeCache->set('rootline-recordcache-' . $currentCacheIdentifier, $row, [self::RUNTIME_CACHE_TAG]);
            }
        }
        if (!is_array($this->runtimeCache->get('rootline-recordcache-' . $currentCacheIdentifier) ?? false)) {
            throw new PageNotFoundException('Broken rootline. Could not resolve page with uid ' . $uid . '.', 1343464101);
        }
        return $this->runtimeCache->get('rootline-recordcache-' . $currentCacheIdentifier);
    }

    /**
     * Resolve relations as defined in TCA and add them to the provided $pageRecord array.
     *
     * @param int $uid page ID
     * @param array<string, string|int|float|null> $pageRecord Page record (possibly overlaid) to be extended with relations
     * @throws PagePropertyRelationNotFoundException
     * @return array<string, string|int|float|null> $pageRecord with additional relations
     */
    protected function enrichWithRelationFields(int $uid, array $pageRecord): array
    {
        $resultFieldUidArray = [];
        $localRelationColumns = [];
        $foreignRelationColumns = [];
        $foreignRelationColumnTableFieldMapping = [];
        $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages');
        foreach ($schema->getFields() as $column => $fieldType) {
            $configuration = $fieldType->getConfiguration();
            if ($this->columnHasRelationToResolve($configuration)) {
                $resultFieldUidArray[$column] = [];
                if (!empty($configuration['MM']) && !empty($configuration['MM_opposite_field']) && !empty($configuration['foreign_table'])) {
                    $foreignRelationColumns[] = $column;
                    // This is a solution when multiple fields are on the foreign side in an MM relation to the same local side.
                    // For instance, when there are two category fields in pages.
                    $foreignRelationColumnTableFieldMapping[$configuration['foreign_table']][$configuration['MM_opposite_field']][$column] = 1;
                } else {
                    $localRelationColumns[] = $column;
                }
            }
        }
        if (empty($localRelationColumns) && empty($foreignRelationColumns)) {
            // Early return if there are no relations to resolve at all. Typically, this does not kick in with pages, though.
            return $pageRecord;
        }

        // @todo: There is a general issue with starttime & endtime restrictions: The date aspect of course always changes.
        //        Since the result of this operation is cached into rootline cache, resolving restrictions based on time
        //        may result in invalid caches. We cannot add the time restriction to the cache identifier since that
        //        would not match cache rows constantly. That cache may also kick in when admin panel "simulate time"
        //        is used and no-cache is not forced in admin panel, also leading to invalid results.
        //        This is currently not a *huge* issue, since timed records attached to pages are "relatively" seldom, and
        //        FE instances that use it most likely do something like "clearCacheAtMidnight" anyways.
        //        To ultimately solve the issue, we should either drop the persisted rootline_cache altogether (which should
        //        be do-able when the main rootline query switches to a CTE and does not need the cache anymore), OR we
        //        remove the starttime/endtime handling here again, and let consumers sort out timed records on their own,
        //        which would be a pity.
        // @todo: We could potentially handle ['enablecolumns']['fe_group'] here as well. This however is more work
        //        since we then need two further fields in refindex to track it. Also fe_group is one of those CSV
        //        fields that has "virtual" db connections "-2" and "-1" that don't point to true records. refindex
        //        already behaves funny with those (and has no good test coverage). Also, having (negative) int uids
        //        is a violation for select, those should at least use non-int strings and set "allowNonIdValues".
        //        At best, we'd find some other way for -2 and -1 to get rid of those virtual values entirely. Everything
        //        in this area is probably breaking and needs upgrade wizards.
        // @todo: The queries below may benefit from being prepared and then fired with values, since they are potentially
        //        executed often. This requires storing the prepared query in runtime cache, and requires switching from
        //        named parameters to positional parameters.
        // @todo: Note the entire thing currently handles only non-CSV relations (there must be a TCA foreign_field or MM),
        //        CSV values are not "filtered" and processed regarding hidden, starttime and similar at all. This could be
        //        added later, but needs a careful implementation, for instance because of "allowNonIdValues", and combined
        //        "table_uid" in type=group, and fe_group "virtual" -2 fields.
        // @todo: This operation always returns already workspace uids if they exist. It however does *not* return localization
        //        uids in most cases, this still needs to be done manually, when working with localized pages. Also,
        //        hidden, starttime and endtime of the default language record kicks in, not of the localization overlay
        //        row. We could potentially model this in refindex, by de-normalizing localization overlays in refindex
        //        as well, but this needs work and some decisions since language overlays may need to consider fallback chains.
        $visibilityAspect = $this->context->getAspect('visibility');
        $includeHiddenContent = $visibilityAspect->includeHiddenContent();
        $includeScheduledRecords = $visibilityAspect->includeScheduledRecords();
        $dateTimestamp = (int)$this->context->getAspect('date')->get('timestamp');

        if (!empty($localRelationColumns) && empty($foreignRelationColumns)) {
            // We only have local side relations. Run a simple refindex query. Typically, this does not kick in with pages since it has categories MM.
            // @todo: Add at least one test that manipulates TCA to verify this code branch works.
            $queryBuilder = $this->createQueryBuilder('sys_refindex');
            $result = $queryBuilder->select('tablename', 'field', 'ref_uid')
                ->from('sys_refindex')
                ->orderBy('sorting')
                ->where(
                    $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter('pages')),
                    $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)),
                    $queryBuilder->expr()->in('field', $queryBuilder->createNamedParameter($localRelationColumns, Connection::PARAM_STR_ARRAY)),
                    $queryBuilder->expr()->eq('workspace', $this->workspaceUid),
                    $queryBuilder->expr()->neq('ref_t3ver_state', VersionState::DELETE_PLACEHOLDER->value),
                    $includeHiddenContent
                        ? $queryBuilder->expr()->in('ref_hidden', [0, 1]) // Dummy restriction to not break combined index.
                        : $queryBuilder->expr()->eq('ref_hidden', 0),
                    $includeScheduledRecords
                        ? $queryBuilder->expr()->lte('ref_starttime', 2147483647) // Dummy restriction to not break combined index.
                        : $queryBuilder->expr()->lt('ref_starttime', $dateTimestamp),
                    $includeScheduledRecords
                        ? $queryBuilder->expr()->gte('ref_endtime', 0) // Dummy restriction to not break combined index.
                        : $queryBuilder->expr()->gt('ref_endtime', $dateTimestamp)
                )
                ->executeQuery();
            while ($row = $result->fetchAssociative()) {
                $resultFieldUidArray[$row['field']][] = (int)$row['ref_uid'];
            }
            foreach ($resultFieldUidArray as $column => $connectedUids) {
                if (empty($connectedUids)) {
                    $pageRecord[$column] = '';
                } else {
                    $pageRecord[$column] = implode(',', $connectedUids);
                }
            }
            return $pageRecord;
        }

        if (empty($localRelationColumns)) {
            // We only have foreign side relations. Run a simple refindex query. Typically, this does not kick in with pages since it has inline media.
            // @todo: Add at least one test that manipulates TCA to verify this code branch works.
            $queryBuilder = $this->createQueryBuilder('sys_refindex');
            $result = $queryBuilder->select('tablename', 'field', 'recuid', 'ref_field')
                ->from('sys_refindex')
                ->orderBy('ref_sorting')
                ->where(
                    $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter('pages')),
                    // Use workspace-uid if the record is an overlay.
                    $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)),
                    $queryBuilder->expr()->in('tablename', $queryBuilder->createNamedParameter(array_keys($foreignRelationColumnTableFieldMapping), Connection::PARAM_STR_ARRAY)),
                    $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($this->workspaceUid, Connection::PARAM_INT)),
                    $queryBuilder->expr()->neq('t3ver_state', VersionState::DELETE_PLACEHOLDER->value),
                    $includeHiddenContent
                        ? $queryBuilder->expr()->in('hidden', [0, 1]) // Dummy restriction to not break combined index.
                        : $queryBuilder->expr()->eq('hidden', 0),
                    $includeScheduledRecords
                        ? $queryBuilder->expr()->lte('starttime', 2147483647)
                        : $queryBuilder->expr()->lt('starttime', $dateTimestamp),
                    $includeScheduledRecords
                        ? $queryBuilder->expr()->gte('endtime', 0)
                        : $queryBuilder->expr()->gt('endtime', $dateTimestamp)
                )
                ->executeQuery();
            while ($row = $result->fetchAssociative()) {
                if (isset($foreignRelationColumnTableFieldMapping[$row['tablename']][$row['field']][$row['ref_field']])) {
                    $resultFieldUidArray[$row['ref_field']][] = (int)$row['recuid'];
                }
            }
            foreach ($resultFieldUidArray as $column => $connectedUids) {
                if (empty($connectedUids)) {
                    $pageRecord[$column] = '';
                } else {
                    $pageRecord[$column] = implode(',', $connectedUids);
                }
            }
            return $pageRecord;
        }

        // We need rows from refindex by looking at both local side and foreign side.
        // This is done using a UNION of two distinct queries. This is pretty useful
        // since it saves a round trip and can use distinct indexes per "sub" query.
        // Named arguments however are global for both queries, so we use a dummy query
        // builder to gather them all.
        // Also, postgres and sqlite don't support sorting on single queries with UNION,
        // so we sort the final result set just before imploding to the final CSV per field.
        $namedArgumentsQB = $this->createQueryBuilder('sys_refindex');
        $localQB = $this->createQueryBuilder('sys_refindex');
        $localQB->select('tablename', 'field', 'sorting', 'recuid', 'ref_uid', 'ref_field', 'ref_sorting')
            ->from('sys_refindex')
            ->where(
                $localQB->expr()->eq('tablename', $namedArgumentsQB->createNamedParameter('pages')),
                $localQB->expr()->eq('recuid', $namedArgumentsQB->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)),
                $localQB->expr()->in('field', $namedArgumentsQB->createNamedParameter($localRelationColumns, Connection::PARAM_STR_ARRAY)),
                $localQB->expr()->eq('workspace', $this->workspaceUid),
                $localQB->expr()->neq('ref_t3ver_state', VersionState::DELETE_PLACEHOLDER->value),
                $includeHiddenContent
                    ? $localQB->expr()->in('ref_hidden', [0, 1]) // Dummy restriction to not break combined index.
                    : $localQB->expr()->eq('ref_hidden', 0),
                $includeScheduledRecords
                    ? $localQB->expr()->lte('ref_starttime', 2147483647) // Dummy restriction to not break combined index.
                    : $localQB->expr()->lt('ref_starttime', $dateTimestamp),
                $includeScheduledRecords
                    ? $localQB->expr()->gte('ref_endtime', 0) // Dummy restriction to not break combined index.
                    : $localQB->expr()->gt('ref_endtime', $dateTimestamp)
            );
        $foreignQB = $this->createQueryBuilder('sys_refindex');
        $foreignQB->select('tablename', 'field', 'sorting', 'recuid', 'ref_uid', 'ref_field', 'ref_sorting')
            ->from('sys_refindex')
            ->where(
                $foreignQB->expr()->eq('ref_table', $namedArgumentsQB->createNamedParameter('pages')),
                // Use workspace-uid if the record is an overlay.
                $foreignQB->expr()->eq('ref_uid', $namedArgumentsQB->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)),
                $foreignQB->expr()->in('tablename', $namedArgumentsQB->createNamedParameter(array_keys($foreignRelationColumnTableFieldMapping), Connection::PARAM_STR_ARRAY)),
                $foreignQB->expr()->eq('workspace', $namedArgumentsQB->createNamedParameter($this->workspaceUid, Connection::PARAM_INT)),
                $foreignQB->expr()->neq('t3ver_state', VersionState::DELETE_PLACEHOLDER->value),
                $includeHiddenContent
                    ? $foreignQB->expr()->in('hidden', [0, 1]) // Dummy restriction to not break combined index.
                    : $foreignQB->expr()->eq('hidden', 0),
                $includeScheduledRecords
                    ? $foreignQB->expr()->lte('starttime', 2147483647)
                    : $foreignQB->expr()->lt('starttime', $dateTimestamp),
                $includeScheduledRecords
                    ? $foreignQB->expr()->gte('endtime', 0)
                    : $foreignQB->expr()->gt('endtime', $dateTimestamp)
            );
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_refindex');
        $result = $connection->executeQuery(
            $localQB->getSQL() . ' UNION ALL ' . $foreignQB->getSQL(),
            $namedArgumentsQB->getParameters(),
            $namedArgumentsQB->getParameterTypes()
        );
        while ($row = $result->fetchAssociative()) {
            if ($row['tablename'] === 'pages') {
                $resultFieldUidArray[$row['field']][(int)$row['sorting']] = (int)$row['ref_uid'];
            } elseif (isset($foreignRelationColumnTableFieldMapping[$row['tablename']][$row['field']][$row['ref_field']])) {
                $resultFieldUidArray[$row['ref_field']][(int)$row['ref_sorting']] = (int)$row['recuid'];
            }
        }
        foreach ($resultFieldUidArray as $column => $connectedUids) {
            if (empty($connectedUids)) {
                $pageRecord[$column] = '';
            } else {
                ksort($connectedUids, SORT_NUMERIC);
                $pageRecord[$column] = implode(',', $connectedUids);
            }
        }
        return $pageRecord;
    }

    /**
     * Checks whether the TCA Configuration array of a column
     * describes a relation which is not stored as CSV in the record
     *
     * @param array $configuration TCA configuration to check
     * @return bool TRUE, if it describes a non-CSV relation
     */
    protected function columnHasRelationToResolve(array $configuration): bool
    {
        if (!empty($configuration['MM']) && !empty($configuration['type']) && in_array($configuration['type'], ['select', 'inline', 'group'])) {
            return true;
        }
        if (!empty($configuration['foreign_field']) && !empty($configuration['type']) && in_array($configuration['type'], ['inline', 'file'])) {
            return true;
        }
        if (($configuration['type'] ?? '') === 'category' && ($configuration['relationship'] ?? '') === 'manyToMany') {
            return true;
        }
        return false;
    }

    /**
     * Actual function to generate the rootline and cache it
     *
     * @throws BrokenRootLineException
     * @throws CircularRootLineException
     * @throws PageNotFoundException
     * @throws DoctrineException
     */
    protected function generateRootlineCache(): void
    {
        $pageId = $this->pageUid;
        $page = $this->getRecordArray($pageId);
        $parentPageId = $page['pid'];
        $workspaceId = $this->workspaceUid;
        if ($this->isMountedPage($pageId)) {
            // If the current page is a mounted (according to the MP parameter) handle the mount-point
            $page = $this->getRecordArray($pageId);
            $mountPoint = $this->getRecordArray($this->parsedMountPointParameters[$pageId]);
            $page = $this->processMountedPage($page, $mountPoint);
            $parentPageId = $mountPoint['pid'];
            // Anyhow after reaching the mount-point, we have to go up that rootline
            unset($this->parsedMountPointParameters[$this->pageUid]);
        }
        $rootline = $this->getRootlineFromRuntimeCache($parentPageId);
        if (!is_array($rootline)) {
            $rootline = $this->getRootlineRecords($parentPageId, $workspaceId);
        }
        $rootline[] = $page;
        $firstEntry = reset($rootline);
        // ensure valid rootline down to virtual tree root
        if (is_array($firstEntry) && $firstEntry['pid'] !== 0) {
            throw new PageNotFoundException('Broken rootline. Could not resolve full rootline for uid ' . $pageId . '.', 1721913589);
        }
        $cacheTags = [];
        foreach ($rootline as $entry) {
            $cacheTags[] = 'pageId_' . $entry['uid'];
        }
        krsort($rootline);
        $this->cache->set($this->cacheIdentifier, $rootline, $cacheTags);
        $this->runtimeCache->set('rootline-localcache-' . $this->cacheIdentifier, $rootline, [self::RUNTIME_CACHE_TAG]);

        // Reduce rootline page by page and set to runtime cache to eliminate the need to fetch rootline for a parent
        // page as a performance optimization when a children already generated the rootline.
        while ($rootline !== []) {
            // Behaves similar to array_shift(), but preserves the array keys.
            $rootline = array_slice($rootline, 1, null, true);
            if ($rootline !== []) {
                $cacheIdentifier = $this->getCacheIdentifier(array_first($rootline)['uid']);
                $this->runtimeCache->set('rootline-localcache-' . $cacheIdentifier, $rootline, [self::RUNTIME_CACHE_TAG]);
            }
        }
    }

    /**
     * Checks whether the current Page is a Mounted Page
     * (according to the MP-URL-Parameter)
     */
    protected function isMountedPage(int $pageId): bool
    {
        return array_key_exists($pageId, $this->parsedMountPointParameters);
    }

    /**
     * Enhances with mount point information or replaces the node if needed
     *
     * @param array<string, string|int|float|null> $mountedPageData page record array of mounted page
     * @param array<string, string|int|float|null> $mountPointPageData page record array of mount point page
     * @throws BrokenRootLineException
     * @return array<string, string|int|float|null>
     */
    protected function processMountedPage(array $mountedPageData, array $mountPointPageData): array
    {
        $mountPid = $mountPointPageData['mount_pid'] ?? null;
        $uid = $mountedPageData['uid'] ?? null;
        if ((int)$mountPid !== (int)$uid) {
            throw new BrokenRootLineException('Broken rootline. Mountpoint parameter does not match the actual rootline. mount_pid (' . $mountPid . ') does not match page uid (' . $uid . ').', 1343464100);
        }
        // Current page replaces the original mount-page
        $mountUid = $mountPointPageData['uid'] ?? null;
        if (!empty($mountPointPageData['mount_pid_ol'])) {
            $mountedPageData['_MOUNT_OL'] = true;
            $mountedPageData['_MOUNT_PAGE'] = [
                'uid' => $mountUid,
                'pid' => $mountPointPageData['pid'] ?? null,
                'title' => $mountPointPageData['title'] ?? null,
            ];
        } else {
            // The mount-page is not replaced, the mount-page itself has to be used
            $mountedPageData = $mountPointPageData;
        }
        $mountedPageData['_MOUNTED_FROM'] = $this->pageUid;
        $mountedPageData['_MP_PARAM'] = $this->pageUid . '-' . $mountUid;
        return $mountedPageData;
    }

    /**
     * Sanitize the MountPoint Parameter
     * Splits the MP-Param via "," and removes mountpoints
     * that don't have the format \d+-\d+
     */
    protected function sanitizeMountPointParameter(string $mountPointParameter): string
    {
        $mountPointParameter = trim($mountPointParameter);
        if ($mountPointParameter === '') {
            return '';
        }
        $mountPoints = GeneralUtility::trimExplode(',', $mountPointParameter);
        foreach ($mountPoints as $key => $mP) {
            // If MP has incorrect format, discard it
            if (!preg_match('/^\d+-\d+$/', $mP)) {
                unset($mountPoints[$key]);
            }
        }
        return implode(',', $mountPoints);
    }

    /**
     * Parse the MountPoint Parameters
     * Splits the MP-Param via "," for several nested mountpoints
     * and afterwords registers the mountpoint configurations
     */
    protected function parseMountPointParameter(): void
    {
        $mountPoints = GeneralUtility::trimExplode(',', $this->mountPointParameter);
        foreach ($mountPoints as $mP) {
            [$mountedPageUid, $mountPageUid] = GeneralUtility::intExplode('-', $mP);
            $this->parsedMountPointParameters[$mountedPageUid] = $mountPageUid;
        }
    }

    /**
     * Fetches the UID of the page.
     *
     * If the page was moved in a workspace, actually returns the UID
     * of the moved version in the workspace.
     */
    protected function resolvePageId(int $pageId): int
    {
        if ($pageId === 0 || $this->workspaceUid === 0) {
            return $pageId;
        }

        $page = $this->resolvePageRecord($pageId);
        if (!isset($page['t3ver_state']) || VersionState::tryFrom($page['t3ver_state']) !== VersionState::MOVE_POINTER) {
            return $pageId;
        }

        $movePointerId = $this->resolveMovePointerId((int)$page['t3ver_oid']);
        return $movePointerId ?: $pageId;
    }

    protected function resolvePageRecord(int $pageId): ?array
    {
        $queryBuilder = $this->createQueryBuilder('pages');
        $queryBuilder->getRestrictions()->removeAll()
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));

        $statement = $queryBuilder
            ->from('pages')
            ->select('uid', 't3ver_oid', 't3ver_state')
            ->where(
                $queryBuilder->expr()->eq(
                    'uid',
                    $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
                )
            )
            ->executeQuery();

        $record = $statement->fetchAssociative();
        return $record ?: null;
    }

    /**
     * Fetched the UID of the versioned record if the live record has been moved in a workspace.
     */
    protected function resolveMovePointerId(int $liveId): ?int
    {
        $queryBuilder = $this->createQueryBuilder('pages');
        $queryBuilder->getRestrictions()->removeAll()
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));

        $statement = $queryBuilder
            ->from('pages')
            ->select('uid')
            ->setMaxResults(1)
            ->where(
                $queryBuilder->expr()->eq(
                    't3ver_wsid',
                    $queryBuilder->createNamedParameter($this->workspaceUid, Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->eq(
                    't3ver_state',
                    $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)
                ),
                $queryBuilder->expr()->eq(
                    't3ver_oid',
                    $queryBuilder->createNamedParameter($liveId, Connection::PARAM_INT)
                )
            )
            ->executeQuery();

        $movePointerId = $statement->fetchOne();
        return $movePointerId ? (int)$movePointerId : null;
    }

    /**
     * Get enriched RootLine page records.
     *
     * This method uses a recursive common-table-expression (CTE) doing the first workspace overlay handling withing the
     * RootLine traversal. Language overlay and record enrichment (references data) are applied on retrieved records.
     *
     * In case a received record is a mounted page, a new instance of `RootlineUtility` is created to retrieve the
     * mounted page rootline and result spliced together.
     *
     * In case only live-workspace rootline is required, for example in normal frontend visitors without backend login
     * and selected workspaces, the created CTE is simplified avoiding irrelevant joins and further improve performance
     * in that case.
     *
     * Note that the created recursive CTE contains two guards against cycling rootline issues:
     *
     * Guard 1 - ancestor path guard:
     * ------------------------------
     *
     * During the recursing a uid path is created (`__CTE_PATH__`) and in case that a page is already contained in the
     * parent record `__CTE_PATH__` the flag field `__CTE_IS_CYCLE__` is set to 1, otherwise it is 0.
     *
     * If the parent record `__CTE_IS_CYLCE__` is `1` no records are retrieved which retrieves a duplicate page record
     * except of this flag. During the record retrieval {@see CircularRootLineException} is thrown to abort and state
     * this crucial data corruption.
     *
     * This guard is designed to abort cycling recursion on the database side as early as possible while still transport
     * the cycling issue information to this method.
     *
     * Guard 2 - max recursion level guard:
     * ------------------------------------
     *
     * During the recursive CTE handling recursion level info is created (`__CTE_LEVEL__`) and used as a hard recursion
     * limit fence. That means, if the level reaches {@see self::MAX_CTE_TRAVERSAL_LEVELS} no more page records are
     * received. In the case that neither GUARD 1, nor PID=0 abort criteria is reached and max level hit a meaningful
     * {@see BrokenRootLineException} exception is thrown.
     *
     * Note that further improvements are possible, for example adding language overlay handling directly to the CTE
     * when strategy how to deal with the three dispatched PSR-14 events in PageRepository has been made up.
     *
     * @todo As already mentioned above, mountpoint pages are resolved by calling a new `RootlineUtility` instance
     *       which prevents the detection of circular mountpoint configuration like in the prior implementation. A
     *       way to add mountpoint page replacements within the CTE needs to be evaluated and implemented to make a
     *       circular mountpoint configuration finally detectable. At least first cycling rootline revealing database
     *       data corruption are now included.
     *
     * @throws BrokenRootLineException
     * @throws CircularRootLineException
     * @throws DoctrineException
     */
    protected function getRootlineRecords(int $pageId, int $workspaceId): array
    {
        if ($pageId === 0) {
            return [];
        }
        $cte = $this->createQueryBuilder('pages');
        $cte->getRestrictions()->removeAll();
        $expr = $cte->expr();
        $pagesFields = $this->getPagesFields();
        $resolvedPagesFields = array_filter($pagesFields, static fn($value) => $value !== 'uid');
        array_walk(
            $resolvedPagesFields,
            static function (string &$value, int|string $key, string $prefixAlias) {
                $value = sprintf('%s.%s', $prefixAlias, $value);
            },
            'finalpages',
        );
        $cte
            ->typo3_withRecursive(
                'cte',
                // Unique rows is omitted to avoid superfluous distinct row determination by the database query executor
                // due to having level based data in the traversal part they would be distinct anyway. Duplicates are
                // sorted out by the implemented ancestor guard - see self::createTraversalQueryBuilder().
                false,
                $this->createInitialQueryBuilder($cte, $pageId, $workspaceId),
                $this->createTraversalQueryBuilder($cte, $workspaceId),
                [
                    // data fields
                    $cte->quoteIdentifier('uid'),
                    $cte->quoteIdentifier('pid'),
                    // workspace handling values
                    $cte->quoteIdentifier('_ORIG_pid'),
                    $cte->quoteIdentifier('_ORIG_uid'),
                    // recursive handling fields
                    $cte->quoteIdentifier('__CTE_JOIN_UID__'),
                    $cte->quoteIdentifier('__CTE_LEVEL__'),
                    $cte->quoteIdentifier('__CTE_PATH__'),
                    $cte->quoteIdentifier('__CTE_IS_CYCLE__'),
                ],
            )
            ->select(...[
                'cte.uid',
                ...$resolvedPagesFields,
                'cte._ORIG_uid',
                'cte._ORIG_pid',
                // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by
                // all supported Database vendors at all or not following the SQL standard. The guard stops cycling
                // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level.
                // Note that these values are removed from result rows by `self::cleanWorkspaceResolvedPageRecord()`
                'cte.__CTE_PATH__',
                'cte.__CTE_IS_CYCLE__',
                'cte.__CTE_LEVEL__',
            ])
            ->from('cte')
            // It's important to traverse determined rootline records in the correct order, which means from the page
            // record down to the rootpage. The recursive CTE builds up the CTE level starting from current record as
            // level 1 and incrementing the level for each parent record, which means that we need to order by the
            // level in ascending order (1, 2, 3, 4).
            ->orderBy('cte.__CTE_LEVEL__', 'ASC')
            ->addOrderBy('cte.uid', 'ASC')
            ->innerJoin(
                'cte',
                'pages',
                'finalpages',
                $expr->eq('finalpages.uid', $cte->quoteIdentifier('cte.__CTE_JOIN_UID__'))
            );
        $records = [];
        $result = $cte->executeQuery();
        while ($record = $result->fetchAssociative()) {
            $cyclingDetected = (bool)($record['__CTE_IS_CYCLE__'] ?? false);
            $recordLevel = (int)($record['__CTE_LEVEL__'] ?? 0);
            $recordId = (int)$record['uid'];
            $recordPid = (int)$record['pid'];
            $recordCacheIdentifier = $this->getCacheIdentifier($recordId);
            $record = $this->cleanWorkspaceResolvedPageRecord($record);
            if ($cyclingDetected) {
                // Cycling page records found by CTE path guard.
                // @todo CTE does not handle mountpoint page resolving yet and calling RootlineUtility in recursive
                //       manner below not detecting cycling mountpoint configurations yet. CTE **must* be improved to
                //       handle mountpoint replacements directly and ensure cycling detection finally works - which has
                //       never been the case.
                throw new CircularRootLineException(
                    'Circular connection in rootline for page with uid ' . $this->pageUid . ' found. '
                    . 'Check your mountpoint configuration and page data with pid value pointing to a sub page.',
                    1343464103
                );
            }
            // Throw a concrete BrokenRootLineException with explaining message in case maximal CTE traversal limit has
            // been reached without ending on PID 0 - the virtual tree root node.
            // @todo Find a way to test this case which is not that easy because of the MAX_CTE_TRAVERSAL_LEVEL.
            if ($recordLevel >= self::MAX_CTE_TRAVERSAL_LEVELS && $recordPid !== 0) {
                throw new BrokenRootLineException(
                    sprintf(
                        'Broken rootline. Could not resolve full rootline for uid %s. '
                        . 'Reached max traversal level %s without ending on pid 0.',
                        $pageId,
                        self::MAX_CTE_TRAVERSAL_LEVELS,
                    ),
                    1722118090,
                );
            }
            if ($this->isMountedPage($recordId)) {
                // free result, because we will not iterator further through it and instead invoke RootlineUtility.
                $result->free();
                // @todo Find a way to implement mountpoint resolving directly in the CTE to remove calling and splicing
                //       recursive RootlineUtility call results and handle everything in one database query.
                // Get rootline of (and including) parent page
                $mountPointParameter = !empty($this->parsedMountPointParameters) ? $this->mountPointParameter : '';
                $rootlineUtility = GeneralUtility::makeInstance(self::class, $recordId, $mountPointParameter, $this->context);
                $rootline = $rootlineUtility->get();
                foreach ($rootline as $rootlineRecord) {
                    $records[] = $rootlineRecord;
                }
                break;
            }
            if (!$this->runtimeCache->has('rootline-recordcache-' . $recordCacheIdentifier)) {
                $record = $this->enrichPageRecordArray($record, $recordId);
                $this->runtimeCache->set('rootline-recordcache-' . $recordCacheIdentifier, $record, [self::RUNTIME_CACHE_TAG]);
            }
            $record = $this->runtimeCache->get('rootline-recordcache-' . $recordCacheIdentifier);
            if (!is_array($record)) {
                throw new PageNotFoundException('Broken rootline. Could not resolve page with uid ' . $recordId . '.', 1721982337);
            }
            $records[] = $record;

        }
        // `$records` are build having the current record as first item. We need to revers it here to ensure correct
        // rootline completion and indexing within `RootlineUtility::generateRootlineCache()`, which expects to have
        // a record with `pid=0` as first item in the returned records. Note, that keys are not preserved on purpose.
        return array_reverse($records);
    }

    /**
     * Creates the QueryBuilder for the recursive CTE initial part within {@see self::getRootlineRecords()}.
     *
     * Not to be used standalone. {@see self::getRootlineRecords()} method docblock for overall CTE information.
     */
    protected function createInitialQueryBuilder(QueryBuilder $cte, int $pageId, int $workspaceId): QueryBuilder
    {
        $expr = $cte->expr();
        $initial = $this->createQueryBuilder('pages');
        $initial->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
        if ($workspaceId === 0) {
            // Return simplified initial expression for live workspace resolving only.
            return $initial
                ->selectLiteral(...[
                    // data fields
                    $cte->quoteIdentifier('uid'),
                    $cte->quoteIdentifier('pid'),
                    // adding fake columns to be compatible with workspace aware columns
                    $expr->as('null', '_ORIG_pid'),
                    $expr->as('null', '_ORIG_uid'),
                    // recursive handling fields
                    $expr->as($cte->quoteIdentifier('uid'), '__CTE_JOIN_UID__'),
                    $expr->castInt('1', '__CTE_LEVEL__'),
                    // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by
                    // all supported Database vendors at all or not following the SQL standard. The guard stops cycling
                    // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level.
                    $expr->castText($cte->quoteIdentifier('uid'), '__CTE_PATH__'),
                    // Because of Postgres we need to have this cte colum boolean type and thus needing a comparison here
                    $expr->castInt('0 <> 0', '__CTE_IS_CYCLE__'),
                ])
                ->from('pages')
                ->where(...[
                    $expr->eq('uid', $cte->createNamedParameter($pageId, Connection::PARAM_INT)),
                    // only select live workspace
                    $expr->eq('t3ver_wsid', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                ]);
        }

        $initial
            ->selectLiteral(...[
                // data fields
                $cte->quoteIdentifier('live.uid'),
                $cte->quoteIdentifier('workspace_resolved.pid'),
                // workspace handling
                // For move pointers, store the actual live PID in the _ORIG_pid
                // The only place where PID is actually different in a workspace
                $expr->if(
                    $expr->and(
                        $expr->isNotNull('workspace.t3ver_state'),
                        $expr->eq(
                            'workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT),
                        ),
                    ),
                    $cte->quoteIdentifier('live.pid'),
                    'null',
                    '_ORIG_pid'
                ),
                // For versions of single elements or page+content, preserve online UID
                // (this will produce true "overlay" of element _content_, not any references)
                // For new versions there is no online counterpart
                $expr->if(
                    $expr->and(
                        $expr->isNotNull('workspace.t3ver_state'),
                        $expr->neq(
                            'workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT),
                        ),
                    ),
                    $cte->quoteIdentifier('workspace.uid'),
                    'null',
                    '_ORIG_uid',
                ),
                // recursive handling fields
                $expr->if(
                    $expr->and(
                        $expr->isNotNull('workspace.t3ver_state'),
                        $expr->neq(
                            'workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT),
                        ),
                    ),
                    $cte->quoteIdentifier('workspace_resolved.uid'),
                    $cte->quoteIdentifier('live.uid'),
                    '__CTE_JOIN_UID__',
                ),
                $expr->castInt('1', '__CTE_LEVEL__'),
                // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by
                // all supported Database vendors at all or not following the SQL standard. The guard stops cycling
                // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level.
                $expr->castText($cte->quoteIdentifier('live.uid'), '__CTE_PATH__'),
                // Because of Postgres we need to have this cte colum boolean type and thus needing a comparison here
                $expr->castInt('0 <> 0', '__CTE_IS_CYCLE__'),
            ])
            ->from('pages', 'source')
            ->innerJoin(
                'source',
                'pages',
                'live',
                $expr->eq(
                    'live.uid',
                    $expr->if(
                        $expr->and(
                            $expr->gt('source.t3ver_oid', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                            $expr->eq('source.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)),
                        ),
                        $cte->quoteIdentifier('source.t3ver_oid'),
                        $cte->quoteIdentifier('source.uid'),
                    )
                ),
            )
            ->leftJoin(
                'live',
                'pages',
                'workspace',
                $expr->and(
                    $expr->eq(
                        'workspace.t3ver_wsid',
                        $cte->createNamedParameter($workspaceId, Connection::PARAM_INT)
                    ),
                    $expr->or(
                        // t3ver_state=1 does not contain a t3ver_oid, and returns itself
                        $expr->and(
                            $expr->eq(
                                'workspace.uid',
                                $cte->quoteIdentifier('live.uid'),
                            ),
                            $expr->eq(
                                'workspace.t3ver_state',
                                $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT),
                            ),
                        ),
                        $expr->eq(
                            'workspace.t3ver_oid',
                            $cte->quoteIdentifier('live.uid'),
                        )
                    )
                ),
            )
            ->innerJoin(
                'workspace',
                'pages',
                'workspace_resolved',
                (string)$expr->and(
                    $expr->eq(
                        'workspace_resolved.uid',
                        $expr->if(
                            $expr->isNotNull('workspace.uid'),
                            $cte->quoteIdentifier('workspace.uid'),
                            $cte->quoteIdentifier('live.uid'),
                        ),
                    ),
                ),
            )
            ->where(...[
                $expr->eq('source.uid', $cte->createNamedParameter($pageId, Connection::PARAM_INT)),
                $expr->in('source.t3ver_wsid', $cte->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)),
                $expr->or(
                    // retrieve live workspace if no overlays exists
                    $expr->isNull('workspace.uid'),
                    // discard(omit) record if it turned out to be deleted in workspace
                    $expr->and(
                        $expr->isNotNull('workspace.uid'),
                        $expr->neq(
                            'workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT),
                        ),
                    ),
                ),
            ]);

        return $initial;
    }

    /**
     * Creates the QueryBuilder for the recursive CTE traversal part within {@see self::getRootlineRecords()}.
     *
     * Not to be used standalone. {@see self::getRootlineRecords()} method docblock for overall CTE information.
     */
    protected function createTraversalQueryBuilder(QueryBuilder $cte, int $workspaceId): QueryBuilder
    {
        $expr = $cte->expr();
        $traversal = $this->createQueryBuilder('pages');
        $traversal->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
        if ($workspaceId === 0) {
            $traversal
                ->selectLiteral(...[
                    // data fields
                    $cte->quoteIdentifier('p.uid'),
                    $cte->quoteIdentifier('p.pid'),
                    // adding fake columns to be compatible with workspace aware columns
                    $expr->as('null', '_ORIG_pid'),
                    $expr->as('null', '_ORIG_uid'),
                    // recursive handling fields
                    $expr->as($cte->quoteIdentifier('p.uid'), '__CTE_JOIN_UID__'),
                    $expr->castInt(sprintf('(%s + 1)', $expr->castInt($cte->quoteIdentifier('c.__CTE_LEVEL__'))), '__CTE_LEVEL__'),
                    // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by
                    // all supported Database vendors at all or not following the SQL standard. The guard stops cycling
                    // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level.
                    $expr->castText(
                        $expr->concat(
                            $expr->trim('c.__CTE_PATH__', TrimMode::TRAILING, ' '),
                            $cte->quote(','),
                            $cte->quoteIdentifier('p.uid')
                        ),
                        '__CTE_PATH__'
                    ),
                    $expr->as(
                        sprintf(
                            '%s <> 0',
                            $expr->castInt(sprintf(
                                // Nesting is needed because inSet() creates a comparision expression return a boolean value
                                '(%s)',
                                $expr->inSet(
                                    'c.__CTE_PATH__',
                                    $cte->quoteIdentifier('p.uid'),
                                    true,
                                ),
                            )),
                        ),
                        '__CTE_IS_CYCLE__'
                    ),
                ])
                ->from('cte', 'c')
                ->innerJoin(
                    'c',
                    'pages',
                    'p',
                    (string)$expr->and(
                        $expr->eq('p.uid', $cte->quoteIdentifier('c.pid')),
                        // only select live workspace
                        $expr->eq('p.t3ver_wsid', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                        // ensure that resolve page is not the child page
                        $expr->neq('c.uid', $cte->quoteIdentifier('p.uid')),
                    )
                )
                ->where(...[
                    // If last parent has been detected as start of a recursive cycle, stop here. Note that this is done to
                    // keep the cycle detection value in the result set to allow proper handling later on retrieved rows.
                    $expr->eq('c.__CTE_IS_CYCLE__', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                    // do not try to fetch page with uid 0, which is the virtual tree root point
                    $expr->neq('c.pid', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                    // place a maximal traversal level guard against invalid cycling rootlines to mitigate endless recursion
                    $expr->lt('c.__CTE_LEVEL__', $cte->createNamedParameter(self::MAX_CTE_TRAVERSAL_LEVELS, Connection::PARAM_INT)),
                ]);

            return $traversal;
        }

        $traversal
            ->selectLiteral(...[
                // data fields
                $cte->quoteIdentifier('traversal_live.uid'),
                $cte->quoteIdentifier('traversal_workspace_resolved.pid'),
                // workspace handling
                // For move pointers, store the actual live PID in the _ORIG_pid
                // The only place where PID is actually different in a workspace
                $expr->if(
                    $expr->and(
                        $expr->isNotNull('traversal_workspace.t3ver_state'),
                        $expr->eq(
                            'traversal_workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT),
                        ),
                    ),
                    $cte->quoteIdentifier('traversal_live.pid'),
                    'null',
                    '_ORIG_pid'
                ),
                // For versions of single elements or page+content, preserve online UID
                // (this will produce true "overlay" of element _content_, not any references)
                // For new versions there is no online counterpart
                $expr->if(
                    $expr->and(
                        $expr->isNotNull('traversal_workspace.t3ver_state'),
                        $expr->neq(
                            'traversal_workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT),
                        ),
                    ),
                    $cte->quoteIdentifier('traversal_workspace.uid'),
                    'null',
                    '_ORIG_uid',
                ),
                // recursive handling fields
                $expr->if(
                    $expr->and(
                        $expr->isNotNull('traversal_workspace.t3ver_state'),
                        $expr->neq(
                            'traversal_workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT),
                        ),
                    ),
                    $cte->quoteIdentifier('traversal_workspace_resolved.uid'),
                    $cte->quoteIdentifier('traversal_live.uid'),
                    '__CTE_JOIN_UID__',
                ),
                $expr->castInt(sprintf('(%s + 1)', $expr->castInt($cte->quoteIdentifier('traversal_c.__CTE_LEVEL__'))), '__CTE_LEVEL__'),
                // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by
                // all supported Database vendors at all or not following the SQL standard. The guard stops cycling
                // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level.
                $expr->castText(
                    $expr->concat(
                        $expr->trim('traversal_c.__CTE_PATH__', TrimMode::TRAILING, ' '),
                        $cte->quote(','),
                        $cte->quoteIdentifier('traversal_live.uid')
                    ),
                    '__CTE_PATH__'
                ),
                $expr->as(
                    sprintf(
                        '%s <> 0',
                        $expr->castInt(sprintf(
                            // Nesting is needed because inSet() creates a comparision expression return a boolean value
                            '(%s)',
                            $expr->inSet(
                                'traversal_c.__CTE_PATH__',
                                $cte->quoteIdentifier('traversal_live.uid'),
                                true,
                            )
                        )),
                    ),
                    '__CTE_IS_CYCLE__'
                ),
            ])
            ->from('cte', 'traversal_c')
            ->innerJoin(
                'traversal_c',
                'pages',
                'traversal_source',
                (string)$expr->and(
                    $expr->eq(
                        'traversal_source.uid',
                        $cte->quoteIdentifier('traversal_c.pid')
                    ),
                    $expr->in('traversal_source.t3ver_wsid', $cte->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)),
                ),
            )
            ->innerJoin(
                'traversal_source',
                'pages',
                'traversal_live',
                $expr->eq(
                    'traversal_live.uid',
                    $expr->if(
                        $expr->and(
                            $expr->gt('traversal_source.t3ver_oid', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                            $expr->eq('traversal_source.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)),
                        ),
                        $cte->quoteIdentifier('traversal_source.t3ver_oid'),
                        $cte->quoteIdentifier('traversal_source.uid'),
                    )
                ),
            )
            ->leftJoin(
                'traversal_live',
                'pages',
                'traversal_workspace',
                (string)$expr->and(
                    $expr->eq(
                        'traversal_workspace.t3ver_wsid',
                        $cte->createNamedParameter($workspaceId, Connection::PARAM_INT)
                    ),
                    $expr->or(
                        // t3ver_state=1 does not contain a t3ver_oid, and returns itself
                        $expr->and(
                            $expr->eq(
                                'traversal_workspace.uid',
                                $cte->quoteIdentifier('traversal_live.uid'),
                            ),
                            $expr->eq(
                                'traversal_workspace.t3ver_state',
                                $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT),
                            ),
                        ),
                        $expr->eq(
                            'traversal_workspace.t3ver_oid',
                            $cte->quoteIdentifier('traversal_live.uid'),
                        )
                    )
                ),
            )
            ->innerJoin(
                'traversal_workspace',
                'pages',
                'traversal_workspace_resolved',
                (string)$expr->and(
                    $expr->eq(
                        'traversal_workspace_resolved.uid',
                        $expr->if(
                            $expr->isNotNull('traversal_workspace.uid'),
                            $cte->quoteIdentifier('traversal_workspace.uid'),
                            $cte->quoteIdentifier('traversal_live.uid'),
                        ),
                    ),
                ),
            )
            ->where(...[
                // If last parent has been detected as start of a recursive cycle, stop here. Note that this is done to
                // keep the cycle detection value in the result set to allow proper handling later on retrieved rows.
                $expr->eq('traversal_c.__CTE_IS_CYCLE__', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                // do not try to fetch page with uid 0, which is the virtual tree root point
                $expr->neq('traversal_c.pid', $cte->createNamedParameter(0, Connection::PARAM_INT)),
                // place a maximal traversal level guard against invalid cycling rootlines to mitigate endless recursion
                $expr->lt('traversal_c.__CTE_LEVEL__', $cte->createNamedParameter(self::MAX_CTE_TRAVERSAL_LEVELS, Connection::PARAM_INT)),
                // workspace handling
                $expr->or(
                    // retrieve live workspace if no overlays exists
                    $expr->isNull('traversal_workspace.uid'),
                    // discard(omit) record if it turned out to be deleted in workspace
                    $expr->and(
                        $expr->isNotNull('traversal_workspace.uid'),
                        $expr->neq(
                            'traversal_workspace.t3ver_state',
                            $cte->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT),
                        ),
                    ),
                ),
            ]);

        return $traversal;
    }

    /**
     * {@see self::getRecordArray()} used {@see PageRepository::versionOL()} to compose workspace overlayed records in
     * the based, bypassing access checks (aka enableFields) resulting in multiple queries. This constellation of method
     * and database query chains can be condensed down to a single database query, which this method uses to retrieve
     * the equal workspace overlay database page record in one and thus reducing overall database query count.
     */
    protected function getWorkspaceResolvedPageRecord(int $pageId, int $workspaceId): ?array
    {
        $queryBuilder = $this->createQueryBuilder('pages');
        $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
        if ($workspaceId === 0) {
            // For live workspace only we can simplify this even more
            $queryBuilder
                ->select('*')
                ->from('pages')
                ->where(
                    $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)),
                    $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
                );
            return $queryBuilder->executeQuery()->fetchAssociative() ?: null;
        }
        $fields = $this->getPagesFields();
        $prefixedFields = array_filter($fields, static fn($value) => $value !== 'uid');
        array_walk(
            $prefixedFields,
            static function (string &$value, int|string $key, QueryBuilder $queryBuilder) {
                $value = $queryBuilder->quoteIdentifier(sprintf('%s.%s', 'workspace_resolved', $value));
            },
            $queryBuilder,
        );
        $queryBuilder
            ->selectLiteral(
                $queryBuilder->quoteIdentifier('live.uid'),
                ...$prefixedFields,
                ...[
                    // For move pointers, store the actual live PID in the _ORIG_pid
                    // The only place where PID is actually different in a workspace
                    $queryBuilder->expr()->if(
                        $queryBuilder->expr()->and(
                            $queryBuilder->expr()->isNotNull('workspace.t3ver_state'),
                            $queryBuilder->expr()->eq('workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)),
                        ),
                        $queryBuilder->quoteIdentifier('live.pid'),
                        'null',
                        '_ORIG_pid'
                    ),
                    // For versions of single elements or page+content, preserve online UID
                    // (this will produce true "overlay" of element _content_, not any references)
                    // For new versions there is no online counterpart
                    $queryBuilder->expr()->if(
                        $queryBuilder->expr()->and(
                            $queryBuilder->expr()->isNotNull('workspace.t3ver_state'),
                            $queryBuilder->expr()->neq('workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT)),
                        ),
                        $queryBuilder->quoteIdentifier('workspace.uid'),
                        'null',
                        '_ORIG_uid',
                    ),
                ]
            )
            ->from('pages', 'source')
            ->innerJoin(
                'source',
                'pages',
                'live',
                $queryBuilder->expr()->eq(
                    'live.uid',
                    $queryBuilder->expr()->if(
                        (string)$queryBuilder->expr()->and(
                            $queryBuilder->expr()->gt('source.t3ver_oid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
                            $queryBuilder->expr()->eq('source.t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)),
                        ),
                        $queryBuilder->quoteIdentifier('source.t3ver_oid'),
                        $queryBuilder->quoteIdentifier('source.uid'),
                    )
                ),
            )
            ->leftJoin(
                'live',
                'pages',
                'workspace',
                (string)$queryBuilder->expr()->and(
                    $queryBuilder->expr()->eq(
                        'workspace.t3ver_wsid',
                        $queryBuilder->createNamedParameter($workspaceId, Connection::PARAM_INT)
                    ),
                    $queryBuilder->expr()->or(
                        // t3ver_state=1 does not contain a t3ver_oid, and returns itself
                        $queryBuilder->expr()->and(
                            $queryBuilder->expr()->eq(
                                'workspace.uid',
                                $queryBuilder->quoteIdentifier('live.uid'),
                            ),
                            $queryBuilder->expr()->eq(
                                'workspace.t3ver_state',
                                $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT),
                            ),
                        ),
                        $queryBuilder->expr()->eq(
                            'workspace.t3ver_oid',
                            $queryBuilder->quoteIdentifier('live.uid'),
                        )
                    )
                ),
            )
            ->innerJoin(
                'workspace',
                'pages',
                'workspace_resolved',
                (string)$queryBuilder->expr()->and(
                    $queryBuilder->expr()->eq(
                        'workspace_resolved.uid',
                        $queryBuilder->expr()->if(
                            $queryBuilder->expr()->isNotNull('workspace.uid'),
                            $queryBuilder->quoteIdentifier('workspace.uid'),
                            $queryBuilder->quoteIdentifier('live.uid'),
                        ),
                    ),
                ),
            )
            ->where(
                $queryBuilder->expr()->eq('source.uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)),
                $queryBuilder->expr()->in('source.t3ver_wsid', $queryBuilder->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)),
                $queryBuilder->expr()->or(
                    // retrieve live workspace if no overlays exists
                    $queryBuilder->expr()->isNull('workspace.uid'),
                    // discard(omit) record if it turned out to be deleted in workspace
                    $queryBuilder->expr()->and(
                        $queryBuilder->expr()->isNotNull('workspace.uid'),
                        $queryBuilder->expr()->neq(
                            'workspace.t3ver_state',
                            $queryBuilder->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT),
                        ),
                    ),
                ),
            )
            ->setMaxResults(1);
        $row = $queryBuilder->executeQuery()->fetchAssociative() ?: null;
        return $this->cleanWorkspaceResolvedPageRecord($row);
    }

    protected function cleanWorkspaceResolvedPageRecord(?array $row = null): ?array
    {
        if ($row === null) {
            return $row;
        }
        // Remove cycle detection fields from result row
        unset(
            $row['__CTE_PATH__'],
            $row['__CTE_IS_CYCLE__'],
            $row['__CTE_LEVEL__'],
        );
        // Remove helper fields if null, keeping them only if they contain valid data to mimic the way PHP methods
        // throughout the TYPO3 core added these fields.
        $removeNullableFields = [
            '_ORIG_uid',
            '_ORIG_pid',
        ];
        foreach ($removeNullableFields as $removeNullableField) {
            if (array_key_exists($removeNullableField, $row) && $row[$removeNullableField] === null) {
                unset($row[$removeNullableField]);
            }
        }
        return $row;
    }

    protected function enrichPageRecordArray(array $row, int $pageId): array
    {
        $row = $this->pageRepository->getLanguageOverlay('pages', $row, $this->context->getAspect('language'));
        $row = $this->enrichWithRelationFields($row['_LOCALIZED_UID'] ?? $pageId, $row);
        return $row;
    }

    /**
     * Uses a two-layer cache to ensure that this check is really called VERY VERY SELDOM.
     */
    protected function getPagesFields(): array
    {
        // SchemaInformation provides a 2-level cache (runtime and persisted), no need to cache this here in the class.
        return GeneralUtility::makeInstance(ConnectionPool::class)
            ->getConnectionForTable('pages')
            ->getSchemaInformation()->listTableColumnNames('pages');
    }

    protected function createQueryBuilder(string $tableName): QueryBuilder
    {
        return GeneralUtility::makeInstance(ConnectionPool::class)
            ->getQueryBuilderForTable($tableName);
    }

    private function getRootlineFromRuntimeCache(int $pageId): ?array
    {
        $cacheIdentifier = $this->getCacheIdentifier($pageId);
        if ($this->runtimeCache->has('rootline-localcache-' . $cacheIdentifier)) {
            $rootline = $this->runtimeCache->get('rootline-localcache-' . $cacheIdentifier);
            if (is_array($rootline)) {
                return array_reverse($rootline);
            }
        }
        return null;
    }
}
