<?php
/***************************************************************
 *  Copyright notice
 *
 *  (c) 2010 Glowbase GmbH
 *
 *  This script is part of the TYPO3 project. The TYPO3 project is
 *  free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or
 *  (at your option) any later version.
 *
 *  The GNU General Public License can be found at
 *  http://www.gnu.org/copyleft/gpl.html.
 *
 *  This script is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  This copyright notice MUST APPEAR in all copies of the script!
 ***************************************************************/

namespace IMATHUZH\Qfq\Core\Report;

use IMATHUZH\Qfq\Core\BodytextParser;
use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Evaluate;
use IMATHUZH\Qfq\Core\Exception\RedirectResponse;
use IMATHUZH\Qfq\Core\Form\FormAsFile;
use IMATHUZH\Qfq\Core\Helper\EncryptDecrypt;
use IMATHUZH\Qfq\Core\Helper\HelperFile;
use IMATHUZH\Qfq\Core\Helper\KeyValueStringParser;
use IMATHUZH\Qfq\Core\Helper\OnArray;
use IMATHUZH\Qfq\Core\Helper\OnString;
use IMATHUZH\Qfq\Core\Helper\Path;
use IMATHUZH\Qfq\Core\Helper\Sanitize;
use IMATHUZH\Qfq\Core\Helper\Support;
use IMATHUZH\Qfq\Core\Parser\MixedTypeParser;
use IMATHUZH\Qfq\Core\Parser\SignedNumber;
use IMATHUZH\Qfq\Core\Parser\SimpleParser;
use IMATHUZH\Qfq\Core\Store\Session;
use IMATHUZH\Qfq\Core\Store\Sip;
use IMATHUZH\Qfq\Core\Store\Store;
use IMATHUZH\Qfq\Core\Store\T3Info;
use IMATHUZH\Qfq\Core\Typo3\T3Handler;

use IMATHUZH\Messenger\AuthorizationToken;
use IMATHUZH\Messenger\TokenStorage;

use Firebase\JWT\JWT;

const DEFAULT_QUESTION = 'question';
const DEFAULT_ICON = 'icon';
const DEFAULT_BOOTSTRAP_BUTTON = 'bootstrapButton';

/**
 * Class Report
 * @package qfq
 *
 */
class Report {

    /**
     * @var SIP
     */
    private $sip = null;

    /**
     * @var Link
     */
    private $link = null;

    /**
     * @var AuthLink
     */
    private $authLink = null;

    /**
     * @var Store
     */
    private $store = null;

    /**
     * @var Evaluate
     */
    private $evaluate = null;

    /**
     * @var string
     */
    private $dbAlias = '';

    // frArray[10.50.5.sql][select ...]
    private $frArray = array();

    // $indexArray[10][50][5]   one entry per 'sql' statement
    private $indexArray = array();

    /**
     * @var array
     */
    private array $aliases = [];
    // TODO to explain
//	private $resultArray = array();
    private $levelCount = 0;
    //private $counter = 0;

    // performance report; both arrays have the qfq Levels as keys
    private $performanceTimesArray = array(); // stores the execution times for each qfq lvl
    private $performanceCounterArray = array(); // stores the number of times each qfq lvl was executed

    /**
     * @var Variables
     */
    private $variables = null;

    /**
     * @var Database
     */
    private $dbArr = array();

    private $dbIndexData = false;
    private $dbIndexQfq = false;

    /**
     * @var Thumbnail
     */
    private $thumbnail = null;

    /**
     * @var Monitor
     */
    private $monitor = null;

    /**
     * @var array
     */
    private $pageDefaults = array();

    /**
     * @var array - Emulate global variable: will be set much earlier in other functions. Will be shown in error
     *      messages.
     */
    private $fr_error = array('uid' => '', 'pid' => '', 'row' => '', 'debug_level' => '0', 'full_level' => '');

    private $phpUnit = false;

    private $showDebugInfoFlag = false;

    private $performanceReportFlag = false;
    /** Tracks what levels have already been given defaults and are tracked by $indexArray
     * @var array
     */
    private $levelMap = array();
    private bool $backendUser;

    /**
     * Report constructor.
     *
     * @param array $t3data
     * @param Evaluate $evaluate
     * @param bool $phpUnit
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function __construct(array $t3data, Evaluate $evaluate, $phpUnit = false) {

        #TODO: rewrite $phpUnit to: "if (!defined('PHPUNIT_QFQ')) {...}"
        $this->phpUnit = $phpUnit;

        $t3data["uid"] = $t3data["uid"] ?? 0;

        $this->sip = new Sip($phpUnit);
        if ($phpUnit) {
            $this->sip->sipUniqId('badcaffee1234');
            //TODO Webserver Umgebung besser faken
            $_SERVER['REQUEST_URI'] = 'localhost';
        }

        $this->evaluate = $evaluate;
        $this->store = Store::getInstance();

        $this->showDebugInfoFlag = (Support::findInSet(SYSTEM_SHOW_DEBUG_INFO_YES, $this->store->getVar(SYSTEM_SHOW_DEBUG_INFO, STORE_SYSTEM)));

        $this->checkUpdateSqlLog();

        $this->pageDefaults[DEFAULT_QUESTION]["pagec"] = "Please confirm!:info";
        $this->pageDefaults[DEFAULT_QUESTION]["paged"] = QUESTION_DELETE . ":warning";

        $this->pageDefaults[DEFAULT_ICON]["paged"] = TOKEN_DELETE;
        $this->pageDefaults[DEFAULT_ICON]["pagee"] = TOKEN_EDIT;
        $this->pageDefaults[DEFAULT_ICON]["pageh"] = TOKEN_HELP;
        $this->pageDefaults[DEFAULT_ICON]["pagei"] = TOKEN_INFO;
        $this->pageDefaults[DEFAULT_ICON]["pagen"] = TOKEN_NEW;
        $this->pageDefaults[DEFAULT_ICON]["pages"] = TOKEN_SHOW;

        $this->pageDefaults[DEFAULT_BOOTSTRAP_BUTTON]["pagec"] = TOKEN_BOOTSTRAP_BUTTON;
        $this->pageDefaults[DEFAULT_BOOTSTRAP_BUTTON]["paged"] = TOKEN_BOOTSTRAP_BUTTON;
        $this->pageDefaults[DEFAULT_BOOTSTRAP_BUTTON]["pagee"] = TOKEN_BOOTSTRAP_BUTTON;
        $this->pageDefaults[DEFAULT_BOOTSTRAP_BUTTON]["pagen"] = TOKEN_BOOTSTRAP_BUTTON;
        $this->pageDefaults[DEFAULT_BOOTSTRAP_BUTTON]["pages"] = TOKEN_BOOTSTRAP_BUTTON;

        // Default should already set in QuickFormQuery() Constructor
        $this->dbIndexData = $this->store->getVar(TOKEN_DB_INDEX, STORE_TYPO3);
        $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM);
        if ($this->dbIndexData === false) {
            $this->dbIndexData = DB_INDEX_DEFAULT;
        }

        $this->dbArr[$this->dbIndexData] = new Database($this->dbIndexData);
        $this->dbArr[$this->dbIndexQfq] = new Database($this->dbIndexQfq);
        $this->variables = new Variables($evaluate, $t3data["uid"]);

        $this->link = new Link($this->sip, $this->dbIndexData, $phpUnit);

        // Set static values, which won't change during this run.
        $this->fr_error["pid"] = isset($this->variables->resultArray['global.']['page_id']) ? $this->variables->resultArray['global.']['page_id'] : 0;
        $this->fr_error["uid"] = $t3data['uid'];
        $this->fr_error["debug_level"] = 0;

        // Sanitize function for POST and GET Parameters.
        // Merged URL-Parameter (key1, id etc...) in resultArray.
        $this->variables->resultArray = array_merge($this->variables->resultArray, array("global." => $this->variables->collectGlobalVariables()));

    }

    /**
     * Returns an instance of {@link AuthLink} for rendering `authenticate` columns.
     *
     * This method creates and caches an instance of {@link AuthLink} on the first
     * call. This instance is then returned in subsequent calls.
     *
     * @return AuthLink
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    protected function getAuthLink(): AuthLink {
        if (!isset($this->authLink)) {
            $this->authLink = new AuthLink($this->sip, $this->dbIndexData, $this->phpUnit);
        }
        return $this->authLink;
    }

    /**
     * If a variable 'sqlLog' is given in STORE_TYPO3 (=Bodytext) make them relative to SYSTEM_PATH_EXT and copy it to
     * STORE_SYSTEM
     *
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function checkUpdateSqlLog() {

        $sqlLog = $this->store->getVar(TYPO3_SQL_LOG_ABSOLUTE, STORE_TYPO3);
        if (false !== $sqlLog) {
            $sqlLogAbsolute = HelperFile::joinPathFilename(Path::absoluteApp(), $sqlLog);
            Path::setAbsoluteSqlLogFile($sqlLogAbsolute);
        }

        $sqlLogMode = $this->store->getVar(TYPO3_SQL_LOG_MODE, STORE_TYPO3);
        if (false !== $sqlLogMode) {
            $this->store->setVar(SYSTEM_SQL_LOG_MODE, $sqlLogMode, STORE_SYSTEM);
        }
    }

    /**
     * Returns a <tr> for the performance report with all the calculated and styled values.
     */
    private function buildPerformanceReportLine($qfqLevel, $executionTime, $queryCounter): string {
        $averageTime = $executionTime / (($queryCounter > 0) ? $queryCounter : 1);
        return "<tr><td>$qfqLevel</td><td style='text-align: right;' data-text='$executionTime'>" .
            number_format($executionTime, 2, '.', "'") . " ms</td>" .
            "<td>$queryCounter</td>" .
            "<td style='text-align: right;'data-text='" . $averageTime . "'>" .
            number_format($averageTime, 2, '.', "'") .
            " ms</td></tr>";
    }

    /**
     * Returns the SQL performance report table (using the data from $performanceTimesArray and $performanceCounterArray)
     */
    private function buildPerformanceReport(): string {
        $performanceReport = "<div class='panel panel-success'>" .
            "<div class='panel-heading'><h3 class='panel-title'>SQL Performance Report</h3></div>" .
            "<table class='table table-hover tablesorter qfq-table-50'>" .
            "<thead><tr><th>QFQ Level</th><th style='text-align:right;'>Total Milliseconds</th>" .
            "<th>Query Counter</th><th style='text-align: right;'>Milliseconds per Query</th>" .
            "</tr></thead><tbody>";
        $totalTime = 0;
        $totalCounter = 0;
        foreach ($this->performanceTimesArray as $qfqLevel => $executionTime) {
            $performanceCounter = $this->performanceCounterArray[$qfqLevel] ?? 0;
            $performanceReport .= $this->buildPerformanceReportLine($qfqLevel, $executionTime, $performanceCounter);
            $totalTime += $executionTime;
            $totalCounter += $performanceCounter;
        }
        $performanceReport .= $this->buildPerformanceReportLine("Total", $totalTime, $totalCounter);
        $performanceReport .= "</tbody></table></div>";
        return $performanceReport;
    }

    /**
     * Main function. Parses bodytext and iterates over all queries.
     *
     * @param $bodyText
     * @return string
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
     * @throws RedirectResponse
     */
    public function process($bodyText): string {

        //phpUnit Test: clean environment
        $this->frArray = array();
        $this->indexArray = array();
        $this->levelCount = 0;
        $this->levelMap = array();

        // Iteration over Bodytext
        $ttLineArray = explode("\n", $bodyText);

        foreach ($ttLineArray as $index => $line) {
            // Fill $frArray, $indexArray, $resultArray
            $this->parseLine($line);
        }

        // Sort array
        $this->sortIndexArray($this->indexArray, $this->generateSortArg());

        // Performance Report
        if (1 == $this->store->getVar(TOKEN_PERFORMANCE_REPORT, STORE_TYPO3)
            && $this->store->getVar(TYPO3_BE_USER, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX)) {
            $this->performanceReportFlag = true;
        }
        // Report
        $content = $this->triggerReport();

        // Attach performanceReport
        if ($this->performanceReportFlag) {
            $content .= $this->buildPerformanceReport();
        }

        // Append filter SIPs (combined with allowedValues for validation)
        $content .= $this->feInlineFilterRenderSips();

        return $content;
    }

