<?php

/*
 * 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\Frontend\ContentObject;

use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Result;
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Compatibility\PublicPropertyDeprecationTrait;
use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\Query\Restriction\DocumentTypeExclusionRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Domain\Record;
use TYPO3\CMS\Core\Domain\RecordInterface;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Html\HtmlCropper;
use TYPO3\CMS\Core\Html\HtmlParser;
use TYPO3\CMS\Core\Html\SanitizerBuilderFactory;
use TYPO3\CMS\Core\Html\SanitizerInitiator;
use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Imaging\ImageResource;
use TYPO3\CMS\Core\Localization\DateFormatter;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\Page\DefaultJavaScriptAssetTrait;
use TYPO3\CMS\Core\Page\PageLayoutResolver;
use TYPO3\CMS\Core\Resource\Exception;
use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Security\AllowedCallableAssertion;
use TYPO3\CMS\Core\Security\RawValue;
use TYPO3\CMS\Core\SystemResource\Publishing\SystemResourcePublisherInterface;
use TYPO3\CMS\Core\SystemResource\Publishing\UriGenerationOptions;
use TYPO3\CMS\Core\SystemResource\SystemResourceFactory;
use TYPO3\CMS\Core\Text\TextCropper;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Core\Type\BitSet;
use TYPO3\CMS\Core\Type\DocType;
use TYPO3\CMS\Core\TypoScript\TypoScriptService;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\DebugUtility;
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
use TYPO3\CMS\Frontend\Cache\CacheLifetimeCalculator;
use TYPO3\CMS\Frontend\ContentObject\Event\AfterContentObjectRendererInitializedEvent;
use TYPO3\CMS\Frontend\ContentObject\Event\AfterGetDataResolvedEvent;
use TYPO3\CMS\Frontend\ContentObject\Event\AfterImageResourceResolvedEvent;
use TYPO3\CMS\Frontend\ContentObject\Event\AfterStdWrapFunctionsExecutedEvent;
use TYPO3\CMS\Frontend\ContentObject\Event\AfterStdWrapFunctionsInitializedEvent;
use TYPO3\CMS\Frontend\ContentObject\Event\BeforeStdWrapContentStoredInCacheEvent;
use TYPO3\CMS\Frontend\ContentObject\Event\BeforeStdWrapFunctionsExecutedEvent;
use TYPO3\CMS\Frontend\ContentObject\Event\BeforeStdWrapFunctionsInitializedEvent;
use TYPO3\CMS\Frontend\ContentObject\Exception\ContentRenderingException;
use TYPO3\CMS\Frontend\ContentObject\Exception\ExceptionHandlerInterface;
use TYPO3\CMS\Frontend\ContentObject\Exception\ProductionExceptionHandler;
use TYPO3\CMS\Frontend\Imaging\GifBuilder;
use TYPO3\CMS\Frontend\Page\FrontendUrlPrefix;
use TYPO3\CMS\Frontend\Typolink\LinkFactory;
use TYPO3\CMS\Frontend\Typolink\LinkResult;
use TYPO3\CMS\Frontend\Typolink\LinkResultInterface;
use TYPO3\CMS\Frontend\Typolink\LinkResultService;
use TYPO3\CMS\Frontend\Typolink\LinkVarsCalculator;
use TYPO3\CMS\Frontend\Typolink\UnableToLinkException;
use TYPO3\HtmlSanitizer\Builder\BuilderInterface;

/**
 * This class contains all main TypoScript features, is the backbone of TypoScript
 * rendering, include stdWrap and TypoScript content objects, usually referred to as "cObj".
 *
 * When you call your own PHP-code typically through a USER or USER_INT cObject then it is this
 * class that instantiates the object and calls the main method.
 *
 * Note this class should never be injected since it is stateful and "tainted" after use. Similar to
 * other stateful service classes, an instance should be retrieved using GeneralUtility::makeInstance()
 * per single use until the "#[Autoconfigure(shared: false)]" attribute below vanishes.
 */
#[Autoconfigure(shared: false)]
class ContentObjectRenderer
{
    use PublicPropertyDeprecationTrait;

    use DefaultJavaScriptAssetTrait;

    /**
     * Indicates that object type is USER.
     *
     * @see ContentObjectRender::$userObjectType
     */
    public const OBJECTTYPE_USER_INT = 1;

    /**
     * Indicates that object type is USER.
     *
     * @see ContentObjectRender::$userObjectType
     */
    public const OBJECTTYPE_USER = 2;

    /**
     * List of stdWrap functions in their correct order
     */
    protected const STD_WRAP_ORDER = [
        BeforeStdWrapFunctionsInitializedEvent::class => 'event',
        'cacheRead' => 'hook', // this is a placeholder for checking if the content is available in cache
        'setContentToCurrent' => 'boolean',
        'setContentToCurrent.' => 'array',
        'addPageCacheTags' => 'string',
        'addPageCacheTags.' => 'array',
        'setCurrent' => 'string',
        'setCurrent.' => 'array',
        'lang.' => 'array',
        'data' => 'getText',
        'data.' => 'array',
        'field' => 'fieldName',
        'field.' => 'array',
        'current' => 'boolean',
        'current.' => 'array',
        'cObject' => 'cObject',
        'cObject.' => 'array',
        'numRows.' => 'array',
        'preUserFunc' => 'functionName',
        AfterStdWrapFunctionsInitializedEvent::class => 'event',
        'override' => 'string',
        'override.' => 'array',
        'preIfEmptyListNum' => 'listNum',
        'preIfEmptyListNum.' => 'array',
        'ifNull' => 'string',
        'ifNull.' => 'array',
        'ifEmpty' => 'string',
        'ifEmpty.' => 'array',
        'ifBlank' => 'string',
        'ifBlank.' => 'array',
        'listNum' => 'listNum',
        'listNum.' => 'array',
        'trim' => 'boolean',
        'trim.' => 'array',
        'strPad.' => 'array',
        'stdWrap' => 'stdWrap',
        'stdWrap.' => 'array',
        BeforeStdWrapFunctionsExecutedEvent::class => 'event',
        'required' => 'boolean',
        'required.' => 'array',
        'if.' => 'array',
        'fieldRequired' => 'fieldName',
        'fieldRequired.' => 'array',
        'csConv' => 'string',
        'csConv.' => 'array',
        'parseFunc' => 'objectpath',
        'parseFunc.' => 'array',
        'HTMLparser' => 'boolean',
        'HTMLparser.' => 'array',
        'split.' => 'array',
        'replacement.' => 'array',
        'prioriCalc' => 'boolean',
        'prioriCalc.' => 'array',
        'char' => 'integer',
        'char.' => 'array',
        'intval' => 'boolean',
        'intval.' => 'array',
        'hash' => 'string',
        'hash.' => 'array',
        'round' => 'boolean',
        'round.' => 'array',
        'numberFormat.' => 'array',
        'expandList' => 'boolean',
        'expandList.' => 'array',
        'date' => 'dateconf',
        'date.' => 'array',
        'strtotime' => 'strtotimeconf',
        'strtotime.' => 'array',
        'strftime' => 'strftimeconf',
        'strftime.' => 'array',
        'formattedDate' => 'formattedDateconf',
        'formattedDate.' => 'array',
        'age' => 'boolean',
        'age.' => 'array',
        'case' => 'case',
        'case.' => 'array',
        'bytes' => 'boolean',
        'bytes.' => 'array',
        'substring' => 'parameters',
        'substring.' => 'array',
        'cropHTML' => 'crop',
        'cropHTML.' => 'array',
        'stripHtml' => 'boolean',
        'stripHtml.' => 'array',
        'crop' => 'crop',
        'crop.' => 'array',
        'rawUrlEncode' => 'boolean',
        'rawUrlEncode.' => 'array',
        'htmlSpecialChars' => 'boolean',
        'htmlSpecialChars.' => 'array',
        'encodeForJavaScriptValue' => 'boolean',
        'encodeForJavaScriptValue.' => 'array',
        'doubleBrTag' => 'string',
        'doubleBrTag.' => 'array',
        'br' => 'boolean',
        'br.' => 'array',
        'brTag' => 'string',
        'brTag.' => 'array',
        'encapsLines.' => 'array',
        'keywords' => 'boolean',
        'keywords.' => 'array',
        'innerWrap' => 'wrap',
        'innerWrap.' => 'array',
        'innerWrap2' => 'wrap',
        'innerWrap2.' => 'array',
        'preCObject' => 'cObject',
        'preCObject.' => 'array',
        'postCObject' => 'cObject',
        'postCObject.' => 'array',
        'wrapAlign' => 'align',
        'wrapAlign.' => 'array',
        'typolink.' => 'array',
        'wrap' => 'wrap',
        'wrap.' => 'array',
        'noTrimWrap' => 'wrap',
        'noTrimWrap.' => 'array',
        'wrap2' => 'wrap',
        'wrap2.' => 'array',
        'dataWrap' => 'dataWrap',
        'dataWrap.' => 'array',
        'prepend' => 'cObject',
        'prepend.' => 'array',
        'append' => 'cObject',
        'append.' => 'array',
        'wrap3' => 'wrap',
        'wrap3.' => 'array',
        'orderedStdWrap' => 'stdWrap',
        'orderedStdWrap.' => 'array',
        'outerWrap' => 'wrap',
        'outerWrap.' => 'array',
        'insertData' => 'boolean',
        'insertData.' => 'array',
        'postUserFunc' => 'functionName',
        'postUserFuncInt' => 'functionName',
        'prefixComment' => 'string',
        'prefixComment.' => 'array',
        'htmlSanitize' => 'boolean',
        'htmlSanitize.' => 'array',
        'cacheStore' => 'hook', // this is a placeholder for storing the content in cache
        AfterStdWrapFunctionsExecutedEvent::class => 'event',
        'debug' => 'boolean',
        'debug.' => 'array',
        'debugFunc' => 'boolean',
        'debugFunc.' => 'array',
        'debugData' => 'boolean',
        'debugData.' => 'array',
    ];

    /**
     * @deprecated since TYPO3 v14, will be removed in TYPO3 v15.
     */
    protected array $deprecatedPublicProperties = [
        'lastTypoLinkResult' => '$lastTypoLinkResult is deprecated since TYPO3 v14 and will be removed in TYPO3 v15. Use the return value of createLink() instead.',
        'checkPid_badDoktypeList' => '$checkPid_badDoktypeList is deprecated since TYPO3 v14 and will be removed in TYPO3 v15.',
    ];

    /**
     * Loaded with the current data-record.
     *
     * If the instance of this class is used to render records from the database those records are found in this array.
     * The function stdWrap has TypoScript properties that fetch field-data from this array.
     */
    public array $data = [];

    protected string $table = '';

    /**
     * Used by the parseFunc function and is loaded with tag-parameters when parsing tags.
     */
    public array $parameters = [];

    public string $currentValKey = 'currentValue_kidjls9dksoje';

    /**
     * This is set to the [table]:[uid] of the record delivered in the $data-array, if the cObjects CONTENT or RECORD is in operation.
     */
    public string $currentRecord = '';

    /**
     * @deprecated since TYPO3 v14, will be removed in TYPO3 v15.
     * @internal
     */
    public int $currentRecordNumber = 0;

    /**
     * @deprecated since TYPO3 v14, will be removed in TYPO3 v15.
     * @internal
     */
    public int $parentRecordNumber = 0;

    /**
     * @internal
     */
    protected array $parentRecord = [];

    /**
     * @deprecated since TYPO3 v14, will be removed in TYPO3 v15.
     * @internal
     */
    protected string|int|null $checkPid_badDoktypeList = null;

    /**
     * @deprecated since TYPO3 v14, will be removed in TYPO3 v15. Use the return value of createLink() instead.
     * @internal
     */
    protected ?LinkResultInterface $lastTypoLinkResult = null;

    /**
     * Current file object during iterations over files.
     */
    protected File|FileReference|Folder|FileInterface|FolderInterface|null $currentFile = null;

    /**
     * Set to true by doConvertToUserIntObject() if USER object wants to become USER_INT.
     */
    public bool $doConvertToUserIntObject = false;

    /**
     * Indicates current object type. Can hold one of OBJECTTYPE_ constants or false.
     * The value is set and reset inside USER() function. Any time outside of
     * USER() it is false.
     */
    protected int|false $userObjectType = false;

    /**
     * Per-nesting-level stop flags for stdWrap processing. Keyed by $stdWrapNestingLevel.
     * Set to true to abort remaining stdWrap properties at that level (e.g. when "if", "required"
     * or "ifEmpty" conditions are not met). Isolates stop conditions so an inner stdWrap call
     * cannot accidentally abort an outer one.
     */
    protected array $stopRendering = [];

    /**
     * Current stdWrap nesting depth, used as the key into $stopRendering.
     */
    protected int $stdWrapNestingLevel = 0;

    private ?ServerRequestInterface $request = null;

    public function __construct(
        private readonly ContainerInterface $container,
        private readonly Context $context,
        private readonly LoggerInterface $logger,
        private readonly EventDispatcherInterface $eventDispatcher,
        private readonly ConnectionPool $connectionPool,
        private readonly ResourceFactory $resourceFactory,
        #[Autowire(expression: 'service("features").isFeatureEnabled("frontend.cache.autoTagging")')]
        private readonly bool $autoTagging,
        private readonly CacheLifetimeCalculator $cacheLifetimeCalculator,
        #[Autowire(service: 'cache.hash')]
        private readonly FrontendInterface $cacheHash,
        private readonly HashService $hashService,
        private readonly FrontendUrlPrefix $frontendUrlPrefix,
        private readonly Locales $locales,
        private readonly TypoScriptService $typoScriptService,
        private readonly SanitizerBuilderFactory $sanitizerBuilderFactory,
        private readonly TextCropper $textCropper,
        private readonly HtmlCropper $htmlCropper,
        private readonly LinkVarsCalculator $linkVarsCalculator,
        private readonly SystemResourceFactory $systemResourceFactory,
        private readonly SystemResourcePublisherInterface $systemResourcePublisher,
        private readonly PageLayoutResolver $pageLayoutResolver,
        private readonly LanguageServiceFactory $languageServiceFactory,
        private readonly FlexFormTools $flexFormTools,
        private readonly LinkFactory $linkFactory,
        private readonly LinkResultService $linkResultService,
        // TimeTracker is a stateful singleton, we would usually not inject this. This
        // instance however is set up early in middlewares and carried around with its accumulating
        // state throughout entire FE rendering. As such, it is ok to get it injected here,
        // similar to PageRenderer and Context, which are designed in a similar way.
        private readonly TimeTracker $timeTracker,
        private readonly TcaSchemaFactory $tcaSchemaFactory,
    ) {}

    public function setRequest(ServerRequestInterface $request): void
    {
        $this->request = $request;
    }

    /**
     * Used strictly internally to preserve state when dealing with non-cached elements.
     *
     * @internal These are not the methods you are looking for.
     */
    public function getState(): array
    {
        // Request is of course NOT returned!
        $state = [
            'data' => $this->data,
            'table' => $this->table,
            'parameters' => $this->parameters,
            'currentValKey' => $this->currentValKey,
            'currentRecord' => $this->currentRecord,
            'currentRecordNumber' => $this->currentRecordNumber,
            'parentRecordNumber' => $this->parentRecordNumber,
            'parentRecord' => $this->parentRecord,
            'checkPid_badDoktypeList' => $this->checkPid_badDoktypeList,
            'lastTypoLinkResult' => $this->lastTypoLinkResult !== null
                ? $this->linkResultService->getState($this->lastTypoLinkResult)
                : null,
            'doConvertToUserIntObject' => $this->doConvertToUserIntObject,
            'userObjectType' => $this->userObjectType,
            'stopRendering' => $this->stopRendering,
            'stdWrapNestingLevel' => $this->stdWrapNestingLevel,
            'currentFile' => null,
        ];
        if ($this->currentFile instanceof FileReference) {
            $state['currentFile'] = 'FileReference:' . $this->currentFile->getUid();
        } elseif ($this->currentFile instanceof File) {
            $state['currentFile'] = 'File:' . $this->currentFile->getIdentifier();
        }
        return $state;
    }

    /**
     * Counterpart of getState()
     *
     * @internal
     */
    public function updateState(array $state): void
    {
        $this->data = $state['data'];
        $this->table = $state['table'];
        $this->parameters = $state['parameters'];
        $this->currentValKey = $state['currentValKey'];
        $this->currentRecord = $state['currentRecord'];
        $this->currentRecordNumber = $state['currentRecordNumber'];
        $this->parentRecordNumber = $state['parentRecordNumber'];
        $this->parentRecord = $state['parentRecord'];
        $this->checkPid_badDoktypeList = $state['checkPid_badDoktypeList'];
        $this->lastTypoLinkResult = isset($state['lastTypoLinkResult'])
            ? $this->linkResultService->fromState($state['lastTypoLinkResult'])
            : null;
        $this->doConvertToUserIntObject = $state['doConvertToUserIntObject'];
        $this->userObjectType = $state['userObjectType'];
        $this->stopRendering = $state['stopRendering'];
        $this->stdWrapNestingLevel = $state['stdWrapNestingLevel'];
        $this->currentFile = null;
        if (is_string($state['currentFile'])) {
            [$objectType, $identifier] = explode(':', $state['currentFile'], 2);
            try {
                if ($objectType === 'File') {
                    $this->currentFile = $this->resourceFactory->retrieveFileOrFolderObject($identifier);
                } elseif ($objectType === 'FileReference') {
                    $this->currentFile = $this->resourceFactory->getFileReferenceObject((int)$identifier);
                }
            } catch (ResourceDoesNotExistException $e) {
                // Keep $this->currentFile null
            }
        }
    }

    /**
     * After making an instance of the class, call this function and pass to it a
     * database record and the tablename from where the record is from. That will
     * then become the "current" record loaded into memory and accessed by the .fields
     * property found in eg. stdWrap.
     *
     * @param string $table The table that the data record is from.
     */
    public function start(array $data, string $table = ''): void
    {
        $this->data = $data;
        $this->table = $table;
        $this->currentRecord = $table !== '' ? $table . ':' . ($this->data['uid'] ?? '') : '';
        $this->parameters = [];
        $this->eventDispatcher->dispatch(new AfterContentObjectRendererInitializedEvent($this));
        if ($this->currentRecord !== '' && $this->autoTagging && $this->table !== 'pages') {
            // Page lifetime for the requested page is calculated in RequestHandler, taking
            // cache_period and TypoScript into account.
            // When start() is called here, it can be the requested page record, which is handled
            // already, OR it is a page record *used* on this page, for instance from a menu rendering.
            // In the latter case, we do *not* want to reduce the lifetime of the rendered page down
            // to the cache_period of that content related page record. We can thus skip 'pages' records
            // here altogether.
            $lifetime = $this->cacheLifetimeCalculator->calculateLifetimeForRow($this->table, $this->data);
            $cacheTags = [
                sprintf('%s_%s', $this->table, ($this->data['uid'] ?? 0)),
            ];
            if ((int)($this->data['_LOCALIZED_UID'] ?? 0) > 0) {
                $cacheTags[] = sprintf('%s_%s', $this->table, (int)$this->data['_LOCALIZED_UID']);
            }
            $this->getRequest()->getAttribute('frontend.cache.collector')?->addCacheTags(
                ...array_map(
                    static fn(string $cacheTag) => new CacheTag($cacheTag, $lifetime),
                    $cacheTags,
                ),
            );
        }
    }

    /**
     * Returns the current table
     *
     * @return string
     */
    public function getCurrentTable()
    {
        return $this->table;
    }

    /**
     * @param array $data The record array
     * @param string $currentRecord Format: "table:uid"
     * @internal
     */
    public function setParent($data, string $currentRecord): void
    {
        $this->parentRecord = [
            'data' => $data,
            'currentRecord' => $currentRecord,
        ];
    }

    /**
     * Returns the "current" value.
     * The "current" value is just an internal variable that can be used by functions to pass a single value on to another
     * function later in the TypoScript processing. It's like "load accumulator" in the good old C64 days... basically a "register"
     * you can use as you like. The TSref will tell if functions are setting this value before calling some other object so that
     * you know if it holds any special information.
     *
     * @return mixed The "current" value
     */
    public function getCurrentVal()
    {
        return $this->data[$this->currentValKey] ?? null;
    }

    /**
     * Sets the "current" value.
     *
     * @param mixed $value The variable that you want to set as "current
     */
    public function setCurrentVal($value): void
    {
        $this->data[$this->currentValKey] = $value;
    }

    /**
     * Rendering of a "numerical array" of cObjects from TypoScript
     * Will call ->cObjGetSingle() for each cObject found and accumulate the output.
     *
     * @param mixed $setup array with cObjects as values.
     * @param string $addKey A prefix for the debugging information
     * @return string Rendered output from the cObjects in the array.
     * @see cObjGetSingle()
     */
    public function cObjGet($setup, $addKey = ''): string
    {
        if (!is_array($setup)) {
            return '';
        }
        return implode('', $this->cObjGetSeparated($setup, $addKey));
    }

    /**
     * Rendering of a "numerical array" of cObjects from TypoScript
     * Will call ->cObjGetSingle() for each cObject found.
     *
     * @return list<string>
     */
    public function cObjGetSeparated(?array $setup, string $addKey = ''): array
    {
        if ($setup === null || $setup === []) {
            return [];
        }
        $sKeyArray = ArrayUtility::filterAndSortByNumericKeys($setup);
        $contentObjects = [];
        foreach ($sKeyArray as $theKey) {
            $theValue = $setup[$theKey];
            if ((int)$theKey && !str_contains($theKey, '.')) {
                $conf = $setup[$theKey . '.'] ?? [];
                $contentObjects[] = $this->cObjGetSingle($theValue, $conf, $addKey . $theKey);
            }
        }
        return $contentObjects;
    }

    /**
     * Renders a content object
     *
     * @param string $name The content object name, eg. "TEXT" or "USER" or "IMAGE"
     * @param mixed $conf The array with TypoScript properties for the content object
     * @param string $TSkey A string label used for the internal debugging tracking.
     * @return string cObject output
     * @throws \UnexpectedValueException
     */
    public function cObjGetSingle(string $name, $conf, $TSkey = '__')
    {
        $name = trim($name);
        if ($this->timeTracker->LR) {
            $this->timeTracker->push($TSkey, $name);
        }
        $fullConfigArray = [
            'tempKey' => $name,
            'tempKey.' => is_array($conf) ? $conf : [],
        ];
        // Resolve '=<' operator if needed
        $fullConfigArray = $this->mergeTSRef($fullConfigArray, 'tempKey');
        $contentObject = $this->getContentObject($fullConfigArray['tempKey']);
        $content = '';
        if ($contentObject) {
            $content = $this->render($contentObject, $fullConfigArray['tempKey.']);
        }
        if ($this->timeTracker->LR) {
            $this->timeTracker->pull($content);
        }
        return $content;
    }

    /**
     * Returns a new content object of type $name.
     *
     * @throws ContentRenderingException
     */
    public function getContentObject(string $name): ?AbstractContentObject
    {
        $contentObjectFactory = $this->container->get(ContentObjectFactory::class);
        return $contentObjectFactory->getContentObject($name, $this->getRequest(), $this);
    }

    /**
     * Renders a content object by taking exception and cache handling
     * into consideration
     *
     * @param AbstractContentObject $contentObject Content object instance
     * @param array $configuration Array of TypoScript properties
     *
     * @throws ContentRenderingException
     * @throws \Exception
     */
    public function render(AbstractContentObject $contentObject, $configuration = []): string
    {
        $content = '';

        // Evaluate possible cache and return
        $cacheConfiguration = $configuration['cache.'] ?? null;
        if ($cacheConfiguration !== null) {
            unset($configuration['cache.']);
            $cache = $this->getFromCache($cacheConfiguration);
            if ($cache !== false) {
                return $cache;
            }
        }

        // Render content
        try {
            $content .= $contentObject->render($configuration);
        } catch (ContentRenderingException $exception) {
            // Content rendering Exceptions indicate a critical problem which should not be
            // caught e.g. when something went wrong with Exception handling itself
            throw $exception;
        } catch (\Throwable $exception) {
            $exceptionHandler = $this->createExceptionHandler($configuration);
            if ($exceptionHandler === null) {
                throw $exception;
            }
            // Ensure that the exception handler receives an \Exception instance,
            // which is required by the \ExceptionHandlerInterface.
            if (!$exception instanceof \Exception) {
                $exception = new \Exception($exception->getMessage(), 1698347363, $exception);
            }
            $content = $exceptionHandler->handle($exception, $contentObject, $configuration);
        }

        // Store cache
        if ($cacheConfiguration !== null && $this->getRequest()->getAttribute('frontend.cache.instruction')->isCachingAllowed()) {
            $key = $this->calculateCacheKey($cacheConfiguration);
            if (!empty($key)) {
                $tags = $this->calculateCacheTags($cacheConfiguration);
                $cacheLifetime = $this->calculateCacheLifetime($cacheConfiguration);
                $cachedData = [
                    'content' => $content,
                    'cacheTags' => $tags,
                ];
                $this->cacheHash->set($key, $cachedData, $tags, $cacheLifetime);

                // If no tags are given, we restrict the maximum lifetime of the cache to the lifetime of the cache entry.
                if ($tags === []) {
                    $this->getRequest()->getAttribute('frontend.cache.collector')->restrictMaximumLifetime($cacheLifetime);
                }

                $this->getRequest()->getAttribute('frontend.cache.collector')->addCacheTags(
                    ...array_map(fn(string $tag) => new CacheTag($tag, $cacheLifetime), $tags)
                );
            }
        }

        return $content;
    }

    /**
     * Creates the content object exception handler from local content object configuration
     * or, from global configuration if not explicitly disabled in local configuration
     *
     * @throws ContentRenderingException
     */
    protected function createExceptionHandler(array $configuration): ?ExceptionHandlerInterface
    {
        $exceptionHandler = null;
        $exceptionHandlerClassName = $this->determineExceptionHandlerClassName($configuration);
        if (!empty($exceptionHandlerClassName)) {
            $exceptionHandler = GeneralUtility::makeInstance($exceptionHandlerClassName);
            if (!$exceptionHandler instanceof ExceptionHandlerInterface) {
                throw new ContentRenderingException('An exception handler was configured but the class does not exist or does not implement the ExceptionHandlerInterface', 1403653369);
            }
            $exceptionHandler->setConfiguration($this->mergeExceptionHandlerConfiguration($configuration));
        }
        return $exceptionHandler;
    }

    /**
     * Determine exception handler class name from global and content object configuration
     */
    protected function determineExceptionHandlerClassName(array $configuration): ?string
    {
        $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray();
        $exceptionHandlerClassName = null;
        if (!isset($typoScriptConfigArray['contentObjectExceptionHandler'])) {
            if (Environment::getContext()->isProduction()) {
                $exceptionHandlerClassName = '1';
            }
        } else {
            $exceptionHandlerClassName = $typoScriptConfigArray['contentObjectExceptionHandler'];
        }
        if (isset($configuration['exceptionHandler'])) {
            $exceptionHandlerClassName = $configuration['exceptionHandler'];
        }
        if ($exceptionHandlerClassName === '1') {
            $exceptionHandlerClassName = ProductionExceptionHandler::class;
        }
        return $exceptionHandlerClassName;
    }

    /**
     * Merges global exception handler configuration with the one from the content object
     * and returns the merged exception handler configuration
     */
    protected function mergeExceptionHandlerConfiguration(array $configuration): array
    {
        $exceptionHandlerConfiguration = [];
        $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray();
        if (!empty($typoScriptConfigArray['contentObjectExceptionHandler.'])) {
            $exceptionHandlerConfiguration = $typoScriptConfigArray['contentObjectExceptionHandler.'];
        }
        if (!empty($configuration['exceptionHandler.'])) {
            $exceptionHandlerConfiguration = array_replace_recursive($exceptionHandlerConfiguration, $configuration['exceptionHandler.']);
        }
        return $exceptionHandlerConfiguration;
    }

    /**
     * Retrieves a type of object called as USER or USER_INT. Object can detect their
     * type by using this call. It returns OBJECTTYPE_USER_INT or OBJECTTYPE_USER depending on the
     * current object execution. In all other cases it will return FALSE to indicate
     * a call out of context.
     *
     * @return int|false One of OBJECTTYPE_ class constants or false
     */
    public function getUserObjectType(): int|false
    {
        return $this->userObjectType;
    }

    /**
     * Sets the user object type
     */
    public function setUserObjectType(int|false $userObjectType): void
    {
        $this->userObjectType = $userObjectType;
    }

    /**
     * Requests the current USER object to be converted to USER_INT.
     */
    public function convertToUserIntObject(): void
    {
        if ($this->userObjectType !== self::OBJECTTYPE_USER) {
            $this->timeTracker->setTSlogMessage(self::class . '::convertToUserIntObject() is called in the wrong context or for the wrong object type', LogLevel::WARNING);
        } else {
            $this->doConvertToUserIntObject = true;
        }
    }

    /**
     * Converts a given config in Flexform to a conf-array
     *
     * @param string|array $flexData Flexform data
     * @param array $conf Array to write the data into, by reference
     * @param bool $recursive Is set if called recursive. Don't call function with this parameter, it's used inside the function only
     * @deprecated since TYPO3 v14, will be removed in TYPO3 v15.
     */
    public function readFlexformIntoConf($flexData, &$conf, $recursive = false)
    {
        trigger_error(
            'ContentObjectRenderer::readFlexformIntoConf() is deprecated since TYPO3 v14 and will be removed in TYPO3 v15 without replacement.',
            E_USER_DEPRECATED
        );
        if ($recursive === false && is_string($flexData)) {
            $flexData = GeneralUtility::xml2array($flexData, 'T3');
        }
        if (is_array($flexData) && isset($flexData['data']['sDEF']['lDEF'])) {
            $flexData = $flexData['data']['sDEF']['lDEF'];
        }
        if (!is_array($flexData)) {
            return;
        }
        foreach ($flexData as $key => $value) {
            if (!is_array($value)) {
                continue;
            }
            if (isset($value['el'])) {
                if (is_array($value['el']) && !empty($value['el'])) {
                    foreach ($value['el'] as $ekey => $element) {
                        if (isset($element['vDEF'])) {
                            $conf[$ekey] = $element['vDEF'];
                        } else {
                            if (is_array($element)) {
                                $this->readFlexformIntoConf($element, $conf[$key][key($element)][$ekey], true);
                            } else {
                                $this->readFlexformIntoConf($element, $conf[$key][$ekey], true);
                            }
                        }
                    }
                } else {
                    $this->readFlexformIntoConf($value['el'], $conf[$key], true);
                }
            }
            if (isset($value['vDEF'])) {
                $conf[$key] = $value['vDEF'];
            }
        }
    }