    /**
     * Split line in level, command, content and fill 'frArray', 'levelCount', 'indexArray'
     * Example: 10.50.5.sql = select * from person
     *
     * @param string $ttLine : line to split in level, command, content
     *
     * @throws \UserReportException
     */
    private function parseLine($ttLine) {

        // 10.50.5.sql = select ...
        $arr = explode("=", trim($ttLine), 2);

        // no elements or only one: do nothing
        if (count($arr) < 2) {
            return;
        }

        // 10.50.5.sql
        // Warum machen wir hier ein strtolower()? Die Schluesselwoerter: debugShowBodyText|dbIndex|sqlLog|sqlLogMode
        $key = strtolower(trim($arr[0]));

        // comment ?
        if (empty($key) || $key[0] === "#") {
            return;
        }

        // select ... - if needed, trim surrounding single ticks
        $value = trim($arr[1]);
        $value = OnString::trimQuote($value);

        // 10.50.5.sql
        $arrKey = explode('.', $key);

        // frCmd = "sql"
        $frCmd = $arrKey[count($arrKey) - 1];
        // remove possible array definition [0], [1] ... (lbeg, lend)
        $baseCmd = preg_replace('/\[\d*\]$/', '', $frCmd);
        // Check if token is known
        if (strpos('|' . strtolower(TOKEN_VALID_LIST) . '|', '|' . $baseCmd . '|') === false) {
            throw new \UserReportException ("Unknown token: $baseCmd in Line '$ttLine''", ERROR_UNKNOWN_TOKEN);
        }

        // remove last item (cmd)
        unset($arrKey[count($arrKey) - 1]);

        // save elements only if there is a level specified
        if (count($arrKey)) {
            // level = "10.50.5"
            $level = implode(".", $arrKey);

            // fill Array
            $this->setLine($level, $frCmd, $value);
        } else {
            if ($frCmd == TOKEN_DB_INDEX_LC) {
                # Define global dbIndex (=dbIndexData).
                $this->dbIndexData = $this->evaluate->parse($value);
            }
        }
    }

    /**
     * @param $level
     * @param $frCmd
     * @param $value
     */
    private function setLine($level, $frCmd, $value) {

        $index = $level . "." . $frCmd;

        // throw exception if this level was already defined
        if (!empty($this->frArray[$index])) {
            throw new \UserReportException ("Double definition: $index is defined more than once.", ERROR_DOUBLE_DEFINITION);
        }

        $alias = TOKEN_ALIAS . "." . $level;
        $alias = $this->store->getVar($alias, STORE_TYPO3);

        // Throw exception if alias is numeric
        // E.g. 10, 10.20
        if (1 === preg_match('/^([0-9\.])+$/', $alias)) {
            throw new \UserReportException ("Numeric alias detected: $alias cannot be used in report notation 'alias'", ERROR_NUMERIC_ALIAS);
        }

        // Throw exception if this alias was already used
        if (!empty($alias)) {

            // Checks if this alias was already used by a different level
            if (!empty($this->aliases) && in_array($alias, $this->aliases) && array_search($alias, $this->aliases) != $level) {
                throw new \UserReportException ("Double definition: $alias is defined more than once.", ERROR_DOUBLE_DEFINITION);
            } else {
                $this->aliases[$level] = $alias;
            }
        }

        // store complete line reformatted in frArray
        $this->frArray[$index] = $value;

        // per sql command
        // pro sql cmd wird der Indexarray abgefüllt. Dieser wird später verwendet um auf den $frArray zuzugreifen
        if ($frCmd === TOKEN_SQL || $frCmd === TOKEN_FUNCTION || $frCmd === TOKEN_SHEAD || $frCmd === TOKEN_STAIL || $frCmd === TOKEN_ALT_HEAD) {
            // Remember max level
            $this->levelCount = max(substr_count($level, '.') + 1, $this->levelCount);

            // Check if Level was already given
            if (!isset($this->levelMap[$level])) {
                // $indexArray[10][50][5]
                $this->indexArray[] = explode(".", $level);
                $this->levelMap[$level] = 1;

                // Set Default
                $arr = explode('|', TOKEN_VALID_LIST);
                foreach ($arr as $key) {
                    if (!isset($this->frArray[$level . "." . $key])) {
                        $this->frArray[$level . "." . $key] = '';
                    }
                }
            }
        }
    }

    /**
     * Sorts the associative array.
     *
     * @param array $ary : The unsorted Level Array
     * @param string $clause : the sort argument 0 ASC, 1 ASC... according to the number of columns
     * @param bool|true $ascending
     */
    private function sortIndexArray(array &$ary, $clause, $ascending = true): void {

        $clause = str_ireplace('order by', '', $clause);
        $clause = preg_replace('/\s+/', ' ', $clause);
        $keys = explode(',', $clause);
        $dirMap = array('desc' => 1, 'asc' => -1);
        $def = $ascending ? -1 : 1;

        $keyAry = array();
        $dirAry = array();
        foreach ($keys as $key) {
            $key = explode(' ', trim($key));
            $keyAry[] = trim($key[0]);
            if (isset($key[1])) {
                $dir = strtolower(trim($key[1]));
                $dirAry[] = $dirMap[$dir] ? $dirMap[$dir] : $def;
            } else {
                $dirAry[] = $def;
            }
        }

        $sortFn = function ($a, $b) use ($keyAry, $dirAry) {
            for ($i = 0; $i < count($keyAry); $i++) {
                $k = $keyAry[$i];
                $t = $dirAry[$i];
                $f = -1 * $t;

                if (strpos($k, '(') !== false) {
                    $aStr = $a->$k ?? null;
                    $bStr = $b->$k ?? null;
                } else {
                    $aStr = $a[$k] ?? null;
                    $bStr = $b[$k] ?? null;
                }

                if ($aStr == $bStr) {
                    continue;  // Equal, so check next key
                }

                return ($aStr < $bStr) ? $t : $f;  // Different, so we can determine order
            }

            return 0;  // All keys are equal
        };

        usort($ary, $sortFn);
    }

    /**
     * generateSortArg
     *
     * @return string
     */
    private function generateSortArg() {

        $sortArg = "";

        for ($i = 0; $i < $this->levelCount; $i++) {
            $sortArg = $sortArg . $i . " ASC, ";
        }

        $sortArg = substr($sortArg, 0, strlen($sortArg) - 2);

        return $sortArg;
    }

    /**
     * Split $cmd into a) function name, b) call parameter and c) return values.
     * Fire function: tt-content QFQ report
     * Return 'return values' in STORE_RECORD and QFQ function output in {{_output:R}}.
     * BTW: the QFQ function is cached and read only once. The evaluation is not cached.
     *
     * @param string $cmd # 'getFirstName(pId) => firstName, myLink'
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function doQfqFunction(string $cmd): void {
        // QFQ function cache
        static $functionCache = array();
        static $aliasCache = array();

        // Explode cmd
        OnString::splitFunctionCmd($cmd, $rcFunctionName, $rcFunctionParam, $rcReturnParam);

        // Put function parameter back together
        $detail = implode(',', $rcFunctionParam);
        $detail = $this->evaluate->parse($detail);

        // Parse function parameters (identical to subrecord detail)
        // "xId,pId:yId,&{{var:C}}:zId" -> [ [ "xId" => "" ], [ "pId" => "yId" ], [ "&{{var:C}}" => "zId"] ]
        $detailParam = KeyValueStringParser::parse($detail, PARAM_KEY_VALUE_DELIMITER, PARAM_LIST_DELIMITER, KVP_VALUE_GIVEN, KVP_MODE_ALL);

        // Save STORE_RECORD, STORE_TYPO3
        $storeRecord = $this->store->getStore(STORE_RECORD);
        $storeTypo3 = $this->store->getStore(STORE_TYPO3);

        // Fill STORE_RECORD with parameter
        $tmp = array();

        foreach ($detailParam as $detail) {

            $src = array_key_first($detail);
            $dest = $detail[$src];

            // Replace variables (again)
            // Throws error if variable could not be replaced
            $dest = $this->evaluate->parse($dest);

            // Record store (same name)
            // "xId" => ""
            if ($dest === "") {
                $tmp[$src] = $storeRecord[$src] ?? '';
                continue;
            }

            // Constants
            // "&{{var:C}}" => "zId"
            if (($src[0] ?? "") === "&") {
                $tmp[$dest] = substr($src, 1);
                continue;
            }

            // Record store (different name)
            // "pId" => "yId"
            if ($dest !== "") {
                $tmp[$dest] = $storeRecord[$src] ?? '';
            }
        }

        $this->store->setStore($tmp, STORE_RECORD, true);

        // Get tt_content record bodytext
        if (isset($functionCache[$rcFunctionName])) {
            // Already cached: get it from cache
            $bodytextArr = $functionCache[$rcFunctionName];

            if (isset($aliasCache[$rcFunctionName])) {
                $this->store->setStore($aliasCache[$rcFunctionName], STORE_TYPO3, true);
            }
        } else {
            // Multi DB setup: check for the correct DB
            $this->dbIndexT3 = $this->dbIndexQfq;

            if (!defined('PHPUNIT_QFQ')) {
                $t3DbConfig = T3Handler::getTypo3DbConfig($this->dbIndexData, $this->dbIndexQfq, $this->dbIndexT3);
            }

            // Create Typo3 db object if config information exist. In case of api, it doesn't exist.
            if (count($t3DbConfig) > 1 && $this->dbIndexT3 !== 0) {
                $db = new Database($this->dbIndexT3, $t3DbConfig);
            } else {
                // Fallback to qfq user credentials. These are used usually for typo3 db.
                $db = $this->dbArr[$this->dbIndexQfq];
            }

            $bodytextArr = $db->getBodyText($rcFunctionName);

            $btp = new BodytextParser();
            $bodytextArr[T3DATA_BODYTEXT] = $btp->process($bodytextArr[T3DATA_BODYTEXT]);
            $functionCache[$rcFunctionName] = $bodytextArr;

            // Add new aliases to typo3Store
            // We can do this since the store gets set back to default at the ending of this function.
            foreach ($btp->reportLines as $key => $value) {
                $this->store->setVar(TYPO3_TOKEN_REPORT_LINE . '.' . $key, $value, STORE_TYPO3);
            }
            // Check if aliases were used
            if (isset($btp->aliases)) {
                // Adds aliases together with level to TYPO3 store
                // E.g. [alias.1 => "myAlias", alias.1.2 => "mySecondAlias", ...]
                foreach ($btp->aliases as $key => $value) {
                    $this->store->setVar(TOKEN_ALIAS . '.' . $key, $value, STORE_TYPO3);
                }
            }

            $aliasCache[$rcFunctionName] = $this->store->getStore(STORE_TYPO3);
        }

        // Fake uid/pid for meaningful error messages
        $this->store->setVar(TYPO3_PAGE_ID, $bodytextArr[T3DATA_PID], STORE_TYPO3);
        $this->store->setVar(TYPO3_TT_CONTENT_UID, $bodytextArr[T3DATA_UID], STORE_TYPO3);


        // Fire bodytext. output is purged
        $report = new Report(array(), $this->evaluate, $this->phpUnit);
        $storeRecord[COLUMN_FUNCTION_OUTPUT] = $report->process($bodytextArr[T3DATA_BODYTEXT]);

        unset($report);

        // Copy defined 'return values' to STORE_RECORD
        $tmp = $this->store->getStore(STORE_RECORD);
        foreach ($rcReturnParam as $key) {
            $storeRecord[$key] = $tmp[$key] ?? '';
            $storeRecord['&' . $key] = $tmp['&' . $key] ?? '';
        }

        // Restore old state
        $this->store->setStore($storeRecord, STORE_RECORD, true);
        $this->store->setStore($storeTypo3, STORE_TYPO3, true);
    }

    /**
     * Executes the queries recursive. This Method is called for each sublevel.
     *
     * ROOTLEVEL
     * This method is called once from the main method.
     * For the first call the method executes the rootlevels
     *
     * SUBLEVEL
     * For each rootlevel the method calls it self with the level mode 0
     * If the next Level is a Sublevel it will be executed and $this->counter will be added by 1
     * The sublevel calls the method again for a following sublevel
     *
     * @param int $cur_level
     * @param array $super_level_array
     * @param int $counter
     * @return string
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \Twig_Error_Loader
     * @throws \Twig_Error_Runtime
     * @throws \Twig_Error_Syntax
     * @throws \UserFormException
     * @throws \UserReportException
     * @throws RedirectResponse
     */
    private function triggerReport($cur_level = 1, array $super_level_array = array(), $counter = 0) {
        $keys = array();
        $stat = array();
        $content = "";
        $executionTime = 0;

        // CurrentLevel "10.10.50"
        if (isset($this->indexArray[$counter]) && is_array($this->indexArray[$counter])) {
            $fullLevel = implode(".", $this->indexArray[$counter]);
        } else {
            $fullLevel = '';
        }

        // Superlevel "10.10"
        if (!empty($super_level_array) && is_array($super_level_array)) {
            $full_super_level = implode(".", $super_level_array) . '.';
        } else {
            $full_super_level = '';
        }

        $this->store->setVar(SYSTEM_DB_INDEX_DATA, $this->dbIndexData, STORE_VAR);

        //condition1: indexArray
        //condition2: full_level == Superlevel (but at the length of the Superlevel)
        while ($counter < count($this->indexArray) && $full_super_level == substr($fullLevel, 0, strlen($full_super_level))) {
            $contentLevel = '';
            $contentSubqueries = '';

            //True: The cur_level is a subquery -> continue
            if ($cur_level != count($this->indexArray[$counter])) {
                ++$counter;
                if (isset($this->indexArray[$counter]) && is_array($this->indexArray[$counter])) {
                    $fullLevel = implode(".", $this->indexArray[$counter]);
                } else {
                    $fullLevel = '';
                }
                continue;
            }

            // Set debug, if one is specified else keep the parent one.
            $lineDebug = $this->getValueParentDefault(TOKEN_DEBUG, $full_super_level, $fullLevel, $cur_level, 0);

            // Get line number of current SQL statement from TYPO3 store
            $reportLine = $this->store->getVar(TYPO3_TOKEN_REPORT_LINE . '.' . $fullLevel, STORE_TYPO3);

            // Prepare Error reporting
            $this->store->setVar(SYSTEM_REPORT_FULL_LEVEL, $fullLevel, STORE_SYSTEM);
            $this->store->setVar(SYSTEM_REPORT_LINE, $reportLine, STORE_SYSTEM);

            // Prepare SQL: replace variables. Actual 'line.total' or 'line.count' will recalculated: don't replace them now!
            unset($this->variables->resultArray[$fullLevel . ".line."][LINE_TOTAL]);
            unset($this->variables->resultArray[$fullLevel . ".line."][LINE_COUNT]);

            $dbIndexDataLast = $this->dbIndexData;
            if (($this->frArray[$fullLevel . "." . TOKEN_DB_INDEX_LC] ?? '') != '') {
                $this->dbIndexData = $this->evaluate->parse($this->frArray[$fullLevel . "." . TOKEN_DB_INDEX_LC]);
                $this->store->setVar(SYSTEM_DB_INDEX_DATA, $this->dbIndexData, STORE_VAR);
            }

            if (!isset($this->dbArr[$this->dbIndexData])) {
                $this->dbArr[$this->dbIndexData] = new Database($this->dbIndexData);
            }

            // If defined, fire QFQ function
            if ($this->frArray[$fullLevel . "." . TOKEN_FUNCTION] != '') {
                $this->doQfqFunction($this->frArray[$fullLevel . "." . TOKEN_FUNCTION]);
            }

            $sql = $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_SQL], TOKEN_SQL);