    /**
     * Returns all parents of the given PID (Page UID) list
     *
     * @param string $pidList A list of page Content-Element PIDs (Page UIDs) / stdWrap
     * @param array $pidConf stdWrap array for the list
     * @return string A list of PIDs
     * @internal
     */
    public function getSlidePids($pidList, array $pidConf = []): string
    {
        $pidList = $pidConf !== [] ? trim((string)$this->stdWrap($pidList, $pidConf)) : trim($pidList);
        if ($pidList === '') {
            $pidList = 'this';
        }
        $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
        if (trim($pidList)) {
            $contentPid = $this->getRequest()->getAttribute('frontend.page.information')->getContentFromPid();
            $listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $pidList));
            $listArr = $this->checkPidArray($listArr);
            $pidList = [];
            foreach ($listArr as $uid) {
                $page = $pageRepository->getPage((int)$uid);
                if (!$page['is_siteroot']) {
                    $pidList[] = $page['pid'];
                }
            }
            return implode(',', $pidList);
        }
        return '';
    }

    /**
     * Wraps the input string in link-tags that opens the image in a new window.
     *
     * @param string $string String to wrap, probably an <img> tag
     * @param string|File|FileReference $imageFile The original image file
     * @param array $conf TypoScript properties for the "imageLinkWrap" function
     * @return string The input string, $string, wrapped as configured.
     * @internal This method should be used within TYPO3 Core only
     */
    public function imageLinkWrap($string, $imageFile, $conf)
    {
        $string = (string)$string;
        $enable = $this->stdWrapValue('enable', $conf);
        if (!$enable) {
            return $string;
        }
        $content = (string)$this->typoLink($string, $conf['typolink.'] ?? []);
        if (isset($conf['file.']) && is_scalar($imageFile)) {
            $imageFile = $this->stdWrap((string)$imageFile, $conf['file.']);
        }

        if ($imageFile instanceof File) {
            $file = $imageFile;
        } elseif ($imageFile instanceof FileReference) {
            $file = $imageFile->getOriginalFile();
        } elseif (MathUtility::canBeInterpretedAsInteger($imageFile)) {
            $file = $this->resourceFactory->getFileObject((int)$imageFile);
        } else {
            $file = $this->resourceFactory->getFileObjectFromCombinedIdentifier($imageFile);
        }

        // Create imageFileLink if not created with typolink
        if ($content === $string && $file !== null) {
            $parameterNames = ['width', 'height', 'effects', 'bodyTag', 'title', 'wrap', 'crop'];
            $parameters = [];
            $sample = $this->stdWrapValue('sample', $conf);
            if ($sample) {
                $parameters['sample'] = 1;
            }
            foreach ($parameterNames as $parameterName) {
                if (isset($conf[$parameterName . '.'])) {
                    $conf[$parameterName] = $this->stdWrap($conf[$parameterName] ?? '', $conf[$parameterName . '.'] ?? []);
                }
                if (isset($conf[$parameterName]) && $conf[$parameterName]) {
                    $parameters[$parameterName] = $conf[$parameterName];
                }
            }
            $parametersEncoded = base64_encode((string)json_encode($parameters));
            $hmac = $this->hashService->hmac(implode('|', [$file->getUid(), $parametersEncoded]), 'tx_cms_showpic', HashAlgo::SHA3_256);
            $params = '&md5=' . $hmac;
            foreach (str_split($parametersEncoded, 64) as $index => $chunk) {
                $params .= '&parameters' . rawurlencode('[') . $index . rawurlencode(']') . '=' . rawurlencode($chunk);
            }
            $absRefPrefix = $this->frontendUrlPrefix->getUrlPrefix($this->getRequest());
            $url = $absRefPrefix . 'index.php?eID=tx_cms_showpic&file=' . $file->getUid() . $params;
            $directImageLink = $this->stdWrapValue('directImageLink', $conf);
            if ($directImageLink) {
                $imgResourceConf = [
                    'file' => $imageFile,
                    'file.' => $conf,
                ];
                $url = $this->cObjGetSingle('IMG_RESOURCE', $imgResourceConf);
                if (!$url) {
                    // Either imagemagick/gm is not available or image URL could not be resolved due to invalid image file
                    if ($imageFile instanceof File || $imageFile instanceof FileReference) {
                        $url = $imageFile->getPublicUrl();
                    } else {
                        $url = $imageFile;
                    }
                }
            }
            $target = (string)$this->stdWrapValue('target', $conf);
            if ($target === '') {
                $target = 'thePicture';
            }
            $a1 = '';
            $a2 = '';
            $conf['JSwindow'] = $this->stdWrapValue('JSwindow', $conf);
            if ($conf['JSwindow']) {
                $altUrl = $this->stdWrapValue('altUrl', $conf['JSwindow.'] ?? []);
                if ($altUrl) {
                    $url = $altUrl . (($conf['JSwindow.']['altUrl_noDefaultParams'] ?? false) ? '' : '?file=' . rawurlencode((string)$imageFile) . $params);
                }

                if ($file instanceof ProcessedFile) {
                    // TypoScript record delivered like 'file = fileadmin/something.jpg' which can result
                    // in an already processed file. Process the original file with the proper config now.
                    $file = $file->getOriginalFile();
                }
                $processedFile = $file->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $conf);
                $JSwindowExpand = $this->stdWrapValue('expand', $conf['JSwindow.'] ?? []);
                $offset = GeneralUtility::intExplode(',', $JSwindowExpand . ',');
                $newWindow = $this->stdWrapValue('newWindow', $conf['JSwindow.'] ?? []);
                $params = [
                    'width' => ($processedFile->getProperty('width') + $offset[0]),
                    'height' => ($processedFile->getProperty('height') + $offset[1]),
                    'status' => '0',
                    'menubar' => '0',
                ];
                // params override existing parameters from above, or add more
                $windowParams = (string)$this->stdWrapValue('params', $conf['JSwindow.'] ?? []);
                $windowParams = explode(',', $windowParams);
                foreach ($windowParams as $windowParam) {
                    $windowParamParts = explode('=', $windowParam);
                    $paramKey = $windowParamParts[0];
                    $paramValue = $windowParamParts[1] ?? null;

                    if ($paramKey === '') {
                        continue;
                    }

                    if ($paramValue !== '') {
                        $params[$paramKey] = $paramValue;
                    } else {
                        unset($params[$paramKey]);
                    }
                }
                $paramString = '';
                foreach ($params as $paramKey => $paramValue) {
                    $paramString .= htmlspecialchars($paramKey) . '=' . htmlspecialchars((string)$paramValue) . ',';
                }

                $attrs = [
                    'href' => (string)$url,
                    'data-window-url' => $url,
                    'data-window-target' => $newWindow ? md5((string)$url) : 'thePicture',
                    'data-window-features' => rtrim($paramString, ','),
                    'target' => $target,
                ];

                $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray();
                $a1 = sprintf(
                    '<a %s%s>',
                    GeneralUtility::implodeAttributes($attrs, true),
                    trim($typoScriptConfigArray['ATagParams'] ?? '') ? ' ' . trim($typoScriptConfigArray['ATagParams']) : ''
                );
                $a2 = '</a>';
                $this->addDefaultFrontendJavaScript($this->getRequest());
            } else {
                $conf['linkParams.']['directImageLink'] = (bool)($conf['directImageLink'] ?? false);
                $conf['linkParams.']['parameter'] = $url;
                $string = (string)$this->typoLink($string, $conf['linkParams.']);
            }
            if (isset($conf['stdWrap.'])) {
                $string = (string)$this->stdWrap($string, $conf['stdWrap.']);
            }
            $content = $a1 . $string . $a2;
        }
        return $content;
    }

    /**
     * Sets the SYS_LASTCHANGED timestamp if input timestamp is larger than current value.
     * The SYS_LASTCHANGED timestamp can be used by various caching/indexing applications to determine if the page has new content.
     * Therefore, you should call this function with the last-changed timestamp of any element you display.
     *
     * @param RecordInterface|int|string|float|null $item a record objet or a Unix timestamp (number of seconds since 1970)
     */
    public function lastChanged(RecordInterface|int|string|float|null $item): void
    {
        if (MathUtility::canBeInterpretedAsInteger($item)) {
            $item = (int)$item;
        } elseif ($item instanceof Record) {
            $item = $item->getSystemProperties()->getLastUpdatedAt()->getTimestamp();
        } else {
            return;
        }
        $pageParts = $this->getRequest()->getAttribute('frontend.page.parts');
        if ($item > $pageParts->getLastChanged()) {
            $pageParts->setLastChanged($item);
        }
    }

    /**
     * Sets the current file object during iterations over files.
     */
    public function setCurrentFile(File|FileReference|Folder|FileInterface|FolderInterface|null $fileObject): void
    {
        $this->currentFile = $fileObject;
    }

    /**
     * Gets the current file object during iterations over files.
     */
    public function getCurrentFile(): File|FileReference|Folder|FileInterface|FolderInterface|null
    {
        return $this->currentFile;
    }

    /**
     * The "stdWrap" function. This is the implementation of what is known as "stdWrap properties" in TypoScript.
     * Basically "stdWrap" performs some processing of a value based on properties in the input $conf array, holding
     * the TypoScript "stdWrap properties".
     *
     * @param string $content Input value undergoing processing in this function.
     * @param mixed $conf TypoScript "stdWrap properties". - should be enforced to be an array at some point
     * @return string|null The processed input value
     */
    public function stdWrap($content = '', $conf = [])
    {
        $content = (string)$content;
        if (!is_array($conf) || $conf === []) {
            return $content;
        }

        // Activate the stdWrap PSR-14 Events - They will be executed
        // as stdWrap functions, based on the STD_WRAP_ORDER constant.
        $conf[BeforeStdWrapFunctionsInitializedEvent::class] = 1;
        $conf[AfterStdWrapFunctionsInitializedEvent::class] = 1;
        $conf[BeforeStdWrapFunctionsExecutedEvent::class] = 1;
        $conf[AfterStdWrapFunctionsExecutedEvent::class] = 1;

        // Cache handling
        if (is_array($conf['cache.'] ?? null)) {
            $conf['cache.']['key'] = $this->stdWrapValue('key', $conf['cache.']);
            $conf['cache.']['tags'] = $this->stdWrapValue('tags', $conf['cache.']);
            $conf['cache.']['lifetime'] = $this->stdWrapValue('lifetime', $conf['cache.']);
            $conf['cacheRead'] = 1;
            $conf['cacheStore'] = 1;
        }
        // The configuration is sorted and filtered by intersection with the defined STD_WRAP_ORDER.
        $sortedConf = array_keys(array_intersect_key(self::STD_WRAP_ORDER, $conf));
        // Functions types that should not make use of nested stdWrap function calls to avoid conflicts with internal TypoScript used by these functions
        $stdWrapDisabledFunctionTypes = 'cObject,functionName,stdWrap';
        // Additional Array to check whether a function has already been executed
        $isExecuted = [];
        // Additional switch to make sure 'required', 'if' and 'fieldRequired'
        // will still stop rendering immediately in case they return FALSE
        $this->stdWrapNestingLevel++;
        $this->stopRendering[$this->stdWrapNestingLevel] = false;
        // execute each function in the predefined order
        foreach ($sortedConf as $stdWrapName) {
            // eliminate the second key of a pair 'key'|'key.' to make sure functions get called only once and check if rendering has been stopped
            if (!isset($isExecuted[$stdWrapName]) && !$this->stopRendering[$this->stdWrapNestingLevel]) {
                $functionName = rtrim($stdWrapName, '.');
                $functionProperties = $functionName . '.';
                $functionType = self::STD_WRAP_ORDER[$functionName] ?? '';
                // If there is any code on the next level, check if it contains "official" stdWrap functions
                // if yes, execute them first - will make each function stdWrap aware
                // so additional stdWrap calls within the functions can be removed, since the result will be the same
                if (!empty($conf[$functionProperties]) && !GeneralUtility::inList($stdWrapDisabledFunctionTypes, $functionType)) {
                    if (array_intersect_key(self::STD_WRAP_ORDER, $conf[$functionProperties])) {
                        // Check if there's already content available before processing
                        // any ifEmpty or ifBlank stdWrap properties
                        if (($functionName === 'ifBlank' && $content !== '') ||
                            ($functionName === 'ifEmpty' && !empty(trim((string)$content)))) {
                            continue;
                        }

                        $conf[$functionName] = $this->stdWrap($conf[$functionName] ?? '', $conf[$functionProperties]);
                    }
                }
                // Check if key is still containing something, since it might have been changed by next level stdWrap before
                if ((isset($conf[$functionName]) || ($conf[$functionProperties] ?? null))
                    && ($functionType !== 'boolean' || ($conf[$functionName] ?? null))
                ) {
                    // Get just that part of $conf that is needed for the particular function
                    $singleConf = [
                        $functionName => $conf[$functionName] ?? null,
                        $functionProperties => $conf[$functionProperties] ?? null,
                    ];
                    // Hand over the whole $conf array to the hooks
                    if ($functionType === 'hook') {
                        $singleConf = $conf;
                    }
                    // Add both keys - with and without the dot - to the set of executed functions
                    $isExecuted[$functionName] = true;
                    $isExecuted[$functionProperties] = true;
                    if ($functionType === 'event') {
                        // @phpstan-ignore-next-line phpstan does not understand $functionName is only called on 'event' types
                        $content = $this->eventDispatcher->dispatch(new $functionName($content, $conf, $this))->getContent();
                    } else {
                        // Call the function with the prefix stdWrap_ to make sure nobody can execute functions just by adding their name to the TS Array
                        $functionName = 'stdWrap_' . $functionName;
                        // @phpstan-ignore-next-line phpstan complains about some methods not having a second argument, which is not required/useful - doing reflection here would be too intense.
                        $content = $this->{$functionName}($content, $singleConf);
                    }
                } elseif ($functionType === 'boolean' && !($conf[$functionName] ?? null)) {
                    $isExecuted[$functionName] = true;
                    $isExecuted[$functionProperties] = true;
                }
            }
        }
        unset($this->stopRendering[$this->stdWrapNestingLevel]);
        $this->stdWrapNestingLevel--;

        return $content;
    }

    /**
     * Gets a configuration value by passing them through stdWrap first and taking a default value if stdWrap doesn't yield a result.
     *
     * @param string $key The config variable key (from TS array).
     * @param array $config The TypoScript array.
     * @param string|int|bool|null $defaultValue Optional default value.
     * @return string|int|bool|null Value of the config variable
     */
    public function stdWrapValue($key, array $config, $defaultValue = '')
    {
        if (isset($config[$key])) {
            if (!isset($config[$key . '.'])) {
                return $config[$key];
            }
        } elseif (isset($config[$key . '.'])) {
            $config[$key] = '';
        } else {
            return $defaultValue;
        }
        $stdWrapped = $this->stdWrap($config[$key], $config[$key . '.']);
        // The string "0" should be returned.
        return $stdWrapped !== '' ? $stdWrapped : $defaultValue;
    }

    /**
     * Check if content was cached before (depending on the given cache key)
     *
     * @param string $content Input value undergoing processing in these functions.
     * @param array $conf All stdWrap properties, not just the ones for a particular function.
     * @return string The processed input value
     */
    public function stdWrap_cacheRead($content = '', $conf = [])
    {
        if (!isset($conf['cache.'])) {
            return $content;
        }
        $result = $this->getFromCache($conf['cache.']);
        return $result === false ? $content : $result;
    }

    /**
     * Add tags to page cache (comma-separated list)
     *
     * @param string $content Input value undergoing processing in these functions.
     * @param array $conf All stdWrap properties, not just the ones for a particular function.
     * @return string The processed input value
     */
    public function stdWrap_addPageCacheTags($content = '', $conf = [])
    {
        $tags = (string)$this->stdWrapValue('addPageCacheTags', $conf);
        if (!empty($tags)) {
            $cacheTags = GeneralUtility::trimExplode(',', $tags, true);
            $this->getRequest()->getAttribute('frontend.cache.collector')->addCacheTags(
                ...array_map(fn(string $tag) => new CacheTag($tag), $cacheTags)
            );
        }
        return $content;
    }

    /**
     * Actually it just does the contrary: Set the value of 'current' based on current content. Wait. What?
     *
     * @param string $content Input value undergoing processing in this function.
     * @return string The processed input value
     */
    public function stdWrap_setContentToCurrent($content = '')
    {
        $this->data[$this->currentValKey] = $content;
        return $content;
    }

    /**
     * Sets the value of 'current' based on the outcome of stdWrap operations
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for setCurrent.
     * @return string The processed input value
     */
    public function stdWrap_setCurrent($content = '', $conf = [])
    {
        $this->data[$this->currentValKey] = $conf['setCurrent'] ?? null;
        return $content;
    }

    /**
     * Translates content based on the language currently used by the FE
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for lang.
     * @return string The processed input value
     */
    public function stdWrap_lang($content = '', $conf = [])
    {
        // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request.
        $siteLanguage = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage();
        $currentLanguageCode = $siteLanguage->getTypo3Language();
        if (!$currentLanguageCode) {
            return $content;
        }
        if (isset($conf['lang.'][$currentLanguageCode])) {
            $content = $conf['lang.'][$currentLanguageCode];
        } else {
            // @todo: use the Locale object and its dependencies in TYPO3 v13
            // Check language dependencies
            foreach ($this->locales->getLocaleDependencies($currentLanguageCode) as $languageCode) {
                if (isset($conf['lang.'][$languageCode])) {
                    $content = $conf['lang.'][$languageCode];
                    break;
                }
            }
        }
        return $content;
    }

    /**
     * Gets content from different sources based on getText functions.
     *
     * @param string $_ Unused
     * @param array $conf stdWrap properties for data.
     * @return string The processed input value
     */
    public function stdWrap_data($_ = '', $conf = [])
    {
        return $this->getData($conf['data'], $this->data);
    }

    /**
     * Gets content from a DB field
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for field.
     * @return string|null The processed input value
     */
    public function stdWrap_field($content = '', $conf = [])
    {
        return $this->getFieldVal($conf['field']);
    }

    /**
     * Gets content that has been previously set as 'current'
     * Can be set via setContentToCurrent or setCurrent or will be set automatically i.e. inside the split function
     *
     * @return string The processed input value
     */
    public function stdWrap_current(mixed $_ = null, mixed $__ = null)
    {
        return $this->getCurrentVal();
    }

    /**
     * Will replace the content with the value of an official TypoScript cObject
     * like TEXT, COA, HMENU
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for cObject.
     * @return string The processed input value
     */
    public function stdWrap_cObject($content = '', $conf = [])
    {
        return $this->cObjGetSingle($conf['cObject'] ?? '', $conf['cObject.'] ?? [], '/stdWrap/.cObject');
    }

    /**
     * Counts the number of returned records of a DB operation, using select.
     *
     * @param string $_ Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for numRows.
     */
    public function stdWrap_numRows($_ = '', $conf = []): int
    {
        return $this->numRows($conf['numRows.']);
    }

    /**
     * Will execute a user public function before the content will be modified by any other stdWrap function
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for preUserFunc.
     * @return string The processed input value
     */
    public function stdWrap_preUserFunc($content = '', $conf = [])
    {
        return $this->callUserFunction($conf['preUserFunc'], $conf['preUserFunc.'] ?? [], $content);
    }

    /**
     * Will override the current value of content with its own value'
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for override.
     * @return string The processed input value
     */
    public function stdWrap_override($content = '', $conf = [])
    {
        if (trim($conf['override'] ?? false)) {
            $content = $conf['override'];
        }
        return $content;
    }

    /**
     * Gets a value off a CSV list before the following ifEmpty check
     * Makes sure that the result of ifEmpty will be TRUE in case the CSV does not contain a value at the position given by preIfEmptyListNum
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for preIfEmptyListNum.
     * @return string The processed input value
     */
    public function stdWrap_preIfEmptyListNum($content = '', $conf = [])
    {
        return $this->listNum($content, $conf['preIfEmptyListNum'] ?? '0', $conf['preIfEmptyListNum.']['splitChar'] ?? ',');
    }

    /**
     * Will set content to a replacement value in case the value of content is NULL
     *
     * @param string|null $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for ifNull.
     * @return string The processed input value
     */
    public function stdWrap_ifNull($content = '', $conf = [])
    {
        return $content ?? $conf['ifNull'];
    }

    /**
     * Will set content to a replacement value in case the trimmed value of content returns FALSE
     * 0 (zero) will be replaced as well
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for ifEmpty.
     * @return string The processed input value
     */
    public function stdWrap_ifEmpty($content = '', $conf = [])
    {
        if (empty(trim((string)$content))) {
            $content = $conf['ifEmpty'];
        }
        return $content;
    }

    /**
     * Will set content to a replacement value in case the trimmed value of content has no length
     * 0 (zero) will not be replaced
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for ifBlank.
     * @return string The processed input value
     */
    public function stdWrap_ifBlank($content = '', $conf = [])
    {
        if (trim((string)$content) === '') {
            $content = $conf['ifBlank'];
        }
        return $content;
    }

    /**
     * Gets a value off a CSV list after ifEmpty check
     * Might return an empty value in case the CSV does not contain a value at the position given by listNum
     * Use preIfEmptyListNum to avoid that behaviour
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for listNum.
     * @return string The processed input value
     */
    public function stdWrap_listNum($content = '', $conf = [])
    {
        return $this->listNum($content, $conf['listNum'] ?? '0', $conf['listNum.']['splitChar'] ?? ',');
    }

    /**
     * Cut off any whitespace at the beginning and the end of the content
     *
     * @param string $content Input value undergoing processing in this function.
     */
    public function stdWrap_trim($content = ''): string
    {
        return trim((string)$content);
    }

    /**
     * Return a string padded left/right/on both sides, based on configuration given as stdWrap properties
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for strPad.
     */
    public function stdWrap_strPad($content = '', $conf = []): string
    {
        // Must specify a length in conf for this to make sense
        $length = (int)$this->stdWrapValue('length', $conf['strPad.'] ?? [], 0);
        // Padding with space is PHP-default
        $padWith = (string)$this->stdWrapValue('padWith', $conf['strPad.'] ?? [], ' ');
        // Padding on the right side is PHP-default
        $padType = STR_PAD_RIGHT;
        if (!empty($conf['strPad.']['type'])) {
            $type = (string)$this->stdWrapValue('type', $conf['strPad.']);
            if (strtolower($type) === 'left') {
                $padType = STR_PAD_LEFT;
            } elseif (strtolower($type) === 'both') {
                $padType = STR_PAD_BOTH;
            }
        }
        return StringUtility::multibyteStringPad($content, $length, $padWith, $padType);
    }

    /**
     * A recursive call of the stdWrap function set
     * This enables the user to execute stdWrap functions in another than the predefined order
     * It modifies the content, not the property
     * while the new feature of chained stdWrap functions modifies the property and not the content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for stdWrap.
     * @return string The processed input value
     */
    public function stdWrap_stdWrap($content = '', $conf = [])
    {
        return $this->stdWrap($content, $conf['stdWrap.']);
    }

    /**
     * Will immediately stop rendering and return an empty value
     * when there is no content at this point
     *
     * @param string $content Input value undergoing processing in this function.
     * @return string The processed input value
     */
    public function stdWrap_required($content = '')
    {
        if ((string)$content === '') {
            $content = '';
            $this->stopRendering[$this->stdWrapNestingLevel] = true;
        }
        return $content;
    }

    /**
     * Will immediately stop rendering and return an empty value
     * when the result of the checks returns FALSE
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for if.
     * @return string The processed input value
     */
    public function stdWrap_if($content = '', $conf = [])
    {
        if (empty($conf['if.']) || $this->checkIf($conf['if.'])) {
            return $content;
        }
        $this->stopRendering[$this->stdWrapNestingLevel] = true;
        return '';
    }

    /**
     * Will immediately stop rendering and return an empty value
     * when there is no content in the field given by fieldRequired
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for fieldRequired.
     * @return string The processed input value
     */
    public function stdWrap_fieldRequired($content = '', $conf = [])
    {
        $fieldName = (string)($conf['fieldRequired'] ?? '');
        if ($fieldName !== '' && !trim($this->data[$fieldName] ?? '')) {
            $content = '';
            $this->stopRendering[$this->stdWrapNestingLevel] = true;
        }
        return $content;
    }

    /**
     * stdWrap csConv: Converts the input to UTF-8
     *
     * The character set of the input must be specified. Returns the input if
     * matters go wrong, for example if an invalid character set is given.
     *
     * @param string $content The string to convert.
     * @param array $conf stdWrap properties for csConv.
     * @return string The processed input.
     */
    public function stdWrap_csConv($content = '', $conf = [])
    {
        if (!empty($conf['csConv'])) {
            $output = mb_convert_encoding($content, 'utf-8', trim(strtolower($conf['csConv'])));
            return $output !== false && $output !== '' ? $output : $content;
        }
        return $content;
    }

    /**
     * Will parse the content based on functions given as stdWrap properties
     * Heavily used together with RTE based content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for parseFunc.
     * @return string The processed input value
     */
    public function stdWrap_parseFunc($content = '', $conf = [])
    {
        return $this->parseFunc($content, $conf['parseFunc.'], $conf['parseFunc']);
    }

    /**
     * Will parse HTML content based on functions given as stdWrap properties
     * Heavily used together with RTE based content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for HTMLparser.
     * @return string The processed input value
     */
    public function stdWrap_HTMLparser($content = '', $conf = [])
    {
        if (isset($conf['HTMLparser.']) && is_array($conf['HTMLparser.'])) {
            $content = $this->HTMLparser_TSbridge($content, $conf['HTMLparser.']);
        }
        return $content;
    }

    /**
     * Split the content by a given token and treat the results separately
     * Automatically fills 'current' with a single result
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for split.
     * @return string|int The processed input value
     */
    public function stdWrap_split($content = '', $conf = []): string|int
    {
        return $this->splitObj($content, $conf['split.']);
    }

    /**
     * Will execute replacements on the content (optionally with preg-regex)
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for replacement.
     * @return string The processed input value
     */
    public function stdWrap_replacement($content = '', $conf = [])
    {
        $configuration = $conf['replacement.'] ?? [];
        // Sort actions in configuration by numeric index
        ksort($configuration, SORT_NUMERIC);
        foreach ($configuration as $index => $action) {
            // Check whether we have a valid action and a numeric key ending with a dot ("10.")
            if (is_array($action)
                && str_ends_with($index, '.')
                && MathUtility::canBeInterpretedAsInteger(substr($index, 0, -1))
                && (isset($action['search']) || isset($action['search.']))
                && (isset($action['replace']) || isset($action['replace.']))
            ) {
                $search = (string)$this->stdWrapValue('search', $action);
                $replace = (string)$this->stdWrapValue('replace', $action, null);
                // Determines whether regular expression shall be used
                $useRegularExpression = (bool)$this->stdWrapValue('useRegExp', $action, false);
                // Determines whether replace-pattern uses option-split
                $useOptionSplitReplace = (bool)$this->stdWrapValue('useOptionSplitReplace', $action, false);
                // Performs a replacement by preg_replace()
                if ($useRegularExpression) {
                    // Get separator-character which precedes the string and separates search-string from the modifiers
                    $separator = $search[0];
                    $startModifiers = strrpos($search, $separator);
                    if ($startModifiers > 0) {
                        $modifiers = substr($search, $startModifiers + 1);
                        // remove "e" (eval-modifier), which would otherwise allow to run arbitrary PHP-code
                        $modifiers = str_replace('e', '', $modifiers);
                        $search = substr($search, 0, $startModifiers + 1) . $modifiers;
                    }
                    if ($useOptionSplitReplace) {
                        // init for replacement
                        $splitCount = preg_match_all($search, $content);
                        $replaceArray = $this->typoScriptService->explodeConfigurationForOptionSplit([$replace], $splitCount);
                        $replaceCount = 0;
                        $replaceCallback = static function ($match) use ($replaceArray, $search, &$replaceCount) {
                            $replaceCount++;
                            return preg_replace($search, $replaceArray[$replaceCount - 1][0], $match[0]);
                        };
                        $content = preg_replace_callback($search, $replaceCallback, $content);
                    } else {
                        $content = preg_replace($search, $replace, $content);
                    }
                } elseif ($useOptionSplitReplace) {
                    // turn search-string into a preg-pattern
                    $searchPreg = '#' . preg_quote($search, '#') . '#';
                    // init for replacement
                    $splitCount = preg_match_all($searchPreg, $content);
                    $replaceArray = $this->typoScriptService->explodeConfigurationForOptionSplit([$replace], $splitCount);
                    $replaceCount = 0;
                    $replaceCallback = static function () use ($replaceArray, &$replaceCount) {
                        $replaceCount++;
                        return $replaceArray[$replaceCount - 1][0];
                    };
                    $content = preg_replace_callback($searchPreg, $replaceCallback, $content);
                } else {
                    $content = str_replace($search, $replace, $content);
                }
            }
        }
        return $content;
    }

    /**
     * Will use the content as a mathematical term and calculate the result
     * Can be set to 1 to just get a calculated value or 'intval' to get the integer of the result
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for prioriCalc.
     */
    public function stdWrap_prioriCalc($content = '', $conf = []): string|int
    {
        $content = MathUtility::calculateWithParentheses($content);
        if (!empty($conf['prioriCalc']) && $conf['prioriCalc'] === 'intval') {
            $content = (int)$content;
        }
        return $content;
    }

    /**
     * Returns a one-character string containing the character specified by ascii code.
     * Reliable results only for character codes in the integer range 0 - 127.
     *
     * @see https://php.net/manual/en/function.chr.php
     * @param array $conf stdWrap properties for char.
     */
    public function stdWrap_char($_ = '', $conf = []): string
    {
        return chr((int)$conf['char']);
    }

    /**
     * Will return an integer value of the current content
     *
     * @param string $content Input value undergoing processing in this function.
     */
    public function stdWrap_intval($content = ''): int
    {
        return (int)$content;
    }

    /**
     * Will return a hashed value of the current content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for hash.
     * @link https://php.net/manual/de/function.hash-algos.php for a list of supported hash algorithms
     */
    public function stdWrap_hash($content = '', array $conf = []): string
    {
        $algorithm = (string)$this->stdWrapValue('hash', $conf);
        if (in_array($algorithm, hash_algos())) {
            return hash($algorithm, $content);
        }
        // Non-existing hashing algorithm
        return '';
    }

    /**
     * stdWrap_round will return a rounded number with ceil(), floor() or round(), defaults to round()
     * Only the english number format is supported . (dot) as decimal point
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for round.
     */
    public function stdWrap_round($content = '', $conf = []): float
    {
        $decimals = (int)$this->stdWrapValue('decimals', $conf['round.'] ?? [], 0);
        $type = $this->stdWrapValue('roundType', $conf['round.'] ?? []);
        $floatVal = (float)$content;
        return match ($type) {
            'ceil' => ceil($floatVal),
            'floor' => floor($floatVal),
            default => round($floatVal, $decimals),
        };
    }

    /**
     * Will return a formatted number based on configuration given as stdWrap properties
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for numberFormat.
     */
    public function stdWrap_numberFormat($content = '', $conf = []): string
    {
        return $this->numberFormat((float)$content, $conf['numberFormat.'] ?? []);
    }

    /**
     * Will return a formatted number based on configuration given as stdWrap properties
     *
     * @param string $content Input value undergoing processing in this function.
     * @return string The processed input value
     */
    public function stdWrap_expandList($content = ''): string
    {
        return GeneralUtility::expandList($content);
    }

    /**
     * Will return a formatted date based on configuration given according to PHP date/gmdate properties
     * Will return gmdate when the property GMT returns TRUE
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for date.
     */
    public function stdWrap_date($content = '', $conf = []): string
    {
        // Check for zero length string to mimic default case of date/gmdate.
        $content = (string)$content === '' ? $GLOBALS['EXEC_TIME'] : (int)$content;
        return !empty($conf['date.']['GMT']) ? gmdate($conf['date'] ?? null, $content) : date($conf['date'] ?? null, $content);
    }

    /**
     * Will return a formatted date based on configuration given according to PHP strftime/gmstrftime properties
     * Will return gmstrftime when the property GMT returns TRUE
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for strftime.
     * @return string The processed input value
     */
    public function stdWrap_strftime($content = '', $conf = [])
    {
        // Check for zero length string to mimic default case of strtime/gmstrftime
        $content = (string)$content === '' ? $GLOBALS['EXEC_TIME'] : (int)$content;
        $content = (isset($conf['strftime.']['GMT']) && $conf['strftime.']['GMT'])
            ? (new DateFormatter())->strftime($conf['strftime'] ?? '', $content, null, true)
            : (new DateFormatter())->strftime($conf['strftime'] ?? '', $content);
        if (!empty($conf['strftime.']['charset'])) {
            $output = mb_convert_encoding((string)$content, 'utf-8', trim(strtolower($conf['strftime.']['charset'])));
            return $output ?: $content;
        }
        return $content;
    }

    /**
     * Will return a timestamp based on configuration given according to PHP strtotime
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for strtotime.
     */
    public function stdWrap_strtotime($content = '', $conf = []): int|false
    {
        if ($conf['strtotime'] !== '1') {
            $content .= ' ' . $conf['strtotime'];
        }
        return strtotime($content, $GLOBALS['EXEC_TIME']);
    }

    /**
     * php-intl date format
     * Will return a timestamp based on configuration given according to PHP-intl DateFormatter->format()
     * see https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for formattedDate.
     */
    public function stdWrap_formattedDate(string $content, array $conf): string
    {
        $pattern = $conf['formattedDate'] ?? 'LONG';
        // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request.
        $language = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage();
        $locale = $conf['formattedDate.']['locale'] ?? $language->getLocale();

        if ($content === '' || $content === '0') {
            $content = $this->context->getAspect('date')->getDateTime();
        } else {
            // format this to a timestamp now
            $content = strtotime((MathUtility::canBeInterpretedAsInteger($content) ? '@' : '') . $content);
            if ($content === false) {
                $content = $this->context->getAspect('date')->getDateTime();
            }
        }
        return (new DateFormatter())->format($content, $pattern, $locale);
    }

    /**
     * Return the age of a given timestamp based on configuration given by stdWrap properties
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for age.
     * @return string The processed input value
     */
    public function stdWrap_age($content = '', $conf = []): string
    {
        return $this->calcAge((int)($GLOBALS['EXEC_TIME'] ?? 0) - (int)$content, $conf['age'] ?? null);
    }

    /**
     * Transform the content to be upper or lower case only
     * Leaves HTML tags untouched
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for case.
     */
    public function stdWrap_case($content = '', $conf = []): string
    {
        return $this->HTMLcaseshift($content, $conf['case']);
    }

    /**
     * Will return the size of a given number in Bytes	 *
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for bytes.
     * @return string The processed input value
     */
    public function stdWrap_bytes($content = '', $conf = [])
    {
        $decimals = $conf['bytes.']['decimals'] ?? null;
        if ($decimals !== null) {
            $decimals = (int)$decimals;
        }
        return GeneralUtility::formatSize((int)$content, $conf['bytes.']['labels'] ?? '', $conf['bytes.']['base'] ?? 0, $decimals);
    }

    /**
     * Will return a substring based on position information given by stdWrap properties
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for substring.
     */
    public function stdWrap_substring($content = '', $conf = []): string
    {
        $options = GeneralUtility::intExplode(',', ($conf['substring'] ?? '') . ',');
        if ($options[1]) {
            return mb_substr($content, $options[0], $options[1], 'utf-8');
        }
        return mb_substr($content, $options[0], null, 'utf-8');
    }

    /**
     * Crops content to a given size while leaving HTML tags untouched
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for cropHTML.
     */
    public function stdWrap_cropHTML($content = '', $conf = []): string
    {
        return $this->cropHTML($content, $conf['cropHTML'] ?? '');
    }

    /**
     * Completely removes HTML tags from content
     *
     * @param string $content Input value undergoing processing in this function.
     */
    public function stdWrap_stripHtml($content = ''): string
    {
        return strip_tags((string)$content);
    }

    /**
     * Crops content to a given size without caring about HTML tags
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for crop.
     */
    public function stdWrap_crop($content = '', $conf = []): string
    {
        return $this->crop($content, $conf['crop']);
    }

    /**
     * Encode content to be used within URLs
     */
    public function stdWrap_rawUrlEncode($content = ''): string
    {
        return rawurlencode($content);
    }

    /**
     * Transforms HTML tags to readable text by replacing special characters with their HTML entity
     * When preserveEntities returns TRUE, existing entities will be left untouched
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for htmlSpecialChars.
     */
    public function stdWrap_htmlSpecialChars($content = '', $conf = [])
    {
        if (!empty($conf['htmlSpecialChars.']['preserveEntities'])) {
            $content = htmlspecialchars((string)$content, ENT_COMPAT, 'UTF-8', false);
        } else {
            $content = htmlspecialchars((string)$content);
        }
        return $content;
    }

    /**
     * Escapes content to be used inside JavaScript strings. Single quotes are added around the value.
     *
     * @param string $content Input value undergoing processing in this function
     */
    public function stdWrap_encodeForJavaScriptValue($content = ''): string
    {
        return GeneralUtility::quoteJSvalue($content);
    }

    /**
     * Searches for double line breaks and replaces them with the given value
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for doubleBrTag.
     * @return string The processed input value
     */
    public function stdWrap_doubleBrTag($content = '', $conf = [])
    {
        return preg_replace('/\R{1,2}[\t\x20]*\R{1,2}/', $conf['doubleBrTag'] ?? '', $content);
    }

    /**
     * Searches for single line breaks and replaces them with a <br />/<br> tag
     * according to the doctype
     *
     * @param string $content Input value undergoing processing in this function.
     */
    public function stdWrap_br($content = ''): string
    {
        return nl2br($content, DocType::createFromRequest($this->getRequest())->isXmlCompliant());
    }

    /**
     * Searches for single line feeds and replaces them with the given value
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for brTag.
     */
    public function stdWrap_brTag($content = '', $conf = []): string
    {
        return str_replace(LF, (string)($conf['brTag'] ?? ''), $content);
    }

    /**
     * Modifies text blocks by searching for lines which are not surrounded by HTML tags yet
     * and wrapping them with values given by stdWrap properties
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for erncapsLines.
     * @return string The processed input value
     */
    public function stdWrap_encapsLines($content = '', $conf = [])
    {
        return $this->encaps_lineSplit($content, $conf['encapsLines.'] ?? []);
    }

    /**
     * Transforms content into a CSV list to be used i.e. as keywords within a meta tag
     *
     * @param string $content Input value undergoing processing in this function.
     */
    public function stdWrap_keywords($content = ''): string
    {
        return $this->keywords($content);
    }

    /**
     * First of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for innerWrap.
     * @return string The processed input value
     */
    public function stdWrap_innerWrap($content = '', $conf = [])
    {
        return $this->wrap($content, $conf['innerWrap'] ?? null);
    }

    /**
     * Second of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for innerWrap2.
     * @return string The processed input value
     */
    public function stdWrap_innerWrap2($content = '', $conf = [])
    {
        return $this->wrap($content, $conf['innerWrap2'] ?? null);
    }

    /**
     * A content object that is prepended to the current content but between the innerWraps and the rest of the wraps
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for preCObject.
     * @return string The processed input value
     */
    public function stdWrap_preCObject($content = '', $conf = [])
    {
        return $this->cObjGetSingle($conf['preCObject'], $conf['preCObject.'], '/stdWrap/.preCObject') . $content;
    }

    /**
     * A content object that is appended to the current content but between the innerWraps and the rest of the wraps
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for postCObject.
     * @return string The processed input value
     */
    public function stdWrap_postCObject($content = '', $conf = [])
    {
        return $content . $this->cObjGetSingle($conf['postCObject'], $conf['postCObject.'], '/stdWrap/.postCObject');
    }

    /**
     * Wraps content with a div container having the style attribute text-align set to the given value
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for wrapAlign.
     * @return string The processed input value
     */
    public function stdWrap_wrapAlign($content = '', $conf = [])
    {
        $wrapAlign = trim($conf['wrapAlign'] ?? '');
        if ($wrapAlign) {
            $content = $this->wrap($content, '<div style="text-align:' . htmlspecialchars($wrapAlign) . ';">|</div>');
        }
        return $content;
    }

    /**
     * Wraps the content with a link tag
     * URLs and other attributes are created automatically by the values given in the stdWrap properties
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for typolink.
     * @return string The processed input value
     */
    public function stdWrap_typolink($content = '', $conf = [])
    {
        return $this->typoLink((string)$content, $conf['typolink.'] ?? []);
    }

    /**
     * This is the "mother" of all wraps
     * Third of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     * Basically it will put additional content before and after the current content using a split character as a placeholder for the current content
     * The default split character is | but it can be replaced with other characters by the property splitChar
     * Any other wrap that does not have own splitChar settings will be using the default split char though
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for wrap.
     * @return string The processed input value
     */
    public function stdWrap_wrap($content = '', $conf = [])
    {
        return $this->wrap($content, $conf['wrap'] ?? null, $conf['wrap.']['splitChar'] ?? '|');
    }

    /**
     * Fourth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     * The major difference to any other wrap is, that this one can make use of whitespace without trimming	 *
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for noTrimWrap.
     * @return string The processed input value
     */
    public function stdWrap_noTrimWrap($content = '', $conf = [])
    {
        $splitChar = isset($conf['noTrimWrap.']['splitChar.'])
            ? $this->stdWrap($conf['noTrimWrap.']['splitChar'] ?? '', $conf['noTrimWrap.']['splitChar.'])
            : $conf['noTrimWrap.']['splitChar'] ?? '';
        if ($splitChar === null || $splitChar === '') {
            $splitChar = '|';
        }
        return $this->noTrimWrap($content, $conf['noTrimWrap'], $splitChar);
    }

    /**
     * Fifth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     * The default split character is | but it can be replaced with other characters by the property splitChar
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for wrap2.
     * @return string The processed input value
     */
    public function stdWrap_wrap2($content = '', $conf = [])
    {
        return $this->wrap($content, $conf['wrap2'] ?? null, $conf['wrap2.']['splitChar'] ?? '|');
    }

    /**
     * Sixth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     * Can fetch additional content the same way data does (i.e. {field:whatever}) and apply it to the wrap before that is applied to the content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for dataWrap.
     * @return string The processed input value
     */
    public function stdWrap_dataWrap($content = '', $conf = [])
    {
        return $this->dataWrap($content, $conf['dataWrap']);
    }

    /**
     * A content object that will be prepended to the current content after most of the wraps have already been applied
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for prepend.
     * @return string The processed input value
     */
    public function stdWrap_prepend($content = '', $conf = [])
    {
        return $this->cObjGetSingle($conf['prepend'], $conf['prepend.'], '/stdWrap/.prepend') . $content;
    }

    /**
     * A content object that will be appended to the current content after most of the wraps have already been applied
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for append.
     * @return string The processed input value
     */
    public function stdWrap_append($content = '', $conf = [])
    {
        return $content . $this->cObjGetSingle($conf['append'], $conf['append.'], '/stdWrap/.append');
    }

    /**
     * Seventh of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     * The default split character is | but it can be replaced with other characters by the property splitChar
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for wrap3.
     * @return string The processed input value
     */
    public function stdWrap_wrap3($content = '', $conf = [])
    {
        return $this->wrap($content, $conf['wrap3'] ?? null, $conf['wrap3.']['splitChar'] ?? '|');
    }

    /**
     * Calls stdWrap for each entry in the provided array
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for orderedStdWrap.
     * @return string The processed input value
     */
    public function stdWrap_orderedStdWrap($content = '', $conf = [])
    {
        $sortedKeysArray = ArrayUtility::filterAndSortByNumericKeys($conf['orderedStdWrap.'], true);
        foreach ($sortedKeysArray as $key) {
            $content = (string)$this->stdWrap($content, $conf['orderedStdWrap.'][$key . '.'] ?? null);
        }
        return $content;
    }

    /**
     * Eighth of a set of different wraps which will be applied in a certain order before or after other functions that modify the content
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for outerWrap.
     * @return string The processed input value
     */
    public function stdWrap_outerWrap($content = '', $conf = [])
    {
        return $this->wrap($content, $conf['outerWrap'] ?? null);
    }

    /**
     * Can fetch additional content the same way data does and replaces any occurrence of {field:whatever} with this content
     *
     * @param string $content Input value undergoing processing in this function.
     */
    public function stdWrap_insertData($content = ''): string
    {
        return $this->insertData($content);
    }

    /**
     * Will execute a user function after the content has been modified by any other stdWrap function
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for postUserFunc.
     * @return string The processed input value
     */
    public function stdWrap_postUserFunc($content = '', $conf = [])
    {
        return $this->callUserFunction($conf['postUserFunc'], $conf['postUserFunc.'] ?? [], $content);
    }

    /**
     * Will execute a user function after the content has been created and each time it is fetched from Cache
     * The result of this function itself will not be cached
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for postUserFuncInt.
     */
    public function stdWrap_postUserFuncInt($content = '', $conf = []): string
    {
        $substKey = 'INT_SCRIPT.' . md5(StringUtility::getUniqueId());
        $pageParts = $this->getRequest()->getAttribute('frontend.page.parts');
        $pageParts->addNotCachedContentElement([
            'substKey' => $substKey,
            'content' => $content,
            'postUserFunc' => $conf['postUserFuncInt'],
            'conf' => $conf['postUserFuncInt.'],
            'type' => 'POSTUSERFUNC',
            'cObjData' => serialize($this->getState()),
        ]);
        return '<!--' . $substKey . '-->';
    }

    /**
     * Add HTML comments to the content to make it easier to identify certain content elements within the HTML output later on
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for prefixComment.
     * @return string The processed input value
     */
    public function stdWrap_prefixComment($content = '', $conf = [])
    {
        $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray();
        if ((!isset($typoScriptConfigArray['disablePrefixComment']) || !$typoScriptConfigArray['disablePrefixComment'])
            && !empty($conf['prefixComment'])
        ) {
            $content = $this->prefixComment($conf['prefixComment'], [], $content);
        }
        return $content;
    }

    public function stdWrap_htmlSanitize(string $content = '', array $conf = []): string
    {
        $build = $conf['build'] ?? 'default';
        // @todo: Simplify: There is a factory to build a builder, and this is wrapped here
        //        to build a different builder. This is not one, but two levels too complex.
        if (class_exists($build) && is_a($build, BuilderInterface::class, true)) {
            $builder = GeneralUtility::makeInstance($build);
        } else {
            $builder = $this->sanitizerBuilderFactory->build($build);
        }
        $sanitizer = $builder->build();
        $initiator = $this->shallDebug()
            ? GeneralUtility::makeInstance(SanitizerInitiator::class, DebugUtility::debugTrail())
            : null;
        return $sanitizer->sanitize($content, $initiator);
    }

    /**
     * Store content into cache
     *
     * @param string|null $content Input value undergoing processing in these functions.
     * @param array $conf All stdWrap properties, not just the ones for a particular function.
     * @return string|null The processed input value
     */
    public function stdWrap_cacheStore($content = '', $conf = []): ?string
    {
        if (!isset($conf['cache.'])) {
            return $content;
        }
        $key = $this->calculateCacheKey($conf['cache.']);
        if (empty($key)) {
            return $content;
        }
        $event = $this->eventDispatcher->dispatch(
            new BeforeStdWrapContentStoredInCacheEvent(
                content: $content,
                tags: $this->calculateCacheTags($conf['cache.']),
                key: (string)$key,
                lifetime: $this->calculateCacheLifetime($conf['cache.']),
                configuration: $conf,
                contentObjectRenderer: $this
            )
        );
        $this->cacheHash->set(
            $event->getKey(),
            ['content' => $event->getContent(), 'cacheTags' => $event->getTags()],
            $event->getTags(),
            $event->getLifetime()
        );
        // If no tags are given, we restrict the maximum lifetime of the cache to the lifetime of the cache entry.
        $cacheCollector = $this->getRequest()->getAttribute('frontend.cache.collector');
        if ($event->getTags() === []) {
            $cacheCollector->restrictMaximumLifetime($event->getLifetime());
        }
        $cacheCollector->addCacheTags(...array_map(fn(string $tag) => new CacheTag($tag, $event->getLifetime()), $event->getTags()));
        return $event->getContent();
    }

    /**
     * Will output the content as readable HTML code
     *
     * @param string $content Input value undergoing processing in this function.
     */
    public function stdWrap_debug($content = ''): string
    {
        return '<pre>' . htmlspecialchars($content) . '</pre>';
    }

    /**
     * Will output the content in a debug table
     *
     * @param string $content Input value undergoing processing in this function.
     * @param array $conf stdWrap properties for debugFunc.
     * @return string The processed input value
     */
    public function stdWrap_debugFunc($content = '', $conf = [])
    {
        debug((int)$conf['debugFunc'] === 2 ? [$content] : $content);
        return $content;
    }

    /**
     * Will output the data used by the current record in a debug table
     *
     * @param string $content Input value undergoing processing in this function.
     * @return string The processed input value
     */
    public function stdWrap_debugData($content = '')
    {
        debug($this->data, '$cObj->data:');
        return $content;
    }

    /**
     * Returns number of rows selected by the query made by the properties set.
     * Implements the stdWrap "numRows" property
     *
     * @param array $conf TypoScript properties for the property (see link to "numRows")
     * @internal
     */
    public function numRows($conf): int
    {
        $conf['select.']['selectFields'] = 'count(*)';
        $statement = $this->exec_getQuery($conf['table'], $conf['select.']);
        return (int)$statement->fetchOne();
    }

    /**
     * Explode a string by the $delimiter value and return the value of index $listNum
     *
     * @param string $content String to explode
     * @param string $listNum Index-number | 'last' | 'rand' | arithmetic expression. You can place the word "last" in it and it will be
     *                        substituted with the pointer to the last value. You can use math operators like "+-/*" (passed to calc())
     * @param string $delimiter Either a string used to explode the content string or an integer value (as string) which will then be
     *                          changed into a character, eg. "10" for a linebreak char.
     * @return string
     */
    public function listNum($content, $listNum, $delimiter = ',')
    {
        $delimiter = $delimiter ?: ',';
        if (MathUtility::canBeInterpretedAsInteger($delimiter)) {
            $delimiter = chr((int)$delimiter);
        }
        $temp = explode($delimiter, $content);
        if ($temp === ['']) {
            return '';
        }
        $last = '' . (count($temp) - 1);
        // Take a random item if requested
        if ($listNum === 'rand') {
            $listNum = (string)random_int(0, count($temp) - 1);
        }
        $index = $this->calc(str_ireplace('last', $last, $listNum));
        return $temp[$index] ?? '';
    }

    /**
     * Compares values together based on the settings in the input TypoScript array and returns the comparison result.
     * Implements the "if" function in TYPO3 TypoScript
     *
     * @param mixed $conf TypoScript properties defining what to compare, ideally an array
     */
    public function checkIf($conf): bool
    {
        if (!is_array($conf)) {
            return true;
        }
        if (isset($conf['directReturn'])) {
            return (bool)$conf['directReturn'];
        }
        $flag = true;
        if (isset($conf['isNull.'])) {
            $isNull = $this->stdWrap('', $conf['isNull.']);
            if ($isNull !== null) {
                $flag = false;
            }
        }
        if (isset($conf['isTrue']) || isset($conf['isTrue.'])) {
            $isTrue = trim((string)$this->stdWrapValue('isTrue', $conf));
            if (!$isTrue) {
                $flag = false;
            }
        }
        if (isset($conf['isFalse']) || isset($conf['isFalse.'])) {
            $isFalse = trim((string)$this->stdWrapValue('isFalse', $conf));
            if ($isFalse) {
                $flag = false;
            }
        }
        if (isset($conf['isPositive']) || isset($conf['isPositive.'])) {
            $number = $this->calc((string)$this->stdWrapValue('isPositive', $conf));
            if ($number < 1) {
                $flag = false;
            }
        }
        if ($flag) {
            $comparisonValue = trim((string)$this->stdWrapValue('value', $conf));
            if (isset($conf['isGreaterThan']) || isset($conf['isGreaterThan.'])) {
                $number = trim((string)$this->stdWrapValue('isGreaterThan', $conf));
                if ($number <= $comparisonValue) {
                    $flag = false;
                }
            }
            if (isset($conf['isLessThan']) || isset($conf['isLessThan.'])) {
                $number = trim((string)$this->stdWrapValue('isLessThan', $conf));
                if ($number >= $comparisonValue) {
                    $flag = false;
                }
            }
            if (isset($conf['equals']) || isset($conf['equals.'])) {
                $number = trim((string)$this->stdWrapValue('equals', $conf));
                if ($number != $comparisonValue) {
                    $flag = false;
                }
            }
            if (isset($conf['contains']) || isset($conf['contains.'])) {
                $needle = trim((string)$this->stdWrapValue('contains', $conf));
                if (!str_contains($comparisonValue, $needle)) {
                    $flag = false;
                }
            }
            if (isset($conf['startsWith']) || isset($conf['startsWith.'])) {
                $needle = trim((string)$this->stdWrapValue('startsWith', $conf));
                if (!str_starts_with($comparisonValue, $needle)) {
                    $flag = false;
                }
            }
            if (isset($conf['endsWith']) || isset($conf['endsWith.'])) {
                $needle = trim((string)$this->stdWrapValue('endsWith', $conf));
                if (!str_ends_with($comparisonValue, $needle)) {
                    $flag = false;
                }
            }
            if (isset($conf['isInList']) || isset($conf['isInList.'])) {
                $singleValueWhichNeedsToBeInList = trim((string)$this->stdWrapValue('isInList', $conf));
                if (!GeneralUtility::inList($comparisonValue, $singleValueWhichNeedsToBeInList)) {
                    $flag = false;
                }
            }
            if (isset($conf['bitAnd']) || isset($conf['bitAnd.'])) {
                $number = (int)trim((string)$this->stdWrapValue('bitAnd', $conf));
                if ((new BitSet($number))->get((int)$comparisonValue) === false) {
                    $flag = false;
                }
            }
        }
        if ($conf['negate'] ?? false) {
            $flag = !$flag;
        }
        return $flag;
    }

    /**
     * Passes the input value, $theValue, to an instance of "\TYPO3\CMS\Core\Html\HtmlParser"
     * together with the TypoScript options which are first converted from a TS style array
     * to a set of arrays with options for the \TYPO3\CMS\Core\Html\HtmlParser class.
     *
     * @param string $theValue The value to parse by the class \TYPO3\CMS\Core\Html\HtmlParser
     * @param array $conf TypoScript properties for the parser. See link.
     * @see stdWrap()
     * @internal
     */
    public function HTMLparser_TSbridge($theValue, $conf): string
    {
        $htmlParser = GeneralUtility::makeInstance(HtmlParser::class);
        $htmlParserCfg = $htmlParser->HTMLparserConfig($conf);
        return $htmlParser->HTMLcleaner($theValue, $htmlParserCfg[0], $htmlParserCfg[1], $htmlParserCfg[2], $htmlParserCfg[3]);
    }

    /**
     * Wrapping input value in a regular "wrap" but parses the wrapping value first for "insertData" codes.
     *
     * @param string $content Input string being wrapped
     * @param string $wrap The wrap string, eg. "<strong></strong>" or more likely here '<a href="index.php?id={TSFE:id}"> | </a>'
     *                     which will wrap the input string in a <a> tag linking to the current page.
     * @return string Output string wrapped in the wrapping value.
     * @see insertData()
     * @see stdWrap()
     */
    public function dataWrap($content, $wrap)
    {
        return $this->wrap($content, $this->insertData($wrap));
    }

    /**
     * Implements the "insertData" property of stdWrap meaning that if strings matching {...} is found in the input string they
     * will be substituted with the return value from getData (datatype) which is passed the content of the curly braces.
     * If the content inside the curly braces starts with a hash sign {#...} it is a field name that must be quoted by Doctrine
     * DBAL and is skipped here for later processing.
     *
     * Example: If input string is "This is the page title: {page:title}" then the part, '{page:title}', will be substituted with
     * the current pages title field value.
     *
     * @param string $str Input value
     * @see getData()
     * @see stdWrap()
     * @see dataWrap()
     * @internal
     */
    public function insertData($str): string
    {
        $inside = 0;
        $newVal = '';
        $pointer = 0;
        $totalLen = strlen($str);
        do {
            if (!$inside) {
                $len = strcspn(substr($str, $pointer), '{');
                $newVal .= substr($str, $pointer, $len);
                $inside = true;
                if (substr($str, $pointer + $len + 1, 1) === '#') {
                    $len2 = strcspn(substr($str, $pointer + $len), '}');
                    $newVal .= substr($str, $pointer + $len, $len2);
                    $len += $len2;
                    $inside = false;
                }
            } else {
                $len = strcspn(substr($str, $pointer), '}') + 1;
                $newVal .= $this->getData(substr($str, $pointer + 1, $len - 2), $this->data);
                $inside = false;
            }
            $pointer += $len;
        } while ($pointer < $totalLen);
        return $newVal;
    }

    /**
     * Returns an HTML comment with the second part of input string (divided by "|") where first part is
     * an integer telling how many trailing tabs to put before the comment on a new line.
     * This function (used by stdWrap) can be disabled by a "config.disablePrefixComment" setting in TypoScript.
     *
     * @param string $str Input value
     * @param string $content The content to wrap the comment around.
     * @internal
     */
    public function prefixComment($str, $_, $content): string
    {
        if (empty($str)) {
            return $content;
        }
        $parts = explode('|', $str);
        $indent = (int)$parts[0];
        $comment = htmlspecialchars($this->insertData($parts[1]));
        return LF
            . str_pad('', $indent, "\t") . '<!-- ' . $comment . ' [begin] -->' . LF
            . str_pad('', $indent + 1, "\t") . $content . LF
            . str_pad('', $indent, "\t") . '<!-- ' . $comment . ' [end] -->' . LF
            . str_pad('', $indent + 1, "\t");
    }

    /**
     * Implements the stdWrap property "crop" which is a modified "substr" function allowing to limit
     * a string length to a certain number of chars (from either start or end of string) and having a
     * pre/postfix applied if the string really was cropped.
     *
     * @param string $content The string to perform the operation on
     * @param string $options The parameters splitted by "|": First parameter is the max number of chars of the string.
     *                        Negative value means cropping from end of string. Second parameter is the pre/postfix
     *                        string to apply if cropping occurs. Third parameter is a boolean value. If set then crop
     *                        will be applied at nearest space.
     * @return string The processed input value.
     * @see stdWrap()
     * @internal
     */
    public function crop($content, $options): string
    {
        $options = explode('|', $options);
        $numberOfChars = (int)$options[0];
        $replacementForEllipsis = trim($options[1] ?? '');
        $cropToSpace = trim($options[2] ?? '') === '1';
        return $this->textCropper->crop($content, $numberOfChars, $replacementForEllipsis, $cropToSpace);
    }

    /**
     * Implements the stdWrap property "cropHTML" which is a modified "substr" function allowing to limit a string length
     * to a certain number of chars (from either start or end of string) and having a pre/postfix applied if the string
     * really was cropped.
     *
     * Compared to stdWrap.crop it respects HTML tags and entities.
     *
     * @param string $content The string to perform the operation on
     * @param string $options The parameters splitted by "|": First parameter is the max number of chars of the string.
     *                        Negative value means cropping from end of string. Second parameter is the pre/postfix
     *                        string to apply if cropping occurs. Third parameter is a boolean value. If set then crop
     *                        will be applied at nearest space.
     * @see stdWrap()
     * @return string The processed input value.
     * @internal
     */
    public function cropHTML(string $content, string $options): string
    {
        $options = explode('|', $options);
        $numberOfChars = (int)$options[0];
        $replacementForEllipsis = trim($options[1] ?? '');
        $cropToSpace = trim($options[2] ?? '') === '1';
        return $this->htmlCropper->crop($content, $numberOfChars, $replacementForEllipsis, $cropToSpace);
    }

    /**
     * Performs basic mathematical evaluation of the input string. Does NOT take parenthesis and operator precedence
     * into account! (for that, see \TYPO3\CMS\Core\Utility\MathUtility::calculateWithPriorityToAdditionAndSubtraction())
     *
     * @param string $val The string to evaluate. Example: "3+4*10/5" will generate "35". Only integer numbers can be used.
     * @return int The result (might be a float if you did a division of the numbers).
     * @see \TYPO3\CMS\Core\Utility\MathUtility::calculateWithPriorityToAdditionAndSubtraction()
     */
    public function calc($val): int
    {
        $parts = GeneralUtility::splitCalc($val, '+-*/');
        $value = 0;
        foreach ($parts as $part) {
            $theVal = $part[1];
            $sign = $part[0];
            if ((string)(int)$theVal === (string)$theVal) {
                $theVal = (int)$theVal;
            } else {
                $theVal = 0;
            }
            if ($sign === '-') {
                $value -= $theVal;
            }
            if ($sign === '+') {
                $value += $theVal;
            }
            if ($sign === '/') {
                if ((int)$theVal) {
                    $value /= (int)$theVal;
                }
            }
            if ($sign === '*') {
                $value *= $theVal;
            }
        }
        return $value;
    }

    /**
     * Implements the "split" property of stdWrap; Splits a string based on a token (given in TypoScript properties),
     * sets the "current" value to each part and then renders a content object pointer to by a number.
     * In classic TypoScript (like 'content (default)'/'styles.content (default)') this is used to render tables,
     * splitting rows and cells by tokens and putting them together again wrapped in <td> tags etc.
     * Implements the "optionSplit" processing of the TypoScript options for each splitted value to parse.
     *
     * @param string $value The string value to explode by $conf[token] and process each part
     * @param array $conf TypoScript properties for "split
     * @internal
     * @see stdWrap()
     * @see \TYPO3\CMS\Frontend\ContentObject\Menu\AbstractMenuContentObject::processItemStates()
     */
    public function splitObj($value, $conf): string|int
    {
        $conf['token'] = isset($conf['token.']) ? $this->stdWrap($conf['token'] ?? '', $conf['token.']) : $conf['token'] ?? '';
        if ($conf['token'] === '') {
            return $value;
        }
        $valArr = explode($conf['token'], $value);

        // return value directly by returnKey. No further processing
        if ($valArr !== [''] && (MathUtility::canBeInterpretedAsInteger($conf['returnKey'] ?? null) || ($conf['returnKey.'] ?? false))) {
            $key = (int)$this->stdWrapValue('returnKey', $conf);
            return $valArr[$key] ?? '';
        }

        // return the amount of elements. No further processing
        if ($valArr !== [''] && (($conf['returnCount'] ?? false) || ($conf['returnCount.'] ?? false))) {
            $returnCount = (bool)$this->stdWrapValue('returnCount', $conf);
            return $returnCount ? count($valArr) : 0;
        }

        // calculate splitCount
        $splitCount = count($valArr);
        $max = (int)$this->stdWrapValue('max', $conf);
        if ($max && $splitCount > $max) {
            $splitCount = $max;
        }
        $min = (int)$this->stdWrapValue('min', $conf);
        if ($min && $splitCount < $min) {
            $splitCount = $min;
        }
        $wrap = (string)$this->stdWrapValue('wrap', $conf);
        $cObjNumSplitConf = isset($conf['cObjNum.']) ? $this->stdWrap($conf['cObjNum'] ?? '', $conf['cObjNum.']) : (string)($conf['cObjNum'] ?? '');
        $splitArr = [];
        if ($wrap !== '' || $cObjNumSplitConf !== '') {
            $splitArr['wrap'] = $wrap;
            $splitArr['cObjNum'] = $cObjNumSplitConf;
            $splitArr = $this->typoScriptService->explodeConfigurationForOptionSplit($splitArr, $splitCount);
        }
        $content = '';
        for ($a = 0; $a < $splitCount; $a++) {
            $this->getRequest()->getAttribute('frontend.register.stack')->current()->set('SPLIT_COUNT', $a);
            $value = $valArr[$a];
            $this->data[$this->currentValKey] = $value;
            if ($splitArr[$a]['cObjNum'] ?? false) {
                $objName = (int)$splitArr[$a]['cObjNum'];
                $value = (string)(isset($conf[$objName . '.'])
                    ? $this->stdWrap($this->cObjGet($conf[$objName . '.'], $objName . '.'), $conf[$objName . '.'])
                    : '');
            }
            $wrap = (string)$this->stdWrapValue('wrap', $splitArr[$a] ?? []);
            if ($wrap) {
                $value = $this->wrap($value, $wrap);
            }
            $content .= $value;
        }
        return $content;
    }

    /**
     * Implements the stdWrap property "numberFormat"
     * This is a Wrapper function for php's number_format()
     *
     * @param float $content Value to process
     * @param array $conf TypoScript Configuration for numberFormat
     * @internal
     */
    public function numberFormat($content, $conf): string
    {
        $decimals = (int)$this->stdWrapValue('decimals', $conf, 0);
        $dec_point = (string)$this->stdWrapValue('dec_point', $conf, '.');
        $thousands_sep = (string)$this->stdWrapValue('thousands_sep', $conf, ',');
        return number_format((float)$content, $decimals, $dec_point, $thousands_sep);
    }

    /**
     * Implements the stdWrap property, "parseFunc".
     * This is a function with a lot of interesting uses. In classic TypoScript this is used to process text
     * from the bodytext field; This included highlighting of search words, changing http:// and mailto: prefixed strings into etc.
     * It is still a very important function for processing of bodytext which is normally stored in the database
     * in a format which is not fully ready to be outputted.
     * This situation has not become better by having a RTE around...
     *
     * This function is actually just splitting the input content according to the configuration of "external blocks".
     * This means that before the input string is actually "parsed" it will be split into the parts configured to BE parsed
     * (while other parts/blocks should NOT be parsed).
     * Therefore, the actual processing of the parseFunc properties goes on in ->parseFuncInternal()
     *
     * @param string $theValue The value to process.
     * @param non-empty-array<string, mixed>|null $conf TypoScript configuration for parseFunc
     * @param non-empty-string|null $ref Reference to get configuration from. Eg. "< lib.parseFunc" which means that the configuration
     *                                   of the object path "lib.parseFunc" will be retrieved and MERGED with what is in $conf!
     * @return string The processed value
     */
    public function parseFunc($theValue, ?array $conf, ?string $ref = null)
    {
        // Fetch / merge reference, if any
        if (!empty($ref)) {
            $temp_conf = [
                'parseFunc' => $ref,
                'parseFunc.' => $conf ?? [],
            ];
            $temp_conf = $this->mergeTSRef($temp_conf, 'parseFunc');
            $conf = $temp_conf['parseFunc.'];
        }
        if (empty($conf)) {
            // `parseFunc` relies on configuration, either given in `$conf` or resolved from `$ref`
            throw new \LogicException('Invoked ContentObjectRenderer::parseFunc without any configuration', 1641989097);
        }
        // Handle HTML sanitizer invocation
        $conf['htmlSanitize'] = (bool)($conf['htmlSanitize'] ?? true);
        // Process:
        if ((string)($conf['externalBlocks'] ?? '') === '') {
            $result = $this->parseFuncInternal($theValue, $conf);
            if ($conf['htmlSanitize']) {
                $result = $this->stdWrap_htmlSanitize($result, $conf['htmlSanitize.'] ?? []);
            }
            return $result;
        }
        $tags = strtolower(implode(',', GeneralUtility::trimExplode(',', $conf['externalBlocks'])));
        $htmlParser = GeneralUtility::makeInstance(HtmlParser::class);
        $parts = $htmlParser->splitIntoBlock($tags, $theValue);
        foreach ($parts as $k => $v) {
            if ($k % 2) {
                // font:
                $tagName = strtolower($htmlParser->getFirstTagName($v));
                $cfg = $conf['externalBlocks.'][$tagName . '.'] ?? [];
                if ($cfg === []) {
                    continue;
                }
                if (($cfg['stripNLprev'] ?? false) || ($cfg['stripNL'] ?? false)) {
                    $parts[$k - 1] = preg_replace('/' . CR . '?' . LF . '[ ]*$/', '', $parts[$k - 1]);
                }
                if (($cfg['stripNLnext'] ?? false) || ($cfg['stripNL'] ?? false)) {
                    if (!isset($parts[$k + 1])) {
                        $parts[$k + 1] = '';
                    }
                    $parts[$k + 1] = preg_replace('/^[ ]*' . CR . '?' . LF . '/', '', $parts[$k + 1]);
                }
            }
        }
        foreach ($parts as $k => $v) {
            if ($k % 2) {
                $tag = $htmlParser->getFirstTag($v);
                $tagName = strtolower($htmlParser->getFirstTagName($v));
                $cfg = $conf['externalBlocks.'][$tagName . '.'] ?? [];
                if ($cfg === []) {
                    continue;
                }
                if ($cfg['callRecursive'] ?? false) {
                    $parts[$k] = $this->parseFunc($htmlParser->removeFirstAndLastTag($v), $conf);
                    if (!($cfg['callRecursive.']['dontWrapSelf'] ?? false)) {
                        if ($cfg['callRecursive.']['alternativeWrap'] ?? false) {
                            $parts[$k] = $this->wrap($parts[$k], $cfg['callRecursive.']['alternativeWrap']);
                        } else {
                            if (is_array($cfg['callRecursive.']['tagStdWrap.'] ?? false)) {
                                $tag = $this->stdWrap($tag, $cfg['callRecursive.']['tagStdWrap.']);
                            }
                            $parts[$k] = $tag . $parts[$k] . '</' . $tagName . '>';
                        }
                    }
                } elseif ($cfg['HTMLtableCells'] ?? false) {
                    $rowParts = $htmlParser->splitIntoBlock('tr', $parts[$k]);
                    foreach ($rowParts as $kk => $vv) {
                        if ($kk % 2) {
                            $colParts = $htmlParser->splitIntoBlock('td,th', $vv);
                            $cc = 0;
                            foreach ($colParts as $kkk => $vvv) {
                                if ($kkk % 2) {
                                    $cc++;
                                    $tag = $htmlParser->getFirstTag($vvv);
                                    $tagName = strtolower($htmlParser->getFirstTagName($vvv));
                                    $colParts[$kkk] = $htmlParser->removeFirstAndLastTag($vvv);
                                    if (($cfg['HTMLtableCells.'][$cc . '.']['callRecursive'] ?? false)
                                        || (!isset($cfg['HTMLtableCells.'][$cc . '.']['callRecursive']) && ($cfg['HTMLtableCells.']['default.']['callRecursive'] ?? false))) {
                                        if ($cfg['HTMLtableCells.']['addChr10BetweenParagraphs'] ?? false) {
                                            $colParts[$kkk] = str_replace(
                                                '</p><p>',
                                                '</p>' . LF . '<p>',
                                                $colParts[$kkk]
                                            );
                                        }
                                        $colParts[$kkk] = $this->parseFunc($colParts[$kkk], $conf);
                                    }
                                    $tagStdWrap = is_array($cfg['HTMLtableCells.'][$cc . '.']['tagStdWrap.'] ?? false)
                                        ? $cfg['HTMLtableCells.'][$cc . '.']['tagStdWrap.']
                                        : ($cfg['HTMLtableCells.']['default.']['tagStdWrap.'] ?? null);
                                    if (is_array($tagStdWrap)) {
                                        $tag = $this->stdWrap($tag, $tagStdWrap);
                                    }
                                    $stdWrap = is_array($cfg['HTMLtableCells.'][$cc . '.']['stdWrap.'] ?? false)
                                        ? $cfg['HTMLtableCells.'][$cc . '.']['stdWrap.']
                                        : ($cfg['HTMLtableCells.']['default.']['stdWrap.'] ?? null);
                                    if (is_array($stdWrap)) {
                                        $colParts[$kkk] = $this->stdWrap($colParts[$kkk], $stdWrap);
                                    }
                                    $colParts[$kkk] = $tag . $colParts[$kkk] . '</' . $tagName . '>';
                                }
                            }
                            $rowParts[$kk] = implode('', $colParts);
                        }
                    }
                    $parts[$k] = implode('', $rowParts);
                }
                if (is_array($cfg['stdWrap.'] ?? false)) {
                    $parts[$k] = $this->stdWrap($parts[$k], $cfg['stdWrap.']);
                }
            } else {
                $parts[$k] = $this->parseFuncInternal($parts[$k], $conf);
            }
        }
        $result = implode('', $parts);
        if ($conf['htmlSanitize']) {
            $result = $this->stdWrap_htmlSanitize($result, $conf['htmlSanitize.'] ?? []);
        }
        return $result;
    }

    /**
     * Helper function for parseFunc()
     *
     * @param string $theValue The value to process.
     * @param array $conf TypoScript configuration for parseFunc
     * @return string The processed value
     */
    protected function parseFuncInternal($theValue, $conf)
    {
        if (!empty($conf['if.']) && !$this->checkIf($conf['if.'])) {
            return $theValue;
        }
        // Indicates that the data is from within a tag.
        $inside = false;
        // Pointer to the total string position
        $pointer = 0;
        // Loaded with the current typo-tag if any.
        $currentTag = null;
        $stripNL = 0;
        $contentAccum = [];
        $contentAccumP = 0;

        $allowTags = GeneralUtility::trimExplode(',', strtolower($conf['allowTags'] ?? ''), true);
        if (in_array('*', $allowTags, true)) {
            $allowTags = ['*'];
        }
        $denyTags = GeneralUtility::trimExplode(',', strtolower($conf['denyTags'] ?? ''), true);
        if (in_array('*', $denyTags, true)) {
            $denyTags = ['*'];
        }
        $totalLen = strlen($theValue);
        do {
            if (!$inside) {
                if ($currentTag === null) {
                    // These operations should only be performed on code outside the typotags...
                    // data: this checks that we enter tags ONLY if the first char in the tag is alphanumeric OR '/'
                    $len_p = 0;
                    $c = 100;
                    do {
                        $len = strcspn(substr($theValue, $pointer + $len_p), '<');
                        $len_p += $len + 1;
                        $ordValue = strtolower(substr($theValue, $pointer + $len_p, 1));
                        $endChar = empty($ordValue) ? 0 : ord($ordValue);
                        unset($ordValue);
                        $c--;
                    } while ($c > 0 && $endChar && ($endChar < 97 || $endChar > 122) && $endChar != 47);
                    $len = $len_p - 1;
                } else {
                    $len = $this->getContentLengthOfCurrentTag($theValue, $pointer, (string)$currentTag[0]);
                }
                // $data is the content until the next <tag-start or end is detected.
                // In case of a currentTag set, this would mean all data between the start- and end-tags
                $data = substr($theValue, $pointer, $len);
                if ($stripNL) {
                    // If the previous tag was set to strip NewLines in the beginning of the next data-chunk.
                    $data = preg_replace('/^[ ]*' . CR . '?' . LF . '/', '', $data);
                    if ($data === null) {
                        $this->logger->debug('Stripping new lines failed for "{data}"', ['data' => $data]);
                        $data = '';
                    }
                }
                // These operations should only be performed on code outside the tags...
                if (!is_array($currentTag)) {
                    // Short
                    if (isset($conf['short.']) && is_array($conf['short.'])) {
                        $shortWords = $conf['short.'];
                        krsort($shortWords);
                        foreach ($shortWords as $key => $val) {
                            if (is_string($val)) {
                                $data = str_replace($key, $val, $data);
                            }
                        }
                    }
                    // stdWrap
                    if (isset($conf['plainTextStdWrap.']) && is_array($conf['plainTextStdWrap.'])) {
                        $data = $this->stdWrap($data, $conf['plainTextStdWrap.']);
                    }
                    // userFunc
                    if ($conf['userFunc'] ?? false) {
                        $data = $this->callUserFunction($conf['userFunc'], $conf['userFunc.'] ?? [], $data);
                    }
                }
                // Search for tags to process in current data and
                // call this method recursively if found
                if (str_contains($data, '<') && isset($conf['tags.']) && is_array($conf['tags.'])) {
                    // @todo probably use a DOM tree traversal for the whole stuff
                    // This iterations basically re-processes the markup string, as
                    // long as there are `<$tag ` or `<$tag>` "tags" found...
                    foreach (array_keys($conf['tags.']) as $tag) {
                        // only match tag `a` in `<a href"...">` but not in `<abbr>`
                        if (preg_match('#<' . $tag . '[\s/>]#', $data)) {
                            $data = $this->parseFuncInternal($data, $conf);
                            break;
                        }
                    }
                }
                if (!is_array($currentTag) && ($conf['makelinks'] ?? false)) {
                    $data = $this->http_makelinks($data, $conf['makelinks.']['http.'] ?? []);
                    $data = $this->mailto_makelinks($data, $conf['makelinks.']['mailto.'] ?? []);
                }
                $contentAccum[$contentAccumP] = ($contentAccum[$contentAccumP] ?? '') . $data;
                $inside = true;
            } else {
                // tags
                $len = strcspn(substr($theValue, $pointer), '>') + 1;
                $data = substr($theValue, $pointer, $len);
                if (str_ends_with($data, '/>') && !str_starts_with($data, '<link ')) {
                    $tagContent = substr($data, 1, -2);
                } else {
                    $tagContent = substr($data, 1, -1);
                }
                $tag = preg_split('/[\t\n\f ]/', trim($tagContent), 2);
                $tag[0] = strtolower($tag[0]);
                // end tag like </li>
                if (str_starts_with($tag[0], '/')) {
                    $tag[0] = substr($tag[0], 1);
                    $tag['out'] = 1;
                }
                if ($conf['tags.'][$tag[0]] ?? false) {
                    $treated = false;
                    $stripNL = false;
                    // in-tag
                    if (!$currentTag && (!isset($tag['out']) || !$tag['out'])) {
                        // $currentTag (array!) is the tag we are currently processing
                        $currentTag = $tag;
                        $contentAccumP++;
                        $treated = true;
                        // in-out-tag: img and other empty tags
                        if (preg_match('/^(area|base|br|col|hr|img|input|meta|param)$/i', (string)$tag[0])) {
                            $tag['out'] = 1;
                        }
                    }
                    // out-tag
                    if (isset($currentTag[0], $tag['out']) && $currentTag[0] === $tag[0] && $tag['out']) {
                        $theName = $conf['tags.'][$tag[0]];
                        $theConf = $conf['tags.'][$tag[0] . '.'];
                        // This flag indicates, that NL- (13-10-chars) should be stripped first and last.
                        $stripNL = (bool)($theConf['stripNL'] ?? false);
                        // This flag indicates, that this TypoTag section should NOT be included in the nonTypoTag content.
                        $breakOut = (bool)($theConf['breakoutTypoTagContent'] ?? false);
                        $this->parameters = [];
                        if (isset($currentTag[1])) {
                            // decode HTML entities in attributes, since they're processed
                            $params = GeneralUtility::get_tag_attributes((string)$currentTag[1], true);
                            foreach ($params as $option => $val) {
                                // contains non-encoded values
                                $this->parameters[strtolower($option)] = $val;
                            }
                            $this->parameters['allParams'] = trim((string)$currentTag[1]);
                        }
                        // Removes NL in the beginning and end of the tag-content AND at the end of the currentTagBuffer.
                        // $stripNL depends on the configuration of the current tag
                        if ($stripNL) {
                            $contentAccum[$contentAccumP - 1] = preg_replace('/' . CR . '?' . LF . '[ ]*$/', '', $contentAccum[$contentAccumP - 1] ?? '');
                            $contentAccum[$contentAccumP] = preg_replace('/^[ ]*' . CR . '?' . LF . '/', '', $contentAccum[$contentAccumP] ?? '');
                            $contentAccum[$contentAccumP] = preg_replace('/' . CR . '?' . LF . '[ ]*$/', '', $contentAccum[$contentAccumP] ?? '');
                        }
                        $this->data[$this->currentValKey] = $contentAccum[$contentAccumP] ?? null;
                        $newInput = $this->cObjGetSingle($theName, $theConf, '/parseFunc/.tags.' . $tag[0]);
                        // fetch the content object
                        $contentAccum[$contentAccumP] = $newInput;
                        $contentAccumP++;
                        // If the TypoTag section
                        if (!$breakOut) {
                            if (!isset($contentAccum[$contentAccumP - 2])) {
                                $contentAccum[$contentAccumP - 2] = '';
                            }
                            $contentAccum[$contentAccumP - 2] .= ($contentAccum[$contentAccumP - 1] ?? '') . ($contentAccum[$contentAccumP] ?? '');
                            unset($contentAccum[$contentAccumP]);
                            unset($contentAccum[$contentAccumP - 1]);
                            $contentAccumP -= 2;
                        }
                        $currentTag = null;
                        $treated = true;
                    }
                    // other tags
                    if (!$treated) {
                        $contentAccum[$contentAccumP] .= $data;
                    }
                } else {
                    $contentAccum[$contentAccumP] = $contentAccum[$contentAccumP] ?? '';
                    // If a tag was not a typo tag, then it is just added to the content
                    $stripNL = false;
                    if (
                        // Neither allowTags or denyTags set, thus everything is allowed
                        ($denyTags === [] && $allowTags === [])
                        // Explicitly allowed
                        || ($allowTags !== [] && in_array((string)$tag[0], $allowTags, true))
                        // Explicitly denied or everything "denied" (except for the explicitly allowed)
                        || ($denyTags !== [] && $denyTags !== ['*'] && !in_array((string)$tag[0], $denyTags))
                        // All tags are allowed, but not in the denied list above, so this is OK
                        || ($allowTags === ['*'] && !in_array((string)$tag[0], $denyTags))
                    ) {
                        $contentAccum[$contentAccumP] .= $data;
                    } else {
                        $contentAccum[$contentAccumP] .= htmlspecialchars($data);
                    }
                }
                $inside = false;
            }
            $pointer += $len;
        } while ($pointer < $totalLen);
        // Parsing nonTypoTag content (all even keys):
        reset($contentAccum);
        $contentAccumCount = count($contentAccum);
        for ($a = 0; $a < $contentAccumCount; $a++) {
            if ($a % 2 != 1) {
                // stdWrap
                if (isset($conf['nonTypoTagStdWrap.']) && is_array($conf['nonTypoTagStdWrap.'])) {
                    $contentAccum[$a] = $this->stdWrap((string)($contentAccum[$a] ?? ''), $conf['nonTypoTagStdWrap.']);
                }
                // userFunc
                if (!empty($conf['nonTypoTagUserFunc'])) {
                    $contentAccum[$a] = $this->callUserFunction($conf['nonTypoTagUserFunc'], $conf['nonTypoTagUserFunc.'] ?? [], (string)($contentAccum[$a] ?? ''));
                }
            }
        }
        return implode('', $contentAccum);
    }

    /**
     * Lets you split the content by LF and process each line independently. Used to format content made with the RTE.
     *
     * @param string $theValue The input value
     * @param array $conf TypoScript options
     * @return string The processed input value being returned; Split lines imploded by LF again.
     * @internal
     */
    public function encaps_lineSplit($theValue, array $conf): string
    {
        if ((string)$theValue === '') {
            return '';
        }
        $lParts = explode(LF, $theValue);

        // When the last element is an empty linebreak we need to remove it, otherwise we will have a duplicate empty line.
        $lastPartIndex = count($lParts) - 1;
        if ($lParts[$lastPartIndex] === '' && trim($lParts[$lastPartIndex - 1], CR) === '') {
            array_pop($lParts);
        }

        $encapTags = GeneralUtility::trimExplode(',', strtolower($conf['encapsTagList'] ?? ''), true);
        $defaultAlign = trim((string)$this->stdWrapValue('defaultAlign', $conf));

        $str_content = '';
        foreach ($lParts as $k => $l) {
            $sameBeginEnd = false;
            $emptyTag = false;
            $l = trim($l);
            $attrib = [];
            $nonWrapped = false;
            $tagName = '';
            if (isset($l[0]) && $l[0] === '<' && str_ends_with($l, '>')) {
                $fwParts = explode('>', substr($l, 1), 2);
                [$tagName] = explode(' ', $fwParts[0], 2);
                if (!$fwParts[1]) {
                    if (str_ends_with($tagName, '/')) {
                        $tagName = substr($tagName, 0, -1);
                    }
                    if (str_ends_with($fwParts[0], '/')) {
                        $sameBeginEnd = true;
                        $emptyTag = true;
                        // decode HTML entities, they're encoded later again
                        $attrib = GeneralUtility::get_tag_attributes('<' . substr($fwParts[0], 0, -1) . '>', true);
                    }
                } else {
                    $backParts = GeneralUtility::revExplode('<', substr($fwParts[1], 0, -1), 2);
                    // decode HTML entities, they're encoded later again
                    $attrib = GeneralUtility::get_tag_attributes('<' . $fwParts[0] . '>', true);
                    $str_content = $backParts[0];
                    // Ensure that $backParts could be exploded into 2 items
                    if (isset($backParts[1])) {
                        $sameBeginEnd = strtolower(substr($backParts[1], 1, strlen($tagName))) === strtolower($tagName);
                    }
                }
            }
            if ($sameBeginEnd && in_array(strtolower($tagName), $encapTags)) {
                $uTagName = strtoupper($tagName);
                $uTagName = strtoupper($conf['remapTag.'][$uTagName] ?? $uTagName);
            } else {
                $uTagName = strtoupper($conf['nonWrappedTag'] ?? '');
                // The line will be wrapped: $uTagName should not be an empty tag
                $emptyTag = false;
                $str_content = $lParts[$k];
                $nonWrapped = true;
                $attrib = [];
            }
            // Wrapping all inner-content:
            if (is_array($conf['innerStdWrap_all.'] ?? null)) {
                $str_content = (string)$this->stdWrap($str_content, $conf['innerStdWrap_all.']);
            }
            if ($uTagName) {
                // Setting common attributes
                if (isset($conf['addAttributes.'][$uTagName . '.']) && is_array($conf['addAttributes.'][$uTagName . '.'])) {
                    foreach ($conf['addAttributes.'][$uTagName . '.'] as $kk => $vv) {
                        if (!is_array($vv)) {
                            if ((string)($conf['addAttributes.'][$uTagName . '.'][$kk . '.']['setOnly'] ?? '') === 'blank') {
                                if ((string)($attrib[$kk] ?? '') === '') {
                                    $attrib[$kk] = $vv;
                                }
                            } elseif ((string)($conf['addAttributes.'][$uTagName . '.'][$kk . '.']['setOnly'] ?? '') === 'exists') {
                                if (!isset($attrib[$kk])) {
                                    $attrib[$kk] = $vv;
                                }
                            } else {
                                $attrib[$kk] = $vv;
                            }
                        }
                    }
                }
                // Wrapping all inner-content:
                if (isset($conf['encapsLinesStdWrap.'][$uTagName . '.']) && is_array($conf['encapsLinesStdWrap.'][$uTagName . '.'])) {
                    $str_content = (string)$this->stdWrap($str_content, $conf['encapsLinesStdWrap.'][$uTagName . '.']);
                }
                // Default align
                if ((!isset($attrib['align']) || !$attrib['align']) && $defaultAlign) {
                    $attrib['align'] = $defaultAlign;
                }
                // implode (insecure) attributes, that's why `htmlspecialchars` is used here
                $params = GeneralUtility::implodeAttributes($attrib, true);
                if (!isset($conf['removeWrapping']) || !$conf['removeWrapping'] || ($emptyTag && $conf['removeWrapping.']['keepSingleTag'])) {
                    $selfClosingTagList = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
                    if ($emptyTag && in_array(strtolower($uTagName), $selfClosingTagList, true)) {
                        $str_content = '<' . strtolower($uTagName) . (trim($params) ? ' ' . trim($params) : '') . ' />';
                    } else {
                        $str_content = '<' . strtolower($uTagName) . (trim($params) ? ' ' . trim($params) : '') . '>' . $str_content . '</' . strtolower($uTagName) . '>';
                    }
                }
            }
            if ($nonWrapped && isset($conf['wrapNonWrappedLines']) && $conf['wrapNonWrappedLines']) {
                $str_content = $this->wrap($str_content, $conf['wrapNonWrappedLines']);
            }
            $lParts[$k] = $str_content;
        }
        return implode(LF, $lParts);
    }

    /**
     * Finds URLs in text and makes it to a real link.
     * Will find all strings prefixed with "http://" and "https://" in the $data string and make them into a link,
     * linking to the URL we should have found.
     *
     * Helper method of parseFuncInternal().
     *
     * @param string $data The string in which to search for "http://
     * @param array $conf Configuration for makeLinks, see link
     * @return string The processed input string, being returned.
     */
    protected function http_makelinks(string $data, array $conf): string
    {
        $parts = [];
        foreach (['http://', 'https://'] as $scheme) {
            $textpieces = explode($scheme, $data);
            $pieces = count($textpieces);
            $textstr = $textpieces[0];
            for ($i = 1; $i < $pieces; $i++) {
                $len = strcspn($textpieces[$i], chr(32) . "\t" . CRLF);
                if (!(trim(substr($textstr, -1)) === '' && $len)) {
                    $textstr .= $scheme . $textpieces[$i];
                    continue;
                }
                $lastChar = substr($textpieces[$i], $len - 1, 1);
                if (!preg_match('/[A-Za-z0-9\\/#_-]/', $lastChar)) {
                    $len--;
                }
                // Included '\/' 3/12
                $parts[0] = substr($textpieces[$i], 0, $len);
                $parts[1] = substr($textpieces[$i], $len);
                $keep = $conf['keep'] ?? '';
                $linkParts = parse_url($scheme . $parts[0]);
                // Check if link couldn't be parsed properly
                if (!is_array($linkParts)) {
                    $textstr .= $scheme . $textpieces[$i];
                    continue;
                }
                $linktxt = '';
                if (str_contains($keep, 'scheme')) {
                    $linktxt = $scheme;
                }
                $linktxt .= $linkParts['host'] ?? '';
                if (str_contains($keep, 'path')) {
                    $linktxt .= ($linkParts['path'] ?? '');
                    // Added $linkParts['query'] 3/12
                    if (str_contains($keep, 'query') && $linkParts['query']) {
                        $linktxt .= '?' . $linkParts['query'];
                    } elseif (($linkParts['path'] ?? '') === '/') {
                        $linktxt = substr($linktxt, 0, -1);
                    }
                }
                $typolinkConfiguration = $conf;
                $typolinkConfiguration['parameter'] = $scheme . $parts[0];
                $textstr .= $this->typoLink($linktxt, $typolinkConfiguration) . $parts[1];
            }
            $data = $textstr;
        }
        return $textstr;
    }

    /**
     * Will find all strings prefixed with "mailto:" in the $data string and make them into a link,
     * linking to the email address they point to.
     *
     * Helper method of parseFuncInternal().
     *
     * @param string $data The string in which to search for "mailto:
     * @param array $conf Configuration for makeLinks, see link
     * @return string The processed input string, being returned.
     */
    protected function mailto_makelinks(string $data, array $conf): string
    {
        $conf = (array)$conf;
        $parts = [];
        // split by mailto logic
        $textpieces = explode('mailto:', $data);
        $pieces = count($textpieces);
        $textstr = $textpieces[0];
        for ($i = 1; $i < $pieces; $i++) {
            $len = strcspn($textpieces[$i], chr(32) . "\t" . CRLF);
            if (trim(substr($textstr, -1)) === '' && $len) {
                $lastChar = substr($textpieces[$i], $len - 1, 1);
                if (!preg_match('/[A-Za-z0-9]/', $lastChar)) {
                    $len--;
                }
                $parts[0] = substr($textpieces[$i], 0, $len);
                $parts[1] = substr($textpieces[$i], $len);
                $linktxt = (string)preg_replace('/\\?.*/', '', $parts[0]);
                $typolinkConfiguration = $conf;
                $typolinkConfiguration['parameter'] = 'mailto:' . $parts[0];
                $textstr .= (string)$this->typoLink($linktxt, $typolinkConfiguration) . $parts[1];
            } else {
                $textstr .= 'mailto:' . $textpieces[$i];
            }
        }
        return $textstr;
    }

    /**
     * Creates and returns a TypoScript "imgResource".
     * The value ($file) can either be a file reference (TypoScript resource) or the string "GIFBUILDER".
     * In the first case a current image is returned, possibly scaled down or otherwise processed.
     * In the latter case a GIFBUILDER image is returned; This means an image is made by TYPO3 from layers of elements as GIFBUILDER defines.
     * In the function IMG_RESOURCE() this function is called like $this->getImgResource($conf['file'], $conf['file.']);
     *
     * Structure of the returned info array:
     *  0 => width
     *  1 => height
     *  2 => file extension
     *  3 => file name
     *  origFile => original file name
     *  origFile_mtime => original file mtime
     *  -- only available if processed via FAL: --
     *  originalFile => original file object
     *  processedFile => processed file object
     *  fileCacheHash => checksum of processed file
     *
     * @param string|File|FileReference $file A "imgResource" TypoScript data type. Either a TypoScript file resource, a file
     *                                        or a file reference object or the string GIFBUILDER. See description above.
     * @param array $fileArray TypoScript properties for the imgResource type
     * @see cImage()
     */
    public function getImgResource($file, array $fileArray): ?ImageResource
    {
        $importedFile = null;
        $fileReference = null;
        if (empty($file) && empty($fileArray)) {
            return null;
        }
        $imageResource = null;
        if ($file === 'GIFBUILDER') {
            $gifBuilder = GeneralUtility::makeInstance(GifBuilder::class);
            $gifBuilder->start($fileArray, $this->data);
            $imageResource = $gifBuilder->gifBuild();
        } else {
            if ($file instanceof File) {
                $fileObject = $file;
            } elseif ($file instanceof FileReference) {
                $fileReference = $file;
                $fileObject = $file->getOriginalFile();
            } else {
                try {
                    if (isset($fileArray['import.']) && $fileArray['import.']) {
                        $importedFile = trim((string)$this->stdWrap('', $fileArray['import.']));
                        if (!empty($importedFile)) {
                            $file = $importedFile;
                        }
                    }

                    if (MathUtility::canBeInterpretedAsInteger($file)) {
                        $treatIdAsReference = $this->stdWrapValue('treatIdAsReference', $fileArray);
                        if (!empty($treatIdAsReference)) {
                            $fileReference = $this->resourceFactory->getFileReferenceObject((int)$file);
                            $fileObject = $fileReference->getOriginalFile();
                        } else {
                            $fileObject = $this->resourceFactory->getFileObject((int)$file);
                        }
                    } elseif (preg_match('/^(0|[1-9][0-9]*):/', $file)) { // combined identifier
                        $fileObject = $this->resourceFactory->retrieveFileOrFolderObject($file);
                    } else {
                        if ($importedFile && !empty($fileArray['import'])) {
                            $file = $fileArray['import'] . $file;
                        }
                        $fileObject = $this->resourceFactory->retrieveFileOrFolderObject($file);
                    }
                } catch (Exception $exception) {
                    $this->logger->warning('The image "{file}" could not be found and won\'t be included in frontend output', [
                        'file' => $file,
                        'exception' => $exception,
                    ]);
                    return null;
                }
            }
            if ($fileObject instanceof File) {
                $processingConfiguration['width'] = $this->stdWrapValue('width', $fileArray);
                $processingConfiguration['height'] = $this->stdWrapValue('height', $fileArray);
                $processingConfiguration['fileExtension'] = $this->stdWrapValue('ext', $fileArray);
                $processingConfiguration['maxWidth'] = (int)$this->stdWrapValue('maxW', $fileArray);
                $processingConfiguration['maxHeight'] = (int)$this->stdWrapValue('maxH', $fileArray);
                $processingConfiguration['minWidth'] = (int)$this->stdWrapValue('minW', $fileArray);
                $processingConfiguration['minHeight'] = (int)$this->stdWrapValue('minH', $fileArray);
                $processingConfiguration['noScale'] = $this->stdWrapValue('noScale', $fileArray);
                $processingConfiguration['sample'] = (bool)$this->stdWrapValue('sample', $fileArray);
                $processingConfiguration['additionalParameters'] = $this->stdWrapValue('params', $fileArray);
                $processingConfiguration['frame'] = (int)$this->stdWrapValue('frame', $fileArray);
                if ($fileReference === null) {
                    $processingConfiguration['crop'] = $this->getCropAreaFromFromTypoScriptSettings($fileObject, $fileArray);
                } else {
                    $processingConfiguration['crop'] = $this->getCropAreaFromFileReference($fileReference, $fileArray);
                }

                // Possibility to cancel/force profile extraction
                // see $GLOBALS['TYPO3_CONF_VARS']['GFX']['processor_stripColorProfileParameters']
                if (isset($fileArray['stripProfile'])) {
                    $processingConfiguration['stripProfile'] = $fileArray['stripProfile'];
                }
                // Check if we can handle this type of file for editing
                if ($fileObject->isImage()) {
                    $maskArray = $fileArray['m.'] ?? false;
                    // Must render mask images and include in hash-calculating
                    // - otherwise we cannot be sure the filename is unique for the setup!
                    if (is_array($maskArray)) {
                        $processingConfiguration['maskImages']['maskImage'] = $this->getImgResource($maskArray['mask'] ?? '', $maskArray['mask.'] ?? [])?->getProcessedFile();
                        $processingConfiguration['maskImages']['backgroundImage'] = $this->getImgResource($maskArray['bgImg'] ?? '', $maskArray['bgImg.'] ?? [])?->getProcessedFile();
                        $processingConfiguration['maskImages']['maskBottomImage'] = $this->getImgResource($maskArray['bottomImg'] ?? '', $maskArray['bottomImg.'] ?? [])?->getProcessedFile();
                        $processingConfiguration['maskImages']['maskBottomImageMask'] = $this->getImgResource($maskArray['bottomImg_mask'] ?? '', $maskArray['bottomImg_mask.'] ?? [])?->getProcessedFile();
                    }
                    $processedFileObject = $fileObject->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $processingConfiguration);
                    if ($processedFileObject->isProcessed()) {
                        $imageResource = ImageResource::createFromProcessedFile($processedFileObject);
                    }
                }
            } elseif ($fileObject instanceof ProcessedFile) {
                $imageResource = ImageResource::createFromProcessedFile($fileObject);
            }
        }

        return $this->eventDispatcher->dispatch(
            new AfterImageResourceResolvedEvent($file, $fileArray, $imageResource)
        )->getImageResource();
    }

    /**
     * Returns an ImageManipulation\Area object for the given cropVariant (or 'default')
     * or null if the crop settings or crop area is empty.
     *
     * The cropArea from file reference is used, if not set in TypoScript.
     *
     * Example TypoScript settings:
     * file.crop =
     * OR
     * file.crop = 50,50,100,100
     * OR
     * file.crop.data = file:current:crop
     *
     * @param array $fileArray TypoScript properties for the imgResource type
     */
    protected function getCropAreaFromFileReference(FileReference $fileReference, array $fileArray): ?Area
    {
        // Use cropping area from file reference if nothing is configured in TypoScript.
        if (!isset($fileArray['crop']) && !isset($fileArray['crop.'])) {
            // Set crop variant from TypoScript settings. If not set, use default.
            $cropVariant = $fileArray['cropVariant'] ?? 'default';
            $fileCropArea = $this->createCropAreaFromJsonString((string)$fileReference->getProperty('crop'), $cropVariant);
            return $fileCropArea->isEmpty() ? null : $fileCropArea->makeAbsoluteBasedOnFile($fileReference);
        }
        return $this->getCropAreaFromFromTypoScriptSettings($fileReference, $fileArray);
    }

    /**
     * Returns an ImageManipulation\Area object for the given cropVariant (or 'default')
     * or null if the crop settings or crop area is empty.
     */
    protected function getCropAreaFromFromTypoScriptSettings(FileInterface $file, array $fileArray): ?Area
    {
        $cropArea = null;
        // Resolve TypoScript configured cropping.
        $cropSettings = isset($fileArray['crop.'])
            ? $this->stdWrap($fileArray['crop'] ?? '', $fileArray['crop.'])
            : ($fileArray['crop'] ?? null);
        if (is_string($cropSettings)) {
            // Set crop variant from TypoScript settings. If not set, use default.
            $cropVariant = $fileArray['cropVariant'] ?? 'default';
            // Get cropArea from CropVariantCollection, if cropSettings is a valid json.
            // CropVariantCollection::create does json_decode.
            $jsonCropArea = $this->createCropAreaFromJsonString($cropSettings, $cropVariant);
            $cropArea = $jsonCropArea->isEmpty() ? null : $jsonCropArea->makeAbsoluteBasedOnFile($file);
            // Cropping is configured in TypoScript in the following way: file.crop = 50,50,100,100
            if ($jsonCropArea->isEmpty() && preg_match('/^[0-9]+,[0-9]+,[0-9]+,[0-9]+$/', $cropSettings)) {
                $cropSettings = explode(',', $cropSettings);
                if (count($cropSettings) === 4) {
                    $cropSettings = array_map(floatval(...), $cropSettings);
                    $stringCropArea = GeneralUtility::makeInstance(Area::class, ...$cropSettings);
                    $cropArea = $stringCropArea->isEmpty() ? null : $stringCropArea;
                }
            }
        }
        return $cropArea;
    }

    /**
     * Takes a JSON string and creates CropVariantCollection and fetches the corresponding CropArea for that.
     */
    protected function createCropAreaFromJsonString(string $cropSettings, string $cropVariant): Area
    {
        return CropVariantCollection::create($cropSettings)->getCropArea($cropVariant);
    }

    /**
     * Returns the value for the field from $this->data. If "//" is found in the $field value that token will split
     * the field values apart and the first field having a non-blank value will be returned.
     *
     * @param string $field The fieldname, e.g. "title" or "navtitle // title" (in the latter case the value of
     *                      $this->data[navtitle] is returned if not blank, otherwise $this->data[title] will be)
     * @return string|null
     * @internal
     */
    public function getFieldVal($field)
    {
        if (!str_contains($field, '//')) {
            return $this->data[trim($field)] ?? null;
        }
        $sections = GeneralUtility::trimExplode('//', $field, true);
        foreach ($sections as $k) {
            if ((string)($this->data[$k] ?? '') !== '') {
                return $this->data[$k];
            }
        }
        return '';
    }

    /**
     * Implements the TypoScript data type "getText". This takes a string with parameters
     * and based on those a value from somewhere in the system is returned.
     *
     * @param mixed $string The parameter string, eg. "field : title" or "field : navtitle // field : title"
     *                       In the latter case and example of how the value is FIRST split by "//" is shown. Should be a string obviously
     * @param array|null $fieldArray Alternative field array; If you set this to an array this variable will be used to
     *                               look up values for the "field" key. Otherwise, the current page record is used.
     * @return mixed The value fetched
     */
    public function getData($string, $fieldArray = null)
    {
        if (!is_array($fieldArray)) {
            $fieldArray = $this->getRequest()->getAttribute('frontend.page.information')->getPageRecord();
        }
        $retVal = '';
        // @todo: getData should not be called with non-string as $string. example trigger:
        //        SecureHtmlRenderingTest htmlViewHelperAvoidsCrossSiteScripting set #07 PHP 8
        $sections = is_string($string) ? explode('//', $string) : [];
        foreach ($sections as $secVal) {
            if ($retVal) {
                break;
            }
            $parts = explode(':', $secVal, 2);
            $type = strtolower(trim($parts[0]));
            $typesWithOutParameters = ['level', 'date', 'current', 'pagelayout', 'applicationcontext'];
            $key = trim($parts[1] ?? '');
            if (($key != '') || in_array($type, $typesWithOutParameters)) {
                switch ($type) {
                    case 'gp':
                        // Merge GET and POST and get $key out of the merged array
                        $requestParameters = $this->getRequest()->getQueryParams();
                        $requestParameters = array_replace_recursive($requestParameters, (array)$this->getRequest()->getParsedBody());
                        $retVal = $this->getGlobal($key, $requestParameters);
                        break;
                    case 'request':
                        $retVal = $this->getValueFromRecursiveData(GeneralUtility::trimExplode('|', $key), $this->getRequest());
                        break;
                    case 'tsfe':
                        $valueParts = GeneralUtility::trimExplode('|', $key);
                        if (($valueParts[0] ?? '') === 'fe_user') {
                            $frontendUser = $this->getRequest()->getAttribute('frontend.user');
                            array_shift($valueParts);
                            $retVal = $this->getValueFromRecursiveData($valueParts, $frontendUser);
                        } elseif (($valueParts[0] ?? '') === 'linkVars') {
                            $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')->getConfigArray();
                            $typoScriptConfigLinkVars = (string)($typoScriptConfigArray['linkVars'] ?? '');
                            $retVal = $this->linkVarsCalculator->getAllowedLinkVarsFromRequest($typoScriptConfigLinkVars, $this->getRequest()->getQueryParams(), $this->context);
                        } elseif (($valueParts[0] ?? '') === 'id') {
                            $retVal = $this->getRequest()->getAttribute('frontend.page.information')->getId();
                        } elseif (($valueParts[0] ?? '') === 'contentPid') {
                            $retVal = $this->getRequest()->getAttribute('frontend.page.information')->getContentFromPid();
                        } elseif (($valueParts[0] ?? '') === 'rootLine') {
                            array_shift($valueParts);
                            $retVal = $this->getValueFromRecursiveData($valueParts, $this->getRequest()->getAttribute('frontend.page.information')->getRootLine());
                        } elseif (($valueParts[0] ?? '') === 'page') {
                            array_shift($valueParts);
                            $retVal = $this->getValueFromRecursiveData($valueParts, $this->getRequest()->getAttribute('frontend.page.information')->getPageRecord());
                        } elseif (($valueParts[0] ?? '') === 'config' && ($valueParts[1] ?? '') === 'rootLine') {
                            array_shift($valueParts);
                            array_shift($valueParts);
                            $retVal = $this->getValueFromRecursiveData($valueParts, $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine());
                        }
                        break;
                    case 'getenv':
                        $retVal = getenv($key);
                        break;
                    case 'getindpenv':
                        $normalizedParams = $this->getRequest()->getAttribute('normalizedParams');
                        $retVal = match ($key) {
                            'HTTP_HOST' => $normalizedParams->getHttpHost(),
                            'TYPO3_HOST_ONLY' => $normalizedParams->getRequestHostOnly(),
                            'TYPO3_PORT' => $normalizedParams->getRequestPort(),
                            'PATH_INFO' => $normalizedParams->getPathInfo(),
                            'QUERY_STRING' => $normalizedParams->getQueryString(),
                            'REQUEST_URI' => $normalizedParams->getRequestUri(),
                            'HTTP_REFERER' => $normalizedParams->getHttpReferer(),
                            'TYPO3_REQUEST_HOST' => $normalizedParams->getRequestHost(),
                            'TYPO3_REQUEST_URL' => $normalizedParams->getRequestUrl(),
                            'TYPO3_REQUEST_SCRIPT' => $normalizedParams->getRequestScript(),
                            'TYPO3_REQUEST_DIR' => $normalizedParams->getRequestDir(),
                            'TYPO3_SITE_URL' => $normalizedParams->getSiteUrl(),
                            'TYPO3_SITE_SCRIPT' => $normalizedParams->getSiteScript(),
                            'TYPO3_SSL' => $normalizedParams->isHttps(),
                            'TYPO3_REV_PROXY' => $normalizedParams->isBehindReverseProxy(),
                            'SCRIPT_NAME' => $normalizedParams->getScriptName(),
                            'TYPO3_DOCUMENT_ROOT' => $normalizedParams->getDocumentRoot(),
                            'SCRIPT_FILENAME' => $normalizedParams->getScriptFilename(),
                            'REMOTE_ADDR' => $normalizedParams->getRemoteAddress(),
                            'REMOTE_HOST' => $normalizedParams->getRemoteHost(),
                            'HTTP_USER_AGENT' => $normalizedParams->getHttpUserAgent(),
                            'HTTP_ACCEPT_LANGUAGE' => $normalizedParams->getHttpAcceptLanguage(),
                            default => null,
                        };
                        break;
                    case 'field':
                        $retVal = $this->getGlobal($key, $fieldArray);
                        break;
                    case 'file':
                        $retVal = $this->getFileDataKey($key);
                        break;
                    case 'asset':
                    case 'path':
                        $options = null;
                        if ($type === 'path') {
                            $options = new UriGenerationOptions(cacheBusting: false);
                        }
                        try {
                            $resource = $this->systemResourceFactory->createPublicResource($key);
                            $retVal = (string)$this->systemResourcePublisher->generateUri($resource, $this->getRequest(), $options);
                        } catch (Exception) {
                            $retVal = null;
                        }
                        break;
                    case 'parameters':
                        $retVal = $this->parameters[$key] ?? null;
                        break;
                    case 'register':
                        if ($key === 'SYS_LASTCHANGED') {
                            // b/w compat layer: SYS_LASTCHANGED has been a register entry until TYPO3 v14. It is now part
                            // of a request attribute. The register access via TS should continue to work, though.
                            $retVal = $this->getRequest()->getAttribute('frontend.page.parts')->getLastChanged();
                        } else {
                            $retVal = $this->getRequest()->getAttribute('frontend.register.stack')->current()->get($key);
                        }
                        break;
                    case 'global':
                        $retVal = $this->getGlobal($key);
                        break;
                    case 'level':
                        $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine();
                        $retVal = count($localRootLine) - 1;
                        break;
                    case 'leveltitle':
                        $keyParts = GeneralUtility::trimExplode(',', $key);
                        $pointer = (int)($keyParts[0] ?? 0);
                        $slide = $keyParts[1] ?? '';
                        $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine();
                        $numericKey = $this->getKey($pointer, $localRootLine);
                        $retVal = $this->rootLineValue($numericKey, 'title', strtolower($slide) === 'slide');
                        break;
                    case 'levelmedia':
                        $keyParts = GeneralUtility::trimExplode(',', $key);
                        $pointer = (int)($keyParts[0] ?? 0);
                        $slide = $keyParts[1] ?? '';
                        $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine();
                        $numericKey = $this->getKey($pointer, $localRootLine);
                        $retVal = $this->rootLineValue($numericKey, 'media', strtolower($slide) === 'slide');
                        break;
                    case 'leveluid':
                        $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine();
                        $numericKey = $this->getKey((int)$key, $localRootLine);
                        $retVal = $this->rootLineValue($numericKey, 'uid');
                        break;
                    case 'levelfield':
                        $keyParts = GeneralUtility::trimExplode(',', $key);
                        $pointer = (int)($keyParts[0] ?? 0);
                        $field = $keyParts[1] ?? '';
                        $slide = $keyParts[2] ?? '';
                        $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine();
                        $numericKey = $this->getKey($pointer, $localRootLine);
                        $retVal = $this->rootLineValue($numericKey, $field, strtolower($slide) === 'slide');
                        break;
                    case 'fullrootline':
                        $keyParts = GeneralUtility::trimExplode(',', $key);
                        $pointer = (int)($keyParts[0] ?? 0);
                        $field = $keyParts[1] ?? '';
                        $slide = $keyParts[2] ?? '';
                        $rootLine = $this->getRequest()->getAttribute('frontend.page.information')->getRootLine();
                        $localRootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine();
                        $fullKey = $pointer - count($localRootLine) + count($rootLine);
                        if ($fullKey >= 0) {
                            $retVal = $this->rootLineValue($fullKey, $field, stristr($slide, 'slide') !== false, $rootLine);
                        }
                        break;
                    case 'date':
                        if (!$key) {
                            $key = 'd/m Y';
                        }
                        $retVal = date($key, $GLOBALS['EXEC_TIME']);
                        break;
                    case 'page':
                        $pageRecord = $this->getRequest()->getAttribute('frontend.page.information')->getPageRecord();
                        $retVal = $pageRecord[$key] ?? '';
                        break;
                    case 'pagelayout':
                        $pageInformation = $this->getRequest()->getAttribute('frontend.page.information');
                        $retVal = $this->pageLayoutResolver->getLayoutIdentifierForPage($pageInformation->getPageRecord(), $pageInformation->getRootLine());
                        break;
                    case 'current':
                        $retVal = $this->data[$this->currentValKey] ?? null;
                        break;
                    case 'db':
                        $selectParts = GeneralUtility::trimExplode(':', $key, true);
                        if (!isset($selectParts[1])) {
                            break;
                        }
                        $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
                        $dbRecord = $pageRepository->getRawRecord($selectParts[0], (int)$selectParts[1]);
                        if (is_array($dbRecord) && isset($selectParts[2])) {
                            $retVal = $dbRecord[$selectParts[2]] ?? '';
                        }
                        break;
                    case 'lll':
                        // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request.
                        $language = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage();
                        $languageService = $this->languageServiceFactory->createFromSiteLanguage($language);
                        $retVal = $languageService->sL('LLL:' . $key);
                        break;
                    case 'cobj':
                        // @deprecated: Remove case in TYPO3 v15.
                        switch ($key) {
                            case 'parentRecordNumber':
                                trigger_error(
                                    'getData() type "cobj:parentRecordNumber" is deprecated since TYPO3 v14 and will be removed in TYPO3 v15.',
                                    E_USER_DEPRECATED
                                );
                                $retVal = $this->parentRecordNumber;
                                break;
                        }
                        break;
                    case 'debug':
                        switch ($key) {
                            case 'rootLine':
                                $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine());
                                break;
                            case 'fullRootLine':
                                $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.page.information')->getRootLine());
                                break;
                            case 'data':
                                $retVal = DebugUtility::viewArray($this->data);
                                break;
                            case 'register':
                                $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.register.stack')->current());
                                break;
                            case 'page':
                                $retVal = DebugUtility::viewArray($this->getRequest()->getAttribute('frontend.page.information')->getPageRecord());
                                break;
                        }
                        break;
                    case 'flexform':
                        $keyParts = GeneralUtility::trimExplode(':', $key, true);
                        if (count($keyParts) === 2 && isset($this->data[$keyParts[0]])) {
                            $flexFormContent = $this->data[$keyParts[0]];
                            if (!empty($flexFormContent)) {
                                $flexFormKey = str_replace('.', '|', $keyParts[1]);
                                $settings = $this->flexFormTools->convertFlexFormContentToArray($flexFormContent);
                                $retVal = $this->getGlobal($flexFormKey, $settings);
                            }
                        }
                        break;
                    case 'session':
                        $keyParts = GeneralUtility::trimExplode('|', $key, true);
                        $sessionKey = array_shift($keyParts);
                        $retVal = $this->getRequest()->getAttribute('frontend.user')->getSessionData($sessionKey);
                        foreach ($keyParts as $keyPart) {
                            if (is_object($retVal)) {
                                $retVal = $retVal->{$keyPart};
                            } elseif (is_array($retVal)) {
                                $retVal = $retVal[$keyPart];
                            } else {
                                $retVal = '';
                                break;
                            }
                        }
                        if (!is_scalar($retVal)) {
                            $retVal = '';
                        }
                        break;
                    case 'context':
                        [$aspectName, $propertyName] = GeneralUtility::trimExplode(':', $key, true, 2);
                        $retVal = $this->context->getPropertyFromAspect($aspectName, $propertyName, '');
                        if (is_array($retVal)) {
                            $retVal = implode(',', $retVal);
                        }
                        if (!is_scalar($retVal)) {
                            $retVal = '';
                        }
                        break;
                    case 'site':
                        $site = $this->getRequest()->getAttribute('site');
                        if ($key === 'identifier') {
                            $retVal = $site->getIdentifier();
                        } elseif ($key === 'base') {
                            $retVal = $site->getBase();
                        } else {
                            try {
                                $retVal = ArrayUtility::getValueByPath($site->getConfiguration(), $key, '.');
                            } catch (MissingArrayPathException $exception) {
                                $this->logger->notice('Configuration "{key}" is not defined for site "{site}"', ['key' => $key, 'site' => $site->getIdentifier(), 'exception' => $exception]);
                            }
                        }
                        break;
                    case 'sitelanguage':
                        // @todo: Check when/if there are scenarios where attribute 'language' is not yet set in $request.
                        $siteLanguage = $this->getRequest()->getAttribute('language') ?? $this->getRequest()->getAttribute('site')->getDefaultLanguage();
                        if ($key === 'twoLetterIsoCode') {
                            $key = 'locale:languageCode';
                        }
                        // Harmonizing the namings from the site configuration value with the TypoScript setting
                        if ($key === 'flag') {
                            $key = 'flagIdentifier';
                        }
                        // Special handling for the locale object
                        if (str_starts_with($key, 'locale')) {
                            $localeObject = $siteLanguage->getLocale();
                            if ($key === 'locale') {
                                // backwards-compatibility
                                $retVal = $localeObject->posixFormatted();
                            } else {
                                $keyParts = explode(':', $key, 2);
                                switch ($keyParts[1] ?? '') {
                                    case 'languageCode':
                                        $retVal = $localeObject->getLanguageCode();
                                        break;
                                    case 'countryCode':
                                        $retVal = $localeObject->getCountryCode();
                                        break;
                                    case 'full':
                                    default:
                                        $retVal = $localeObject->getName();
                                }
                            }
                        } else {
                            $config = $siteLanguage->toArray();
                            if (isset($config[$key])) {
                                $retVal = $config[$key];
                            }
                        }
                        break;
                    case 'sitesettings':
                        $siteSettings = $this->getRequest()->getAttribute('site')->getSettings();
                        $retVal = $siteSettings->get($key, '');
                        break;
                    case 'applicationcontext':
                        $retVal = Environment::getContext()->__toString();
                        break;
                }
            }
        }

        return $this->eventDispatcher->dispatch(
            new AfterGetDataResolvedEvent($string, $fieldArray, $retVal, $this)
        )->getResult();
    }

    /**
     * Gets file information. This is a helper function for the getData() method above, which resolves e.g.
     * page.10.data = file:current:title
     * or
     * page.10.data = file:17:title
     *
     * @param string $key A colon-separated key, e.g. 17:name or current:sha1, with the first part being a sys_file uid
     *                    or the keyword "current" and the second part being the key of information to get from
     *                    file (e.g. "title", "size", "description", etc.)
     * @return string|int|null The value as retrieved from the file object.
     */
    protected function getFileDataKey($key)
    {
        [$fileUidOrCurrentKeyword, $requestedFileInformationKey] = GeneralUtility::trimExplode(':', $key, false, 3);
        try {
            if ($fileUidOrCurrentKeyword === 'current') {
                $fileObject = $this->getCurrentFile();
            } elseif (MathUtility::canBeInterpretedAsInteger($fileUidOrCurrentKeyword)) {
                $fileObject = $this->resourceFactory->getFileObject((int)$fileUidOrCurrentKeyword);
            } else {
                $fileObject = null;
            }
        } catch (Exception $exception) {
            $this->logger->warning('The file "{uid}" could not be found and won\'t be included in frontend output', ['uid' => $fileUidOrCurrentKeyword, 'exception' => $exception]);
            $fileObject = null;
        }

        if ($fileObject instanceof FileInterface) {
            // All properties of the \TYPO3\CMS\Core\Resource\FileInterface are available here:
            switch ($requestedFileInformationKey) {
                case 'name':
                    return $fileObject->getName();
                case 'uid':
                    if (method_exists($fileObject, 'getUid')) {
                        return $fileObject->getUid();
                    }
                    return 0;
                case 'originalUid':
                    if ($fileObject instanceof FileReference) {
                        return $fileObject->getOriginalFile()->getUid();
                    }
                    return null;
                case 'size':
                    return $fileObject->getSize();
                case 'sha1':
                    return $fileObject->getSha1();
                case 'extension':
                    return $fileObject->getExtension();
                case 'mimetype':
                    return $fileObject->getMimeType();
                case 'contents':
                    return $fileObject->getContents();
                case 'publicUrl':
                    return $fileObject->getPublicUrl();
                default:
                    // Generic alternative here
                    return $fileObject->getProperty($requestedFileInformationKey);
            }
        } else {
            // @todo fail silently as is common in tslib_content
            return 'Error: no file object';
        }
    }

    /**
     * Returns a value from the current rootline.
     *
     * @param int $key Which level in the root line
     * @param string $field The field in the rootline record to return (a field from the pages table)
     * @param bool $slideBack If set, then we will traverse through the rootline from outer level towards the root level until the value found is TRUE
     * @param mixed $altRootLine If you supply an array for this it will be used as an alternative root line array
     * @return string The value from the field of the rootline.
     */
    protected function rootLineValue($key, $field, $slideBack = false, $altRootLine = ''): string
    {
        if (is_array($altRootLine)) {
            $rootLine = $altRootLine;
        } else {
            $rootLine = $this->getRequest()->getAttribute('frontend.page.information')->getLocalRootLine();
        }
        if (!$slideBack) {
            return $rootLine[$key][$field] ?? '';
        }
        for ($a = $key; $a >= 0; $a--) {
            $val = $rootLine[$a][$field] ?? '';
            if ($val) {
                return $val;
            }
        }
        return '';
    }

    /**
     * Return global variable where the input string $var defines array keys separated by "|"
     * Example: $var = "HTTP_SERVER_VARS | something" will return the value $GLOBALS['HTTP_SERVER_VARS']['something'] value
     *
     * @param string $keyString Global var key, eg. "HTTP_GET_VAR" or "HTTP_GET_VARS|id" to get the GET parameter "id" back.
     * @param array $source Alternative array than $GLOBAL to get variables from.
     * @return float|int|string Whatever value. If none, then blank string.
     */
    public function getGlobal($keyString, $source = null)
    {
        $keys = GeneralUtility::trimExplode('|', $keyString);
        // remove the first key, as this is only used for finding the original value
        $rootKey = array_shift($keys);
        $value = isset($source) ? ($source[$rootKey] ?? '') : ($GLOBALS[$rootKey] ?? '');
        return $this->getValueFromRecursiveData($keys, $value);
    }

    /**
     * This method recursively checks for values in methods, arrays, objects, but
     * does not fall back to $GLOBALS object instead of getGlobal().
     */
    protected function getValueFromRecursiveData(array $keys, mixed $startValue): int|float|string
    {
        $value = $startValue;
        $numberOfLevels = count($keys);
        for ($i = 0; $i < $numberOfLevels && isset($value); $i++) {
            $currentKey = $keys[$i];
            if (is_object($value)) {
                // getter method
                if (method_exists($value, 'get' . ucfirst($currentKey))) {
                    $getterMethod = 'get' . ucfirst($currentKey);
                    $value = $value->$getterMethod(...)();
                    // server request attribute, such as "routing"
                } elseif ($value instanceof ServerRequestInterface) {
                    $value = $value->getAttribute($currentKey);
                } else {
                    // Public property
                    $value = $value->{$currentKey};
                }
            } elseif (is_array($value)) {
                $value = $value[$currentKey] ?? '';
            } else {
                $value = '';
                break;
            }
        }
        if (!is_scalar($value)) {
            $value = '';
        }
        return $value;
    }

    /**
     * Processing of key values pointing to entries in $arr; Here negative values are converted to positive keys pointer
     * to an entry in the array but from behind (based on the negative value).
     * Example: entrylevel = -1 means that entryLevel ends up pointing at the outermost-level, -2 means the level before the outermost
     *
     * @param int $key The integer to transform
     * @param array $arr array in which the key should be found.
     * @return int The processed integer key value.
     * @internal
     */
    public function getKey($key, array $arr): int
    {
        $key = (int)$key;
        if ($key < 0) {
            $key = count($arr) + $key;
        }
        if ($key < 0) {
            $key = 0;
        }
        return $key;
    }

    /**
     * Implements the "typolink" property of stdWrap (and others)
     * Basically the input string, $linkText, is (typically) wrapped in a <a>-tag linking to some page, email address,
     * file or URL based on a parameter defined by the configuration array $conf.
     * This function is best used from internal functions as is. There are some API functions defined after this
     * function which is more suited for general usage in external applications.
     *
     * Generally the concept "typolink" should be used in your own applications as an API for making links to pages with
     * parameters and more. The reason for this is that you will then automatically make links compatible with all the
     * centralized functions for URL simulation and manipulation of parameters into hashes and more.
     *
     * For many more details on the parameters and how they are interpreted, please see the link to TSref below.
     *
     * @param string $linkText The string (text) to link
     * @param array $conf TypoScript configuration (see link below)
     * @return string|LinkResult A link-wrapped string.
     * @see stdWrap()
     */
    public function typoLink(string $linkText, array $conf)
    {
        try {
            $linkResult = $this->createLink($linkText, $conf);
        } catch (UnableToLinkException $e) {
            return $e->getLinkText();
        }

        // If flag "returnLast" set, then just return the latest URL / url / target that was built.
        // This returns the information without being wrapped in a "LinkResult" object.
        switch ($conf['returnLast'] ?? null) {
            case 'url':
                return $linkResult->getUrl();
            case 'target':
                return $linkResult->getTarget();
            case 'result':
                // kept for backwards-compatibility, as this was added in TYPO3 v11
                return LinkResult::adapt($linkResult, LinkResult::STRING_CAST_JSON);
        }

        $wrap = (string)$this->stdWrapValue('wrap', $conf);
        if ($conf['ATagBeforeWrap'] ?? false) {
            $linkResult = $linkResult->withLinkText($this->wrap((string)$linkResult->getLinkText(), $wrap));
            return LinkResult::adapt($linkResult)->getHtml();
        }
        $result = LinkResult::adapt($linkResult)->getHtml();
        return $this->wrap($result, $wrap);
    }

    /**
     * Similar to ->typoLink(), however it does not evaluate the .wrap and .ATagBeforeWrap
     * functionality.
     *
     * For this reason, it also does not consider the LinkResult functionality,
     * and "returnLast" logic, as the whole LinkResult object is available.
     *
     * It is recommended to use this method when working with PHP and wanting to create
     * a typolink, but be aware that you need to escape the Link yourself as PHP developer depending
     * on the needs.
     *
     * @param string $linkText the text to be wrapped in a link
     * @param array $conf the typolink configuration
     * @throws UnableToLinkException
     * @see typoLink()
     * @see createUrl()
     */
    public function createLink(string $linkText, array $conf): LinkResultInterface
    {
        $this->lastTypoLinkResult = null;
        $linkResult = $this->linkFactory->create($linkText, $conf, $this);
        $this->lastTypoLinkResult = $linkResult;
        return $linkResult;
    }

    /**
     * This method creates a typoLink() and just returns the information of the "href" attribute
     * of the link (most of the time, this is the URL).
     *
     * @param array $conf the typolink configuration.
     * @return string The URL
     * @see typoLink()
     * @see createLink()
     */
    public function createUrl(array $conf): string
    {
        try {
            return $this->createLink('', $conf)->getUrl();
        } catch (UnableToLinkException) {
            // @todo: Inconsistent. createLink() throws it, but createUrl() eats it?
            // URL could not be generated
            return '';
        }
    }

    /**
     * Based on the input "TypoLink" TypoScript configuration this will return the generated URL
     *
     * @param array $conf TypoScript properties for "typolink"
     * @return string The URL of the link-tag that typoLink() would by itself return
     */
    public function typoLink_URL($conf): string
    {
        return $this->createUrl($conf);
    }

    /**
     * Wrapping a string.
     * Implements the TypoScript "wrap" property.
     * Example: $content = "HELLO WORLD" and $wrap = "<strong> | </strong>", result: "<strong>HELLO WORLD</strong>"
     *
     * @param string $content The content to wrap
     * @param string $wrap The wrap value, eg. "<strong> | </strong>
     * @param string $char The char used to split the wrapping value, default is "|
     * @return string Wrapped input string
     * @see noTrimWrap()
     */
    public function wrap($content, $wrap, $char = '|')
    {
        if ($wrap) {
            $wrapArr = explode($char, $wrap);
            $content = trim($wrapArr[0]) . $content . trim($wrapArr[1] ?? '');
        }
        return $content;
    }

    /**
     * Wrapping a string, preserving whitespace in wrap value.
     * Notice that the wrap value uses part 1/2 to wrap (and not 0/1 which wrap() does)
     *
     * @param string $content The content to wrap, eg. "HELLO WORLD
     * @param string $wrap The wrap value, eg. " | <strong> | </strong>
     * @param string $char The char used to split the wrapping value, default is "|"
     * @return string Wrapped input string, eg. " <strong> HELLO WORD </strong>
     */
    public function noTrimWrap($content, $wrap, $char = '|')
    {
        if ($wrap) {
            // expects to be wrapped with (at least) 3 characters (before, middle, after)
            // anything else is not taken into account
            $wrapArr = explode($char, $wrap, 4);
            $content = ($wrapArr[1] ?? '') . $content . ($wrapArr[2] ?? '');
        }
        return $content;
    }

    /**
     * Call a user function/class-method
     *
     * @param string|RawValue $funcName The functionname, eg "user_myfunction" or "user_myclass->main". Notice that there
     *                                  are rules for the names of functions/classes you can instantiate. If a function cannot
     *                                  be called for some reason it will be seen in the TypoScript log in the AdminPanel.
     * @param array $conf The TypoScript configuration to pass the function
     * @param mixed $content The content payload to pass the function
     * @return mixed The return content from the function call. Should probably be a string.
     */
    public function callUserFunction(string|RawValue $funcName, $conf, $content)
    {
        if ($funcName instanceof RawValue) {
            $isTrusted = $funcName->trusted;
            $funcName = $funcName->value;
        } else {
            $isTrusted = false;
        }
        $invokableAssertion = GeneralUtility::makeInstance(AllowedCallableAssertion::class);
        // Split parts
        $parts = explode('->', $funcName);
        if (count($parts) === 2) {
            // Check whether PHP class is available
            if (class_exists($parts[0])) {
                if (!$isTrusted) {
                    $invokableAssertion->assertCallable($parts);
                }
                if ($this->container->has($parts[0])) {
                    $classObj = $this->container->get($parts[0]);
                } else {
                    $classObj = GeneralUtility::makeInstance($parts[0]);
                }
                $methodName = $parts[1];
                $callable = [$classObj, $methodName];

                if (is_object($classObj) && method_exists($classObj, $parts[1]) && is_callable($callable)) {
                    if (is_callable([$classObj, 'setContentObjectRenderer'])) {
                        $classObj->setContentObjectRenderer($this);
                    }
                    $content = $callable($content, $conf, $this->getRequest()->withAttribute('currentContentObject', $this));
                } else {
                    $this->timeTracker->setTSlogMessage('Method "' . $parts[1] . '" did not exist in class "' . $parts[0] . '"', LogLevel::ERROR);
                }
            } else {
                $this->timeTracker->setTSlogMessage('Class "' . $parts[0] . '" did not exist', LogLevel::ERROR);
            }
        } elseif (function_exists($funcName)) {
            if (!$isTrusted) {
                $invokableAssertion->assertCallable($funcName);
            }
            $content = $funcName($content, $conf, $this->getRequest()->withAttribute('currentContentObject', $this));
        } else {
            $this->timeTracker->setTSlogMessage('Function "' . $funcName . '" did not exist', LogLevel::ERROR);
        }
        return $content;
    }

    /**
     * Cleans up a string of keywords. Keywords are split by "," (comma)  ";" (semicolon) and linebreak
     *
     * @param string $content String of keywords
     * @return string Cleaned up string, keywords will be separated by a comma only.
     */
    public function keywords($content): string
    {
        $listArr = preg_split('/[,;' . LF . ']/', $content);
        if ($listArr === false) {
            return '';
        }
        foreach ($listArr as $k => $v) {
            $listArr[$k] = trim($v);
        }
        return implode(',', $listArr);
    }

    /**
     * Changing character case of a string, converting typically used western charset characters as well.
     *
     * @param string $theValue The string to change case for.
     * @param string $case The direction; either "upper" or "lower
     * @return string
     * @internal
     */
    public function caseshift($theValue, $case)
    {
        switch (strtolower($case)) {
            case 'upper':
                $theValue = mb_strtoupper($theValue, 'utf-8');
                break;
            case 'lower':
                $theValue = mb_strtolower($theValue, 'utf-8');
                break;
            case 'capitalize':
                $theValue = mb_convert_case($theValue, MB_CASE_TITLE, 'utf-8');
                break;
            case 'ucfirst':
                $firstChar = mb_substr($theValue, 0, 1, 'utf-8');
                $firstChar = mb_strtoupper($firstChar, 'utf-8');
                $remainder = mb_substr($theValue, 1, null, 'utf-8');
                $theValue = $firstChar . $remainder;
                break;
            case 'lcfirst':
                $firstChar = mb_substr($theValue, 0, 1, 'utf-8');
                $firstChar = mb_strtolower($firstChar, 'utf-8');
                $remainder = mb_substr($theValue, 1, null, 'utf-8');
                $theValue = $firstChar . $remainder;
                break;
            case 'uppercamelcase':
                $theValue = GeneralUtility::underscoredToUpperCamelCase($theValue);
                break;
            case 'lowercamelcase':
                $theValue = GeneralUtility::underscoredToLowerCamelCase($theValue);
                break;
        }
        return $theValue;
    }

    /**
     * Shifts the case of characters outside of HTML tags in the input string
     *
     * @param string $theValue The string to change case for.
     * @param string $case The direction; either "upper" or "lower"
     * @internal
     */
    public function HTMLcaseshift($theValue, $case): string
    {
        $inside = 0;
        $newVal = '';
        $pointer = 0;
        $totalLen = strlen($theValue);
        do {
            if (!$inside) {
                $len = strcspn(substr($theValue, $pointer), '<');
                $newVal .= $this->caseshift(substr($theValue, $pointer, $len), $case);
                $inside = 1;
            } else {
                $len = strcspn(substr($theValue, $pointer), '>') + 1;
                $newVal .= substr($theValue, $pointer, $len);
                $inside = 0;
            }
            $pointer += $len;
        } while ($pointer < $totalLen);
        return $newVal;
    }

    /**
     * Returns the 'age' of the tstamp $seconds
     *
     * @param int $seconds Seconds to return age for. Example: "70" => "1 min", "3601" => "1 hrs
     * @param string|int|null $labels The labels of the individual units. Defaults to : ' min| hrs| days| yrs'
     */
    public function calcAge($seconds, $labels = null): string
    {
        $now = DateTimeFactory::createFromTimestamp($GLOBALS['EXEC_TIME']);
        $then = DateTimeFactory::createFromTimestamp($GLOBALS['EXEC_TIME'] - $seconds);
        // Show past dates without a leading sign, but future dates with.
        // This does not make sense, but is kept for legacy reasons.
        $sign = $then > $now ? '-' : '';
        // Take an absolute diff, since we don't want formatDateInterval to output the (correct) sign
        $diff = $now->diff($then, true);
        $labels = ($labels === null || MathUtility::canBeInterpretedAsInteger($labels)) ? 'min|hrs|days|yrs|min|hour|day|year' : str_replace('"', '', $labels);
        return $sign . (new DateFormatter())->formatDateInterval($diff, $labels);
    }

    /**
     * Resolve a TypoScript reference value to the full set of properties BUT overridden with any local properties set.
     * So the reference is resolved but overlaid with local TypoScript properties of the reference value.
     *
     * In short: This parses the "=<" operator for a couple of special properties like "parseFunc" and "tt_content.*".
     *
     * Note the "=<" operator is not a general TypoScript language construct, but applied here for a couple
     * of special objects only.
     *
     * @param array $typoScriptArray The TypoScript array: ['someProperty' => 'somePropertyValue', 'someProperty.' => [ 'someSubProperty' => 'someSubValue', ... ]
     * @param string $propertyName The property name: If this value in $typoScriptArray[$prop] is a reference (eg. "< lib.contentElement"), then
     *                             the reference will be retrieved and inserted at that position and overlaid with given local properties if any.
     * @return array The modified TypoScript array with resolved "=<" reference operator
     * @internal
     * @todo: It would be better if this method would get the setup object tree to resolve a
     *        ReferenceChildNode only once per node. This would however mean the object tree
     *        is moved around in the entire rendering chain, which is quite hard to achieve.
     */
    public function mergeTSRef(array $typoScriptArray, string $propertyName): array
    {
        if (!isset($typoScriptArray[$propertyName]) || !str_starts_with($typoScriptArray[$propertyName], '<')) {
            return $typoScriptArray;
        }
        $frontendTypoScript = $this->getRequest()->getAttribute('frontend.typoscript');
        if (!$frontendTypoScript || !$frontendTypoScript->hasSetup()) {
            return $typoScriptArray;
        }
        $fullTypoScriptArray = $frontendTypoScript->getSetupArray();
        $dottedSourceIdentifier = trim(substr($typoScriptArray[$propertyName], 1));
        $dottedSourceIdentifierArray = StringUtility::explodeEscaped('.', $dottedSourceIdentifier);
        $overrideConfig = $typoScriptArray[$propertyName . '.'] ?? [];
        $resolvedValue = $dottedSourceIdentifier;
        $resolvedConfig = $fullTypoScriptArray;
        foreach ($dottedSourceIdentifierArray as $identifierPart) {
            if (!isset($resolvedConfig[$identifierPart . '.'])) {
                $resolvedValue = $dottedSourceIdentifier;
                $resolvedConfig = $overrideConfig;
                break;
            }
            $resolvedValue = $resolvedConfig[$identifierPart] ?? $resolvedValue;
            $resolvedConfig = $resolvedConfig[$identifierPart . '.'];
        }
        $resolvedConfig = array_replace_recursive($resolvedConfig, $overrideConfig);
        $typoScriptArray[$propertyName] = $resolvedValue;
        $typoScriptArray[$propertyName . '.'] = $resolvedConfig;
        if (!isset($typoScriptArray[$propertyName]) || !str_starts_with($typoScriptArray[$propertyName], '<')) {
            return $typoScriptArray;
        }
        // Call recursive to resolve a nested =< operator
        return $this->mergeTSRef($typoScriptArray, $propertyName);
    }

    /**
     * Generates a search where clause based on the input search words (AND operation - all search words must be found in record.)
     * Example: The $sw is "content management, system" (from an input form) and the $searchFieldList is "bodytext,header" then
     * the output will be ' AND (bodytext LIKE "%content%" OR header LIKE "%content%") AND (bodytext LIKE "%management%" OR header
     * LIKE "%management%") AND (bodytext LIKE "%system%" OR header LIKE "%system%")'
     *
     * @param string $searchWords The search words. These will be separated by space and comma.
     * @param string $searchFieldList The fields to search in
     * @param string $searchTable The table name you search in (recommended for DBAL compliance. Will be prepended field names as well)
     * @return string The WHERE clause.
     */
    public function searchWhere($searchWords, $searchFieldList, $searchTable): string
    {
        if (!$searchWords) {
            return '';
        }

        $prefixTableName = $searchTable ? $searchTable . '.' : '';
        $queryBuilder = $this->connectionPool->getQueryBuilderForTable($searchTable);
        $where = $queryBuilder->expr()->and();
        $searchFields = explode(',', $searchFieldList);
        $searchWords = preg_split('/[ ,]/', $searchWords);
        foreach ($searchWords as $searchWord) {
            $searchWord = trim($searchWord);
            if (strlen($searchWord) < 3) {
                continue;
            }
            $searchWordConstraint = $queryBuilder->expr()->or();
            $searchWord = $queryBuilder->escapeLikeWildcards($searchWord);
            foreach ($searchFields as $field) {
                $searchWordConstraint = $searchWordConstraint->with(
                    $queryBuilder->expr()->like($prefixTableName . $field, $queryBuilder->quote('%' . $searchWord . '%'))
                );
            }

            if ($searchWordConstraint->count()) {
                $where = $where->with($searchWordConstraint);
            }
        }

        if ((string)$where === '') {
            return '';
        }

        return ' AND (' . $where . ')';
    }

    /**
     * Executes a SELECT query for records from $table and with conditions based on the configuration in the $conf array
     * This function is preferred over ->getQuery() if you just need to create and then execute a query.
     *
     * @param string $table The table name
     * @param array $conf The TypoScript configuration properties
     */
    public function exec_getQuery($table, $conf): Result
    {
        $connection = $this->connectionPool->getConnectionForTable($table);
        $statement = $this->getQuery($connection, $table, $conf);
        return $connection->executeQuery($statement);
    }

    /**
     * Executes a SELECT query for records from $table and with conditions based on the configuration in the $conf array
     * and overlays with translation and version if available
     *
     * @param string $tableName the name of the TCA database table
     * @param array $queryConfiguration The TypoScript configuration properties, see .select in TypoScript reference
     * @throws \UnexpectedValueException
     */
    public function getRecords($tableName, array $queryConfiguration): array
    {
        $records = [];
        $statement = $this->exec_getQuery($tableName, $queryConfiguration);
        $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
        while ($row = $statement->fetchAssociative()) {
            // Versioning preview:
            $pageRepository->versionOL($tableName, $row, true);
            // Language overlay:
            if (is_array($row)) {
                $row = $pageRepository->getLanguageOverlay($tableName, $row);
            }
            // Might be unset in the language overlay
            if (is_array($row)) {
                $records[] = $row;
            }
        }
        if ($this->autoTagging) {
            $cacheTags = array_map(fn(array $record) => new CacheTag(
                name: sprintf('%s_%s', $tableName, ($record['uid'] ?? 0)),
                lifetime: $this->cacheLifetimeCalculator->calculateLifetimeForRow($tableName, $record)
            ), $records);
            $this->getRequest()->getAttribute('frontend.cache.collector')?->addCacheTags(...$cacheTags);
        }
        return $records;
    }

    /**
     * Creates and returns a SELECT query for records from $table and with conditions based on the
     * configuration in the $conf array. Implements the "select" function in TypoScript.
     *
     * @param string $table See ->exec_getQuery()
     * @param array $conf See ->exec_getQuery()
     * @throws \RuntimeException
     * @throws \InvalidArgumentException
     * @internal
     */
    public function getQuery(Connection $connection, string $table, array $conf): string
    {
        // Resolve stdWrap in these properties first
        $properties = [
            'pidInList',
            'uidInList',
            'languageField',
            'selectFields',
            'max',
            'begin',
            'groupBy',
            'orderBy',
            'join',
            'leftjoin',
            'rightjoin',
            'recursive',
            'where',
        ];
        foreach ($properties as $property) {
            $conf[$property] = trim(
                isset($conf[$property . '.'])
                    ? (string)$this->stdWrap($conf[$property] ?? '', $conf[$property . '.'] ?? [])
                    : (string)($conf[$property] ?? '')
            );
            if ($conf[$property] === '') {
                unset($conf[$property]);
            } elseif (in_array($property, ['languageField', 'selectFields', 'join', 'leftjoin', 'rightjoin', 'where'], true)) {
                $conf[$property] = QueryHelper::quoteDatabaseIdentifiers($connection, $conf[$property]);
            }
            if (isset($conf[$property . '.'])) {
                // stdWrapping already done, so remove the sub-array
                unset($conf[$property . '.']);
            }
        }
        // Handle PDO-style named parameter markers first
        $queryMarkers = $this->getQueryMarkers($connection, $conf);
        // Replace the markers in the non-stdWrap properties
        foreach ($queryMarkers as $marker => $markerValue) {
            $properties = [
                'uidInList',
                'selectFields',
                'where',
                'max',
                'begin',
                'groupBy',
                'orderBy',
                'join',
                'leftjoin',
                'rightjoin',
            ];
            foreach ($properties as $property) {
                if ($conf[$property] ?? false) {
                    $conf[$property] = str_replace('###' . $marker . '###', $markerValue, $conf[$property]);
                }
            }
        }

        // Construct WHERE clause:
        // Handle recursive function for the pidInList
        if (isset($conf['recursive'])) {
            $conf['recursive'] = (int)$conf['recursive'];
            if ($conf['recursive'] > 0) {
                $pidList = GeneralUtility::trimExplode(',', $conf['pidInList'], true);
                array_walk($pidList, function (&$storagePid) {
                    if ($storagePid === 'this') {
                        $storagePid = $this->getRequest()->getAttribute('frontend.page.information')->getId();
                    }
                });
                $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
                $expandedPidList = $pageRepository->getPageIdsRecursive($pidList, $conf['recursive']);
                $conf['pidInList'] = implode(',', $expandedPidList);
            }
        }
        if ((string)($conf['pidInList'] ?? '') === '') {
            $conf['pidInList'] = 'this';
        }

        $queryParts = $this->getQueryConstraints($connection, $table, $conf);

        $queryBuilder = $connection->createQueryBuilder();
        // @todo Check against getQueryConstraints, can probably use FrontendRestrictions
        // @todo here and remove enableFields there.
        $queryBuilder->getRestrictions()->removeAll();
        $queryBuilder->select('*')->from($table);

        if ($queryParts['where'] ?? false) {
            $queryBuilder->where($queryParts['where']);
        }

        if ($queryParts['groupBy'] ?? false) {
            $queryBuilder->groupBy(...$queryParts['groupBy']);
        }

        if (is_array($queryParts['orderBy'] ?? false)) {
            foreach ($queryParts['orderBy'] as $orderBy) {
                $queryBuilder->addOrderBy(...$orderBy);
            }
        }

        // Fields:
        if ($conf['selectFields'] ?? false) {
            $queryBuilder->selectLiteral($this->sanitizeSelectPart($connection, $conf['selectFields'], $table));
        }

        // Setting LIMIT:
        if (($conf['max'] ?? false) || ($conf['begin'] ?? false)) {
            // Finding the total number of records, if used:
            if (str_contains(strtolower(($conf['begin'] ?? '') . ($conf['max'] ?? '')), 'total')) {
                $countQueryBuilder = $connection->createQueryBuilder();
                $countQueryBuilder->getRestrictions()->removeAll();
                $countQueryBuilder->count('*')
                    ->from($table)
                    ->where($queryParts['where']);

                if (is_array($queryParts['groupBy'])) {
                    $countQueryBuilder->groupBy(...$queryParts['groupBy']);
                }

                try {
                    $count = $countQueryBuilder->executeQuery()->fetchOne();
                    if (isset($conf['max'])) {
                        $conf['max'] = str_ireplace('total', $count, (string)$conf['max']);
                    }
                    if (isset($conf['begin'])) {
                        $conf['begin'] = str_ireplace('total', $count, (string)$conf['begin']);
                    }
                } catch (DBALException $e) {
                    $this->timeTracker->setTSlogMessage($e->getMessage());
                    return '';
                }
            }

            if (isset($conf['begin']) && $conf['begin'] > 0) {
                $conf['begin'] = MathUtility::forceIntegerInRange((int)ceil($this->calc($conf['begin'])), 0);
                $queryBuilder->setFirstResult($conf['begin']);
            }
            if (isset($conf['max'])) {
                $conf['max'] = MathUtility::forceIntegerInRange((int)ceil($this->calc($conf['max'])), 0);
                $queryBuilder->setMaxResults($conf['max'] ?: 100000);
            }
        }

        // Setting up tablejoins:
        if ($conf['join'] ?? false) {
            $joinParts = QueryHelper::parseJoin($conf['join']);
            $queryBuilder->join(
                $table,
                $joinParts['tableName'],
                $joinParts['tableAlias'],
                $joinParts['joinCondition']
            );
        } elseif ($conf['leftjoin'] ?? false) {
            $joinParts = QueryHelper::parseJoin($conf['leftjoin']);
            $queryBuilder->leftJoin(
                $table,
                $joinParts['tableName'],
                $joinParts['tableAlias'],
                $joinParts['joinCondition']
            );
        } elseif ($conf['rightjoin'] ?? false) {
            $joinParts = QueryHelper::parseJoin($conf['rightjoin']);
            $queryBuilder->rightJoin(
                $table,
                $joinParts['tableName'],
                $joinParts['tableAlias'],
                $joinParts['joinCondition']
            );
        }

        // Convert the QueryBuilder object into a SQL statement.
        $query = $queryBuilder->getSQL();

        // Replace the markers in the queryParts to handle stdWrap enabled properties
        foreach ($queryMarkers as $marker => $markerValue) {
            // @todo Ugly hack that needs to be cleaned up, with the current architecture
            // @todo for exec_Query / getQuery it's the best we can do.
            $query = str_replace('###' . $marker . '###', $markerValue, $query);
        }
        return $query;
    }

    /**
     * Helper function for getQuery(), creating the WHERE clause of the SELECT query
     *
     * @param string $table The table name
     * @param array $conf The TypoScript configuration properties
     * @return array Associative array containing the prepared data for WHERE, ORDER BY and GROUP BY fragments
     * @see getQuery()
     */
    protected function getQueryConstraints(Connection $connection, string $table, array $conf): array
    {
        $queryBuilder = $connection->createQueryBuilder();
        $expressionBuilder = $queryBuilder->expr();
        $request = $this->getRequest();
        $contentPid = $request->getAttribute('frontend.page.information')->getContentFromPid();
        $constraints = [];
        $pid_uid_flag = 0;
        $enableFieldsIgnore = [];
        $queryParts = [
            'where' => null,
            'groupBy' => null,
            'orderBy' => null,
        ];

        $isInWorkspace = $this->context->getPropertyFromAspect('workspace', 'isOffline');

        if (trim($conf['uidInList'] ?? '')) {
            $listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $conf['uidInList']));

            // If moved records shall be considered, select via t3ver_oid
            $considerMovePointers = $isInWorkspace && $table !== 'pages' && $this->getTcaSchema($table)?->isWorkspaceAware();
            if ($considerMovePointers) {
                $constraints[] = (string)$expressionBuilder->or(
                    $expressionBuilder->in($table . '.uid', $listArr),
                    $expressionBuilder->and(
                        $expressionBuilder->eq(
                            $table . '.t3ver_state',
                            VersionState::MOVE_POINTER->value
                        ),
                        $expressionBuilder->in($table . '.t3ver_oid', $listArr)
                    )
                );
            } else {
                $constraints[] = (string)$expressionBuilder->in($table . '.uid', $listArr);
            }
            $pid_uid_flag++;
        }

        // Static_* tables are allowed to be fetched from root page
        if (str_starts_with($table, 'static_')) {
            $pid_uid_flag++;
        }

        if (trim($conf['pidInList'])) {
            $listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $conf['pidInList']));
            // Removes all pages which are not visible for the user!
            $listArr = $this->checkPidArray($listArr);
            if (GeneralUtility::inList($conf['pidInList'], 'root')) {
                $listArr[] = 0;
            }
            if (GeneralUtility::inList($conf['pidInList'], '-1')) {
                $listArr[] = -1;
                $enableFieldsIgnore['pid'] = true;
            }
            if (!empty($listArr)) {
                $constraints[] = $expressionBuilder->in($table . '.pid', array_map('intval', $listArr));
                $pid_uid_flag++;
            } else {
                // If not uid and not pid then uid is set to 0 - which results in nothing!!
                $pid_uid_flag = 0;
            }
        }

        // If not uid and not pid then uid is set to 0 - which results in nothing!!
        if (!$pid_uid_flag) {
            $constraints[] = $expressionBuilder->eq($table . '.uid', 0);
        }

        $where = trim((string)$this->stdWrapValue('where', $conf));
        if ($where) {
            $constraints[] = QueryHelper::stripLogicalOperatorPrefix($where);
        }

        // Check if the default language should be fetched (= doing overlays), or if only the records of a language should be fetched
        // but only do this for TCA tables that have languages enabled
        $languageConstraint = $this->getLanguageRestriction($expressionBuilder, $table, $conf);
        if ($languageConstraint !== null) {
            $constraints[] = $languageConstraint;
        }

        // default constraints from TCA
        $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
        $constraints = array_merge($constraints, array_values($pageRepository->getDefaultConstraints($table, $enableFieldsIgnore)));

        // MAKE WHERE:
        if ($constraints !== []) {
            $queryParts['where'] = $expressionBuilder->and(...$constraints);
        }
        // GROUP BY
        $groupBy = trim((string)$this->stdWrapValue('groupBy', $conf));
        if ($groupBy) {
            $queryParts['groupBy'] = QueryHelper::parseGroupBy($groupBy);
        }

        // ORDER BY
        $orderByString = trim((string)$this->stdWrapValue('orderBy', $conf));
        if ($orderByString) {
            $queryParts['orderBy'] = QueryHelper::parseOrderBy($orderByString);
        }

        // Return result:
        return $queryParts;
    }

    /**
     * Adds parts to the WHERE clause that are related to language.
     * This only works on TCA tables which have the [ctrl][languageField] field set or if they
     * have select.languageField = my_language_field set explicitly.
     *
     * It is also possible to disable the language restriction for a query by using select.languageField = 0,
     * if select.languageField is not explicitly set, the TCA default values are taken.
     *
     * If the table is "localizeable" (= any of the criteria above is met), then the DB query is restricted:
     *
     * If the current language aspect has overlays enabled, then the only records with language "0" or "-1" are
     * fetched (the overlays are taken care of later-on).
     * if the current language has overlays but also records without localization-parent (free mode) available,
     * then these are fetched as well. This can explicitly set via select.includeRecordsWithoutDefaultTranslation = 1
     * which overrules the overlayType within the language aspect.
     *
     * If the language aspect has NO overlays enabled, it behaves as in "free mode" (= only fetch the records
     * for the current language.
     */
    protected function getLanguageRestriction(ExpressionBuilder $expressionBuilder, string $table, array $conf): string|CompositeExpression|null
    {
        $languageField = '';
        $localizationParentField = '';
        $languageCapability = null;
        $schema = $this->getTcaSchema($table);
        if ($schema?->isLanguageAware()) {
            $languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
            $localizationParentField = $languageCapability->getTranslationOriginPointerField()->getName();
        }
        // Check if the table is translatable, and set the language field by default from the TCA information
        if (!empty($conf['languageField']) || !isset($conf['languageField'])) {
            if (isset($conf['languageField']) && $schema?->hasField($conf['languageField'])) {
                $languageField = $conf['languageField'];
            } elseif ($languageCapability) {
                $languageField = $table . '.' . $languageCapability->getLanguageField()->getName();
            }
        }

        // No language restriction enabled explicitly or available via TCA
        if (empty($languageField)) {
            return null;
        }

        /** @var LanguageAspect $languageAspect */
        $languageAspect = $this->context->getAspect('language');
        if ($languageAspect->doOverlays() && !empty($localizationParentField)) {
            // Sys language content is set to zero/-1 - and it is expected that whatever routine processes the output will
            // OVERLAY the records with localized versions!
            $languageQuery = $expressionBuilder->in($languageField, [0, -1]);
            // Use this option to include records that don't have a default language counterpart ("free mode")
            // (originalpointerfield is 0 and the language field contains the requested language)
            if (isset($conf['includeRecordsWithoutDefaultTranslation']) || !empty($conf['includeRecordsWithoutDefaultTranslation.'])) {
                $includeRecordsWithoutDefaultTranslation = isset($conf['includeRecordsWithoutDefaultTranslation.'])
                    ? $this->stdWrap($conf['includeRecordsWithoutDefaultTranslation'], $conf['includeRecordsWithoutDefaultTranslation.'])
                    : $conf['includeRecordsWithoutDefaultTranslation'];
                $includeRecordsWithoutDefaultTranslation = trim((string)$includeRecordsWithoutDefaultTranslation);
                $includeRecordsWithoutDefaultTranslation = $includeRecordsWithoutDefaultTranslation !== '' && $includeRecordsWithoutDefaultTranslation !== '0';
            } else {
                // Option was not explicitly set, check what's in for the language overlay type.
                // OVERLAYS_ON means that we do not include the "floating" records (records without default translation)
                $includeRecordsWithoutDefaultTranslation = $languageAspect->getOverlayType() !== $languageAspect::OVERLAYS_ON;
            }
            if ($includeRecordsWithoutDefaultTranslation) {
                $languageQuery = $expressionBuilder->or(
                    $languageQuery,
                    $expressionBuilder->and(
                        $expressionBuilder->eq($table . '.' . $localizationParentField, 0),
                        $expressionBuilder->eq($languageField, $languageAspect->getContentId())
                    )
                );
            }
            return $languageQuery;
        }
        // No overlays = only fetch records given for the requested language and "all languages"
        return $expressionBuilder->in($languageField, [$languageAspect->getContentId(), -1]);
    }

    /**
     * Helper function for getQuery, sanitizing the select part
     *
     * This functions checks if the necessary fields are part of the select
     * and adds them if necessary.
     *
     * @see getQuery
     */
    protected function sanitizeSelectPart(Connection $connection, string $selectPart, string $table): string
    {
        // Pattern matching parts
        $matchStart = '/(^\\s*|,\\s*|' . $table . '\\.)';
        $matchEnd = '(\\s*,|\\s*$)/';
        $necessaryFields = ['uid', 'pid'];
        $wsFields = ['t3ver_state'];
        $schema = $this->getTcaSchema($table);
        if ($schema === null) {
            return $selectPart;
        }

        if (!preg_match($matchStart . '\\*' . $matchEnd, $selectPart) && !preg_match('/(count|max|min|avg|sum)\\([^\\)]+\\)|distinct/i', $selectPart)) {
            foreach ($necessaryFields as $field) {
                $match = $matchStart . $field . $matchEnd;
                if (!preg_match($match, $selectPart)) {
                    $selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $field) . ' AS ' . $connection->quoteIdentifier($field);
                }
            }
            if ($schema->isLanguageAware()) {
                $languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
                $languageField = $languageCapability->getLanguageField()->getName();
                $match = $matchStart . $languageField . $matchEnd;
                if (!preg_match($match, $selectPart)) {
                    $selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $languageField) . ' AS ' . $connection->quoteIdentifier($languageField);
                }
            }
            if ($schema->isWorkspaceAware()) {
                foreach ($wsFields as $field) {
                    $match = $matchStart . $field . $matchEnd;
                    if (!preg_match($match, $selectPart)) {
                        $selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $field) . ' AS ' . $connection->quoteIdentifier($field);
                    }
                }
            }
        }
        return $selectPart;
    }

    /**
     * Removes Page UID numbers from the input array which are not available due to enableFields() or the list of bad doktype numbers ($this->checkPid_badDoktypeList)
     *
     * @param int[] $pageIds Array of Page UID numbers for select and for which pages with enablefields and bad doktypes should be removed.
     * @return array Returns the array of remaining page UID numbers
     * @internal
     */
    public function checkPidArray(array $pageIds): array
    {
        if ($pageIds === []) {
            return [];
        }

        if ($pageIds === [$this->getRequest()->getAttribute('frontend.page.information')->getId()]) {
            // Middlewares already checked access to the current page and made sure the current doktype
            // is a doktype whose content should be rendered, so there is no need to check that again.
            return $pageIds;
        }
        $restrictionContainer = GeneralUtility::makeInstance(FrontendRestrictionContainer::class);
        if ($this->checkPid_badDoktypeList) {
            // @deprecated: Remove branch in TYPO3 v15.
            trigger_error(
                'ContentObjectRenderer::$checkPid_badDoktypeList is deprecated since TYPO3 v14 and will be removed in TYPO3 v15.',
                E_USER_DEPRECATED
            );
            $restrictionContainer->add(GeneralUtility::makeInstance(
                DocumentTypeExclusionRestriction::class,
                GeneralUtility::intExplode(',', (string)$this->checkPid_badDoktypeList, true)
            ));
        }
        $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
        return $pageRepository->filterAccessiblePageIds($pageIds, $restrictionContainer);
    }

    /**
     * Builds list of marker values for handling PDO-like parameter markers in select parts.
     * Marker values support stdWrap functionality thus allowing a way to use stdWrap functionality in various
     * properties of 'select' AND prevents SQL-injection problems by quoting and escaping of numeric values,
     * strings, NULL values and comma separated lists.
     *
     * @param array $conf Select part of CONTENT definition
     * @return array List of values to replace markers with
     * @internal
     * @see getQuery()
     */
    public function getQueryMarkers(Connection $connection, array $conf): array
    {
        if (!isset($conf['markers.']) || !is_array($conf['markers.'])) {
            return [];
        }
        $markerValues = [];
        foreach ($conf['markers.'] as $dottedMarker => $dummy) {
            $marker = rtrim($dottedMarker, '.');
            if ($dottedMarker != $marker . '.') {
                continue;
            }
            // Parse definition
            // todo else value is always null
            $tempValue = isset($conf['markers.'][$dottedMarker])
                ? $this->stdWrap($conf['markers.'][$dottedMarker]['value'] ?? '', $conf['markers.'][$dottedMarker])
                : $conf['markers.'][$dottedMarker]['value'];
            // Quote/escape if needed
            if (is_numeric($tempValue)) {
                if ((int)$tempValue == $tempValue) {
                    // Handle integer
                    $markerValues[$marker] = (int)$tempValue;
                } else {
                    // Handle float
                    $markerValues[$marker] = (float)$tempValue;
                }
            } elseif ($tempValue === null) {
                // It represents NULL
                $markerValues[$marker] = 'NULL';
            } elseif (!empty($conf['markers.'][$dottedMarker]['commaSeparatedList'])) {
                // See if it is really a comma separated list of values
                $explodeValues = GeneralUtility::trimExplode(',', $tempValue);
                if (count($explodeValues) > 1) {
                    // Handle each element of list separately
                    $tempArray = [];
                    foreach ($explodeValues as $listValue) {
                        if (is_numeric($listValue)) {
                            if ((int)$listValue == $listValue) {
                                $tempArray[] = (int)$listValue;
                            } else {
                                $tempArray[] = (float)$listValue;
                            }
                        } else {
                            // If quoted, remove quotes before
                            // escaping.
                            if (preg_match('/^\'([^\']*)\'$/', $listValue, $matches)) {
                                $listValue = $matches[1];
                            } elseif (preg_match('/^\\"([^\\"]*)\\"$/', $listValue, $matches)) {
                                $listValue = $matches[1];
                            }
                            $tempArray[] = $connection->quote($listValue);
                        }
                    }
                    $markerValues[$marker] = implode(',', $tempArray);
                } else {
                    // Handle remaining values as string
                    $markerValues[$marker] = $connection->quote($tempValue);
                }
            } else {
                // Handle remaining values as string
                $markerValues[$marker] = $connection->quote($tempValue);
            }
        }
        return $markerValues;
    }

    /**
     * Fetch content from cache
     *
     * @return string|false FALSE on cache miss
     */
    protected function getFromCache(array $configuration): string|false
    {
        if (!$this->getRequest()->getAttribute('frontend.cache.instruction')->isCachingAllowed()) {
            return false;
        }
        $cacheKey = $this->calculateCacheKey($configuration);
        if (empty($cacheKey)) {
            return false;
        }
        $cachedData = $this->cacheHash->get($cacheKey);
        if ($cachedData === false) {
            return false;
        }
        $this->getRequest()->getAttribute('frontend.cache.collector')->addCacheTags(
            ...array_map(fn(string $tag) => new CacheTag($tag), $cachedData['cacheTags'])
        );
        return $cachedData['content'] ?? false;
    }

    /**
     * Calculates the lifetime of a cache entry based on the given configuration
     */
    protected function calculateCacheLifetime(array $configuration): int
    {
        $configuration['lifetime'] = $configuration['lifetime'] ?? '';
        $lifetimeConfiguration = (string)$this->stdWrapValue('lifetime', $configuration);

        if (strtolower($lifetimeConfiguration) === 'unlimited') {
            $lifetime = 31536000; // unlimited lifetime - 1 year.
        } elseif (strtolower($lifetimeConfiguration) === 'default') {
            $lifetime = $this->getDefaultCachePeriod(); // default lifetime of config.cache_period or 86400 seconds
        } elseif ($lifetimeConfiguration > 0) {
            $lifetime = (int)$lifetimeConfiguration;
        } else {
            // If no lifetime is specified, we use the default cache period.
            $lifetime = $this->getDefaultCachePeriod();
        }
        return $lifetime;
    }

    /**
     * Returns the default cache period in seconds
     */
    protected function getDefaultCachePeriod(): int
    {
        $frontendTyposcript = $this->getRequest()->getAttribute('frontend.typoscript');
        return (int)($frontendTyposcript->getConfigArray()['cache_period'] ?? 86400);
    }

    /**
     * Calculates the tags for a cache entry bases on the given configuration
     *
     * @return array
     */
    protected function calculateCacheTags(array $configuration)
    {
        $configuration['tags'] = $configuration['tags'] ?? '';
        $tags = (string)$this->stdWrapValue('tags', $configuration);
        return empty($tags) ? [] : GeneralUtility::trimExplode(',', $tags);
    }

    /**
     * Applies stdWrap to the cache key
     *
     * @return string
     */
    protected function calculateCacheKey(array $configuration)
    {
        $configuration['key'] = $configuration['key'] ?? '';
        return $this->stdWrapValue('key', $configuration);
    }

    protected function getTcaSchema(string $table): ?TcaSchema
    {
        return $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null;
    }

    /**
     * Get content length of the current tag that could also contain nested tag contents
     * Helper method of parseFuncInternal().
     */
    protected function getContentLengthOfCurrentTag(string $theValue, int $pointer, string $currentTag): int
    {
        $tempContent = strtolower(substr($theValue, $pointer));
        $startTag = '<' . $currentTag;
        $endTag = '</' . $currentTag . '>';
        $offsetCount = 0;

        // Take care for nested tags
        do {
            $nextMatchingEndTagPosition = strpos($tempContent, $endTag);
            // only match tag `a` in `<a href"...">` but not in `<abbr>`
            $nextSameTypeTagPosition = preg_match(
                '#' . $startTag . '[\s/>]#',
                $tempContent,
                $nextSameStartTagMatches,
                PREG_OFFSET_CAPTURE
            ) ? $nextSameStartTagMatches[0][1] : false;

            // filter out nested tag contents to help getting the correct closing tag
            if ($nextMatchingEndTagPosition !== false && $nextSameTypeTagPosition !== false && $nextSameTypeTagPosition < $nextMatchingEndTagPosition) {
                $lastOpeningTagStartPosition = (int)strrpos(substr($tempContent, 0, $nextMatchingEndTagPosition), $startTag);
                $closingTagEndPosition = $nextMatchingEndTagPosition + strlen($endTag);
                $offsetCount += $closingTagEndPosition - $lastOpeningTagStartPosition;

                // replace content from latest tag start to latest tag end
                $tempContent = substr($tempContent, 0, $lastOpeningTagStartPosition) . substr($tempContent, $closingTagEndPosition);
            }
        } while (
            ($nextMatchingEndTagPosition !== false && $nextSameTypeTagPosition !== false) &&
            $nextSameTypeTagPosition < $nextMatchingEndTagPosition
        );

        // if no closing tag is found we use length of the whole content
        $endingOffset = strlen($tempContent);
        if ($nextMatchingEndTagPosition !== false) {
            $endingOffset = $nextMatchingEndTagPosition + $offsetCount;
        }

        return $endingOffset;
    }

    protected function shallDebug(): bool
    {
        $typoScriptConfigArray = $this->getRequest()->getAttribute('frontend.typoscript')?->getConfigArray();
        if (isset($typoScriptConfigArray['debug'])) {
            return (bool)($typoScriptConfigArray['debug']);
        }
        return !empty($GLOBALS['TYPO3_CONF_VARS']['FE']['debug']);
    }

    /**
     * @todo: This getRequest() is still a bit messy.
     *        Underling code depends on both, a ContentObjectRenderer instance and a request,
     *        but the API currently only passes one or the other. For instance Extbase and Fluid
     *        only pass the Request, DataProcessors only a ContentObjectRenderer.
     *        This is why getRequest() is currently public here.
     *        A potential refactoring could:
     *        * Create interfaces to pass both where needed (or pass a combined context object)
     *        * Deprecate access to getRequest() here afterward
     *        A circular dependency that the instance of ContentObjectRenderer holds a
     *        request with the instance of itself as attribute must be avoided.
     *        This is currently achieved by adding a new request with
     *        $this->request->withAttribute('currentContentObject', $cObj) in code that needs
     *        it, but this new request is NOT passed back into the ContentObjectRenderer instance.
     *
     * @internal
     */
    public function getRequest(): ServerRequestInterface
    {
        if ($this->request instanceof ServerRequestInterface) {
            return $this->request;
        }
        if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface) {
            // @deprecated: Remove fallback in TYPO3 v15. Third-party code must call setRequest() after
            //              instantiation or unserialization before invoking any method that calls getRequest().
            trigger_error(
                'Fallback to $GLOBALS[\'TYPO3_REQUEST\'] in ContentObjectRenderer::getRequest() is deprecated'
                . ' since TYPO3 v14 and will be removed in TYPO3 v15. Call setRequest() after object instantiation.',
                E_USER_DEPRECATED
            );
            return $GLOBALS['TYPO3_REQUEST'];
        }
        throw new ContentRenderingException(
            'PSR-7 request is missing in ContentObjectRenderer. Call setRequest() after object instantiation.',
            1607172972
        );
    }
}