            $this->store->setVar(SYSTEM_SQL_RAW, $this->frArray[$fullLevel . "." . TOKEN_SQL], STORE_SYSTEM);
            $this->store->setVar(SYSTEM_SQL_FINAL, $sql, STORE_SYSTEM);

            if (FEATURE_FORM_FILE_SYNC) {
                // import form files if changed + delete Forms without form file
                if (FormAsFile::isFormQuery($sql)) {
                    $dbQfq = new Database($this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM));
                    FormAsFile::importAllForms($dbQfq, false, true);
                }
            }

            $stat = array();
            $stat[DB_NUM_ROWS] = 0;
            $result = array();

            //Execute SQL. All errors have been already catched.
            if ($sql != '') {
                $startTime = microtime(true);

                $start = hrtime(true);
                $result = $this->dbArr[$this->dbIndexData]->sql($sql, ROW_KEYS, array(), '', $keys, $stat);
                $end = hrtime(true);
                if (T3Info::beUserLoggedIn() || $this->phpUnit) {
                    // NanoSec to MilliSec and rounded to 2 decimals
                    $eta = round(($end - $start) / 1e+6, 2);
                    Session::fillSqlPerformance($fullLevel, $sql, $eta);
                }

                if ($this->performanceReportFlag) {
                    $executionTime = (microtime(true) - $startTime) * 1000;

                    if (!isset($this->performanceTimesArray[$fullLevel])) {
                        $this->performanceTimesArray[$fullLevel] = 0;
                        $this->performanceCounterArray[$fullLevel] = 0;
                    }

                    $this->performanceTimesArray[$fullLevel] += $executionTime;
                    $this->performanceCounterArray[$fullLevel]++;
                }
            }

            // If an array is returned, $sql was a query, otherwise an 'insert', 'update', 'delete', ...
            // Query: total number of rows
            // insert, delete, update: number of affected rows
            $rowTotal = isset($stat[DB_NUM_ROWS]) ? $stat[DB_NUM_ROWS] : $stat[DB_AFFECTED_ROWS];

            $this->variables->resultArray[$fullLevel . ".line."][LINE_TOTAL] = $rowTotal;
            $this->variables->resultArray[$fullLevel . ".line."][LINE_COUNT] = is_array($result) ? 1 : 0;
            $this->variables->resultArray[$fullLevel . ".line."][LINE_INSERT_ID] = $stat[DB_INSERT_ID] ?? 0;


            /////////////////////////////////
            //    Render SHEAD and HEAD    //
            /////////////////////////////////

            $contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_SHEAD]);
            // HEAD: If there is at least one record, do 'head'.
            if ($rowTotal > 0) {
                $contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_HEAD]);
            }

            ///////////////////////
            //    Render rows    //
            ///////////////////////

            if (is_array($result)) {

                // Prepare row alteration
                $arrRbgd = explode("|", $this->frArray[$fullLevel . "." . TOKEN_RBGD], 2);
                if (count($arrRbgd) < 2) {
                    $arrRbgd[] = '';
                    $arrRbgd[] = '';
                }

                // Prepare skip wrapping of indexed columns
                $fSkipWrap = array();
                if ('' != ($str = ($this->frArray[$fullLevel . "." . TOKEN_FSKIPWRAP]) ?? '')) {
                    $str = str_replace(' ', '', $str);
                    $fSkipWrap = explode(',', $str);
                    // Decrement all values to start counting with 0.
                    foreach ($fSkipWrap as $key => $value) {
                        $fSkipWrap[$key] = $value - 1;
                    }

                    $fSkipWrap = array_flip($fSkipWrap);
                }

                // Parse column names and create control array $newKeys
                $newKeys = $this->splitColumnNames($keys, $fSkipWrap);

                //---------------------------------
                // Level-wrap column index
                $levelIndex = array_search('_level', $keys, true);
                if ($levelIndex === false) {
                    $levelIndex = array_search('level', $keys, true);
                }
                // Track open depth
                $openDepth = 0;

                //---------------------------------
                // Process each row of result set
                $columnValueSeparator = "";
                $rowIndex = 0;

                foreach ($result as $row) {
                    // increment record number counter
                    $this->variables->resultArray[$fullLevel . ".line."][LINE_COUNT] = ++$rowIndex;

                    // replace {{<level>.line.count}} and {{<level>.line.total}} in __result__, if the variables specify
                    // their own full_level. This can't be replaced before firing the query.
                    for ($ii = 0; $ii < count($row); $ii++) {
                        $row[$ii] = ($row[$ii] === null) ? '' : $row[$ii];
                        $row[$ii] = str_replace("{{" . $fullLevel . ".line.count}}", $rowIndex, $row[$ii]);
                        $row[$ii] = str_replace("{{" . $fullLevel . ".line.total}}", $rowTotal, $row[$ii]);
                    }
                    // Collect all column values - prepare for rbeg, lbeg etc.
                    $columnValues = $this->collectRow($row, $newKeys, $fullLevel, $rowIndex);

                    $targetDepth = $this->levelWrapDepth($row, $levelIndex);
                    $nextDepth = ($rowIndex < $rowTotal) ? $this->levelWrapDepth($result[$rowIndex] ?? [], $levelIndex) : 0;

                    $contentLevel .= $this->levelWrap($fullLevel, $openDepth, $targetDepth);

                    // SEP set separator (empty on first run)
                    $contentLevel .= $columnValueSeparator;
                    $columnValueSeparator = $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_RSEP]);

                    // RBEG, RBGD, LIBEG
                    $contentLevel .= $this->emitRowOpen($fullLevel, $targetDepth, $rowIndex, $arrRbgd);

                    //-----------------------------
                    // COLUMNS: Add column values to content
                    $contentLevel .= $columnValues;

                    // REND
                    $contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_REND]);

                    // Trigger subqueries of this level
                    $contentSubquery = $this->triggerReport($cur_level + 1, $this->indexArray[$counter], $counter + 1);
                    $contentSubqueries .= $contentSubquery;
                    $contentLevel .= $contentSubquery;

                    // RENR
                    $contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_RENR]);

                    // LIEND
                    if ($nextDepth === $targetDepth) {
                        // equal depth → only LIEND[targetDepth] (no REND here)
                        if ($targetDepth >= 1) {
                            $tpl = $this->wrapResolve($fullLevel, TOKEN_LIEND, $targetDepth);
                            if ($tpl !== '') {
                                $contentLevel .= $this->variables->doVariables($tpl);
                            }
                        }
                    } elseif ($nextDepth < $targetDepth) {
                        // shallower → close LIEND/LEND chain (REND already done)
                        $contentLevel .= $this->closeDescending($fullLevel, $openDepth, $targetDepth, $nextDepth);
                    }
                }

                $contentLevel .= $this->levelWrap($fullLevel, $openDepth, 0);
            }


            /////////////////////////////////////////////////
            //    Render TAIL, ALT_HEAD, ALT_SQL, STAIL    //
            /////////////////////////////////////////////////

            if ($rowTotal > 0) {
                // tail
                $contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_TAIL]);
            } else {
                // althead
                $contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_ALT_HEAD]);
                // altsql
                $sql = $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_ALT_SQL]);
                if (!empty($sql)) {
                    $start = hrtime(true);
                    $result = $this->dbArr[$this->dbIndexData]->sql($sql, ROW_KEYS, array(), '', $keys, $stat);
                    $end = hrtime(true);
                    if (T3Info::beUserLoggedIn() || $this->phpUnit) {
                        // NanoSec to MilliSec and rounded to 2 decimals
                        $eta = round(($end - $start) / 1e+6, 2);
                        Session::fillSqlPerformance($fullLevel, $sql, $eta);
                    }

                    $newKeys = $this->splitColumnNames($keys);

                    $this->variables->resultArray[$fullLevel . ".line."][LINE_ALT_TOTAL] = $rowTotal;
                    $this->variables->resultArray[$fullLevel . ".line."][LINE_ALT_COUNT] = is_array($result) ? 1 : 0;
                    $this->variables->resultArray[$fullLevel . ".line."][LINE_ALT_INSERT_ID] = $stat[DB_INSERT_ID] ?? 0;

                    if (is_array($result)) {
                        foreach ($result as $row) {
                            $rowIndex = 0;
                            $contentLevel .= $this->collectRow($row, $newKeys, $fullLevel, $rowIndex);
                        }
                    }
                }
            }

            $contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_STAIL]);


            ///////////////////////
            //    render TWIG    //
            ///////////////////////

            $twig_template = $this->frArray[$fullLevel . "." . TOKEN_TWIG];
            if ($twig_template !== '') {
                $contentLevel = $this->renderTwig($twig_template, $result, $keys);
            }

            ////////////////////////////////////////////////////////////
            //    Show / Hide / Save Level Content  (TOKEN_CONTENT)   //
            ////////////////////////////////////////////////////////////

            $token = $this->frArray[$fullLevel . "." . TOKEN_CONTENT];
            switch ($token) {

                case TOKEN_CONTENT_HIDE_LEVEL:
                    $this->variables->resultArray[$fullLevel . ".line."][TOKEN_CONTENT] = $contentLevel;
                    $contentLevel = $contentSubqueries;
                    break;
                case TOKEN_CONTENT_HIDE:
                    $this->variables->resultArray[$fullLevel . ".line."][TOKEN_CONTENT] = $contentLevel;
                    $contentLevel = '';
                    break;
                case TOKEN_CONTENT_STORE:
                    $this->variables->resultArray[$fullLevel . ".line."][TOKEN_CONTENT] = $contentLevel;
                    break;
                case TOKEN_CONTENT_SHOW:
                case '':
                    break;

                default:
                    throw new \UserReportException ("Unknown token: $token in Line '$fullLevel''", ERROR_UNKNOWN_TOKEN);
                    break;
            }

            $content .= $contentLevel;


            ///////////////////////////////
            //    Switch to next Level   //
            ///////////////////////////////

            ++$counter;
            if (isset($this->indexArray[$counter]) && is_array($this->indexArray[$counter])) {
                $fullLevel = implode(".", $this->indexArray[$counter]);
            } else {
                $fullLevel = '';
            }

            if ($this->dbIndexData != $dbIndexDataLast) {
                $this->dbIndexData = $dbIndexDataLast;
                $this->store->setVar(SYSTEM_DB_INDEX_DATA, $this->dbIndexData, STORE_VAR);
            }

        }
        return $content;
    }

    /**
     * Called with an array of column names.
     * Each column name can be split in multiple string by '|': [s1[|s2[|s3]]]
     * Each s1|s2|s3 can be:  {title}, _{special column name}, _hide, _noWrap, _encrypt=<encryption method>, _={title},
     *                          _+{html tag}, _<{html tag1}><{html tag2}>
     *
     * Return an Array: newKeys[idx][C_FULL|C_TITLE|C_NO_WRAP|C_HIDE|C_STORE|C_SPECIAL]
     *
     * @param array $keys
     * @param array $fSkipWrap
     * @return array
     * @throws \CodeException
     */
    private function splitColumnNames(array $keys, array $fSkipWrap = array()) {

        // Split key names in title / specialColumnName / fSkipWrap / hide
        $ii = 0;
        $newKeys = array();

        // From the result set, iterate over all columns.
        foreach ($keys as $key) {

            $newKeys[$ii][C_FULL] = $key;

            // Each column might have several keywords, split by '|'
            $arr = explode(PARAM_DELIMITER, $key);
            $key = $arr[0];
            // Check each keyword
            foreach ($arr as $kk) {
                // Check for '_'
                if (($kk[0] ?? '') == TOKEN_COLUMN_CTRL) {
                    // '_myVar...' >> 'myVar...'
                    $kk = substr($kk, 1);
                    // In case $kk is longer than 8 (=strlen( COLUMN_ENCRYPT . '=') ), cut it. We try to find 'encrypt='
                    switch (substr($kk, 0, 8)) {
                        case COLUMN_NO_WRAP:
                            // noWrap
                            $newKeys[$ii][C_NO_WRAP] = 1;
                            break;
                        case COLUMN_HIDE:
                            // hide
                            $newKeys[$ii][C_HIDE] = 1;
                            break;
                        case COLUMN_ENCRYPT:
                            // Get given encryption method from column name. Strip 'encrypt='
                            $newKeys[$ii][C_ENCRYPTION_METHOD] = substr($kk, 8);
                            break;
                        case '':
                            // In case no column name ist given
                            $newKeys[$ii][C_SPECIAL] = TOKEN_COLUMN_CTRL;
                            break;
                        default:
                            // All remaining special column names. E.g. _link, _pdf, ...
                            $newKeys[$ii][C_SPECIAL] = TOKEN_COLUMN_CTRL . $kk;

                            // Legacy: ... AS '_=varName'
                            if ($kk[0] == COLUMN_STORE_USER) {
                                $kk = substr($kk, 1);
                                $newKeys[$ii][C_STORE] = STORE_USER;
                            }

                            // Detect if a variable should be copied to a store.
                            [$varname, $store] = OnString::checkAndSplitForVarStoreSyntax($kk);
                            if ($store !== false) {
                                $newKeys[$ii][C_STORE] = $store;
                            }
                            $newKeys[$ii][C_TITLE] = $varname;
                            break;
                    }
                } else {
                    $newKeys[$ii][C_TITLE] = $kk;
                }
            }

            // Explicit given fSkipWrap
            if (isset($fSkipWrap[$ii])) {
                $newKeys[$ii][C_NO_WRAP] = $fSkipWrap[$ii];
            }

            // Fallback, if no dedicated title is given.
            if (!isset($newKeys[$ii][C_TITLE])) {
                // If no title is given, check if there is a specialColumnName (backward compatibility)
                $newKeys[$ii][C_TITLE] = isset($newKeys[$ii][C_SPECIAL]) ? $newKeys[$ii][C_SPECIAL] : $newKeys[$ii][C_FULL];
            }

            $ii++;
        }

        return $newKeys;
    }

    /**
     * Render given Twig template with content from $result
     *
     * @param $twig_template
     * @param $result
     * @param $keys
     * @return string
     * @throws \CodeException
     * @throws \Twig_Error_Loader
     * @throws \Twig_Error_Runtime
     * @throws \Twig_Error_Syntax
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function renderTwig($twig_template, $result, $keys) {
        if (count(array_unique($keys)) !== count($keys)) {
            throw new \UserReportException("Twig Error: There are multiple columns with the same name in the SQL query.", ERROR_TWIG_COLUMN_NOT_UNIQUE);
        }

        // Turn Result into Associative array
        $resultAssoc = array();
        if (is_array($result) || is_object($result)) {
            foreach ($result as $i => $row) {
                foreach ($row as $j => $value) {
                    $resultAssoc[$i][$keys[$j]] = $value;
                }
            }
        }

        $tmpl_start = substr($twig_template, 0, 5);

        if ($tmpl_start == "file:") {
            $loader = new \Twig\Loader\FilesystemLoader([".", Path::absoluteExt(Path::EXT_TO_TWIG_TEMPLATES)]);
            $twig_template = substr($twig_template, 5);
        } else {
            $loader = new \Twig\Loader\ArrayLoader(array(
                "string_template" => trim($twig_template, '"\''))  # trim is needed for backward compatibility for MNF
            );
            $twig_template = "string_template";
        }

        $twig = new \Twig\Environment($loader, array());

        // Add QFQ Link Filter
        $filter = new \Twig\TwigFilter('qfqlink', function ($string) {
            return $this->link->renderLink($string);
        }, ['is_safe' => ['html']]);
        $twig->addFilter($filter);

        // Json decode Filter
        // E.g.: {% set obj = '["this is one", "this is two"]' | json_decode%}
        $filter = new \Twig\TwigFilter('json_decode', function ($string) {
            return json_decode($string, true);
        }, ['is_safe' => ['html']]);
        $twig->addFilter($filter);

        // render Twig
        $contentTwig = $twig->render($twig_template, array(
            'context' => $resultAssoc,  // backward compatibility for MNF
            'result' => $resultAssoc,
            'store' => array(
                'access' => $this->store->getStore(STORE_ACCESS),
                'record' => $this->store->getStore(STORE_RECORD),
                'sip' => $this->store->getStore(STORE_SIP),
                'typo3' => $this->store->getStore(STORE_TYPO3),
                'user' => $this->store->getStore(STORE_USER),
                'system' => $this->store->getStore(STORE_SYSTEM),
                'var' => $this->store->getStore(STORE_VAR),
                'web' => $this->store->getStore(STORE_WEB),
            )
        ));
        return $contentTwig;
    }

    /**
     * Determine value:
     * 1) if one specified in line: take it
     * 2) if one specified in upper level: take it
     * 3) if none above take default
     * Set value on $full_level
     *
     * @param string $level_key - 'db' or 'debug'
     * @param string $full_super_level - f.e.: 10.10.
     * @param string $full_level - f.e.: 10.10.10.
     * @param string $cur_level - f.e.: 2
     * @param string $default - f.e.: 0
     *
     * @return   string  The calculated value.
     */
    private function getValueParentDefault($level_key, $full_super_level, $full_level, $cur_level, $default) {

        if (!empty($this->frArray[$full_level . "." . $level_key])) {
            $value = $this->frArray[$full_level . "." . $level_key];
        } else {
            if ($cur_level == 1) {
                $value = $default;
            } else {
                $value = $this->variables->resultArray[$full_super_level . ".line."][$level_key] ?? '';
            }
        }
        $this->variables->resultArray[$full_level . ".line."][$level_key] = $value;

        return ($value);
    }

    /**
     * Steps through 'row' and collects all columns.
     *
     * @param array $row Recent row fetch from sql resultset.
     * @param array $keys List of all columnnames
     * @param string $full_level Recent position to work on.
     * @param string $rowIndex Index of recent row in resultset.
     *
     * @return string               Collected content of all printable columns
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
     * @throws RedirectResponse
     */
    private function collectRow(array $row, array $keys, $full_level, $rowIndex) {
        $content = "";
        $assoc = array();
        $fsep = '';
        // Iterate columns
        for ($ii = 0; $ii < count($keys); $ii++) {

            // Debugging
            $this->store->setVar(SYSTEM_REPORT_COLUMN_INDEX, $ii + 1, STORE_SYSTEM);
            $this->store->setVar(SYSTEM_REPORT_COLUMN_NAME, $keys[$ii][C_FULL], STORE_SYSTEM);
            $this->store->setVar(SYSTEM_REPORT_COLUMN_VALUE, $row[$ii], STORE_SYSTEM);

            [$renderedColumn, $flagOutput] = $this->renderColumn($ii, $keys[$ii], $row[$ii], $full_level, $rowIndex);

            $keyAssoc = $keys[$ii][C_TITLE];
            if (isset($keys[$ii][C_STORE])) {

                // SELECT .. AS '_myVar:A'  >> STORE_ACCESS['myVar']
                $this->store->setVar($keys[$ii][C_TITLE], $row[$ii], $keys[$ii][C_STORE]);
            } else {
                // If no store is specified, collect all values to be copied later to STORE_RECORD
                $assoc[$keyAssoc] = $row[$ii];
                $assoc[REPORT_TOKEN_FINAL_VALUE . $keyAssoc] = $renderedColumn;
            }

            if ($flagOutput) {
                //prints

                if (!isset($keys[$ii][C_NO_WRAP])) {
                    $content .= $this->variables->doVariables($fsep);
                    $content .= $this->variables->doVariables($this->frArray[$full_level . "." . TOKEN_FBEG]);
                }

                $content .= $renderedColumn;

                if (!isset($keys[$ii][C_NO_WRAP])) {
                    $content .= $this->variables->doVariables($this->frArray[$full_level . "." . TOKEN_FEND]);
                }

                $fsep = $this->frArray[$full_level . "." . TOKEN_FSEP];
            }

        }

        $this->store->appendToStore($assoc, STORE_RECORD);

        return ($content);
    }

    /**
     * Renders column depending of column name (if name is a reserved column name)
     *
     * @param string $columnIndex
     * @param array $columnCtrl
     * @param string $columnValue
     * @param string $full_level
     * @param string $rowIndex
     *
     * @return string rendered column
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
     * @throws RedirectResponse
     */
    private function renderColumn($columnIndex, array $columnCtrl, $columnValue, $full_level, $rowIndex): array {
        $content = "";
        $flagControl = false;
        $flagOutput = true;
        $encryptionKey = $this->store->getVar(SYSTEM_ENCRYPTION_KEY, STORE_SYSTEM, SANITIZE_ALLOW_ALL);

        // Special column name:  '_...'? Empty column names are allowed: check with isset
        if (($columnCtrl[C_SPECIAL][0] ?? '') === TOKEN_COLUMN_CTRL) {
            $flagControl = true;
            $columnName = substr($columnCtrl[C_SPECIAL], 1);

            // Special column name and hide output
            if (isset($columnCtrl[C_HIDE])) {
                $flagOutput = false;
            }

            switch ($columnName) {
                case COLUMN_LINK:
                case COLUMN_WEBSOCKET:
                case COLUMN_REST_CLIENT:
                    $content .= $this->link->renderLink($columnValue);
                    break;
                case COLUMN_AUTHENTICATE:
                    $content .= $this->getAuthLink()->renderLink($columnValue);
                    break;
                case COLUMN_SCRIPT:
                    $content .= ColumnScript::render($columnValue);
                    break;

                case COLUMN_EXEC:
                    $rc = '';
                    $content .= Support::qfqExec($columnValue, $rc);
                    break;

                // Uppercase 'P'
                case COLUMN_PPAGE:
                case COLUMN_PPAGEC:
                case COLUMN_PPAGED:
                case COLUMN_PPAGEE:
                case COLUMN_PPAGEH:
                case COLUMN_PPAGEI:
                case COLUMN_PPAGEN:
                case COLUMN_PPAGES:
                    $lowerColumnName = strtolower($columnName);
                    $tokenizedValue = $this->doFixColPosPage($columnName, $columnValue);
                    $linkValue = $this->doPage($lowerColumnName, $tokenizedValue);
                    $content .= $this->link->renderLink($linkValue);
                    break;

                // Lowercase 'P'
                case COLUMN_PAGE:
                case COLUMN_PAGEC:
                case COLUMN_PAGED:
                case COLUMN_PAGEE:
                case COLUMN_PAGEH:
                case COLUMN_PAGEI:
                case COLUMN_PAGEN:
                case COLUMN_PAGES:
                    $linkValue = $this->doPage($columnName, $columnValue);
                    $content .= $this->link->renderLink($linkValue);
                    break;

                case COLUMN_YANK:
                    $linkValue = $this->doYank($columnName, $columnValue);
                    $content .= $this->link->renderLink($linkValue);
                    break;

                case COLUMN_PPDF:
                case COLUMN_FFILE:
                case COLUMN_ZZIP:
                    $lowerColumnName = strtolower($columnName);
                    $tokenizedValue = $this->doFixColPosDownload($columnValue);
                    $linkValue = $this->doDownload($lowerColumnName, $tokenizedValue);
                    $content .= $this->link->renderLink($linkValue);
                    break;

                case COLUMN_PDF:
                case COLUMN_FILE:
                case COLUMN_ZIP:
                case COLUMN_EXCEL:
                    $linkValue = $this->doDownload($columnName, $columnValue);
                    $content .= $this->link->renderLink($linkValue);
                    break;

                case COLUMN_SAVE_ZIP:
                case COLUMN_SAVE_PDF:
                    $tokenGiven = [];
                    $vars = $this->link->fillParameter(array(), $columnValue, $tokenGiven);
                    if ($columnName === COLUMN_SAVE_ZIP) {
                        $vars[DOWNLOAD_MODE] = DOWNLOAD_MODE_SAVE_ZIP;
                        $extension = '.zip';
                        $errorCode = ERROR_INVALID_SAVE_ZIP_FILENAME;
                    } else {
                        $vars[DOWNLOAD_MODE] = DOWNLOAD_MODE_PDF;
                        $extension = '.pdf';
                        $errorCode = ERROR_INVALID_SAVE_PDF_FILENAME;
                    }
                    $vars[SIP_DOWNLOAD_PARAMETER] = implode(PARAM_DELIMITER, $vars[NAME_COLLECT_ELEMENTS]);

                    // Save file with specified export filename
                    $pathFileName = $vars[DOWNLOAD_EXPORT_FILENAME];
                    $sanitizedFileName = Sanitize::safeFilename($pathFileName, false, true);
                    if ($pathFileName == '' ||
                        substr($pathFileName, 0, strlen("fileadmin/")) !== "fileadmin/" ||
                        substr($pathFileName, -4) !== $extension) {
                        throw new \UserReportException("'$columnName' filenames need to be in the fileadmin/ directory and end in '$extension' for security reasons.", $errorCode);
                    } elseif ($pathFileName !== $sanitizedFileName) {
                        throw new \UserReportException("The provided filename '$pathFileName' does not meet sanitize criteria. Use '$sanitizedFileName' instead.", $errorCode);
                    } else {
                        $vars[DOWNLOAD_EXPORT_FILENAME] = $sanitizedFileName;
                        $download = new Download();
                        $download->process($vars, OUTPUT_MODE_COPY_TO_FILE);
                    }
                    break;

                case COLUMN_THUMBNAIL:
                    if ($this->thumbnail == null) {
                        $this->thumbnail = new Thumbnail();
                    }
                    $content .= $this->thumbnail->process($columnValue);
                    break;

                case COLUMN_MONITOR:
                    $content .= $this->link->renderLink(TOKEN_MONITOR . '|' . $columnValue);
                    break;

                case COLUMN_MIME_TYPE:
                    $content .= HelperFile::getMimeType($columnValue, true);
                    break;

                case COLUMN_FILE_SIZE:
                    $arr = HelperFile::getFileStat($columnValue);
                    $content .= $arr[VAR_FILE_SIZE] ?? '-';
                    break;

                case COLUMN_NL2BR:
                    $content .= nl2br($columnValue);
                    break;

                case COLUMN_HTMLENTITIES:
                    $content .= htmlentities($columnValue);
                    break;

                case COLUMN_STRIPTAGS:
                    $content .= strip_tags($columnValue);
                    break;

                case COLUMN_EXCEL_PLAIN:
                    $content .= $columnValue . PHP_EOL;
                    break;
                case COLUMN_EXCEL_STRING:
                    $content .= EXCEL_STRING . '=' . $columnValue . PHP_EOL;
                    break;
                case COLUMN_EXCEL_BASE64:
                    $content .= EXCEL_BASE64 . '=' . base64_encode($columnValue) . PHP_EOL;
                    break;
                case COLUMN_EXCEL_NUMERIC:
                    $content .= EXCEL_NUMERIC . '=' . $columnValue . PHP_EOL;
                    break;

                case COLUMN_BULLET:
                    if ($columnValue === '') {
                        break;
                    }

                    // r:3|B:
                    $linkValue = TOKEN_RENDER . ":3|" . TOKEN_BULLET . ":" . $columnValue;
                    $content .= $this->link->renderLink($linkValue);
                    break;

                case COLUMN_CHECK:
                    if ($columnValue === '') {
                        break;
                    }

                    // "r:3|C:
                    $linkValue = TOKEN_RENDER . ":3|" . TOKEN_CHECK . ":" . $columnValue;
                    $content .= $this->link->renderLink($linkValue);
                    break;

                case COLUMN_IMG:
                    // "<path to image>|[alttext]|[text behind]" renders to: <img src="<path to image>" alt="[alttext]">[text behind]
                    if (empty($columnValue)) {
                        break;
                    }

                    $mailConfig = explode("|", $columnValue, 3);

                    // Fake values for tmp[1], tmp[2] to suppress access errors.
                    $mailConfig[] = '';
                    $mailConfig[] = '';

                    if (empty($mailConfig[0])) {
                        break;
                    }
                    $attribute = Support::doAttribute('src', $mailConfig[0]);
                    $attribute .= Support::doAttribute('alt', $mailConfig[1]);

                    $content .= '<img ' . $attribute . '>' . $mailConfig[2];
                    break;

                case COLUMN_MAILTO:
                    // "<email address>|[Real Name]"  renders to (encrypted via JS): <a href="mailto://<email address>"><email address></a> OR <a href="mailto://<email address>">[Real Name]</a>
                    $mailConfig = explode("|", $columnValue, 2);
                    if (empty($mailConfig[0])) {
                        break;
                    }

                    if (empty($mailConfig[1])) {
                        $mailConfig[1] = $mailConfig[0]; // Copy to text shown
                    }

                    $content .= "<script language=javascript><!--" . chr(10);

                    $toEmail = explode("@", $mailConfig[0], 2);
                    // Broken data - do not stop, might come from a single database record.
                    if (count($toEmail) < 2) {
                        $toEmail[1] = 'broken.email.address';
                    }
                    $content .= 'var email = "' . $toEmail[0] . '"' . chr(10);
                    $content .= 'var emailHost = "' . $toEmail[1] . '"' . chr(10);

                    $toName = explode("@", $mailConfig[1], 2);

                    $secondPart = '';
                    if (count($toName) > 1) {
                        $toName[0] .= "@";
                        $content .= 'var contactHost = "' . $toName[1] . '"' . chr(10);
                        $secondPart = "<span>\" + contactHost + \"</span>";
                    }
                    $content .= 'var contact = "' . $toName[0] . '"' . chr(10);
                    $content .= 'document.write("<a href=" + "mail" + "to:" + email + "@" + emailHost+ "><span>" + contact
                    + "</span>' . $secondPart . '</a>")' . chr(10);
                    $content .= '//--></script>';
                    break;

                case COLUMN_SENDMAIL:
                    $sendMail = new SendMail();
                    $mailConfig = $sendMail->parseStringToArray($columnValue);
                    if (count($mailConfig) < 4) {
                        throw new \UserReportException ("Too few parameter for sendmail: $columnValue", ERROR_TOO_FEW_PARAMETER_FOR_SENDMAIL);
                    }

                    $mailConfig[SENDMAIL_TOKEN_SRC] = "Report: T3 pageId=" . $this->store->getVar('pageId', STORE_TYPO3) .
                        ", T3 ttcontentId=" . $this->store->getVar('ttcontentUid', STORE_TYPO3) .
                        ", Level=" . $full_level;

                    $sendMail->process($mailConfig);

                    break;

                case COLUMN_VERTICAL:
                    // '<Text>|[angle]|[width]|[height]'   , width and height needs a unit (px, em ,...)
                    $arr = explode("|", $columnValue, 2);

                    # angle
                    $angle = $arr[1] ?? 270;

                    # https://stackoverflow.com/questions/16301625/rotated-elements-in-css-that-affect-their-parents-height-correctly
                    $extraOuterWrap = '';
                    if ($angle > 180 || $angle < 0) {
                        $translate = '(-100%)';
                    } else {
                        $translate = '(0, -100%)';
                        $extraOuterWrap = 'margin-left: 1em; ';
                    }
                    // wrap in containing tags to adapt to surrounding content
                    $outerWrapStyle = "display:table; $extraOuterWrap";
                    $innerWrapStyle = "padding:50% 0; height:0;";
                    $style = "transform:rotate(" . $angle . "deg) translate$translate; margin-top:-50%; white-space:nowrap; transform-origin:0 0; height:0px;";
                    $content = Support::wrapTag("<div style='$style'>", $arr[0]);
                    $content = Support::wrapTag("<div style='$innerWrapStyle'>", $content);
                    $content = Support::wrapTag("<div style='$outerWrapStyle'>", $content);
                    break;

                case COLUMN_FORM_ELEMENT_JSON:
                case COLUMN_FORM_JSON:
                    $dbQfq = new Database($this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM));
                    $content .= Support::encryptDoubleCurlyBraces(FormAsFile::renderColumnFormJson($columnValue, $dbQfq, $columnName));
                    break;

                // Author: Krzysztof Putyra
                case COLUMN_JWT:
                    /* Converts a string
                     *  claim1:value, claim2:value, ... | key | alg
                     * into a json web token. Parameters:
                     * - alg    the name of the signing algorithm (default: HS256)
                     * - key    the secret key used by the signing algorithm
                     * Standard claims with an extended interpretation of values:
                     * - iss    the issuer of the token (default: qfq)
                     * - iat    the timestamp the token has been issued (default: current)
                     * - exp    the expiration timestamp or the number of seconds till invalid (if prefixed with '+')
                     * - nbf    the timestamp from when or (if prefixed with '+') the number of seconds after which the token is valid
                     */

                    // Split the column into |-separated sections
                    $parser = new SimpleParser('|', [
                        SimpleParser::OPTION_EMPTY_VALUE => ''
                    ]);
                    $splitContent = $parser->parse($columnValue);
                    // Check that key is provided
                    if (count($splitContent) < 2) {
                        throw new \UserReportException("JWT requires a secret key, but it is missing");
                    }

                    // Parse the payload
                    $currentTime = time();
                    $parser = new MixedTypeParser(null, [
                        SimpleParser::OPTION_KEEP_SIGN => true
                    ]);
                    $claims = array_merge(
                        ['iss' => 'qfq', 'iat' => $currentTime],
                        $parser->parseDictionary($splitContent[0])
                    );
                    foreach (['exp', 'nbf', 'iat'] as $claim) {
                        $value = $claims[$claim] ?? 0;
                        if ($value instanceof SignedNumber) {
                            $claims[$claim] = $value->value + $currentTime;
                        }
                    }

                    // Create the token
                    $content .= JWT::encode(
                        $claims,
                        $splitContent[1],
                        $splitContent[2] ?? 'HS256'
                    );
                    break;

                case COLUMN_MESSENGER_TOKEN:
                    /* Mints an nchan-messages authorization token and stores it in the QFQ session.
                     * Syntax: 'pub:ch1,ch2|sub:ch3,ch4|group:myGroup' AS _messengerToken
                     * Fields (all optional, but at least one of pub/sub should be given):
                     *   pub:<csv>    Publisher channels (comma-separated)
                     *   sub:<csv>    Subscriber channels (comma-separated)
                     *   group:<g>    Messenger group; defaults to SYSTEM_MESSENGER_GROUP (auto-derived from domain)
                     * Output: the 16-hex token id, stored under $_SESSION['qfq'][<id>]. Use with
                     * `AS '_messengerToken|varName|_hide'` to assign the token to a record variable
                     * without output — identical to the `_link|…|_hide` convention.
                     */
                    try {
                        $mtParser = new SimpleParser('|', [SimpleParser::OPTION_EMPTY_VALUE => '']);
                        $mtParts = $mtParser->parse($columnValue);
                        $mtFields = [];
                        foreach ($mtParts as $mtPart) {
                            if (strpos($mtPart, ':') === false) {
                                continue;
                            }
                            [$mtK, $mtV] = explode(':', $mtPart, 2);
                            $mtFields[trim($mtK)] = trim($mtV);
                        }
                        $mtGroup = $mtFields[MESSENGER_TOKEN_FIELD_GROUP] ?? '';
                        if ($mtGroup === '') {
                            $mtGroup = $this->store->getVar(SYSTEM_MESSENGER_GROUP, STORE_SYSTEM) ?: MESSENGER_DEFAULT_GROUP;
                        }
                        $mtPub = array_values(array_filter(array_map('trim', explode(',', $mtFields[MESSENGER_TOKEN_FIELD_PUB] ?? ''))));
                        $mtSub = array_values(array_filter(array_map('trim', explode(',', $mtFields[MESSENGER_TOKEN_FIELD_SUB] ?? ''))));
                        $mtToken = AuthorizationToken::createMessengerToken($mtGroup, $mtPub, $mtSub);
                        $content .= (new TokenStorage(
                            $_SESSION[SESSION_NAME],
                            unserialize($_SESSION[SESSION_NAME][SESSION_STORE_PREFIX . '-' . STORE_USER] ?? '') ?: [],
                            unserialize($_SESSION[SESSION_NAME][SESSION_STORE_PREFIX . '-' . STORE_WEB] ?? '') ?: []
                        ))->saveToken($mtToken);
                    } catch (\Throwable $mtErr) {
                        throw new \UserReportException(json_encode([
                            ERROR_MESSAGE_TO_USER => 'Failed to create messenger token.',
                            ERROR_MESSAGE_TO_DEVELOPER => '_messengerToken (' . $columnValue . '): ' . $mtErr->getMessage(),
                        ]), ERROR_CREATE_MESSENGER_TOKEN);
                    }
                    break;

                case COLUMN_ENCRYPT:
                    $encryptionMethodColumn = $this->store->getVar(SYSTEM_ENCRYPTION_METHOD, STORE_SYSTEM, SANITIZE_ALLOW_ALL);
                    if (isset($columnCtrl[C_ENCRYPTION_METHOD])) {
                        $encryptionMethodColumn = $columnCtrl[C_ENCRYPTION_METHOD];
                    }
                    // check if the given one is valid
                    if (!EncryptDecrypt::checkForValidEncryptMethod($encryptionMethodColumn)) {
                        throw new \UserReportException ("Invalid encryption method used in special column name: $encryptionMethodColumn", ERROR_INVALID_ENCRYPTION_METHOD);
                    }

                    $content .= EncryptDecrypt::buildDatabaseValue($columnValue, $encryptionKey, $encryptionMethodColumn);
                    break;

                case COLUMN_DECRYPT:
                    if (EncryptDecrypt::checkForEncryptedValue($columnValue)) {
                        $content .= EncryptDecrypt::getPlaintext($columnValue, $encryptionKey);
                    } else {
                        $content .= $columnValue;
                    }
                    break;

                case COLUMN_UPLOAD:
                    $content .= $this->createInlineUpload($columnValue);
                    break;
                /*case COLUMN_INLINE_EDIT:
                    // Process given parameters and render inline edit container and label
                    $inlineEdit = new InlineEdit($this->dbArr[$this->dbIndexData]);
                    $inlineEdit->process($columnValue);
                    $content .= $inlineEdit->render();
                    break;*/

                case COLUMN_INPUT:
                    $content .= $this->link->renderLink($columnValue);
                    break;

                default :

                    $flagOutput = false;
                    $token = ($columnName[0] ?? '');
                    switch ($token) {
                        case COLUMN_WRAP_TOKEN:
                            if (isset($columnName[1])) {
                                $content = Support::wrapTag('<' . substr($columnName, 1) . '>', $columnValue);
                                $flagOutput = true;
                            }
                            break;

                        case COLUMN_STORE_USER:
                            if (isset($columnName[1])) {
                                $this->store::setVar(substr($columnName, 1), $columnValue, STORE_USER);
                                $this->store::setVar(substr($columnName, 1), $columnValue, STORE_RECORD);
                            }
                            break;

                        default:
                            break;
                    }
                    break;
            }

        } else {
            // No special column name: just add the column value.
            $content .= $columnValue;
        }

        // Always save column values, even if they are hidden.
        $this->variables->resultArray[$full_level . "."][$columnCtrl[C_TITLE]] = ($content == '' && $flagControl) ? $columnValue : $content;

        return [$content, $flagOutput];
    }

    /**
     * Renders PageX: convert position content to token content. Respect default values depending on PageX
     *
     * @param string $columnName
     * @param string $columnValue
     * @return string rendered link
     *
     * $columnValue:
     * -------------
     * [<page id|alias>[&param=value&...]] | [text] | [tooltip] | [msgbox] | [class] | [target] | [render mode]
     *
     * param[0]: <page id|alias>[&param=value&...]
     * param[2]: text
     * param[3]: tooltip
     * param[4]: msgbox
     * param[5]: class
     * param[6]: target
     * param[7]: render mode
     * @throws \UserReportException
     */
    private function doFixColPosPage($columnName, $columnValue) {

        $tokenList = "";

        if (empty($columnName)) {
            return '';
        }

        // Split definition
        $allParam = explode('|', $columnValue);
        if (count($allParam) > 8) {
            throw new \UserReportException ("Too many parameter (max=8): $columnValue", ERROR_TOO_MANY_PARAMETER);
        }

        // $firstParam is not needed anymore
        // First Parameter: Split PageId|PageAlias and  URL Params
        $firstParam = explode('&', $allParam[0], 2);
        if (empty($firstParam[1])) {
            $firstParam[] = '';
        }

        switch ($columnName) {
            case COLUMN_PPAGED:
                // no pageid /pagealias given.
                $tokenList .= $this->composeLinkPart(TOKEN_URL_PARAM, $allParam[0]);
                break;
            default:

                /*
                 * Previously 'form&r=123&a=hello&type=5&L=3&final=world'
                 * was converted to 'p:form|U:r=123&a=hello&type=5&L=3&final=world'.
                 * Now it is converted to 'p:form&r=123&a=hello&type=5&L=3&final=world'
                 */
                //$tokenList .= $this->composeLinkPart(TOKEN_PAGE, $firstParam[0]);             // -- PageID --
                //$tokenList .= $this->composeLinkPart(TOKEN_URL_PARAM, $firstParam[1]);

                $tokenList .= $this->composeLinkPart(TOKEN_PAGE, $allParam[0]);         // -- PageID --
        }

        if (isset($allParam[1]) && $allParam[1] !== '') {
            $tokenList .= $this->composeLinkPart(TOKEN_TEXT, $allParam[1]);             // -- Text --
        }

        if (isset($allParam[2]) && $allParam[2] !== '') {
            $tokenList .= $this->composeLinkPart(TOKEN_TOOL_TIP, $allParam[2]);         // -- tooltip --
        }

        if (isset($allParam[3]) && $allParam[3] !== '') {
            $text = isset($this->pageDefaults[DEFAULT_QUESTION][$columnName]) ? $this->pageDefaults[DEFAULT_QUESTION][$columnName] : '';
            $tokenList .= $this->composeLinkPart(TOKEN_QUESTION, $allParam[3], $text);                // -- msgbox
        }

        if (isset($allParam[4]) && $allParam[4] !== '') {
            $tokenList .= $this->composeLinkPart(TOKEN_CLASS, $allParam[4]);            // -- class --
        }

        if (isset($allParam[5]) && $allParam[5] !== '') {
            $tokenList .= $this->composeLinkPart(TOKEN_TARGET, $allParam[5]);           // -- target --
        }

        if (isset($allParam[6]) && $allParam[6] !== '') {
            $tokenList .= $this->composeLinkPart(TOKEN_RENDER, $allParam[6]);           // -- render mode --
        }

        if (!isset($allParam[7])) {
            $allParam[7] = '1'; // if no SIP behaviour defined: sip is set
        }

        $tokenList .= $this->composeLinkPart(TOKEN_SIP, $allParam[7]);           // -- SIP --

        if (isset($this->pageDefaults[DEFAULT_ICON][$columnName])) {
            $tokenList .= $this->pageDefaults[DEFAULT_ICON][$columnName] . "|";
        }

        return ($tokenList);
    }

    /**
     * Renders Download: convert position content to token content. Respect default values.
     * <exportFilename> | <text> | <1: urlparam|file> | <2: urlparam|file> | ... | <n: urlparam|file>
     *
     * @param string $columnValue
     *
     * @return string rendered link
     * @throws \UserReportException
     */
    private function doFixColPosDownload($columnValue) {

        $tokenList = '';

        if ($columnValue == '') {
            throw new \UserReportException ("Missing parameter for " . DOWNLOAD_MODE_PDF . '/' . DOWNLOAD_MODE_FILE .
                '/' . DOWNLOAD_MODE_ZIP, ERROR_MISSING_REQUIRED_PARAMETER);
        }

        // Split definition
        $allParam = explode('|', $columnValue);

        $value = array_shift($allParam);
        if ($value !== null) {
            $tokenList .= $this->composeLinkPart(TOKEN_DOWNLOAD, $value); // -- d:<exportFilename> --
        }

        $value = array_shift($allParam);
        if ($value !== null) {
            $tokenList .= $this->composeLinkPart(TOKEN_TEXT, $value); // -- t:<text> --
        }

        // Take all remaining parameter as TOKEN_URL_PARAM or TOKEN_FILE
        while (null !== ($value = array_shift($allParam))) {
            $token = (strpos($value, '=')) ? TOKEN_URL_PARAM : TOKEN_FILE;
            $tokenList .= $this->composeLinkPart($token, $value);             // -- U:<value> | f:<value> --
        }

        return ($tokenList);
    }

    /**
     * If there is a value (or a defaultValue): compose it together with qualifier and delimiter.
     *
     * @param string $qualifier
     * @param string $value
     * @param string $defaultValue
     *
     * @return    string        rendered link
     */
    private function composeLinkPart($qualifier, $value, $defaultValue = "") {

        if ($value === '') {
            $value = $defaultValue;
        }

        if ($value !== '') {
            return ($qualifier . ":" . $value . "|");
        }

        return '';
    }

    /**
     * Renders _pageX: extract token and determine if any default value has to be applied
     *
     * @param string $columnName
     * @param string $columnValue
     *
     * @return    string        rendered link
     */
    private function doPage($columnName, $columnValue) {

        $defaultQuestion = '';
        $defaultActionDelete = '';

        $param = explode('|', $columnValue);

        # get all default values, depending on the columnname
        $defaultImage = isset($this->pageDefaults[DEFAULT_ICON][$columnName]) ? $this->pageDefaults[DEFAULT_ICON][$columnName] : '';
        $defaultSip = TOKEN_SIP;
        if ($columnName === COLUMN_PAGED) {
            $defaultActionDelete = TOKEN_ACTION_DELETE . ':' . TOKEN_ACTION_DELETE_REPORT;
        }

        $defaultBootstrapButton = isset($this->pageDefaults[DEFAULT_BOOTSTRAP_BUTTON][$columnName]) ? $this->pageDefaults[DEFAULT_BOOTSTRAP_BUTTON][$columnName] : '';

        # define default question only, if pagetype needs a question
        if (!empty($this->pageDefaults[DEFAULT_QUESTION][$columnName])) {
            $defaultQuestion = 'q:' . $this->pageDefaults[DEFAULT_QUESTION][$columnName];
        }

        foreach ($param as $key) {
            switch (substr($key, 0, 1)) {
                case TOKEN_PICTURE:
                case TOKEN_EDIT:
                case TOKEN_NEW:
                case TOKEN_DELETE:
                case TOKEN_HELP:
                case TOKEN_INFO:
                case TOKEN_SHOW:
                case TOKEN_BULLET:
                case TOKEN_CHECK:
                    $defaultImage = '';    // if any of the img token is given: no default
                    break;
                case TOKEN_SIP:
                    $defaultSip = '';    // if a hash definition is given: no default
                    break;
                case TOKEN_QUESTION:
                    $defaultQuestion = '';    // if a question is given: no default
                    break;
                case TOKEN_ACTION_DELETE:
                    $defaultActionDelete = '';
                    break;
                case TOKEN_BOOTSTRAP_BUTTON: // if a bootstrapButton setting is given: no default
                    $defaultBootstrapButton = '';
                    break;
                default:
                    break;
            }
        }

        $columnValue .= "|";

        // append defaults
        if ($defaultActionDelete !== '') {
            $columnValue .= $defaultActionDelete . "|";
        }

        if ($defaultImage !== '') {
            $columnValue .= $defaultImage . "|";
        }

        if ($defaultSip !== '') {
            $columnValue .= $defaultSip . "|";
        }

        if ($defaultQuestion !== '') {
            $columnValue .= $defaultQuestion . "|";
        }

        if ($defaultBootstrapButton !== '') {
            $columnValue .= $defaultBootstrapButton . "|";
        }

        return ($columnValue);
    }

    /**
     * Checks if a token 'y' is given. If not, prepend one.
     *
     * @param $columnName
     * @param $columnValue
     * @return string
     * @throws \UserFormException
     */
    private function doYank($columnName, $columnValue) {

        $token = KeyValueStringParser::parse($columnValue, PARAM_TOKEN_DELIMITER, PARAM_DELIMITER);

        if (!isset($token[TOKEN_COPY_TO_CLIPBOARD])) {
            $columnValue = TOKEN_COPY_TO_CLIPBOARD . PARAM_DELIMITER . $columnValue;
        }

        return $columnValue;
    }

    /**
     * Renders _download: extract token and determine if any default value has to be applied
     * [d:<exportFilename][U:<params>][u:<url>][F:file][t:<text>][a:<message>]|[o:<tooltip>]|[c:<class>]|[r:<render
     * mode>]
     *
     * @param string $columnName
     * @param string $columnValue
     *
     * @return string rendered link
     * @throws \CodeException
     */
    private function doDownload($columnName, $columnValue) {

        if ($columnValue == '') {
            return '';
        }

        $columNameToMode = [COLUMN_PDF => DOWNLOAD_MODE_PDF,
            COLUMN_SAVE_PDF => DOWNLOAD_MODE_PDF,
            COLUMN_FILE => DOWNLOAD_MODE_FILE,
            COLUMN_ZIP => DOWNLOAD_MODE_ZIP,
            COLUMN_EXCEL => DOWNLOAD_MODE_EXCEL];

        $param = explode('|', $columnValue);

        // Depending on the $columnName, get mode.
        if (!isset($columNameToMode[$columnName])) {
            throw new \CodeException("Unexpected column name: $columnName", ERROR_UNEXPECTED_TYPE);
        }

        $defaultMode = TOKEN_DOWNLOAD_MODE . ':' . $columNameToMode[$columnName];

        # get all default values, depending on the column name
        $defaultSip = TOKEN_SIP;
        $defaultDownload = TOKEN_DOWNLOAD;

        foreach ($param as $key) {
            switch (substr($key, 0, 1)) {
                case TOKEN_SIP:
                    $defaultSip = '';
                    break;

                case TOKEN_DOWNLOAD:
                    $defaultDownload = '';
                    break;

                case TOKEN_DOWNLOAD_MODE:
                    $defaultMode = '';
                    break;

                case TOKEN_RENDER:
                    if (isset($key[2]) && $key[2] == '5') {
                        return '';
                    }
                    break;

                default:
                    break;
            }
        }

        $columnValue .= "|";

        if ($defaultSip !== '') {
            $columnValue .= $defaultSip . "|";
        }

        if ($defaultDownload !== '') {
            // Action 'Download' needs to be specified at the beginning
//            $columnValue = $defaultDownload . "|" . $columnValue;
            $columnValue .= $defaultDownload . "|";
        }

        if ($defaultMode !== '') {
            $columnValue .= $defaultMode . "|";
        }

        return ($columnValue);
    }

    /**
     * Generate SortArgument
     *
     * @param $variable
     *
     * @return string
     */
    private function getResultArrayIndex($variable) {

        $variable = substr($variable, 1, strlen($variable));

        return "[" . preg_replace_callback("/[a-z]/", "replaceToIndex", $variable) . "][" . preg_replace_callback("/[^a-z]/", "replaceToIndex", $variable) . "]";

    }

    /**
     * @param $arr1
     * @param $arr2
     *
     * @return bool
     */
    private function compareArraystart($arr1, $arr2) {

        for ($i = 0; $i < count($arr1); $i++) {
            if ($arr1[$i] != $arr2[$i]) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param $arr1
     * @param $arr2
     *
     * @return bool
     */
    private function compareArraylength($arr1, $arr2) {

        if (count($arr1) + 1 == count($arr2)) {
            return true;
        }

        return false;
    }

    /**
     * @param $columnValue
     *
     * @return string
     */
    private function createInlineUpload($columnValue) {
        $uploadId = 0;
        $table = '';
        $recordData = '';
        $acceptType = '';
        $dbIndexParam = '';
        $renderHtml = true;
        $singleUploadClass = 'single-upload';
        $dbIndex = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM);
        $dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM);
        $maxFileSize = $this->store->getVar(SYSTEM_FILE_MAX_FILE_SIZE, STORE_SYSTEM) ?? null;
        $maxImageDimension = $this->store->getVar(SYSTEM_MAX_IMAGE_DIMENSION, STORE_SYSTEM) ?? null;
        $pdfAction = $this->store->getVar(SYSTEM_UPLOAD_ACTION, STORE_SYSTEM);
        $multiUpload = 0;
        $defaultText = 'Drag & Drop or <span class="btn btn-default filepond--label-action"> Browse </span>';
        $sipValuePreloadedFiles = '&allowDelete=none';

        $defaultPath = SYSTEM_UPLOAD_DIR_SECURE_DEFAULT . '/' . date("Y") . '/';

        $baseUrl = $this->store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM);
        $apiUrls['upload'] = Path::urlApi(API_FILE_PHP);
        $apiUrls['download'] = Path::urlApi(API_DOWNLOAD_PHP);
        $encodedApiUrls = htmlspecialchars(json_encode($apiUrls, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8');

        $jsonConfig = array();
        $preloadedFiles = '';
        $jsonConfig[UPLOAD_TEXT] = $defaultText;
        $jsonConfig[UPLOAD_DOWNLOAD_ALLOW] = true;
        $jsonConfig[UPLOAD_GLYPH_ICON] = '';
        $jsonConfig[F_UPLOAD_SUCCESS_MESSAGE] = 'Upload complete';

        // Define defaults:
        $defaultValues = 'x:1|table:FileUpload|M:0|maxFileSize:' . $maxFileSize . '|maxImageDimension: ' . $maxImageDimension . '|accept:application/pdf|maxFiles:null|allowUpload:true' . '|' . SYSTEM_UPLOAD_ACTION . ':' . $pdfAction;
        $assocDefault = KeyValueStringParser::explodeKvpSimple($defaultValues, PARAM_TOKEN_DELIMITER, PARAM_DELIMITER);

        // Return an assoc array like [ 'd' => 'file.pdf', 'p' => 'content', ... ]
        $assocGiven = KeyValueStringParser::explodeKvpSimple($columnValue, PARAM_TOKEN_DELIMITER, PARAM_DELIMITER);
        $finalArray = array_merge($assocDefault, $assocGiven);

        foreach ($finalArray as $token => $value) {
            switch ($token) {
                case TOKEN_UPLOAD_ID:
                    $uploadId = $value;
                    $jsonConfig[UPLOAD_ID] = $value;
                    break;
                case TOKEN_FILE:
                    $jsonConfig[UPLOAD_PATH_FILE_NAME] = $value;
                    break;
                case TOKEN_UPLOAD_MIME_TYPE_ACCEPT:
                    if ($value == '*' || $value == '*.*' || $value == '*/*') {
                        $value = '';
                    }
                    $jsonConfig[UPLOAD_MIME_TYPE_ACCEPT] = $value;
                    if ($value != 'null') {
                        $acceptType = '&accept=' . $value;
                    }
                    break;
                case TOKEN_UPLOAD_DELETE:
                    if ($value == '1' || $value == '') {
                        $value = true;
                    } else {
                        $value = false;
                    }
                    $jsonConfig[UPLOAD_DELETE_OPTION] = $value;
                    break;
                case TOKEN_SIP_TABLE:
                    $jsonConfig[SIP_TABLE] = $value;
                    $table = $value;
                    break;
                case TOKEN_UPLOAD_RECORD_DATA:
                    $jsonConfig[UPLOAD_RECORD_DATA] = KeyValueStringParser::explodeKvpSimple(trim($value), PARAM_TOKEN_DELIMITER, ',');
                    $recordData = '&recordData=' . json_encode($jsonConfig[UPLOAD_RECORD_DATA]);
                    break;
                case TOKEN_UPLOAD_MAX_FILE_SIZE:
                    $jsonConfig[UPLOAD_MAX_FILE_SIZE] = $value;
                    if ($value != '0') {
                        $maxFileSize = '&maxFileSize=' . $value;
                    }
                    break;
                case TOKEN_UPLOAD_MULTI_UPLOAD:
                    if ($value == '' || $value == 1) {
                        $value = true;
                        $multiUpload = 1;
                        $singleUploadClass = '';
                    }
                    $jsonConfig[UPLOAD_MULTI_UPLOAD] = $value;
                    break;
                // currently not used. Maybe useful for later implementation.
                case TOKEN_UPLOAD_IMAGE_EDITOR:
                    if ($value == '' || $value == 1) {
                        $value = true;
                    }
                    $jsonConfig[UPLOAD_IMAGE_EDITOR] = $value;
                    break;
                case TOKEN_UPLOAD_ALLOW:
                    if ($value == "0") {
                        $value = false;
                    }
                    $jsonConfig[UPLOAD_ALLOW] = $value;
                    break;
                case TOKEN_TEXT:
                    $jsonConfig[UPLOAD_TEXT] = $value;
                    break;
                case TOKEN_UPLOAD_MAX_FILES:
                    $jsonConfig[UPLOAD_MAX_FILES] = $value;
                    break;
                case TOKEN_DB_INDEX:
                    $dbIndexParam = '&dbIndex=' . $value;
                    if (in_array($value, [$dbIndexData, $this->dbIndexQfq])) {
                        $dbIndex = $value;
                    }
                    break;
                case TOKEN_RENDER:
                    if ($value == 5) {
                        $renderHtml = false;
                    }
                    if ($value == 3) {
                        $jsonConfig[UPLOAD_ALLOW] = false;
                        $jsonConfig[UPLOAD_DOWNLOAD_ALLOW] = false;
                        $jsonConfig[UPLOAD_DELETE_OPTION] = false;
                    }
                    break;
                case SYSTEM_MAX_IMAGE_DIMENSION:
                    // explode & implode to verify valid values.
                    $arr = OnArray::explodeDimension($value);
                    $maxImageDimension = '&maxImageDimension=' . implode('x', $arr);
                    break;
                case SYSTEM_UPLOAD_ACTION:
                    $pdfAction = $value;
                    break;
            }
        }

        if (!isset($jsonConfig[UPLOAD_PATH_FILE_NAME])) {
            $jsonConfig[UPLOAD_PATH_FILE_NAME] = $defaultPath;
            $jsonConfig[UPLOAD_PATH_DEFAULT] = 1;
        }

        // Get all records from given $recordId and $table
        if ($uploadId != 0) {
            if ($multiUpload) {
                $sqlPath = 'SELECT id, pathFileName, fileSize, mimeType FROM ' . $table . ' WHERE uploadId = ? ORDER BY created DESC';
                $groupId = $uploadId;
            } else {
                $sqlPath = 'SELECT id, pathFileName, fileSize, mimeType FROM ' . $table . ' WHERE id = ? ORDER BY created DESC';
                $sqlUploadId = 'SELECT uploadId FROM ' . $table . ' WHERE id = ?';
            }
            $result = $this->dbArr[$dbIndex]->sql($sqlPath, ROW_EXPECT_GE_1, [$uploadId], "File not found in database.");

            $idList = array_map(function ($item) {
                return $item['id'];
            }, $result);
            $idListString = implode(',', $idList);

            // Save result in array notation like this {"id":"recordId", "pathFileName":"/path/dir/file.png"}, {"id":"recordId", "pathFileName":"/path/dir/file.png"}
            $preloadedFiles = json_encode($result, JSON_UNESCAPED_SLASHES);
            $sipValuePreloadedFiles = '&allowDelete=' . $jsonConfig[UPLOAD_DELETE_OPTION] . '&preloadedFileIds=' . $idListString;
        } else {
            $sqlUploadId = 'SELECT MAX(uploadId) AS uploadId FROM ' . $table . ' WHERE id = ? GROUP BY "" LIMIT 1';
        }

        if ($uploadId == 0 || !$multiUpload) {
            $resultUploadId = $this->dbArr[$dbIndex]->sql($sqlUploadId, ROW_EXPECT_0_1, [$uploadId], "File not found in database.");
            $groupId = $resultUploadId[0][COLUMN_UPLOAD_ID] ?? 0;
        }

        $jsonConfig[UPLOAD_GROUP_ID] = $groupId ?? 0;

        // Create sip token
        $pdfAction = '&' . SYSTEM_UPLOAD_ACTION . '=' . $pdfAction;
        $sipValues['download'] = $this->sip->queryStringToSip('action=download&table=' . $table . $dbIndexParam, RETURN_SIP);
        $sipValues['upload'] = $this->sip->queryStringToSip('action=upload2&table=' . $table . $acceptType . $maxFileSize . $maxImageDimension . $recordData . $dbIndexParam . $pdfAction, RETURN_SIP);
        $sipValues['delete'] = $this->sip->queryStringToSip('action=delete&table=' . $table . $sipValuePreloadedFiles . $dbIndexParam, RETURN_SIP);

        $encodedSipValues = htmlspecialchars(json_encode($sipValues, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8');
        $encodedJsonConfig = htmlspecialchars(json_encode($jsonConfig, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8');
        $encodedPreloadFilesConfig = htmlspecialchars($preloadedFiles, ENT_QUOTES, 'UTF-8');

        if ($renderHtml) {
            return '<div class="fileuploadContainer"><input class="fileupload ' . $singleUploadClass . '" data-api-urls="' . $encodedApiUrls . '" data-sips="' . $encodedSipValues . '" data-config="' . $encodedJsonConfig . '" data-preloadedFiles="' . $encodedPreloadFilesConfig . '" type="file" /></div>';
        } else {
            return '';
        }
    }

    /**
     * Brings the actual open depth ($openDepth) to $targetDepth
     * - closes with lend[...] if $openDepth > $targetDepth
     * - opens with lbeg[...] if $targetDepth > $openDepth
     * Uses wrapResolve() wit fallback to lbeg[] and lend[]
     *
     * @return string HTML-Fragment from generated wrap
     */
    private function levelWrap(string $fullLevel, int &$openDepth, int $targetDepth): string {
        $html = '';

        // closing
        if ($openDepth > $targetDepth) {
            for ($d = $openDepth; $d > $targetDepth; $d--) {
                $tpl = $this->wrapResolve($fullLevel, TOKEN_LEND, $d);
                if ($tpl !== '') {
                    $html .= $this->variables->doVariables($tpl);
                }
            }
            $openDepth = $targetDepth;
        }

        // opening
        if ($targetDepth > $openDepth) {
            for ($d = $openDepth + 1; $d <= $targetDepth; $d++) {
                $tpl = $this->wrapResolve($fullLevel, TOKEN_LBEG, $d);
                if ($tpl !== '') {
                    $html .= $this->variables->doVariables($tpl);
                }
            }
            $openDepth = $targetDepth;
        }

        return $html;
    }

    /** Check for column _level value and return depth
     * Example values: 1 -> depth 1, 1.2 -> depth 2, 1.2.3 -> depth 3, etc.
     * _level -> Depth (0 if empty/not exists)
     *
     * @param array $row
     * @param $levelIndex
     * @return int
     */
    private function levelWrapDepth(array $row, $levelIndex): int {
        if ($levelIndex === false || $levelIndex === -1 || !isset($row[$levelIndex])) {
            return 0;
        }
        $levelStr = (string)$row[$levelIndex];
        if ($levelStr === '') return 0;

        $parts = array_filter(explode('.', $levelStr), static fn($x) => $x !== '');
        return count($parts);
    }

    /**
     * Resolve wrap value with fallback:
     * fullLevel.base[n] → base[] → base
     * Possible definitions: 1.lbeg[3], 1.lbeg[], 1.lbeg
     *
     * @param string $fullLevel
     * @param string $base
     * @param int $n
     * @return string
     */
    private function wrapResolve(string $fullLevel, string $base, int $n): string {
        $specific = $this->frArray[$fullLevel . "." . $base . "[$n]"] ?? null;
        if ($specific !== null && $specific !== '') return $specific;
        $bracket = $this->frArray[$fullLevel . "." . $base . "[]"] ?? null;
        if ($bracket !== null && $bracket !== '') return $bracket;
        return $this->frArray[$fullLevel . "." . $base] ?? '';
    }

    /**
     * Opens Row-Item: libeg (outside) + rbeg (inside, inkl. RBGD)
     *
     * @param string $fullLevel
     * @param int $depth
     * @param int $rowIndex
     * @param array $arrRbgd
     * @return string
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function emitRowOpen(string $fullLevel, int $depth, int $rowIndex, array $arrRbgd): string {
        $html = '';

        // libeg outside (if defined)
        $liBeg = $this->wrapResolve($fullLevel, TOKEN_LIBEG, $depth);
        if ($liBeg !== '') {
            $html .= $this->variables->doVariables($liBeg);
        }

        // rbeg inside (if defined) – RBGD replace
        $rbeg = $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_RBEG]);
        if ($rbeg !== '') {
            $html .= str_replace(TOKEN_RBGD, ($arrRbgd[$rowIndex % 2] ?? ''), $rbeg);
        }

        return $html;
    }

    /**
     * Closes Row-Item: rend (inside) + liend (outside)
     *
     * @param string $fullLevel
     * @param int $depth
     * @return string
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function emitRowClose(string $fullLevel, int $depth): string {
        $html = '';

        // rend inside (if defined)
        $rend = $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_REND]);
        if ($rend !== '') {
            $html .= $rend;
        }

        // liend outside (if defined) – Fallback to rend, if only liend used?
        $liEnd = $this->wrapResolve($fullLevel, TOKEN_LIEND, $depth);
        if ($liEnd !== '') {
            $html .= $this->variables->doVariables($liEnd);
        }

        return $html;
    }

    /**
     * Closes the relegation chain: </span></li></ul></li>… (rend is done per row already)
     *
     * @param string $fullLevel
     * @param int $openDepth
     * @param int $fromDepth
     * @param int $toDepth
     * @return string
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function closeDescending(string $fullLevel, int &$openDepth, int $fromDepth, int $toDepth): string {
        $html = '';

        // Close current item <li> at depth $fromDepth (no REND here)
        if ($fromDepth >= 1) {
            $liEnd = $this->wrapResolve($fullLevel, TOKEN_LIEND, $fromDepth);
            if ($liEnd !== '') {
                $html .= $this->variables->doVariables($liEnd);
            }
        }

        // Climb up: for each level, close </ul> then close the parent <li>
        for ($d = $fromDepth; $d > $toDepth; $d--) {
            $lend = $this->wrapResolve($fullLevel, TOKEN_LEND, $d);
            if ($lend !== '') {
                $html .= $this->variables->doVariables($lend);
            }

            if ($d - 1 >= 1) {
                $liEndParent = $this->wrapResolve($fullLevel, TOKEN_LIEND, $d - 1);
                if ($liEndParent !== '') {
                    $html .= $this->variables->doVariables($liEndParent);
                }
            }
        }

        $openDepth = $toDepth;
        return $html;
    }

    /**
     * Generate combined SIPs for all filter targets
     *
     * Creates one SIP per target containing:
     * - uid of the qfqFunction
     * - mode for content loading
     * - allowedValues for server-side validation (as JSON)
     *
     * @return array Map of targetClass => sipToken
     * @throws \DbException
     * @throws \UserReportException
     * @throws \CodeException
     * @throws \UserFormException
     */
    public function feGenerateFilterSips(): array {
        $filterSips = [];
        $db = $this->dbArr[$this->dbIndexQfq];

        // Get filter registry from Link.php
        $filterRegistry = $this->link->getFilterTargetRegistry();

        foreach ($filterRegistry as $targetClass => $targetInfo) {
            $qfqFunctionName = $targetInfo[INPUT_FILTER_KEY_QFQ_FUNCTION_NAME];
            $uidParams = $targetInfo[INPUT_FILTER_KEY_UID_PARAMS] ?? [];
            $paramsWithAllowed = $targetInfo[INPUT_FILTER_KEY_PARAMS];

            // Look up tt_content record by subheader/name
            $ttContentRecord = $db->getBodyText($qfqFunctionName);
            $uid = $ttContentRecord[COLUMN_UID];

            // Build combined SIP with uid, allowedValues, and any uid params
            // uidParams are merged so they're accessible via {{param:SE}} in the QFQ function
            $sipData = array_merge($uidParams, [
                COLUMN_UID => $uid,
                INPUT_FILTER_ALLOWED_VALUES => json_encode($paramsWithAllowed),
            ]);
            $sipParams = http_build_query($sipData);

            $filterSips[$targetClass] = $this->sip->queryStringToSip($sipParams, RETURN_SIP);
        }

        return $filterSips;
    }

    /**
     * Generate HTML output for filter SIPs
     *
     * Outputs a script tag with the SIP registry for JavaScript to use.
     * JS uses data-target to look up the SIP for each filter.
     *
     * @return string HTML with filter SIP data
     */
    public function feInlineFilterRenderSips(): string {
        $filterSips = $this->feGenerateFilterSips();

        if (empty($filterSips)) {
            return '';
        }

        $json = json_encode($filterSips);
        return "<script>window.qfqFilterSips = $json;</script>";
    }

}
