<?php
/**
 * Created by PhpStorm.
 * User: ep
 * Date: 12/23/15
 * Time: 6:33 PM
 */

namespace IMATHUZH\Qfq\Core;

require __DIR__ . '/../../vendor/autoload.php';

use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Database\DatabaseManager;
use IMATHUZH\Qfq\Core\Database\DatabaseUpdate;
use IMATHUZH\Qfq\Core\Exception\RedirectResponse;
use IMATHUZH\Qfq\Core\Form\Chat;
use IMATHUZH\Qfq\Core\Form\Dirty;
use IMATHUZH\Qfq\Core\Form\DragAndDrop;
use IMATHUZH\Qfq\Core\Form\FormAction;
use IMATHUZH\Qfq\Core\Form\FormAsFile;
use IMATHUZH\Qfq\Core\Helper\HelperFile;
use IMATHUZH\Qfq\Core\Helper\HelperFormElement;
use IMATHUZH\Qfq\Core\Helper\KeyValueStringParser;
use IMATHUZH\Qfq\Core\Helper\Logger;
use IMATHUZH\Qfq\Core\Helper\OnArray;
use IMATHUZH\Qfq\Core\Helper\OnString;
use IMATHUZH\Qfq\Core\Helper\Path;
use IMATHUZH\Qfq\Core\Helper\Support;
use IMATHUZH\Qfq\Core\Report\Link;
use IMATHUZH\Qfq\Core\Report\Monitor;
use IMATHUZH\Qfq\Core\Report\Report;
use IMATHUZH\Qfq\Core\Report\ReportAsFile;
use IMATHUZH\Qfq\Core\Store\FillStoreForm;
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 phpDocumentor\Reflection\DocBlock\Tags\See;

/**
 * Class Qfq
 *
 * @package qfq
 *
 * Form will be called
 * a) with a SIP identifier, or
 * b) without a SIP identifier (form setting has to allow this) and will create on the fly a new SIP.
 *
 * The SIP-Store stores:
 *  form=<formname>
 *  r=<record id>  (table.id for a single record form)
 *  <further individual variables>
 *
 */
class QuickFormQuery {

    public static $systemMessages = []; // See CODING.md section "System Messages to Developer"

    /**
     * @var Store instantiated class
     */
    protected $store = null;

    /**
     * @var Database[] - Array of Database instantiated class
     */
    protected $dbArray = array();

    /**
     * @var Evaluate instantiated class
     */
    protected $evaluate = null;

    protected $formSpec = array(); // Stores the form content after parsing SQL queries and QFQ syntax stored in form attributes
    protected $feSpecAction = array();  // Form Definition: copy of the loaded form
    protected $feSpecNative = array(); // FormEelement Definition: all formElement.class='action' of the loaded form
    protected $feSpecNativeRaw = array(); // FormEelement Definition: all formElement.class='action' of the loaded form

    /**
     * @var array
     */
    private $t3data = array(); // FormElement Definition: all formElement.class='native' of the loaded form

    /**
     * @var bool
     */
    private $phpUnit = false;

    /**
     * @var bool
     */
    private $inlineReport = false;

    /**
     * @var Session
     */
    private $session = null;

    /**
     * @var array
     */
    private $defaultValue = array();
    /**
     * @var bool
     */
    private bool $buildDevPanel = false;
    private $dbIndexData;
    private $dbIndexQfq;
    private $dbIndexT3;


    /*
     * TODO:
     *  Preparation: setup logging, database access, record locking
     *  fill stores
     *  Check permission_create / permission_update
     *  Multi: iterate over all records, Single: activate record
     *      Check mode: Load | Save
     *      doActions 'Before'
     *      Do all FormElements
     *      doActions 'After'
     */

    /**
     * Construct the Form Class and Store too. This is the base initialization moment.
     *
     * As a result of instantiating of Form, the class Store will initially called the first time and therefore
     * instantiated automatically. Store might throw an exception, in case the URL-passed SIP is invalid.
     *
     * @param array $t3data
     * @param bool $phpUnit
     * @param bool $inlineReport
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function __construct(array $t3data = array(), $phpUnit = false, $inlineReport = true, $isApi = false) {

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

        mb_internal_encoding("UTF-8");

        $this->session = Session::getInstance($phpUnit);
        // Refresh the session even if no new data saved.
        Session::set(SESSION_LAST_ACTIVITY, time());
        // For Api Request the Dev Panel Does not have to be built or when the preventBuildDevPanel is given.
        if (!($isApi || isset($_GET[DEV_PANEL_PREVENT_BUILD_PARAM]) || ($_COOKIE[DEV_PANEL_PREVENT_BUILD_PARAM] ?? '') === '1')) {
            $this->buildDevPanel = true;
            Session::addEmptyContentBlock();
        }

        // default value are saved in default store
        $this->defaultValue = [
            F_BTN_TOP_RIGHT_ORDER => FORM_BUTTON_HISTORY . ',' . FORM_BUTTON_ISSUE . '+' . FORM_BUTTON_FORM_VIEW . '+' . FORM_BUTTON_FORM_ELEMENT_EDIT . '+' . FORM_BUTTON_FORM_EDIT . ',' . FORM_BUTTON_SAVE . '+' . FORM_BUTTON_CLOSE . ',' . FORM_BUTTON_DELETE . '+' . FORM_BUTTON_NEW,
            F_BTN_TOP_LEFT_ORDER => FORM_BUTTON_NEW . '+' . FORM_BUTTON_DELETE . ',' . FORM_BUTTON_CLOSE . '+' . FORM_BUTTON_SAVE . ',' . FORM_BUTTON_FORM_EDIT . '+' . FORM_BUTTON_FORM_ELEMENT_EDIT . '+' . FORM_BUTTON_FORM_VIEW . ',' . FORM_BUTTON_HISTORY,
            F_BTN_FOOTER_RIGHT_ORDER => '',
            F_BTN_FOOTER_LEFT_ORDER => '',
            F_BTN_TOP_WRAP => '<div class="row"><div class="col-md-12">',
            F_BTN_TOP_LEFT_WRAP => '<div class="btn-toolbar pull-left" role="toolbar">',
            F_BTN_TOP_RIGHT_WRAP => '<div class="btn-toolbar pull-right" role="toolbar">',
            F_BTN_FOOTER_WRAP => '<div class="row"><div class="col-md-12">',
            F_BTN_FOOTER_LEFT_WRAP => '<div class="btn-toolbar pull-left" role="toolbar">',
            F_BTN_FOOTER_RIGHT_WRAP => '<div class="btn-toolbar pull-right" role="toolbar">',
            F_BUTTON_ON_CHANGE_CLASS => 'btn-info alert-info',
            SYSTEM_SAVE_BUTTON_DEFAULT => TOKEN_PAGE . ':' . FORM_BUTTON_SAVE . '|' . TOKEN_GLYPH . ':' . GLYPH_ICON_CHECK . '|' . TOKEN_BOOTSTRAP_BUTTON . '|' . TOKEN_CLASS . ':navbar-btn',
            SYSTEM_CLOSE_BUTTON_DEFAULT => TOKEN_PAGE . ':' . FORM_BUTTON_CLOSE . '|' . TOKEN_GLYPH . ':' . GLYPH_ICON_CLOSE . '|' . TOKEN_BOOTSTRAP_BUTTON . '|' . TOKEN_CLASS . ':navbar-btn',
            SYSTEM_DELETE_BUTTON_DEFAULT => TOKEN_PAGE . ':' . FORM_BUTTON_DELETE . '|' . TOKEN_GLYPH . ':' . GLYPH_ICON_DELETE . '|' . TOKEN_BOOTSTRAP_BUTTON . '|' . TOKEN_CLASS . ':navbar-btn',
            SYSTEM_NEW_BUTTON_DEFAULT => TOKEN_PAGE . ':' . FORM_BUTTON_NEW . '|' . TOKEN_GLYPH . ':' . GLYPH_ICON_NEW . '|' . TOKEN_BOOTSTRAP_BUTTON . '|' . TOKEN_CLASS . ':navbar-btn',
            SYSTEM_FORM_EDIT_BUTTON_DEFAULT => TOKEN_PAGE . ':' . FORM_BUTTON_FORM_EDIT . '|' . TOKEN_GLYPH . ':' . GLYPH_ICON_TOOL . '|' . TOKEN_BOOTSTRAP_BUTTON . '|' . TOKEN_CLASS . ':navbar-btn',
            SYSTEM_FORM_VIEW_BUTTON_DEFAULT => TOKEN_PAGE . ':' . FORM_BUTTON_FORM_VIEW . '|' . TOKEN_GLYPH . ':' . GLYPH_ICON_VIEW . '|' . TOKEN_BOOTSTRAP_BUTTON . '|' . TOKEN_CLASS . ':navbar-btn',
            SYSTEM_FORM_HISTORY_BUTTON_DEFAULT => TOKEN_PAGE . ':' . FORM_BUTTON_HISTORY . '|' . TOKEN_BOOTSTRAP_BUTTON . '|' . TOKEN_CLASS . ':navbar-btn qfq-history-btn'
        ];

        // PHPExcel
        $this->updateIncludePath();

        // set dummy values if QuickFormQuery is not called by Typo3
        $t3data[T3DATA_BODYTEXT] = $t3data[T3DATA_BODYTEXT] ?? '';
        $t3data[T3DATA_UID] = $t3data[T3DATA_UID] ?? 0;
        $t3data[T3DATA_SUBHEADER] = $t3data[T3DATA_SUBHEADER] ?? 0;
        $t3data[T3DATA_HEADER] = $t3data[T3DATA_HEADER] ?? '';

        // Read report file, if file keyword exists in bodytext
        $reportPathFileNameFull = ReportAsFile::parseFileKeyword($t3data[T3DATA_BODYTEXT]);
        if ($reportPathFileNameFull !== null) {
            $t3data[T3DATA_BODYTEXT] = ReportAsFile::read_report_file($reportPathFileNameFull);
        }

        $btp = new BodytextParser();
        $t3data[T3DATA_BODYTEXT_RAW] = $t3data[T3DATA_BODYTEXT];
        $t3data[T3DATA_BODYTEXT] = $btp->process($t3data[T3DATA_BODYTEXT]);

        $this->t3data = $t3data;

        $bodytext = $this->t3data[T3DATA_BODYTEXT];

        $this->store = Store::getInstance($bodytext, $phpUnit);

        $t3ConfigQfq = $this->store::getStore(STORE_SYSTEM);

        Session::checkSessionExpired($t3ConfigQfq[SYSTEM_SESSION_TIMEOUT_SECONDS]);

        if (Session::hasFeUserChanged()) {
            // Clear user stores. In case there is a logged-in user, initialize the user stores
            // with prepared data.
            $this->store->clearUserData(T3Handler::isFeUserLoggedIn());
        }

        if (T3Handler::isFeUserLoggedIn()) {
            $currentLastLogin = $GLOBALS["TSFE"]->fe_user->user["lastlogin"] ?? 0;
            $storedLastLogin = $this->store->getVar(STORE_USER_LAST_LOGIN, STORE_USER);
            if ($storedLastLogin !== false && (int)$storedLastLogin !== (int)$currentLastLogin) {
                // Same user logged out and back in: clear STORE_USER
                $this->store->clearUserData(true);
            }
            if ($this->store->getVar(STORE_USER_LAST_LOGIN, STORE_USER) === false) {
                $this->store->setVar(STORE_USER_LAST_LOGIN, $currentLastLogin, STORE_USER);
            }
        }

        $this->store->setVar(TYPO3_TT_CONTENT_UID, $t3data[T3DATA_UID], STORE_TYPO3);
        $this->store->setVar(TYPO3_TT_CONTENT_SUBHEADER, $t3data[T3DATA_SUBHEADER], STORE_TYPO3);

        // Adds line numbers together with level to TYPO3 store
        // E.g. [parsed.1 => 2, parsed.1.2 => 4, ...]
        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);
            }
        }

        $this->dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM);
        $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM);
        $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) {
            $this->dbArray[$this->dbIndexT3] = new Database($this->dbIndexT3, $t3DbConfig);
        } else {
            // Fallback to qfq user credentials. These are used usually for typo3 db.
            $this->dbArray[$this->dbIndexT3] = new Database($this->dbIndexQfq);
        }

        $this->dbArray[$this->dbIndexData] = new Database($this->dbIndexData);

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


        // SUPER HACK: to render inline editor when an exception is thrown
        // Can't use store, since store needs bodytext to be parsed, which might throw exceptions if there is a syntax error.
        \UserReportException::$report_uid = $t3data[T3DATA_UID];
        \UserReportException::$report_bodytext = $t3data[T3DATA_BODYTEXT];
        \UserReportException::$report_header = $t3data[T3DATA_HEADER];
        \UserReportException::$report_pathFileName = $reportPathFileNameFull;
        \UserReportException::$report_db = $this->dbArray[$this->dbIndexT3];

        $this->evaluate = new Evaluate($this->store, $this->dbArray[$this->dbIndexData]);

        $dbUpdate = $this->store->getVar(SYSTEM_DB_UPDATE, STORE_SYSTEM);
        $updateDb = new DatabaseUpdate($this->dbArray[$this->dbIndexQfq], $this->store);
        if ('' != $this->store::getVar(TYPO3_BE_USER, STORE_TYPO3 . STORE_EMPTY) || defined('PHPUNIT_QFQ')) {
            // Check and update only in a session of a BE User.
            $updateDb->checkNupdate($dbUpdate, $t3ConfigQfq);
        }
        // Set the tableFileSplit into store System
        $updateDb->getTableSplitFlag();

        // Compute dynamic variables
        // Do this after the DB-update
        $this->store->clearDynamicVariablesFromStoreSystem();
        $this->store->fillStoreSystemBySql();
        $this->store->fillStoreSystemBySqlRow();

        // Set dbIndex, evaluate any
        $dbIndex = $this->store->getVar(TOKEN_DB_INDEX, STORE_TYPO3 . STORE_EMPTY);
        $dbIndex = $this->evaluate->parse($dbIndex);
        $dbIndex = ($dbIndex == '') ? $this->dbIndexData : $dbIndex;
        $this->store->setVar(TOKEN_DB_INDEX, $dbIndex, STORE_TYPO3);

        // Create report file if file keyword not found (and auto export is enabled in qfq settings)
        if ($reportPathFileNameFull === null && $t3data[T3DATA_UID] !== 0 && strtolower($this->store->getVar(SYSTEM_REPORT_AS_FILE_AUTO_EXPORT, STORE_SYSTEM)) === 'yes') {
            $reportPathFileNameFull = ReportAsFile::create_file_from_ttContent($t3data[T3DATA_UID], $this->dbArray[$this->dbIndexT3]);
        }

        // Save pathFileName for use in inline editor
        $this->t3data[T3DATA_REPORT_PATH_FILENAME] = $reportPathFileNameFull;

        if (SYSTEM_REPORT_MIN_PHP_VERSION_YES == $this->store::getVar(SYSTEM_REPORT_MIN_PHP_VERSION, STORE_SYSTEM)) {
            if (version_compare(PHP_VERSION, MIN_PHP_VERSION) < 0) {
                throw new \UserReportException("Minimal required PHP Version: " . MIN_PHP_VERSION, ERROR_PHP_VERSION);
            }
        }

        $this->store->fillStoreSystemBySqlRow($this->evaluate);

        // Daily clean up tasks
        $this->checkOncePerDay($this->store->getVar(SYSTEM_CACHE_PURGE_FILES_OLDER_DAYS, STORE_SYSTEM));
    }

    /**
     * Tasks which should be done once a day.
     *
     * @param $days
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function checkOncePerDay($days) {

        // Check if we have been called already today
        $markerOncePerDay = sys_get_temp_dir() . '/' . MARKER_ONCE_A_DAY;
        $diff = 86400;
        if (file_exists($markerOncePerDay)) {
            $diff = time() - filemtime($markerOncePerDay);
        }

        if ($diff < 86400) {
            // Seems we have been called today.
            return;
        }

        // Update marker
        try {
            if (!@touch($markerOncePerDay)) {
                throw new \UserFormException("Permission denied: can't touch " . $markerOncePerDay);
            }
        } catch (Exception $e) {
            throw new \UserFormException("Permission denied: can't touch " . $markerOncePerDay . '\n' . $e->getMessage());
        }

        // Tasks to be done once a day:
        $this->purgeCache();

        // Check if fileadmin/protected is not accessible when flag is active.
        $flagProtectedFolderCheck = $this->store::getVar(SYSTEM_PROTECTED_FOLDER_CHECK, STORE_SYSTEM);
        if ($flagProtectedFolderCheck === '1' && !defined('PHPUNIT_QFQ')) {
            Support::checkProtectedFolderSecured();
        }
    }

    /**
     * Remove files older than SYSTEM_CACHE_PURGE_FILES_OLDER_DAYS days in SYSTEM_CACHE_DIR_SECURE/qfq.temp.*
     *
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function purgeCache() {

        // Purge Cache
        $dir = Path::absoluteApp($this->store->getVar(SYSTEM_CACHE_DIR_SECURE, STORE_SYSTEM));
        $days = $this->store->getVar(SYSTEM_CACHE_PURGE_FILES_OLDER_DAYS, STORE_SYSTEM);
        if (empty($days) || empty($dir) || !file_exists($dir)) {
            return;
        }

        // Take care only to remove QFQ owned cache files.
        $pattern = TMP_FILE_PREFIX . '*';
        $cmd = "find $dir -maxdepth 1 -name '$pattern' -type f -mtime +$days -delete";
        system($cmd, $rc);
    }

    /**
     * Returns the defined forwardMode and set forwardPage
     *
     * @return array
     * @throws \CodeException
     * @throws \UserFormException
     */
    public function getForwardMode() {

        if (!isset($this->formSpec[F_FORWARD_PAGE])) {
            // For QFQ inline editing: no redirect and no further processing.
            return [API_REDIRECT => API_ANSWER_REDIRECT_NO, API_REDIRECT_URL => ''];
        }

        $forwardPage = $this->formSpec[F_FORWARD_PAGE];

        //Language handling for T3 V10 and above
        if (OnString::strStartsWith($forwardPage, '/') && T3Handler::typo3VersionGreaterEqual10()) {
            $languagePath = Store::getVar(TYPO3_PAGE_LANGUAGE_PATH, STORE_TYPO3);
        }

        if (isset($languagePath) && $languagePath !== '') {
            $forwardPage = '/' . $languagePath . $forwardPage;
        }

        switch ($this->formSpec[F_FORWARD_MODE]) {
            case F_FORWARD_MODE_URL_SIP:
                $forwardPage = store::getSipInstance()->queryStringToSip($forwardPage, RETURN_URL);
                $this->formSpec[F_FORWARD_MODE] = F_FORWARD_MODE_URL;
                break;
            case F_FORWARD_MODE_URL_SIP_SKIP_HISTORY:
                // F_FORWARD_MODE_URL_SIP is not defined in API PROTOCOL. At the moment it's only used for 'copyForm'.
                // 'copyForm' behaves better if the page is not in history.
                // An option for better implementing would be to separate SKIP History from ForwardMode. For API, it can be combined again.
                $forwardPage = store::getSipInstance()->queryStringToSip($forwardPage, RETURN_URL);
                $this->formSpec[F_FORWARD_MODE] = F_FORWARD_MODE_URL_SKIP_HISTORY;
                break;
            default:
                break;
        }

        return ([
            API_REDIRECT => $this->formSpec[F_FORWARD_MODE],
            API_REDIRECT_URL => $forwardPage,
        ]);
    }

    /**
     * Main entry point to display content: a) form and/or b) report
     *
     * @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(): string {
        $html = '';

        $render = $this->store->getVar(SYSTEM_RENDER, STORE_TYPO3 . STORE_SYSTEM);
        if ($render == SYSTEM_RENDER_API && isset($GLOBALS['TYPO3_CONF_VARS'])) {
            return '';
        }

        if ($this->store->getVar(TYPO3_DEBUG_SHOW_BODY_TEXT, STORE_TYPO3) === 'yes') {
            $ttContentUid = $this->store->getVar(TYPO3_TT_CONTENT_UID, STORE_TYPO3);

            $htmlId = HelperFormElement::buildFormElementId($ttContentUid, 0, 0);
            $html .= Support::doTooltip($htmlId . HTML_ID_EXTENSION_TOOLTIP, $this->t3data[T3DATA_BODYTEXT]);
        }

        $html .= $this->doForm(FORM_LOAD);
        if ($render == SYSTEM_RENDER_BOTH || $render == SYSTEM_RENDER_API || ($render == SYSTEM_RENDER_SINGLE && $html == '')) {
            $html .= $this->doReport();
        }

        // Only needed if there are potential 'download'-links, which shall show a popup during processing of the download.
        if ($this->store->getVar(SYSTEM_DOWNLOAD_POPUP, STORE_SYSTEM) == DOWNLOAD_POPUP_REQUEST) {
            $html .= $this->getModalCode();
        }

        // Only needed if there are 'drag and drop' elements.
        if ($this->store->getVar(SYSTEM_DRAG_AND_DROP_JS, STORE_SYSTEM) == 'true') {
            $html .= $this->getDragAndDropCode();
        }

        $class = $this->store->getVar(SYSTEM_CSS_CLASS_QFQ_CONTAINER, STORE_SYSTEM);
        if ($class) {
            $html = Support::wrapTag("<div class='$class'>", $html);
        }

        // Show system messages if be-user logged in
        if (T3Info::beUserLoggedIn()) {
            foreach (self::$systemMessages as $message) {
                $html = '<div class="alert alert-warning">' . $message . '</div>' . $html;
            }

            if ($this->buildDevPanel) {
                $html .= $this->buildDevelopmentPanel();
                $this->store->setStoreStateToSession();
            }
        }
        return $html;
    }

    /**
     * Builds and returns the HTML structure for the Developer Panel.
     *
     * The panel includes interactive tabs for viewing Stores, Logs, System Info,
     * Link generation, and Performance data. It generates secure SIP-based API endpoints
     * and log file download links, and embeds them as JSON in data attributes.
     *
     * Visibility and refresh logic are controlled via session flags. If the build flag is set to
     * "no" and the last build was less than 5 minutes ago, the method returns an empty string.
     *
     * @return string The full HTML output for the developer panel
     *
     * @throws \UserReportException If a user-level error occurs
     * @throws \DbException If a database operation fails
     * @throws \CodeException If a logic or configuration error occurs
     * @throws \UserFormException If a form validation or submission error occurs
     * @throws RedirectResponse
     */
    public function buildDevelopmentPanel(): string {
        $buildFlag = Session::get(DEV_PANEL_SESSION_FLAG_BUILD);

        $timeElapsedSinceBuild = hrtime(true) - (Session::get(DEV_PANEL_SESSION_FLAG_BUILD_TIME) ?? 0);
        // Fallback: In case the flag does not get reset by the API then after 5min ignore the flag and build.
        if ($buildFlag === DEV_PANEL_SESSION_FLAG_BUILD_NO && $timeElapsedSinceBuild <= DEV_PANEL_REBUILD_TIMEOUT_NS) {
            return '';
        }

        // Create a Link instance for sip generation
        $sip = $this->store->getSipInstance() ?? new Sip();
        $link = new Link($sip, DatabaseManager::getInstance()->indexData);

        // Build sip link for download of log files
        $downloadButtons = [];
        // qfq.log
        $qfqLogPath = Path::absoluteQfqLogFile();
        $downloadButtons[API_LOGS_QFQ_KEY] = $link->renderLink('', 's|M:file|d|r:7|F:' . $qfqLogPath);
        // sql.log
        $sqlLogPath = Path::absoluteSqlLogFile();
        $downloadButtons[API_LOGS_SQL_KEY] = $link->renderLink('', 's|M:file|d|r:7|F:' . $sqlLogPath);
        // mail.log
        $mailLogPath = Path::absoluteMailLogFile();
        $downloadButtons[API_LOGS_MAIL_KEY] = $link->renderLink('', 's|M:file|d|r:7|F:' . $mailLogPath);


        // Build sip link for API
        $apiSips = [];

        // Stores SIP
        $storeSip = [API_DEV_PANEL_ACTION => API_DEV_PANEL_ACTION_GET_STORE, FAKE_NEVER_USE_ID => uniqid()];
        $apiSips[JS_ARRAY_KEY_STORE] = $sip->queryStringToSip(OnArray::toString($storeSip), RETURN_SIP);

        // Info
        $infoSip = [API_DEV_PANEL_ACTION => API_DEV_PANEL_ACTION_GET_INFO, FAKE_NEVER_USE_ID => uniqid()];
        $apiSips[JS_ARRAY_KEY_INFO] = $sip->queryStringToSip(OnArray::toString($infoSip), RETURN_SIP);

        // Link Gen
        $linkGenSip = [API_DEV_PANEL_ACTION => API_DEV_PANEL_ACTION_GENERATE_LINK, FAKE_NEVER_USE_ID => uniqid()];
        $apiSips[JS_ARRAY_KEY_LINK] = $sip->queryStringToSip(OnArray::toString($linkGenSip), RETURN_SIP);

        // Performance
        $performanceSip = [API_DEV_PANEL_ACTION => API_DEV_PANEL_ACTION_GET_PERFORMANCE, FAKE_NEVER_USE_ID => uniqid()];
        $apiSips[JS_ARRAY_KEY_PERFORMANCE] = $sip->queryStringToSip(OnArray::toString($performanceSip), RETURN_SIP);

        // Logs pooling
        $logsSip = [API_DEV_PANEL_ACTION => API_DEV_PANEL_ACTION_POLL_LOGS, FAKE_NEVER_USE_ID => uniqid()];
        $apiSips[JS_ARRAY_KEY_LOGS] = $sip->queryStringToSip(OnArray::toString($logsSip), RETURN_SIP);

        // Clear Dirty Table
        $clearDirtySip = [API_DEV_PANEL_ACTION => API_DEV_PANEL_ACTION_CLEAR_DIRTY, FAKE_NEVER_USE_ID => uniqid()];
        $apiSips[JS_ARRAY_KEY_CLEAR_DIRTY] = $sip->queryStringToSip(OnArray::toString($clearDirtySip), RETURN_SIP);

        // Apply Changes
        $applyChangesSip = [API_DEV_PANEL_ACTION => API_DEV_PANEL_ACTION_APPLY_CHANGES, FAKE_NEVER_USE_ID => uniqid()];
        $apiSips[JS_ARRAY_KEY_APPLY_CHANGES] = $sip->queryStringToSip(OnArray::toString($applyChangesSip), RETURN_SIP);

        // Endpoint URL
        $endpointUrl = Path::urlApp() . DEV_PANEL_API_ENDPOINT;

        // Prep Links for insertion into dev-panel div
        $apiSips = htmlspecialchars(json_encode($apiSips, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8');
        $downloadButtons = htmlspecialchars(json_encode($downloadButtons, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8');

        // Have changes been applied
        $changesApplied = Session::get(DEV_PANEL_SESSION_FLAG_CHANGES);
        $changesBannerHtml = '';
        if ($changesApplied) {
            $changesBannerHtml = <<<HTML
    <div class="dev-panel-banner">
        🚧 Changes are currently being applied
    </div>
HTML;
        }

        // if it is a unit test clear
        if ($this->phpUnit) {
            $apiSips = '';
            $downloadButtons = '';
            $endpointUrl = '';
        }

        $html = <<<EOD
                <button id="toggle-dev-panel" title="Open Dev Panel">Dev &#9881;</button>
                <div id="dev-panel" data-endpoint-url="$endpointUrl" data-api-sips="$apiSips" data-download-buttons="$downloadButtons">
                    $changesBannerHtml
                    <div class="tabs-dev-panel">
                        <div class="tab-dev-panel active" data-tab="stores">
                            Stores <span class="status-pill loading" id="status-stores" title="Waiting for API call..."></span>
                        </div>
                        <div class="tab-dev-panel" data-tab="logs">
                            Logs <span class="status-pill loading" id="status-logs" title="Waiting for API call..."></span>
                        </div>
                        <div class="tab-dev-panel" data-tab="new-link">
                            Gen Link <span class="status-pill loading" id="status-new-link" title="Waiting for API call..."></span>
                        </div>
                        <div class="tab-dev-panel" data-tab="performance">
                            Performance <span class="status-pill loading" id="status-performance" title="Waiting for API call..."></span>
                        </div>
                        <div class="tab-dev-panel" data-tab="info">
                            Info <span class="status-pill loading" id="status-info" title="Waiting for API call..."></span>
                        </div>
                    </div>
                    <div class="tab-content-dev-panel" id="tab-content-dev-panel">
                
                    </div>
                    <div class="button-row-dev-panel">
                        <button id="clear-dirty-dev-panel">Clear Dirty</button>
                        <button id="apply-changes-dev-panel">Apply Changes</button>
                        <button id="close-dev-panel" class="close-button-dev-panel">&rarr;</button>
                    </div>
                </div>
                EOD;

        // Set Session flag so that this function does not execute when other content is loaded on the same Page
        Session::set(DEV_PANEL_SESSION_FLAG_BUILD, DEV_PANEL_SESSION_FLAG_BUILD_NO);
        Session::set(DEV_PANEL_SESSION_FLAG_BUILD_TIME, hrtime(true));
        return $html;
    }

    /**
     * Determine the name of the language parameter field, which has to be taken to fill language specific definitions.
     *
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function setParameterLanguageFieldName() {

        $typo3PageLanguage = $this->store->getVar(TYPO3_PAGE_LANGUAGE, STORE_TYPO3);
        if (empty($typo3PageLanguage)) {
            return;
        }

        foreach (['A', 'B', 'C', 'D'] as $key) {
            $languageIdx = SYSTEM_FORM_LANGUAGE . "$key" . "Id";
            if ($this->store->getVar($languageIdx, STORE_SYSTEM) == $typo3PageLanguage) {
                $this->store->setVar(SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME, 'parameterLanguage' . $key, STORE_SYSTEM);
                break;
            }
        }
    }

    /**
     * Creates an empty file. This indicates that the current form is in debug mode. Returns HTML element which will be
     * replaced by the logfile.
     *
     * @param $formName
     * @param $formLogMode
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function getFormLog($formName, $formLogMode) {

        $formLogFileName = Support::getFormLogFileName($formName, $formLogMode);
        file_put_contents($formLogFileName, '');

        $monitor = new Monitor();

        return "<pre id='" . FORM_LOG_HTML_ID . "'>Please wait</pre>" .
            $monitor->process([TOKEN_L_FILE => $formLogFileName, TOKEN_L_APPEND => '1', TOKEN_L_HTML_ID => FORM_LOG_HTML_ID]);
    }

    /**
     * Process form.
     * $mode=
     *   FORM_LOAD: The whole form will be rendered as HTML Code, including the values of all form elements
     *   FORM_UPDATE: States and values of all form elements will be returned as JSON.
     *   FORM_SAVE: The submitted form will be saved. Return Failure or Success as JSON.
     *   FORM_DELETE:
     *
     * @param string $formMode FORM_LOAD | FORM_UPDATE | FORM_SAVE | FORM_DELETE
     *
     * @return array|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
     */
    private function doForm($formMode) {
        $data = '';
        $foundInStore = '';
        $flagApiStructureReGroup = true;
        $formModeNew = '';
        $build = null;

        // Check if there is a recordId specified in Bodytext - parse if it is a query
        $rTmp = $this->store->getVar(SIP_RECORD_ID, STORE_TYPO3, SANITIZE_ALLOW_ALL);
        if (false !== $rTmp && !ctype_digit($rTmp)) {
            $rTmp = $this->evaluate->parse($rTmp);
            $this->store->setVar(SIP_RECORD_ID, $rTmp, STORE_TYPO3);
        }

        $recordId = $this->store->getVar(SIP_RECORD_ID, STORE_TYPO3 . STORE_SIP . STORE_CLIENT . STORE_ZERO, SANITIZE_ALLOW_DIGIT, $foundInStore);
        $this->setParameterLanguageFieldName();

        $formName = $this->loadFormSpecification($formMode, $recordId, $foundInStore, $formLogMode);
        if ($formName !== false && $formLogMode !== false) {
            return $this->getFormLog($formName, $formLogMode);
        }

        if ($formName === false) {
            switch ($formMode) {
                /*case FORM_INLINE_EDIT_LOAD:
                    $formModeNew = FORM_INLINE_EDIT_LOAD;
                    break;
                case FORM_INLINE_EDIT_SAVE:
                    $formModeNew = FORM_INLINE_EDIT_SAVE;
                    break;*/
                case FORM_DELETE:
                    $formModeNew = FORM_DELETE;
                    break;
                case FORM_DRAG_AND_DROP:
                    throw new \CodeException('Missing form in SIP', ERROR_MISSING_FORM);
                default:
                    return '';// No form found: do nothing
            }
        }

        // Check 'session expire' happens quite late, because it can be configured per form.
        if ($formName !== false) {
            Session::checkSessionExpired($this->formSpec[F_SESSION_TIMEOUT_SECONDS]);
        }

        // Fill STORE_FORM: might need Form.fillStoreVar={{!SELECT ...}}) to provide STORE_VAR - therefore the FORM-definition should already been processed. #8058
        switch ($formMode) {
            case FORM_UPDATE:
            case FORM_SAVE:
            case FORM_REST:
                $fillStoreForm = new FillStoreForm();
                $fillStoreForm->process($formMode);

                // STORE_TYPO3 has been filled: fire fillStoreVar again.
                if (!empty($this->formSpec[FE_FILL_STORE_VAR])) {
                    $this->fillStoreVar($this->formSpec[FE_FILL_STORE_VAR]);
                }

                break;
        }

        if ($formName !== false) {
            // Validate (only if there is a 'real' form, not a FORM_DELETE with only a table name).
            // Attention: $formModeNew will be set
            $sipFound = $this->validateForm($foundInStore, $formMode, $formModeNew);

        } else {
            // FORM_DELETE without a form definition: Fake the form with only a tableName.
            $table = $this->store->getVar(SIP_TABLE, STORE_SIP);
            if ($table === false) {
                throw new \UserFormException("No 'form' and no 'table' definition found.", ERROR_MISSING_VALUE);
            }

            $sipFound = true;
            $this->formSpec[F_NAME] = '';
            $this->formSpec[F_TABLE_NAME] = $table;
            $this->formSpec[F_RECORD_LOCK_TIMEOUT_SECONDS] = 1; // just indicate a timeout, the exact timeout is stored in the dirty record.
            $this->formSpec[F_DIRTY_MODE] = DIRTY_MODE_EXCLUSIVE; // just set a mode, the exact mode is stored in the dirty record.
            $this->formSpec[F_PRIMARY_KEY] = F_PRIMARY_KEY_DEFAULT;
            $this->formSpec[F_MULTI_MODE] = F_MULTI_MODE_NONE;

            $tmpDbIndexData = $this->store->getVar(PARAM_DB_INDEX_DATA, STORE_SIP);
            if (!empty($tmpDbIndexData)) {
                $this->formSpec[F_DB_INDEX] = $tmpDbIndexData;
                if ($tmpDbIndexData != $this->dbIndexData) {
                    if (!isset($this->dbArray[$tmpDbIndexData])) {
                        $this->dbArray[$tmpDbIndexData] = new Database($tmpDbIndexData);
                    }
                }
            }
        }

        if (FEATURE_FORM_FILE_SYNC) {
            // FormAsFile: If the record is a Form or FormElement get the new and the old form file name and make sure both files are writable (before DB changes are made).
            // Note: This can't be done earlier because $formModeNew might be changed in the lines above.
            list($formFileName, $formFileNameDelete) = $this->formAsFileBeforeSave($recordId, $formModeNew);
        }

        // For 'new' record (non mulitform) always create a new Browser TAB-uniq (for this current form, nowhere else used) SIP.
        // With such a Browser TAB-uniq SIP, multiple Browser TABs and following repeated NEWs are easily implemented.
        if ($formMode != FORM_REST) {
            if (!$sipFound || ($formMode == FORM_LOAD && $recordId == 0 && $this->formSpec[F_MULTI_SQL] == '')) {
                $this->store->createSipAfterFormLoad($formName);
            }
        }

        // Change recordId from Multiform to 0 - No row exception possible
        if (($this->formSpec[F_MULTI_MODE] ?? '') !== F_MULTI_MODE_NONE) {
            if (($this->formSpec[F_MULTIFORM_DELETE_ROW] ?? 0) == 1) {
                $deleterowId = $recordId;
                $this->store->setVar(MULTIFORM_DELETE_ROW_ID, $deleterowId, STORE_SIP);
            }

            $recordId = 0;
            $this->store->setVar(SIP_RECORD_ID, $recordId, STORE_SIP);
        }

        // Fill STORE_BEFORE
        if ($formName !== false && $this->store->getVar($this->formSpec[F_PRIMARY_KEY], STORE_BEFORE) === false) {
            $this->store->fillStoreWithRecord($this->formSpec[F_TABLE_NAME], $recordId,
                $this->dbArray[$this->dbIndexData], $this->formSpec[F_PRIMARY_KEY], STORE_BEFORE);
        }

        // Check (and release) dirtyRecord.
        if ($formModeNew === FORM_DELETE || $formModeNew === FORM_SAVE) {
            $dirty = new Dirty(false, $this->dbIndexData, $this->dbIndexQfq);

            $answer = $dirty->checkDirtyAndRelease($formModeNew, $this->formSpec[F_RECORD_LOCK_TIMEOUT_SECONDS],
                $this->formSpec[F_DIRTY_MODE], $this->formSpec[F_TABLE_NAME], $this->formSpec[F_PRIMARY_KEY], $recordId, true);

            // In case of a conflict, return immediately
            if ($answer[API_STATUS] != API_ANSWER_STATUS_SUCCESS) {
                $answer[API_STATUS] = API_ANSWER_STATUS_ERROR;

                return $answer;
            }
        }

        // FORM_LOAD: if there is a foreign exclusive record lock - show form in F_MODE_READONLY mode.
        if ($formModeNew === FORM_LOAD) {
            $dirty = new Dirty(false, $this->dbIndexData, $this->dbIndexQfq);
            $recordDirty = array();
            $rcLockFound = $dirty->getCheckDirty($this->formSpec[F_TABLE_NAME], $recordId, $recordDirty, $msg);

            // Switch to READONLY
            if (($rcLockFound == LOCK_FOUND_CONFLICT || $rcLockFound == LOCK_FOUND_OWNER)
                && $recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE) {
                $this->formSpec[F_MODE_GLOBAL] = F_MODE_READONLY;
            }
        }

        switch ($formModeNew) {
            case FORM_DELETE:
                $build = new Delete($this->dbIndexData);
                break;
            case FORM_REST:
                break;
            case FORM_LOAD:
            case FORM_SAVE:
            case FORM_UPDATE:
            case FORM_DRAG_AND_DROP:

                $tableDefinition = $this->dbArray[$this->dbIndexData]->getTableDefinition($this->formSpec[F_TABLE_NAME]);
                $this->store->fillStoreTableDefaultColumnType($tableDefinition);

                // Check if empty columns exists and set default from fe or database
                $this->setFeDefaultValues();

                foreach ($this->defaultValue as $key => $value) {
                    $this->store->setVar($key, $value, STORE_DEFAULT, false);
                }

                // Check if the defined column primary key exist.
                if ($this->store::getVar($this->formSpec[F_PRIMARY_KEY], STORE_TABLE_COLUMN_TYPES) === false) {
                    throw new \UserFormException("Primary Key '" . $this->formSpec[F_PRIMARY_KEY] . "' not found in table " . $this->formSpec[F_TABLE_NAME], ERROR_INVALID_OR_MISSING_PARAMETER);
                }

                // Necessary after save, otherwise mode of children is reset
                $this->passDownModeFromContainerToChildren();

                switch ($this->formSpec['render']) {
                    case 'bootstrap':
                        $build = new FormBuilder($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
                        break;
                    default:
                        throw new \CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
                }
                break;

            /*case FORM_INLINE_EDIT_LOAD:
                $renderer = new FormElementRenderBS3();
                // Create formElement based on variables stored in STORE_SIP
                $fe = FeFactory::createFe($this->store::getVar(INLINE_EDIT_RECORD_ID,STORE_SIP),
                    $this->store::getVar(FE_VALUE,STORE_SIP),
                    $this->store::getVar(INLINE_EDIT_COLUMN,STORE_SIP),
                    $this->store::getVar(INLINE_EDIT_TABLE,STORE_SIP),
                    $this->store::getVar(INLINE_EDIT_FE_TYPE,STORE_SIP),
                    $this->dbIndexData
                );
                // Prepare feAttributes for rendering
                $fe->process();
                // Generate HTML representation of formElement
                $data = $renderer->renderFormElement($fe);
                break;
            case FORM_INLINE_EDIT_SAVE:
                // Get all necessary values for the update
                $table = $this->store->getVar(INLINE_EDIT_TABLE, STORE_SIP);
                $column = $this->store->getVar(INLINE_EDIT_COLUMN, STORE_SIP);
                $recordId = $this->store->getVar(INLINE_EDIT_RECORD_ID, STORE_SIP);
                $updatedValue = $this->store->getVar(INLINE_EDIT_UPDATED_VALUE, STORE_CLIENT . STORE_ZERO
                    , SANITIZE_ALLOW_ALL);
                $oldValue = $this->dbArray[$this->dbIndexQfq]->sql("SELECT $column FROM $table WHERE `id` = ?"
                    , ROW_EXPECT_1,[$recordId]);

                // Check if changes were made
                if($updatedValue !== $oldValue){
                    // Update the record with the new value
                    $this->dbArray[$this->dbIndexQfq]->sql("UPDATE $table SET $column = ? WHERE `id` = ?"
                        ,ROW_EXPECT_1,[$updatedValue,$recordId]);
                    $data = $updatedValue;
                    // Prepare formSpec for logging
                    $this->formSpec[F_DO_NOT_LOG_COLUMN] = '';
                    $this->formSpec[F_ID] = $this->store->getVar(FE_FORM_ID, STORE_SIP) ?? '0';
                    $this->formSpec[F_NAME] = $this->store->getVar(INLINE_EDIT_FORM_NAME, STORE_SIP);
                    // Log to formSubmitLog table
                    $this->logFormSubmitRequest(FORM_INLINE_EDIT_SAVE);
                } else {
                    $data = $oldValue;
                }
                break;*/

            default:
                throw new \CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
        }

        $formAction = new FormAction($this->formSpec, $this->dbArray, $this->phpUnit);
        switch ($formModeNew) {
            case FORM_LOAD:

                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_LOAD);

                // Build FORM
                $data = $build->process($formModeNew);

                // Checks if Form.recentLog=0|1 is enabled
                if ($this->formSpec[F_RECENT_LOG] == '1') {
                    $this->recentLog(RECENT_TYPE_FORM_OPEN, $recordId);
                }


                $tmpClass = is_numeric($this->formSpec[F_BS_COLUMNS]) ? ('col-md-' . $this->formSpec[F_BS_COLUMNS]) : $this->formSpec[F_BS_COLUMNS];
//              $data = Support::wrapTag("<div class='" . 'col-md-' . $this->formSpec[F_BS_COLUMNS] . "'>", $data);
                $data = Support::wrapTag('<div class="' . $tmpClass . '">', $data);
                $data = Support::wrapTag('<div class="row">', $data);
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_LOAD);
                break;

            case FORM_UPDATE:
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_LOAD);
                // data['form-update']=....
                $data = $build->process($formModeNew);
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_LOAD);
                break;

            case FORM_DELETE:
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_DELETE);
                $formElements = $this->feSpecNativeRaw ?? false;
                $build->process($this->formSpec[F_TABLE_NAME], $recordId, $this->formSpec[F_PRIMARY_KEY], $formElements);

                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_DELETE);

                // Execute message after delete (P_<pageSlug>|M_<channel>:<action>:<payload>)
                if (($this->formSpec[F_MESSAGE] ?? '') != '') {
                    $formAction->executeMessage($this->formSpec[F_MESSAGE]);
                }
                break;

            case FORM_SAVE:
                $formSubmitLogId = $this->logFormSubmitRequest(FORM_SAVE);
                $recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP . STORE_TYPO3);

                // SAVE
                $save = new Save($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->feSpecNativeRaw);
                $rc = $save->process($recordId);

                // Execute message after save (P_<pageSlug>|M_<channel>:<action>:<payload>)
                if (($this->formSpec[F_MESSAGE] ?? '') != '') {
                    $formAction->executeMessage($this->formSpec[F_MESSAGE]);
                }

                if ($recordId == 0 && $formSubmitLogId != 0) {
                    // Update recordId from formSubmitLog
                    $this->updateLogFormSubmitRequest($formSubmitLogId, $rc);
                }
                if ($formMode == FORM_REST) {
                    $data = $this->doRestPostPut($rc);
                    $flagApiStructureReGroup = false;
                    break;
                }

                $this->setForwardModePage();

                // Logic: If a) r=0 and
                //           b) final: (forwardMode=='auto' and User presses only 'save' (not 'save & close')) OR (forwardMode=='no')
                // then the client should reload the current page with the newly created record. A new SIP is necessary!
                $getJson = true;
                if (0 == $this->store->getVar(SIP_RECORD_ID, STORE_SIP)
                    && (($this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_AUTO
                            && (API_SUBMIT_REASON_SAVE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX) || API_SUBMIT_REASON_SAVE_FORCE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX))
                        ) || $this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_NO)) {
                    $this->formSpec = $this->buildNSetReloadUrl($this->formSpec, $rc);
                    $getJson = false;
                }

                if ($getJson) {

                    // Values of FormElements might be changed during 'afterSave': rebuild the form to load the new values. Especially for non primary template groups.
                    $feSpecNative = $this->getNativeFormElements(SQL_FORM_ELEMENT_NATIVE_TG_COUNT, [$this->formSpec[F_ID]], $this->formSpec);
                    $parameterLanguageFieldName = $this->store->getVar(SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME, STORE_SYSTEM);
                    $feSpecNative = HelperFormElement::setLanguage($feSpecNative, $parameterLanguageFieldName);
                    $feSpecNative = HelperFormElement::cleanFormElements($feSpecNative);

                    $this->feSpecNative = HelperFormElement::setFeContainerFormElementId($feSpecNative, $this->formSpec[F_ID], $recordId);
                    $this->passDownModeFromContainerToChildren();
                    $data = $build->process($formModeNew, false, $this->feSpecNative);
                }
                break;

            case FORM_DRAG_AND_DROP:
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_LOAD);

                $dragAndDrop = new DragAndDrop($this->formSpec);
                $data = $dragAndDrop->process();
                $flagApiStructureReGroup = false;

                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_LOAD);
                break;

            case FORM_REST:
                $flagApiStructureReGroup = false;
                $data = $this->doRestGet();
                break;
            default:
                throw new \CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
        }

        if ($flagApiStructureReGroup && is_array($data)) {
            // $data['element-update']=...
            $data = $this->groupElementUpdateEntries($data);
        }

        if (FEATURE_FORM_FILE_SYNC) {
            // export Form to file, if loaded record is a Form/FormElement
            $this->formAsFileAfterSave($formFileName, $formModeNew, $formFileNameDelete);
        }

        // If there are changed elements after save - build the new ones and store them in response data
        $this->buildChangedElements($data, $save ?? '', $build);

        $unitTestRender = $this->store::getVar(SYSTEM_UNIT_TEST_FORM_CONTENT, STORE_SYSTEM);
        if (isset($unitTestRender) && $unitTestRender !== false) {
            $data = $unitTestRender;
        }

        return $data;
    }

    /**
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function doRestGet() {

        $this->nameGenericRestParam();

        $r = $this->store::getVar(TYPO3_RECORD_ID, STORE_TYPO3);
        $key = empty($r) ? F_REST_SQL_LIST : F_REST_SQL_DATA;

        if (!isset($this->formSpec[$key])) {
            throw new \UserFormException("Missing Parameter '$key'", ERROR_INVALID_VALUE);
        }

        return $this->evaluate->parse($this->formSpec[$key]);
    }

    /**
     * @return bool|array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function doRestPostPut($id) {

        if (!isset($this->formSpec[F_REST_SQL_POST_PUT])) {
            return ['id' => $id];
        }

        $this->nameGenericRestParam();

        return $this->evaluate->parse($this->formSpec[F_REST_SQL_POST_PUT]);
    }

    /**
     * Checks if $serverToken matches HTTP_HEADER_AUTHORIZATION,
     * If not: throw an exception.
     *
     * @param string|array $serverToken
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function restCheckAuthToken($serverToken) {

        // No serverToken: no check necessary
        if ($serverToken === '') {
            return;
        }

        $clientToken = $this->store::getVar(HTTP_HEADER_AUTHORIZATION, STORE_CLIENT, SANITIZE_ALLOW_ALL);
        if ($serverToken === $clientToken) {
            return;
        }

        // Delay before answering.
        $seconds = $this->store::getVar(SYSTEM_SECURITY_FAILED_AUTH_DELAY, STORE_SYSTEM);
        sleep($seconds);

        if ($clientToken == false) {
            throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => 'Missing authorization token',
                ERROR_MESSAGE_TO_DEVELOPER => "Missing HTTP Header: " . HTTP_HEADER_AUTHORIZATION,
                ERROR_MESSAGE_HTTP_STATUS => HTTP_401_UNAUTHORIZED
            ]), ERROR_REST_AUTHORIZATION);
        }

        throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => 'Authorization token not accepted',
            ERROR_MESSAGE_TO_DEVELOPER => "Missing HTTP Header: " . HTTP_HEADER_AUTHORIZATION,
            ERROR_MESSAGE_HTTP_STATUS => HTTP_401_UNAUTHORIZED
        ]), ERROR_REST_AUTHORIZATION);
    }

    /**
     * STORE_CLIENT: copy parameter _id1,_id2,...,_idN to named variables, specified via $this->formSpec[F_REST_PARAM] (CSV list)
     *
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function nameGenericRestParam() {

        $paramNames = explode(',', $this->formSpec[F_REST_PARAM] ?? '');

        $ii = 1;
        foreach ($paramNames as $key) {
            switch ($key) {
                case CLIENT_FORM:
                case CLIENT_RECORD_ID:
                    throw new \UserFormException("Name '$key' is forbidden in " . F_REST_PARAM, ERROR_INVALID_VALUE);
                    break;
                default:
                    break;
            }
            $val = $this->store::getVar(CLIENT_REST_ID . $ii, STORE_CLIENT);
            $this->store::setVar($key, $val, STORE_CLIENT);
            $ii++;
        }
    }

    /**
     * Copies mode from container FE to its children
     * Supported modes for pill: hidden; fieldset: hidden, required, readonly
     * Function is recursive to account for nested fieldsets are used
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function passDownModeFromContainerToChildren($feIdContainer = 0) {

        $containerTypes = [FE_TYPE_PILL, FE_TYPE_FIELDSET];

        foreach ($containerTypes as $containerType) {

            // Fieldsets are only included in "feSpecNativeRaw"
            $feFilter = OnArray::filter($this->feSpecNativeRaw, FE_TYPE, $containerType);
            $feFilter = OnArray::filter($feFilter, FE_ID_CONTAINER, $feIdContainer);

            if (!empty($feFilter)) {
                foreach ($feFilter as $feParent) {

                    if ($feParent[FE_MODE_SQL]) {
                        $mode = $this->evaluate->parse($feParent[FE_MODE_SQL]);
                        if ($mode != '') {
                            $feParent[FE_MODE] = $mode;
                        }
                    }

                    if ($feParent[FE_MODE] === FE_MODE_HIDDEN
                        || ($feParent[FE_MODE] === FE_MODE_READONLY && $feParent[FE_TYPE] === FE_TYPE_FIELDSET)
                        || ($feParent[FE_MODE] === FE_MODE_REQUIRED && $feParent[FE_TYPE] === FE_TYPE_FIELDSET)) {

                        // Fieldsets are only included in "feSpecNativeRaw"
                        $feChild = OnArray::filter($this->feSpecNativeRaw, FE_ID_CONTAINER, $feParent[FE_ID]);

                        foreach ($feChild as $fe) {

                            # Search for origin
                            # Native FEs
                            foreach ($this->feSpecNative as $key => $value) {
                                if ($value[FE_ID] == $fe[FE_ID]) {
                                    $this->feSpecNative[$key][FE_MODE] = $feParent[FE_MODE];
                                    $this->feSpecNative[$key][FE_MODE_SQL] = "";
                                    break;
                                }
                            }

                            # Search for origin
                            # Container FEs: Fieldset
                            foreach ($this->feSpecNativeRaw as $key => $value) {
                                if ($value[FE_ID] == $fe[FE_ID]) {
                                    $this->feSpecNativeRaw[$key][FE_MODE] = $feParent[FE_MODE];
                                    $this->feSpecNativeRaw[$key][FE_MODE_SQL] = "";
                                    break;
                                }
                            }
                        }
                    }

                    // Recursion (nested fieldset)
                    $this->passDownModeFromContainerToChildren($feParent[FE_ID]);
                }
            }
        }
    }

    /**
     * Logs the form submit to table FormSubmitLog.
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function logFormSubmitRequest(string $mode) {
        $tableName = $this->formSpec[F_TABLE_NAME];

        $formSubmitLogMode = $this->formSpec[F_FORM_SUBMIT_LOG_MODE] ??
            $this->store->getVar(SYSTEM_FORM_SUBMIT_LOG_MODE, STORE_SYSTEM, SANITIZE_ALLOW_ALNUMX);

        if ($formSubmitLogMode === FORM_SUBMIT_LOG_MODE_NONE) {
            return 0;
        }

        // Log is ignored in some special cases while mode LOG_MODIFY
        if ($formSubmitLogMode === FORM_SUBMIT_LOG_MODE_MODIFY) {
            // If table FormSubmitLog and given statement INSERT/UPDATE and table Dirty and given statement INSERT/UPDATE/DELETE
            if ($tableName === 'FormSubmitLog' || $tableName === 'Dirty') {
                return 0;
            }
        }

        $formData = $_POST;
        unset($formData[CLIENT_SIP]);
        $recordId = (int)$this->store->getVar(SIP_RECORD_ID, STORE_SIP . STORE_ZERO);

        // Check if specific columns should not be logged into table FormSubmitLog
        $doNotLogColumnList = $this->formSpec[F_DO_NOT_LOG_COLUMN];
        if (!empty($doNotLogColumnList)) {
            $doNotLogColumnListArray = explode(',', $doNotLogColumnList);
            // Replace content of all protected columns.
            foreach ($doNotLogColumnListArray as $column) {
                $column = trim($column);

                // Get final $feName
                $feName = HelperFormElement::buildFormElementName([FE_NAME => $column], $recordId);
                if (isset($formData[$feName])) {
                    // Only if such element exist: wipe it.
                    $formData[$feName] = '*hide in log*';
                }

                // Password fields are often used with option RETYPE. Check and wipe them too.
                $feNameRetype = HelperFormElement::buildFormElementName([FE_NAME => $column . FE_RETYPE_NAME_EXTENSION], $recordId);
                if (isset($formData[$feNameRetype])) {
                    $formData[$feNameRetype] = '*hide in log*';
                }
            }
        }

        $formDataJson = json_encode($formData, JSON_UNESCAPED_UNICODE);
        $currentLength = strlen($formDataJson);
        // FormSubmitLog.formData (TEXT) = 65535
        if ($currentLength > LOG_MAX_FORMDATA) {
            // Oops, FormSubmitLog can only store LOG_MAX_FORMDATA.
            // JSON will be shrinked: remove elements as long as it is too big, return rest.
            $formDataJson = json_encode(OnString::limitSizeJsonEncode($formData, $currentLength, LOG_MAX_FORMDATA), JSON_UNESCAPED_UNICODE);
        }

        $sql = "INSERT INTO `FormSubmitLog` (`formData`, `sipData`, `clientIp`, `feUser`, `userAgent`
                                            , `formId`, `formName`, `recordId`, `pageId`, `sessionId`, `created`)" .
            "VALUES (?, ?, ?, ?, ?,  ?, ?, ?, ?, ?, NOW())";

        $clientIp = $this->store->getVar(CLIENT_REMOTE_ADDRESS, STORE_CLIENT . STORE_EMPTY);
        $userAgent = $this->store->getVar(CLIENT_HTTP_USER_AGENT, STORE_CLIENT . STORE_EMPTY);
        $sipData = json_encode($this->store->getStore(STORE_SIP), JSON_UNESCAPED_UNICODE);
        $formId = $this->formSpec[F_ID];
        $formName = $this->formSpec[F_NAME];
        $feUser = $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX);
        $pageId = $this->store->getVar(TYPO3_PAGE_ID, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX);
        // needed to log pageId in case of inline editing
        if (!$pageId) {
            $pageId = $this->store->getVar(TYPO3_PAGE_ID, STORE_SIP, SANITIZE_ALLOW_ALNUMX);
        }
        $sessionId = session_id();

        $params = [$formDataJson, $sipData, $clientIp, $feUser, $userAgent, $formId, $formName, $recordId, $pageId, $sessionId];

        $this->dbArray[$this->dbIndexData]->sql($sql, ROW_REGULAR, $params);
        $formSubmitLogId = $this->dbArray[$this->dbIndexData]->getLastInsertId();
        $this->store::setVar(EXTRA_FORM_SUBMIT_LOG_ID, $formSubmitLogId, STORE_EXTRA);

        return $formSubmitLogId;
    }

    /** Update last FormSubmitLog record with new inserted recordId
     * @param $formSubmitLogId
     * @param $recordId
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function updateLogFormSubmitRequest($formSubmitLogId, $recordId) {
        $sql = "UPDATE `FormSubmitLog` SET `recordId` = ? WHERE `id` = ?";
        $params = [$recordId, $formSubmitLogId];

        $this->dbArray[$this->dbIndexData]->sql($sql, ROW_REGULAR, $params);
    }

    /**
     * @param string $recentType
     * @return int|string
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function recentLog(string $recentType = RECENT_TYPE_FORM_OPEN, $recordId = 0): void {

        if (defined('PHPUNIT_QFQ')) {
            return;
        }

        $sql = "INSERT INTO `Recent` (`recentType`, `pId`, `feUser`, `formName`
                                            , `formId`, `formTitle`, `args`, `recordId`)" .
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)";

        $pId = $this->store->getVar(SYSTEM_PID_USER, STORE_SYSTEM . STORE_ZERO);
        $feUser = $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3 . STORE_EMPTY);
        $formName = $this->formSpec[F_NAME];
        $formId = $this->formSpec[F_ID];
        $pageId = $this->store->getVar(TYPO3_PAGE_ID, STORE_TYPO3);
        $args = $this->store->getVar(SIP_URLPARAM, STORE_SIP);
        $params = [$recentType, $pId, $feUser, $formName, $formId, $this->formSpec[F_TITLE], $args, $recordId];

        $this->dbArray[$this->dbIndexData]->sql($sql, ROW_REGULAR, $params);
    }


    /**
     * Check if forwardMode='url...'.
     * yes: process 'forwardPage' and fill $this->formSpec[F_FORWARD_MODE] and $this->formSpec[F_FORWARD_PAGE]
     * no: do nothing
     *
     * '$this->formSpec[F_FORWARD_PAGE]' might give a new forwardMode. If so, set $this->formSpec[F_FORWARD_MODE] to
     * it.
     *
     * '$this->formSpec[F_FORWARD_PAGE]':
     * a) url     http://www.nzz.ch/index.html?a=123#bottom, website.html?a=123#bottom,
     *            ?[id=]<T3 Alias pageId>&a=123#bottom, ?id=<T3 pageId>&a=123#bottom
     * b) mode      no|client|url|...
     * c) mode|url  combination of above
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function setForwardModePage() {

        if (F_FORWARD_MODE_URL != substr($this->formSpec[F_FORWARD_MODE], 0, 3)) {
            return;
        }

        $forwardPageTmp = $this->evaluate->parse($this->formSpec[F_FORWARD_PAGE]);

        // Format: [mode/url][|url]
        $forwardArray = explode('|', $forwardPageTmp, 2);
        $forward = trim($forwardArray[0]);
        switch ($forward) {

            case F_FORWARD_MODE_AUTO:
            case F_FORWARD_MODE_CLOSE:
            case F_FORWARD_MODE_NO:
            case F_FORWARD_MODE_URL:
            case F_FORWARD_MODE_URL_SKIP_HISTORY:
            case F_FORWARD_MODE_URL_SIP:
            case F_FORWARD_MODE_URL_SIP_SKIP_HISTORY:
                $this->formSpec[F_FORWARD_MODE] = $forward;
                if (isset($forwardArray[1])) {
                    $this->formSpec[F_FORWARD_PAGE] = trim($forwardArray[1]);
                } else {
                    $this->formSpec[F_FORWARD_PAGE] = '';
                }
                break;

            default:
                $this->formSpec[F_FORWARD_PAGE] = $forward;
                break;
        }

        if (F_FORWARD_MODE_URL == substr($this->formSpec[F_FORWARD_MODE], 0, 3)) {
            if ($this->formSpec[F_FORWARD_PAGE] == '') {
                $this->formSpec[F_FORWARD_MODE] = F_FORWARD_MODE_AUTO;
            }
        }
    }

    /**
     * Set F_FORWARD_MODE to  F_FORWARD_MODE_PAGE and builds a redirection URL to the current page with the already
     * used parameters. Do this by building a new SIP with the new recordId.
     *
     * @param array $formSpec
     * @param int $recordId
     *
     * @return array
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function buildNSetReloadUrl(array $formSpec, $recordId) {

        $formSpec[F_FORWARD_MODE] = API_ANSWER_REDIRECT_URL_SKIP_HISTORY;

        // Rebuild original URL
        $storeT3 = $this->store->getStore(STORE_TYPO3);
        $storeT3['id'] = $storeT3[TYPO3_PAGE_ID];
        $storeT3 = OnArray::getArrayItems($storeT3, ['id', TYPO3_PAGE_TYPE, TYPO3_PAGE_LANGUAGE], true, true);

        $arr = KeyValueStringParser::parse($this->store->getVar(SIP_URLPARAM, STORE_SIP), '=', '&');
        $arr[SIP_RECORD_ID] = $recordId;
        $arr = array_merge($storeT3, $arr);
        // Remove _makeUrlParamUniq so when resaving the form checkHiddenEmpty does not fail
        if ($recordId > 0 && isset($arr[SIP_MAKE_URLPARAM_UNIQ])) unset($arr[SIP_MAKE_URLPARAM_UNIQ]);
        $queryString = KeyValueStringParser::unparse($arr, '=', '&');

        $formSpec[F_FORWARD_PAGE] = store::getSipInstance()->queryStringToSip($queryString, RETURN_URL);

        return $formSpec;

    }

    /**
     * Checks if there is formLog mode active for FORM_LOG_SESSION or FORM_LOG_ALL.
     * If yes, set $form[FORM_LOG_FILE_SESSION] resp.  $form[FORM_LOG_FILE_ALL].
     * If the last action is older FORM_LOG_FILE_EXPIRE, the file will be deleted and formLog mode stops (disabled).
     *
     * @param array $form
     * @return array
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function checkFormLogMode(array $form) {

        $form[FORM_LOG_FILE_SESSION] = '';
        $form[FORM_LOG_FILE_ALL] = '';

        foreach ([FORM_LOG_SESSION, FORM_LOG_ALL] as $mode) {
            $file = Support::getFormLogFileName($form[F_NAME], $mode);
            if (file_exists($file) && false !== ($arr = stat($file))) {

                if (time() - $arr['mtime'] > FORM_LOG_FILE_EXPIRE) {
                    HelperFile::unlink($file);
                } else {
                    $form[FORM_LOG_FILE . '_' . $mode] = $file;
                    $form[FORM_LOG_ACTIVE] = 1;
                }
            }
        }

        return $form;
    }

    /**
     * Get form name
     * Check if the form is in log mode: set formLog and return
     * Load form. Evaluates form. Load FormElements.
     *
     * After processing:
     * Loaded Form is in  $this->formSpec
     * Loaded 'action' FormElements are in $this->feSpecAction
     * Loaded 'native' FormElements are in $this->feSpecNative
     *
     * @param string $mode FORM_LOAD|FORM_SAVE|FORM_UPDATE|FORM_REST
     * @param int $recordId
     * @param string $foundInStore
     * @param string $formLogMode
     * @return bool|string if found the formName, else 'false'.
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function loadFormSpecification($mode, $recordId, &$foundInStore = '', &$formLogMode = '') {

        $formLogMode = false; // Important: if no form is found, formLogMode needs also to be false.

        // formName
        if (false === ($formName = $this->getFormName($mode, $foundInStore))) {
            return false;
        }

        foreach ($this->defaultValue as $key => $value) {
            $this->store->setVar($key, $value, STORE_DEFAULT, false);
        }

        // Check for '_formLogMode'=logSession|logAll
        $formLogMode = $this->store::getVar(FORM_LOG_MODE, STORE_SIP);
        if ($formLogMode !== false) {
            return $formName; // fomLog: getting the formName is sufficient.
        }

        if (!$this->dbArray[$this->dbIndexQfq]->existTable(TABLE_NAME_FORM)) {
            throw new \UserFormException("Table '" . TABLE_NAME_FORM . "' not found", ERROR_MISSING_TABLE);
        }

        // Preparation for Log, Debug
        $this->store->setVar(SYSTEM_FORM, $formName, STORE_SYSTEM);

        if (FEATURE_FORM_FILE_SYNC) {
            // Check for form file changes
            FormAsFile::importForm($formName, $this->dbArray[$this->dbIndexQfq]);
        }

        // Load form
        $constant = F_NAME; // PhpStorm complains if the constant is directly defined in the string below
        $form = $this->dbArray[$this->dbIndexQfq]->sql("SELECT * FROM `Form` AS f WHERE `f`.`$constant` LIKE ? AND `f`.`deleted`='no'", ROW_EXPECT_1,
            [$formName], 'Form "' . $formName . '" not found or multiple forms with the same name.');

        if (FEATURE_FORM_FILE_SYNC) {
            // Import Form from file if loaded record is Form/FormElement (If form file was changed, throw exception)
            FormAsFile::importFormRecordId($recordId, $form[F_TABLE_NAME], $this->dbArray[$this->dbIndexQfq]);
        }

        $form = $this->checkFormLogMode($form);
        $form = $this->modeCleanFormConfig($mode, $form);

        HelperFormElement::explodeParameter($form, F_PARAMETER);
        unset($form[F_PARAMETER]);

        // Save specific elements to be expanded later.
        $parseLater = OnArray::getArrayItems($form, [F_FORWARD_PAGE, FE_FILL_STORE_VAR, F_REST_SQL_LIST, F_REST_SQL_DATA, F_REST_SQL_POST_PUT, F_MULTI_SQL]);
        $form[FE_FILL_STORE_VAR] = '';
        $form[F_FORWARD_PAGE] = '';
        $form[F_REST_SQL_LIST] = '';
        $form[F_REST_SQL_DATA] = '';

        // Setting defaults later is too late.
        if (empty($form[F_DB_INDEX])) {
            // Inherit dbIndex from parent form (via SIP) if available, otherwise use system default.
            $sipDbIndex = $this->store->getVar(PARAM_DB_INDEX_DATA, STORE_SIP);
            $form[F_DB_INDEX] = empty($sipDbIndex) ? $this->dbIndexData : $sipDbIndex;
        } else {
            $form[F_DB_INDEX] = $this->evaluate->parse($form[F_DB_INDEX]);
            // Copy custom dbIndexData to STORE_VARS: can be retrieved via {{indexData:VY}}. Useful for FormEditor in MultiDB
            $this->store->setVar(SYSTEM_DB_INDEX_DATA, $form[F_DB_INDEX], STORE_VAR);
        }

        // Always propagate the effective dbIndex to STORE_SIP so dirty.php, FillStoreForm etc. get the correct value.
        // This also clears any inherited __dbIndexData from a parent form's subrecord link.
        $this->store->setVar(PARAM_DB_INDEX_DATA, $form[F_DB_INDEX], STORE_SIP);

        if (empty($form[F_PRIMARY_KEY])) {
            $form[F_PRIMARY_KEY] = F_PRIMARY_KEY_DEFAULT;
        }

        // Some forms load/save the form data on extra defined databases.
        if ($this->dbIndexData != $form[F_DB_INDEX]) {
            if (!isset($this->dbArray[$form[F_DB_INDEX]])) {
                $this->dbArray[$form[F_DB_INDEX]] = new Database($form[F_DB_INDEX]);
            }
            $this->dbIndexData = $form[F_DB_INDEX];

            unset($this->evaluate);
            $this->evaluate = new Evaluate($this->store, $this->dbArray[$this->dbIndexData]);
        }

        //Change recordId from Multiform to 0 - No row exception possible
        if ($form[F_MULTI_MODE] !== F_MULTI_MODE_NONE) {
            $recordId = 0;
            $this->store->setVar(SIP_RECORD_ID, $recordId, STORE_SIP);
        }

        // This is needed for filling templateGroup records with their default values
        // and for evaluating variables in the Form title
        $this->store->fillStoreWithRecord($form[F_TABLE_NAME], $recordId, $this->dbArray[$this->dbIndexData],
            $form[F_PRIMARY_KEY], STORE_RECORD, "Record '$recordId' not found in table '" . $form[F_TABLE_NAME] . "'.");

        // In case $form[F_REST_TOKEN] is a query which results to an empty answer; every token will fail.
        $flagRestToken = !empty($form[F_REST_TOKEN]);

        // Needed in case of FORM_SAVE, when title should be updated
        $unevaluatedTitle = $form[F_TITLE];
        // Evaluate all fields
        $formSpec = $this->evaluate->parseArray($form);

        $formSpec[F_UNEVALUATED_TITLE] = $unevaluatedTitle;


        // If it is empty, set it to true to force the TOKEN check (which will always fail)
        if ($flagRestToken && $form[F_REST_TOKEN] == '') {
            $form[F_REST_TOKEN] = true;
        }

        $parameterLanguageFieldName = $this->store->getVar(SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME, STORE_SYSTEM);
        $formSpec = HelperFormElement::setLanguage($formSpec, $parameterLanguageFieldName);

        if (!empty($formSpec[F_SUBMIT_BUTTON_TEXT])) {
            // set defaults for submit button (different from save button defaults)
            $formSpec[F_SUBMIT_BUTTON_CLASS] = $formSpec[F_SAVE_BUTTON_CLASS] ?? 'btn btn-default';
            $formSpec[F_SUBMIT_BUTTON_GLYPH_ICON] = $formSpec[F_SAVE_BUTTON_GLYPH_ICON] ?? '';
            $formSpec[F_SUBMIT_BUTTON_TOOLTIP] = $formSpec[F_SAVE_BUTTON_TOOLTIP] ?? $formSpec[F_SUBMIT_BUTTON_TEXT];
        }

        $formSpec = $this->syncSystemFormConfig($formSpec);

        // Set form parameter which are expected to exist.
        $formSpec = $this->initForm($formSpec, $recordId);

        $formSpec = array_merge($formSpec, $parseLater);

        // Set F_FINAL_DELETE_FORM
        $formSpec[F_FINAL_DELETE_FORM] = ($formSpec[F_EXTRA_DELETE_FORM] != '') ? $formSpec[F_EXTRA_DELETE_FORM] : $formSpec[F_NAME];

        // LOG
        !empty($form[FORM_LOG_ACTIVE]) && Logger::logFormLine($form, "F:$mode:evaluated:" . date('Y-m-d H:i:s'), $form, true);

        // Fire FE_FILL_STORE_VAR after the primary form record has been loaded
        if (!empty($formSpec[FE_FILL_STORE_VAR])) {
            $this->fillStoreVar($formSpec[FE_FILL_STORE_VAR]);
//            unset($formSpec[FE_FILL_STORE_VAR]);
        }

        if ($formSpec[F_FORWARD_MODE] === '') {
            // This should not happen since '' is not a valid choice for column forwardMode. But it happened anyway. So to be safe.
            $formSpec[F_FORWARD_MODE] = F_FORWARD_MODE_AUTO;
        }

        $this->formSpec = $formSpec;

        // Clear
        $this->store->setVar(SYSTEM_FORM_ELEMENT, '', STORE_SYSTEM);

        // Read all 'active' FE
        $this->feSpecNativeRaw = $this->dbArray[$this->dbIndexQfq]->sql(SQL_FORM_ELEMENT_RAW, ROW_REGULAR, [$this->formSpec["id"]]);

        // FE: Action
        $this->feSpecAction = $this->dbArray[$this->dbIndexQfq]->sql(SQL_FORM_ELEMENT_ALL_CONTAINER, ROW_REGULAR, ['no', $this->formSpec["id"], 'action']);
        HelperFormElement::explodeParameterInArrayElements($this->feSpecAction, FE_PARAMETER);

        // FE: Native & Container
        // "SELECT *, ? AS 'nestedInFieldSet' FROM FormElement AS fe WHERE fe.formId = ? AND fe.deleted = 'no' AND FIND_IN_SET(fe.class, ? ) AND fe.feIdContainer = ? AND fe.enabled='yes' ORDER BY fe.ord, fe.id";
        $feSpecNative = array();
        switch ($mode) {
            case FORM_LOAD:
                // Select all Native elements (native, pill, fieldset, templateGroup) which are NOT nested = Root level.
                $feSpecNative = $this->dbArray[$this->dbIndexQfq]->getNativeFormElements(SQL_FORM_ELEMENT_SPECIFIC_CONTAINER, ['no', $this->formSpec["id"], 'native,container', 0], $this->formSpec);
                break;

            case FORM_SAVE:
            case FORM_UPDATE:
            case FORM_REST:
                $feSpecNative = $this->getNativeFormElements(SQL_FORM_ELEMENT_NATIVE_TG_COUNT, [$this->formSpec[F_ID]], $this->formSpec);
                break;

            case FORM_DELETE:
                $this->feSpecNative = array();
                break;

            default:
                break;
        }

        $this->feSpecNative = HelperFormElement::setLanguage($feSpecNative, $parameterLanguageFieldName);
        $this->feSpecNative = HelperFormElement::setFeContainerFormElementId($this->feSpecNative, $this->formSpec[F_ID], $recordId);
        $this->feSpecNative = HelperFormElement::cleanFormElements($this->feSpecNative);

        return $formName;
    }


    /**
     * If $sql selects one row, append the row to STORE_VAR.
     *
     * @param $sql
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function fillStoreVar($sql) {

        if (empty($sql)) {
            return;
        }

        $rows = $this->evaluate->parse($sql, ROW_EXPECT_0_1);

        if (is_array($rows)) {
            $this->store->appendToStore($rows, STORE_VAR);
            // LOG
            if (!empty($form[FORM_LOG_ACTIVE])) {
                Logger::logFormLine($form, "F:add to STORE_VAR", $rows);
            }
        } else {
            if (!empty($rows)) {
                throw new \UserFormException("Invalid statement for '" . FE_FILL_STORE_VAR . "': " . $this->formSpec[FE_FILL_STORE_VAR], ERROR_INVALID_OR_MISSING_PARAMETER);
            }
        }
    }

    /**
     * Depending on $sql reads FormElements to a specific container or all. Preprocess all FormElements.
     * This code is dirty: the nearly same function exists in class 'Database' - the difference is only
     * 'explodeTemplateGroupElements()'.
     *
     * @param string $sql SQL_FORM_ELEMENT_SPECIFIC_CONTAINER | SQL_FORM_ELEMENT_ALL_CONTAINER
     * @param array $param Parameter which matches the prepared statement in $sql
     * @param array $formSpec Main FormSpec to copy generic parameter to FormElements
     *
     * @return array|int
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function getNativeFormElements($sql, array $param, $formSpec) {
        $feSpecNative = $this->dbArray[$this->dbIndexQfq]->sql($sql, ROW_REGULAR, $param);

        $feSpecNative = HelperFormElement::formElementSetDefault($feSpecNative, $formSpec);

        // Explode and Do $FormElement.parameter
        HelperFormElement::explodeParameterInArrayElements($feSpecNative, FE_PARAMETER);

        // Check for retype FormElements which have to be duplicated.
        $feSpecNative = HelperFormElement::duplicateRetypeElements($feSpecNative);

        // Check for templateGroup Elements to explode them
        $feSpecNative = $this->explodeTemplateGroupElements($feSpecNative);

        // Copy Attributes to FormElements
        $feSpecNative = HelperFormElement::copyAttributesToFormElements($formSpec, $feSpecNative);

        return $feSpecNative;
    }

    /**
     * Iterate over all FormElements in $elements. If a row has a column NAME_TG_COPIES, copy those elements
     * NAME_TG_COPIES-times. Adjust FE_TEMPLATE_GROUP_NAME_PATTERN (='%d') with current count on column FE_NAME and
     * FE_LABEL.
     *
     * This code is dirty: only to get JSON value, we have to initialize the STORE_RECORD (done earlier) to be capable
     * to parse fe[FE_VALUE], which probably contains as string like '{{!SELECT value FROM table WHERE xId={{id}} ORDER
     * BY id}}' - the {{id}} needs to be replaced by the current recordId (primary record).
     *
     * Attention: The resulting order of the FormElements, is not the same as on the Form during FormLoad!
     *
     * @param array $elements
     *
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function explodeTemplateGroupElements(array $elements) {
        $new = array();

        // No FormElements or no NAME_TG_COPIES column: nothing to do, return.
        if ($elements == array() || count($elements) == 0 || !isset($elements[0][NAME_TG_COPIES])) {
            return $elements;
        }

        // Iterate over all
        foreach ($elements as $row) {
            if (isset($row[NAME_TG_COPIES]) && $row[NAME_TG_COPIES] > 0) {
                $row[FE_VALUE] = $this->evaluate->parse($row[FE_VALUE]);
                for ($ii = 1; $ii <= $row[NAME_TG_COPIES]; $ii++) {
                    $tmpRow = $row;
                    if (is_array($row[FE_VALUE])) {
                        $tmpRow[FE_VALUE] = ($ii <= count($row[FE_VALUE])) ? current($row[FE_VALUE][$ii - 1]) : '';
                    }
                    unset($tmpRow[NAME_TG_COPIES]);
                    $tmpRow[FE_NAME] = str_replace(FE_TEMPLATE_GROUP_NAME_PATTERN, $ii, $tmpRow[FE_NAME]);
                    $tmpRow[FE_LABEL] = str_replace(FE_TEMPLATE_GROUP_NAME_PATTERN, $ii, $tmpRow[FE_LABEL]);
                    $tmpRow[FE_TG_INDEX] = $ii;
                    $new[] = $tmpRow;
                }
            } else {
                $new[] = $row;
            }
        }

        return $new;
    }


    /**
     * Get the formName from STORE_TYPO3 (bodytext), STORE_SIP or by STORE_CLIENT (URL).
     *
     * FORM_LOAD:
     *   Specified in T3 body text with form=<formname>            Returned Store:Typo3
     *   Specified in T3 body text with form={{form}} ':FSRD'      Returned Store:SIP
     *   Specified in T3 body text with form={{form:C:ALNUMX}}     Returned Store:Client
     *   Specified in T3 body text with form={{SELECT registrationFormName FROM Conference WHERE id={{conferenceId:S0}}
     *   }} Specified in T3 body text with form={{SELECT registrationFormName FROM Conference WHERE
     *   id={{conferenceId:C0:DIGIT}} }} Specified in SIP
     *
     * FORM_SAVE:
     *   Specified in SIP
     *
     *
     * @param string $mode FORM_LOAD|FORM_SAVE|FORM_UPDATE|FORM_REST
     * @param string $foundInStore
     *
     * @return bool|string  Formname (Form.name) or FALSE (if no formname found)
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function getFormName($mode, &$foundInStore = '') {
        $dummy = array();

        switch ($mode) {
            case FORM_LOAD:
            case FORM_REST:
                $store = STORE_TYPO3;
                break;
            case FORM_SAVE:
            case FORM_UPDATE:
            case FORM_DELETE:
            case FORM_DRAG_AND_DROP:
                $store = STORE_SIP;
                break;
            default:
                throw new \CodeException("Unknown mode: $mode.", ERROR_UNKNOWN_MODE);
        }

        $storeFormName = $this->store->getVar(SIP_FORM, $store, '', $foundInStore);
        $formName = $this->evaluate->parse($storeFormName, ROW_IMPLODE_ALL, 0, $dummy, $foundInStore);

        // If the formname is empty or if 'form' has not been found in any store: no form.
        if ($formName === '' || $foundInStore === '') {
            return false;
        }

        return $formName;
    }

    /**
     * Depending on $mode various formSpec fields might be adjusted.
     * E.g.: the form title is not important during a delete.
     *
     * @param string $mode
     * @param array $form
     *
     * @return array
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function modeCleanFormConfig($mode, array $form) {

        switch ($mode) {
            case FORM_DELETE:
                $form[F_TITLE] = '';
                break;
            default:
                break;
        }

        unset($form[F_NOTE_INTERNAL]);

        if (isset($form[F_ESCAPE_TYPE_DEFAULT]) && $form[F_ESCAPE_TYPE_DEFAULT] == TOKEN_ESCAPE_CONFIG) {
            $form[F_ESCAPE_TYPE_DEFAULT] = $this->store->getVar(SYSTEM_ESCAPE_TYPE_DEFAULT, STORE_SYSTEM);
        }

        return $form;
    }

    /**
     * The named $keys will be synced between STORE_SYSTEM and $formSpec (both directions).
     * The per form definition has precedence over STORE_SYSTEM.
     * STORE_SYSTEM if filled with the default values (config.qfq.php or if not exist than QFQ hardcoded)
     * Copying the 'Form' definition back to the system store helps to access the values
     * by '{{ ...:Y}}' (system store). E.g. the value of bs-*-columns might be displayed as placeholder in the
     * corresponding input field.
     *
     * @param array $formSpec
     *
     * @return array
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function syncSystemFormConfig(array $formSpec) {

        // List all fields which should be copied from STORE_SYSTEM
        $keys = [
            F_BS_COLUMNS,
            F_BS_LABEL_COLUMNS,
            F_BS_INPUT_COLUMNS,
            F_BS_NOTE_COLUMNS,
            F_FE_DATA_PATTERN_ERROR,
            F_FE_DATA_REQUIRED_ERROR,
            F_FE_DATA_MATCH_ERROR,
            F_FE_DATA_ERROR,
            F_CLASS,
            F_CLASS_PILL,
            F_CLASS_BODY,
            F_BUTTON_ON_CHANGE_CLASS,
            F_ESCAPE_TYPE_DEFAULT,
            F_SAVE_BUTTON_TEXT,
            F_SAVE_BUTTON_TOOLTIP,
            F_SAVE_BUTTON_CLASS,
            F_SAVE_BUTTON_GLYPH_ICON,

            F_NOTE_BUTTON_TEXT,
            F_NOTE_BUTTON_CLASS,

            F_CLOSE_BUTTON_TEXT,
            F_CLOSE_BUTTON_TOOLTIP,
            F_CLOSE_BUTTON_CLASS,
            F_CLOSE_BUTTON_GLYPH_ICON,

            F_DELETE_BUTTON_TEXT,
            F_DELETE_BUTTON_TOOLTIP,
            F_DELETE_BUTTON_CLASS,
            F_DELETE_BUTTON_GLYPH_ICON,

            F_NEW_BUTTON_TEXT,
            F_NEW_BUTTON_TOOLTIP,
            F_NEW_BUTTON_CLASS,
            F_NEW_BUTTON_GLYPH_ICON,
            F_FE_FIELDSET_CLASS,

            F_RECORD_LOCK_TIMEOUT_SECONDS,

            FE_INPUT_EXTRA_BUTTON_INFO_CLASS,
            F_SHOW_ID_IN_FORM_TITLE,
            F_FE_INPUT_CLEAR_ME,
            F_DATE_TIME_PICKER_TYPE,
            UPLOAD_TYPE,
            F_DO_NOT_LOG_COLUMN,

            FE_FILE_MAX_FILE_SIZE,
            F_FE_DATA_PATTERN_ERROR_SYSTEM,  // Not a classical element to overwrite by form definition, but should be copied to detect changes per custom setting.
            F_UPLOAD_SUCCESS_MESSAGE,
            F_UPLOAD_IDLE_TEXT,
            F_FILE_NAME_TO_LOWER,
            F_FE_MAX_IMAGE_DIMENSION,

            F_UPLOAD_PDF_PROTECTED_ACTION,
        ];

        // By definition: existing vars which are empty, means: EMPTY - do not use any default!
        // But: a) if these variables are table columns, they always exist. For those: empty value means 'not set'
        //      b) some values have a special meaning. E.g. empty FE_FILE_MAX_FILE_SIZE means take system config
        // - unset those.
        foreach ([F_BS_LABEL_COLUMNS, F_BS_INPUT_COLUMNS, F_BS_NOTE_COLUMNS, F_ESCAPE_TYPE_DEFAULT, FE_FILE_MAX_FILE_SIZE] as $key) {
            if (($formSpec[$key] ?? '') == '') {
                unset ($formSpec[$key]);
            }
        }

        if ($formSpec[F_FE_LABEL_ALIGN] == F_FE_LABEL_ALIGN_DEFAULT) {
            $formSpec[F_FE_LABEL_ALIGN] = $this->store->getVar(SYSTEM_LABEL_ALIGN, STORE_SYSTEM . STORE_EMPTY);
        }

        $storeSystem = $this->store::getStore(STORE_SYSTEM);

        foreach ($keys as $key) {

            if (isset($formSpec[$key])) {
                $this->store->setVar($key, $formSpec[$key], STORE_SYSTEM);
            } else {
                // if not found set ''
                $formSpec[$key] = $storeSystem[$key] ?? '';
            }
        }

        return $formSpec;
    }

    /**
     * Set form parameter which are expected to exist.
     *
     * @param array $formSpec
     * @param int $recordId
     *
     * @return array
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function initForm(array $formSpec, $recordId) {

        Support::setIfNotSet($formSpec, F_EXTRA_DELETE_FORM, '');
        Support::setIfNotSet($formSpec, F_SUBMIT_BUTTON_TEXT, '');
        Support::setIfNotSet($formSpec, F_BUTTON_ON_CHANGE_CLASS, '');
        Support::setIfNotSet($formSpec, F_LDAP_USE_BIND_CREDENTIALS, '');
        Support::setIfNotSet($formSpec, F_DB_INDEX, $this->store->getVar(F_DB_INDEX, STORE_SYSTEM));
        Support::setIfNotSet($formSpec, F_ENTER_AS_SUBMIT, $this->store->getVar(SYSTEM_ENTER_AS_SUBMIT, STORE_SYSTEM));
        Support::setIfNotSet($formSpec, F_SESSION_TIMEOUT_SECONDS, $this->store->getVar(SYSTEM_SESSION_TIMEOUT_SECONDS, STORE_SYSTEM));
        Support::setIfNotSet($formSpec, F_FE_REQUIRED_POSITION, F_FE_REQUIRED_POSITION_LABEL_RIGHT);
        Support::setIfNotSet($formSpec, F_MULTI_MSG_NO_RECORD, F_MULTI_MSG_NO_RECORD_TEXT);
        Support::setIfNotSet($formSpec, F_FE_MIN_WIDTH, F_FE_MIN_WIDTH_DEFAULT);
        Support::setIfNotSet($formSpec, FE_INPUT_EXTRA_BUTTON_INFO_MIN_WIDTH, FE_INPUT_EXTRA_BUTTON_INFO_MIN_WIDTH_DEFAULT);
        Support::setIfNotSet($formSpec, F_ACTIVATE_FIRST_REQUIRED_TAB, 1);
        Support::setIfNotSet($formSpec, F_REMEMBER_LAST_PILL, $this->store->getVar(SYSTEM_REMEMBER_LAST_PILL, STORE_SYSTEM));
        Support::setIfNotSet($formSpec, F_RECENT_LOG, $this->store->getVar(SYSTEM_RECENT_LOG, STORE_SYSTEM));

        // In case there is no F_MODE defined on the form, check if there is one in STORE_SIP.
//        if ($formSpec[F_MODE] == '') {
//            $formModeGlobal = $this->store->getVar(F_MODE_GLOBAL, STORE_SIP);
//            if ($formModeGlobal !== false) {
//                $formSpec[F_MODE] = $formModeGlobal;
//            }
//        }
//
        // Check for deprecated legacy code
        if (isset($formSpec['mode'])) {
            throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => 'Outdated form definition',
                ERROR_MESSAGE_TO_DEVELOPER => "form.parameter.mode is deprecated. Please use form.parameter.formModeGlobal instead."
            ]));
        }

        // Unify F_MODE_GLOBAL
        $formSpec[F_MODE_GLOBAL] = Support::getFormModeGlobal($formSpec[F_MODE_GLOBAL] ?? '');


        if ($formSpec[F_MODE_GLOBAL] == F_MODE_READONLY) {
            $formSpec[F_SHOW_BUTTON] = FORM_BUTTON_CLOSE;
            $formSpec[F_SUBMIT_BUTTON_TEXT] = '';
        }

        if ($formSpec[F_ESCAPE_TYPE_DEFAULT] == TOKEN_ESCAPE_CONFIG) {
            $formSpec[F_ESCAPE_TYPE_DEFAULT] = $this->store->getVar(F_ESCAPE_TYPE_DEFAULT, STORE_SYSTEM);
        }

        // Append recordId to title
        if ($formSpec[F_SHOW_ID_IN_FORM_TITLE] == '1') {
            $formSpec[F_TITLE] .= ($recordId == 0) ? " (new)" : " ($recordId)";
        }

        return $formSpec;
    }

    /**
     * Check if the form loading is permitted. If not, throw an exception.
     *
     * @param string $formNameFoundInStore
     * @param string $formMode
     *
     * @param $formModeNew
     * @return bool 'true' if SIP exists, else 'false'
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function validateForm($formNameFoundInStore, $formMode, &$formModeNew) {

        $formModeNew = $formMode;

        // Retrieve record_id either from SIP (preferred) or via URL
        $r = $this->store->getVar(SIP_RECORD_ID, STORE_SIP . STORE_TYPO3 . STORE_CLIENT, '', $recordIdFoundInStore);

        // No record id: Fake a definition in STORE_TYPO3.
        if ($r === false) {
            $r = 0;
            $this->store->setVar(TYPO3_RECORD_ID, $r, STORE_TYPO3);
            $recordIdFoundInStore = STORE_TYPO3;
        }

        // If there is a record_id>0: EDIT else NEW: 'sip','logged_in','logged_out','always','never'
        $permitMode = ($r > 0) ? $this->formSpec['permitEdit'] : $this->formSpec['permitNew'];

        $feUserLoggedIn = isset($GLOBALS["TSFE"]->fe_user->user["uid"]) && $GLOBALS["TSFE"]->fe_user->user["uid"] > 0;

        $sipFound = $this->store->getVar(SIP_SIP, STORE_SIP) !== false;

        if ($sipFound) {
            if (($formNameFoundInStore === STORE_CLIENT) || ($recordIdFoundInStore === STORE_CLIENT)) {
                throw new \UserFormException("SIP exist but FORM or RECORD_ID are given by CLIENT.", ERROR_SIP_EXIST_BUT_OTHER_PARAM_GIVEN_BY_CLIENT);
            }
        }

        if ($formMode == FORM_REST) {

            $method = $this->store::getVar(CLIENT_REQUEST_METHOD, STORE_CLIENT);
            if (false === Support::findInSet(strtolower($method), $this->formSpec[F_REST_METHOD])) {

                throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => 'Invalid HTTP method: endpoint (form) not found or access not allowed',
                    ERROR_MESSAGE_TO_DEVELOPER => "Endpoint '" . $this->formSpec[F_NAME] . "' is not allowed with HTTP method '$method'",
                    ERROR_MESSAGE_HTTP_STATUS => HTTP_401_UNAUTHORIZED
                ]), ERROR_FORM_REST);

            }

            $this->restCheckAuthToken($this->formSpec[F_REST_TOKEN] ?? '');

            switch ($method) {
                case REQUEST_METHOD_GET:
                    break;

                case REQUEST_METHOD_POST:
                case REQUEST_METHOD_PUT:
                    $formModeNew = FORM_SAVE;
                    break;

                case REQUEST_METHOD_DELETE:
                    $formModeNew = FORM_DELETE;
                    break;

                default:
                    throw new \CodeException('This code should never be reached', ERROR_CODE_SHOULD_NOT_HAPPEN);
            }

        } else {

            switch ($permitMode) {
                case  FORM_PERMISSION_SIP:
                    if (!$sipFound || $formNameFoundInStore === STORE_CLIENT || $recordIdFoundInStore === STORE_CLIENT) {
                        throw new \UserFormException("SIP Parameter needed for this form.", ERROR_SIP_NEEDED_FOR_THIS_FORM);
                    }
                    break;
                case  FORM_PERMISSION_LOGGED_IN:
                    if (!$feUserLoggedIn) {
                        throw new \UserFormException("User not logged in.", ERROR_USER_NOT_LOGGED_IN);
                    }
                    break;
                case FORM_PERMISSION_LOGGED_OUT:
                    if ($feUserLoggedIn) {
                        throw new \UserFormException("User logged in.", ERROR_USER_LOGGED_IN);
                    }
                    break;
                case FORM_PERMISSION_ALWAYS:
                    break;
                case FORM_PERMISSION_NEVER:
                    throw new \UserFormException("Loading form forbidden.", ERROR_FORM_FORBIDDEN);
                    break;
                default:
                    throw new \CodeException("Unknown permission mode: '" . $permitMode . "'", ERROR_FORM_UNKNOWN_PERMISSION_MODE);
            }
        }

        if ($formMode === FORM_DELETE) {
            return $sipFound;
        }
        $sipArray = $this->store->getStore(STORE_SIP);

        // Check: requiredParameter: '' or 'form' or 'form,grId' or 'form #formname for form,grId'
        $requiredParameter = ($r > 0) ? $this->formSpec[F_REQUIRED_PARAMETER_EDIT] : $this->formSpec[F_REQUIRED_PARAMETER_NEW];

        if (trim($requiredParameter) == '') {
            return $sipFound;
        }

        $requiredParameterArr = explode('#', $requiredParameter, 2);

        $param = explode(',', $requiredParameterArr[0]);
        foreach ($param as $name) {

            $name = trim($name);

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

            if (!isset($sipArray[$name])) {
                throw new \UserFormException("Missing required SIP parameter: $name", ERROR_MISSING_REQUIRED_PARAMETER);
            }
        }

        return $sipFound;
    }

    /**
     * Searches the whole array $dataArray on the second level for API_ELEMENT_UPDATE.
     * All found elements collect under $collect[API_ELEMENT_UPDATE]... . Leave the rest unchanged.
     *
     * @param array $dataArray
     *
     * @return array to build JSON
     */
    private function groupElementUpdateEntries(array $dataArray) {
        $collect = array();
        $currentGroup = null;

        foreach ($dataArray as $data) {

            // Extracting the numeric suffix from the form-element
            if (isset($data[API_FORM_UPDATE_FORM_ELEMENT])) {

                // Splitting the string by '-' and getting the last element (the group number)
                // Example: If $data[API_FORM_UPDATE_FORM_ELEMENT] is 'firstName-1', explode returns ['firstName', '1'].
                $parts = explode('-', $data[API_FORM_UPDATE_FORM_ELEMENT]);
                $lastPart = end($parts);

                // If last part ends with [], extract the numeric part before []
                if (preg_match('/(\d+)\[\]$/', $lastPart, $matches)) {
                    $groupNumber = $matches[1]; // Capture the digits before []
                } else {
                    $groupNumber = $lastPart; // Use the last part as is if no [] present
                }

                // Check if currentGroup is set and does not match the extracted group number
                if ($currentGroup !== null && $currentGroup !== $groupNumber && $groupNumber !== '' && is_numeric($groupNumber)) {
                    break;
                }
                // Set or update currentGroup
                $currentGroup = $groupNumber;
            }

            if (isset($data[API_ELEMENT_UPDATE])) {
                foreach ($data[API_ELEMENT_UPDATE] as $key => $item) {
                    $collect[API_ELEMENT_UPDATE][$key] = $item;
                }
                unset($data[API_ELEMENT_UPDATE]);
            }

            if (is_array($data) && count($data) > 0) {
                $collect[API_FORM_UPDATE][] = $data;
            }
        }

        return $collect;
    }

    /**
     * Process the SQL Queries from bodytext. Return the output.
     *
     * @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
     */
    private function doReport() {

        // Session Expire happens quite late, cause it can be configured per form.
        Session::checkSessionExpired($this->store->getVar(SYSTEM_SESSION_TIMEOUT_SECONDS, STORE_SYSTEM));

        $report = new Report($this->t3data, $this->evaluate, $this->phpUnit);
        $html = '';

        $beUserLoggedIn = $this->store->getVar(TYPO3_BE_USER, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX);
        $systemInlineReport = $this->store->getVar(SYSTEM_EDIT_INLINE_REPORTS, STORE_SYSTEM, SANITIZE_ALLOW_ALLBUT);
        if ($systemInlineReport === '1') {
            $systemInlineReport = true;
        } else {
            $systemInlineReport = false;
        }

        if ($this->inlineReport) {
            $this->inlineReport = $systemInlineReport;
        }

        if ($beUserLoggedIn && $this->inlineReport) {
            // Check if report is loaded from file and give specific bodytext
            if ($this->t3data[T3DATA_REPORT_PATH_FILENAME] !== '') {
                $bodytext = $this->t3data[T3DATA_BODYTEXT_RAW];
            }

            $tooltip = 'tt-content: uid=' . $this->t3data['uid'] . ', header=' . $this->t3data['header'];

            # Define inline editor theme
            $systemInlineReportDarkTheme = $this->store->getVar(SYSTEM_EDIT_INLINE_REPORT_DARK_THEME, STORE_SYSTEM, SANITIZE_ALLOW_ALLBUT);
            $editorTheme = $systemInlineReportDarkTheme ? 'qfqdark' : 'default';

            $html .= $this->buildInlineReport($this->t3data[T3DATA_UID] ?? null,
                $this->t3data[T3DATA_REPORT_PATH_FILENAME] ?? null, $this->dbArray[$this->dbIndexT3],
                $bodytext ?? null, null, null, $tooltip, $editorTheme);
        }
        $html .= $report->process($this->t3data[T3DATA_BODYTEXT]);

        return $html;
    }

    /**
     * Constructs a form to directly edit qfq content elements inline.
     *
     * @param int|null $uid
     * @param string|null $reportPathFileNameFull
     * @param Database $db
     * @param string|null $bodytext
     * @param string|null $btnClass
     * @param string|null $buttonText
     * @param string|null $btnTooltip
     * @param string $editorTheme
     * @return string - the html code
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function buildInlineReport(?int $uid, ?string $reportPathFileNameFull, Database $db, ?string $bodytext = '', ?string $btnClass = 'btn-xs btn-default', ?string $buttonText = '', ?string $btnTooltip = '', string $editorTheme = 'default'): string {
        if ($uid === null) {
            return '';
        }
        $btnClass = $btnClass ?? 'btn-xs btn-default';
        $t3vars = T3Info::getVars();
        $baseUrl = Store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM);
        $icon = Support::renderGlyphIcon('glyphicon-edit');
        $showFormJs = '$("#tt-content-edit-' . $uid . '").toggleClass("hidden")';
        $toggleBtn = Support::wrapTag("<a class='targetEditReport $btnClass' data-base-url='$baseUrl' onclick='$showFormJs' style='float:right;' title='"
            . htmlentities($btnTooltip, ENT_QUOTES) . "'>", $icon . ' ' . $buttonText);

        $ttContentParam = $db->getBodyText($uid, false);
        $pageParam = $db->getPageParam($ttContentParam[COLUMN_PID], false);
        $pageTitle = $pageParam[COLUMN_TITLE];
        if ($reportPathFileNameFull !== '' && $reportPathFileNameFull !== null) {
            $reportPathFileNameFullHtml = "<small>File: '$reportPathFileNameFull'</small>";
        } else {
            $reportPathFileNameFullHtml = '';
        }

        $saveBtnAttributes = Support::doAttribute('class', 'btn btn-default') .
            Support::doAttribute('id', "tt-content-save-$uid") .
            Support::doAttribute('type', 'submit') .
            Support::doAttribute('title', 'Save & Reload');
        $saveBtnIcon = Support::renderGlyphIcon(GLYPH_ICON_CHECK);
        $saveBtn = Support::wrapTag("<button $saveBtnAttributes>", $saveBtnIcon);
        $ids = '<span class="badge" style="vertical-align:baseline; margin-right: 5px;">
                    <span title="pages.uid" >' . $ttContentParam[COLUMN_PID] . '</span> | 
                    <span title="tt_content.uid">' . $uid . '</span></span>';
        $headerLine = '<div style="display: flex;">
                            <div>' . $ids . '</div><i title="pages.title"><b>' . $pageTitle . ' </b></i>
                            <div style="flex-grow:1;"></div>
                            <div>
                                <label for="tt-content-header-' . $uid . '">Header:</label>
                                <input type="text" id="tt-content-header-' . $uid . '" style="margin-right:10px;" name="header" value="' . $ttContentParam[COLUMN_HEADER] . '"/>
                            </div>
                            <div>
                                <label for="tt-content-subheader-' . $uid . '">Subheader:</label>
                                <input type="text" id="tt-content-subheader-' . $uid . '" style="margin-right:10px;" name="subheader"  value="' . $ttContentParam[COLUMN_SUBHEADER] . '"/>
                            </div>
                            <div style="width:60px; position:relative;"><div class="save-message"></div></div>'
            . $saveBtn . '</div>';
        $headerBar = Support::wrapTag("<div class='tt-content-bar' style='padding: 10px; position: sticky;top: 0;z-index: 1000;'>", $headerLine . $reportPathFileNameFullHtml);

        // In special cases bodytext is given over arguments. One case is when file is used as report, which content is not the same as from tt_content table.
        if ($bodytext === '' || $bodytext === null) {
            $bodytext = $ttContentParam[COLUMN_BODYTEXT];
        }
        $ttContentCode = Support::htmlEntityEncodeDecode(MODE_ENCODE, $bodytext);

        // Generate tableColumns info to pass as config
        $store = Store::getInstance('', false);
        $dbIndexData = $store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM);
        // This function is static - therefore we can not rely on that $this->dbArray[] is filled.
        $db = new Database($dbIndexData);
        $sql = "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ?";
        $columns = $db->sql($sql, ROW_REGULAR, [$db->getDbName()]);
        foreach ($columns as &$column) {
            $column = implode('.', $column);
        }
        $tableColumns = implode(',', $columns);

        $json = json_encode(array('mode' => 'qfq', 'tableColumns' => $tableColumns,
            'electricChars' => true, 'lineNumbers' => true, 'lineWrapping' => true,
            'theme' => $editorTheme, 'styleActiveLine' => true,
            'hint' => 'CodeMirror.hint.qfq', 'autoCloseBrackets' => true, 'matchBrackets' => true,
            'highlightSelectionMatches' => array('showToken' => true, "style" => "matchhighlight")), JSON_UNESCAPED_SLASHES);
        $codeBoxAttributes = Support::doAttribute('style', "width:100%;") .
            Support::doAttribute('id', "tt-content-code-$uid") .
            Support::doAttribute('name', REPORT_INLINE_BODYTEXT) .
            Support::doAttribute('class', 'qfq-codemirror cm-extern') .
            Support::doAttribute('data-config', $json, true);
        $codeBox = Support::wrapTag("<textarea $codeBoxAttributes>", $ttContentCode);

        $form = join(' ', [$headerBar, $codeBox]);
        $sipObj = new Sip;
        $action = $sipObj->queryStringToSip(Path::urlApi(API_SAVE_PHP) . "?uid=$uid&beUser=" .
            $t3vars[TYPO3_BE_USER] . "&beUserUid=" . $t3vars[TYPO3_BE_USER_UID] . "&" . REPORT_SAVE . "=1&" .
            REPORT_SAVE_FILE . "=" . (is_null($reportPathFileNameFull) ? 0 : 1) . '&t3Version=' . ($t3vars[TYPO3_VERSION] ?? ''));
        $formAttributes = Support::doAttribute('id', "tt-content-edit-$uid") .
            Support::doAttribute('style', 'display: none;') .
            Support::doAttribute('method', 'post') .
            Support::doAttribute('action', $action);
        $form = Support::wrapTag("<form $formAttributes>", $form);

        return $toggleBtn . $form;
    }

    /**
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function saveReport() {
        $uid = $this->store->getVar(T3DATA_UID, STORE_SIP . STORE_ZERO, SANITIZE_ALLOW_DIGIT);
        $isFile = $this->store->getVar(REPORT_SAVE_FILE, STORE_SIP . STORE_ZERO, SANITIZE_ALLOW_DIGIT);
        if ($uid == 0) {
            // Check if it was called with a SIP (containing a uid)
            // If not, this might be an attack => cancel.
            return;
        }
        $bodytextNew = $_POST[REPORT_INLINE_BODYTEXT];
        $headerNew = $_POST[REPORT_INLINE_HEADER];
        $subheaderNew = $_POST[REPORT_INLINE_SUBHEADER];

        // removed the entity decode of bodytext since it replaced
        //   10.sql = SELECT '&amp; X &'
        // with
        //   10.sql = SELECT '& X &'
        // $bodytextNew = Support::htmlEntityEncodeDecode(MODE_DECODE, $_POST[REPORT_INLINE_BODYTEXT]);

        if (intval($isFile) === 1) {
            ReportAsFile::write_file_uid($uid, $bodytextNew, $this->dbArray[$this->dbIndexT3], $headerNew, $subheaderNew);
        } else {
            ReportAsFile::write_tt_content($uid, $this->dbArray[$this->dbIndexT3], $bodytextNew, $headerNew, $subheaderNew);
        }
        $this->formSpec[F_FORWARD_MODE] = 'auto';
    }

    /**
     * Save the current form.
     *
     * @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
     */
    public function saveForm() {
        if ($this->store->getVar(REPORT_SAVE, STORE_SIP . STORE_ZERO) == '1') {
            // Before saving report, first save history to get unchanged bodytext
            $this->saveHistory();
            $this->saveReport();
            $json = array();
            $json[REPORT_SAVE] = 1;
        } else {
            $json = $this->doForm(FORM_SAVE);
        }

        return $json;
    }

    /** Execute chat modifications
     *
     * @param $mode
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function saveChat($mode): array {
        $json = $this->doChat($mode);

        return $json;
    }

    /**
     * Update FormElements and form values. Receives the current form values via POST.
     *
     * @return array
     * @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 updateForm() {

        $json = $this->doForm(FORM_UPDATE);

        return $json;
    }

    /**
     * Update Chat FE.
     *
     * @return array
     * @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 updateChat($mode) {

        $json = $this->doChat($mode);

        return $json;
    }

    /**
     * Update FormElements and form values. Receives the current form values via POST.
     *
     * @return array
     * @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 dragAndDrop() {

        //TODO: CR - dnd kommt via GET rein, warum soll hier der FORM Store gefuellt werden? Diese Aenderung kam mit '579e0f7' rein - check ob das sinnvoll ist.
        $fillStoreForm = new FillStoreForm();
        $fillStoreForm->process(FORM_DRAG_AND_DROP);

        $json = "";

        $dndSubrecordId = $this->store::getVar(DND_SUBRECORD_ID, STORE_SIP . STORE_CLIENT . STORE_ZERO);
        if ($dndSubrecordId > 0) {

            // Save dbIndex
            $dbIndex = $this->store::getVar(DND_DB_INDEX, STORE_SIP . STORE_ZERO);

            // Get FormElement of current 'Subrecord' DragAndDrop
            $feSubrecord = $this->dbArray[$this->dbIndexQfq]->sql(SQL_FORM_ELEMENT_BY_ID, ROW_REGULAR, [$dndSubrecordId]);
            $recordId = $this->store::getVar(DND_SUBRECORD_FORM_ID, STORE_SIP . STORE_ZERO);

            $tableName = $this->store::getVar(DND_SUBRECORD_FORM_TABLE, STORE_SIP);
//            $this->store->fillStoreWithRecord($tableName, $recordId, $this->dbArray[$this->dbIndexQfq]);
            $this->store->fillStoreWithRecord($tableName, $recordId, $this->dbArray[$dbIndex]);

            $formSip = $this->store::getVar(DND_FORM_SIP_VALUES, STORE_SIP);
            // Backup STORE_SIP
            $save = $this->store::getStore(STORE_SIP);
            // Fake STORE_SIP
            $this->store::fillStoreSip($formSip);

            // Fire query, which might use SIP Vars
            $sql = $feSubrecord[0][FE_SQL1];
            $sqlSelect = str_replace(' ', '', substr($sql, 0, 9));
            $sqlRest = substr($sql, 9);

            // In case there is already a dbIndex given: change nothing '{{[123]!SELECT'. Else set the one defined by the SIP
            if ($sqlSelect[2] != '[') {
                $sqlSelect = str_replace('{{', '{{[' . $dbIndex . ']', $sqlSelect);
                $sql = $sqlSelect . $sqlRest;
            }

            $dndOrderSql = $this->evaluate->parse($sql);

            // Restore STORE_SIP
            $this->store::setStore($save, STORE_SIP, true);

            // Copy all '_...' columns to '...' (without the dash)
//            foreach ($dndOrderSql as $i => $row) {
//                foreach ($row as $key => $value) {
//                    if (substr($key, 0, 1) === '_') {
//                        $dndOrderSql[$i][substr($key, 1)] = $value;
//                    }
//                }
//            }

            $dummyFormSpec = [
                F_ORDER_INTERVAL => $this->store->getVar(FE_ORDER_INTERVAL, STORE_SIP . STORE_ZERO),
                F_ORDER_COLUMN => $this->store->getVar(FE_ORDER_COLUMN, STORE_SIP . STORE_ZERO),
                F_DRAG_AND_DROP_ORDER_SQL => $dndOrderSql,
                F_DB_INDEX => $dbIndex,
                F_TABLE_NAME => $this->store->getVar(FE_DND_TABLE, STORE_SIP . STORE_ZERO)
            ];

            $dragAndDrop = new DragAndDrop($dummyFormSpec);
            $json = $dragAndDrop->process();
        } else {
            // User-defined DragAndDrop
            $json = $this->doForm(FORM_DRAG_AND_DROP);
        }

        return $json;
    }

    /**
     * Setting: Tablesorter
     *
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function setting() {

        // Get STORE_SIP: SETTING_TABLESORTER_TABLE_ID, SETTING_TABLESORTER_FE_USER
        $sipArr = Store::getStore(STORE_SIP);

        // If the 'tableId' exist: this indicates a valid access.
        $tableId = $sipArr[SETTING_TABLESORTER_TABLE_ID] ?? false;
        if (false === $tableId) {
            throw new \UserReportException("Missing SIP for 'tablesorter setting'.", ERROR_TABLESORTER_SIP_NOT_FOUND);
        }

        // Get feUser from SIP. If no FE USER logged in, it might be the QFQ cookie.
        $feUser = $sipArr[SETTING_TABLESORTER_FE_USER];
        $mode = Store::getVar(SETTING_TABLESORTER_MODE, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX);
        $public = Store::getVar(SETTING_TABLESORTER_PUBLIC, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX);
        $public = ($public == 'true') ? 1 : 0;

        $name = Store::getVar(SETTING_TABLESORTER_NAME, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX);
        if (empty($name)) {
            throw new \UserReportException("Missing name)", ERROR_MISSING_VALUE);
        }

        if ($name === SANITIZE_VIOLATE . SANITIZE_ALLOW_ALNUMX . SANITIZE_VIOLATE) {
            throw new \UserReportException("Invalid characters. As special character please only use @-_.,;:/()", ERROR_TABLESORTER_INVALID_CHAR);
        }

        if (strlen($name) > 64) {
            throw new \UserReportException("Name too long (max. 64 characters).", ERROR_TABLESORTER_NAME_TOO_LONG);
        }

        // The $view is base64 encoded. javascript base64 Alphabet: "A-Z", "a-z", "0-9", "+", "/" and "="
        $view = Store::getVar(SETTING_TABLESORTER_VIEW, STORE_CLIENT, SANITIZE_ALLOW_ALLBUT);
        if (!preg_match("#^[A-Za-z0-9+/=]*$#", $view)) {
            throw new \UserReportException("Encoding error of table data. This should not happen. Please contact support.", ERROR_TABLESORTER_INVALID_CHAR);
        }

        $rows = $this->dbArray[$this->dbIndexQfq]->sql(
            'SELECT `sett`.`id`, `sett`.`readonly` FROM `' . SETTING_TABLE_NAME . '` AS sett WHERE `tableId`=? AND `name`=? AND IF(?, public, feUser=? AND !public)',
            ROW_REGULAR, [$tableId, $name, $public, $feUser]);

        // Protect Setting 'Clear'
        if ($name == SETTING_TABLESORTER_CLEAR && $public) {
            throw new \UserReportException("Sorry, it's not allowed to delete/modify the public setting '" . SETTING_TABLESORTER_CLEAR . "'.", ERROR_SETTING_SYSTEM);
        }

        switch (count($rows)) {

            case 0:
                if ($mode != SETTING_TABLESORTER_MODE_DELETE) {
                    // Insert
                    $this->dbArray[$this->dbIndexQfq]->sql(
                        'INSERT INTO `' . SETTING_TABLE_NAME . '` (`type`, `name`, `public`, `feUser`, `tableId`, `view`) VALUES (?,?,?,?,?,?)',
                        ROW_REGULAR, [SETTING_TYPE_TABLESORTER, $name, $public, $feUser, $tableId, $view]);
                }
                break;

            case 1:
                // Take care not to modify 'system' settings
                if ($rows[0][SETTING_TABLESORTER_READONLY] == 'yes') {
                    throw new \UserReportException("Sorry, this is a readonly setting and can't be modified. Please save the setting with a different name.", ERROR_SETTING_SYSTEM);
                }

                if ($mode == SETTING_TABLESORTER_MODE_DELETE) {
                    // Delete 'view'
                    $this->dbArray[$this->dbIndexQfq]->sql(
                        'DELETE FROM `' . SETTING_TABLE_NAME . '` WHERE `id`=?',
                        ROW_REGULAR, [$rows[0]['id']]);

                } else {
                    // Update 'view'
                    $this->dbArray[$this->dbIndexQfq]->sql(
                        'UPDATE `' . SETTING_TABLE_NAME . '` SET `view`=? WHERE `id`=?',
                        ROW_REGULAR, [$view, $rows[0]['id']]);
                }
                break;

            default:
                throw new \UserReportException("Found more than 1 setting records", ERROR_SETTING_RECORD_TOO_MUCH);
        }
    }

    /**
     * Process given tt-content record triggered by AJAX Call
     *
     * @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
     */
    public function dataReport() {
        $this->validateInputFilterParams();

        $uid = Store::getVar(NAME_UID, STORE_SIP . STORE_CLIENT . STORE_ZERO, SANITIZE_ALLOW_ALNUMX);
        return $this->getEvaluatedBodyText($uid);
    }

    /**
     * @param $uid
     *
     * @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
     */
    private function getEvaluatedBodyText($uid) {

        $dbT3 = $this->store->getVar(SYSTEM_DB_NAME_T3, STORE_SYSTEM);
        $tt_content = array();

        if (is_numeric($uid)) {
            $sql = "SELECT `bodytext` FROM `$dbT3`.`tt_content` WHERE `uid` = ?";
            $tt_content = $this->dbArray[$this->dbIndexQfq]->sql($sql, ROW_EXPECT_1, [$uid]);
        } else {
            $pathFileNameSystem = Path::absoluteExt(Path::EXT_TO_REPORT_SYSTEM, $uid . REPORT_FILE_EXTENSION);
            if (HelperFile::isReadableException($pathFileNameSystem)) {
                $tt_content[T3DATA_BODYTEXT] = ReportAsFile::read_report_file($pathFileNameSystem);
            }
        }

        $qfq = new QuickFormQuery([T3DATA_BODYTEXT => $tt_content[T3DATA_BODYTEXT]], false, false);
        return $qfq->process();
    }

    /**
     * Delete a record (tablename and recordid are given) or process a 'delete form'
     *
     * @return array
     * @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 delete() {
        return $this->doForm(FORM_DELETE);

    }

    /**
     * If the record is a Form or FormElement get the new and the old (if name changed) form file name and make sure both files are writable.
     *
     * @param $recordId
     * @param string $formModeNew
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function formAsFileBeforeSave($recordId, string $formModeNew): array {
        $formFileName = null;
        $formNameDB = FormAsFile::formNameFromFormRelatedRecord($recordId, $this->formSpec[F_TABLE_NAME] ?? '', $this->dbArray[$this->dbIndexQfq]);
        switch ($this->formSpec[F_TABLE_NAME] ?? '') {
            case TABLE_NAME_FORM: // cases covered: new form, existing form, existing form but form name changed
                $formFileName = $this->store->getVar(F_NAME, STORE_FORM, SANITIZE_ALLOW_ALNUMX);
                $formFileName = $formFileName === false ? $formNameDB : $formFileName;
                if ($formNameDB !== null && $formFileName !== $formNameDB && $formModeNew === FORM_SAVE) {
                    $formFileNameDelete = $formNameDB;
                    FormAsFile::enforceFormFileWritable($formFileNameDelete, $this->dbArray[$this->dbIndexQfq]); // file will be deleted after DB changes
                }
                break;
            case TABLE_NAME_FORM_ELEMENT: // cases covered: new formElement, existing formElement
                $formId = $this->store->getVar(FE_FORM_ID, STORE_FORM);
                $formFileName = $formId !== false ? FormAsFile::formNameFromFormRelatedRecord($formId, TABLE_NAME_FORM, $this->dbArray[$this->dbIndexQfq]) : $formNameDB;
                break;
            default:
                $formFileName = $formNameDB;
        }
        if ($formFileName !== null && in_array($formModeNew, [FORM_SAVE, FORM_DRAG_AND_DROP, FORM_DELETE])) {
            FormAsFile::enforceFormFileWritable($formFileName, $this->dbArray[$this->dbIndexQfq]);
        }
        return array($formFileName, $formFileNameDelete);
    }

    /** Load the form record history data and return it as an array.
     * Is called by api.
     *
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \DbException
     * @throws \UserReportException
     */
    public function loadRecordHistory(): array {

        // Get params from sip store
        $recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP, SANITIZE_ALLOW_DIGIT);
        $tableName = $this->store->getVar(SIP_TABLE, STORE_SIP, SANITIZE_ALLOW_ALNUMX);
        $formId = $this->store->getVar(SIP_FORM_ID, STORE_SIP, SANITIZE_ALLOW_ALNUMX);
        $formHistoryFeUserSet = $this->store->getVar(SIP_SHOW_HISTORY_FE_USER_SET, STORE_SIP, SANITIZE_ALLOW_ALNUMX);
        $dbQfq = $this->dbArray[$this->dbIndexQfq];

        // Use the form's dbIndex from SIP for FormSubmitLog lookups.
        $sipDbIndexData = $this->store->getVar(PARAM_DB_INDEX_DATA, STORE_SIP);
        $dbIndexData = empty($sipDbIndexData) ? $this->dbIndexData : $sipDbIndexData;
        if (!isset($this->dbArray[$dbIndexData])) {
            $this->dbArray[$dbIndexData] = new Database($dbIndexData);
        }
        $dbData = $this->dbArray[$dbIndexData];

        // History records
        $formHistoryRecords = $dbData->getAllFormHistoryRecords($tableName, $recordId, $formHistoryFeUserSet, false, $dbQfq);

        $formData = '';

        // Get $formDataKeys used by current form
        foreach ($formHistoryRecords as $formHistoryRecord) {
            if ($formHistoryRecord[RECORD_HISTORY_FORM_ID] == $formId) {
                $formData = $formHistoryRecord[RECORD_HISTORY_FORM_DATA];
                break;
            }
        }

        $formDataKeys = array_keys(json_decode($formData, true));

        // Remove the first four keys
        $formDataKeys = array_slice($formDataKeys, 4);

        // Remove keys named '_sipForTypo3Vars', 'filepond', 'recordHashMd5'
        $formDataKeys = array_filter($formDataKeys, function ($key) {
            return $key !== '_sipForTypo3Vars' && $key !== 'filepond' && $key !== 'recordHashMd5';
        });

        // When saving a new record (r=0) the keys will end in '-0'
        // Replace '0' with the actual $recordId
        foreach ($formDataKeys as $key => $value) {

            $newValue = preg_replace('/-0{1}$/', "-$recordId", $value);
            $formDataKeys[$key] = $newValue;
        }

        // Remove the last part of the key ('-{digit}')
        $formDataKeysCleaned = array_map(function ($key) {
            return preg_replace('/-\d+$/', '', $key);
        }, $formDataKeys);

        $nameList = implode(',', $formDataKeysCleaned);
        $excludedTypes = implode(',', [FE_TYPE_UPLOAD]);

        // Get name, label combinations from FormElement table about the current form
        $sql = 'SELECT name, label FROM ' . TABLE_NAME_FORM_ELEMENT . ' WHERE formId = ? AND FIND_IN_SET(name, ?) AND NOT FIND_IN_SET(type, ?) ORDER BY ord';
        $formLabelData = $dbQfq->sql($sql, ROW_REGULAR, [$formId, $nameList, $excludedTypes]);

        // Build a new associative array with keys and values from $formLabelData
        $formLabelData = array_column($formLabelData, 'label', 'name');

        // Replace FE.name with the keys from the record history
        foreach ($formLabelData as $key => $value) {

            $index = array_search($key, $formDataKeysCleaned);
            $newKey = $formDataKeys[$index];

            unset($formLabelData[$key]);
            $formLabelData[$newKey] = $value;
        }

        // Build a array with all needed data
        $formHistoryData = array();

        foreach ($formHistoryRecords as $formHistoryRecord) {

            $formData = $formHistoryRecord[RECORD_HISTORY_FORM_DATA];
            $formData = json_decode($formData, true);

            // Remove the first four keys
            $formData = array_slice($formData, 4);

            // Remove keys named '_sipForTypo3Vars', 'filepond', 'recordHashMd5'
            $formData = array_filter($formData, function ($key) {
                return $key !== '_sipForTypo3Vars' && $key !== 'filepond' && $key !== 'recordHashMd5';
            }, ARRAY_FILTER_USE_KEY);

            // When saving a new record (r=0) the keys will end in '-0'
            // Replace '0' with the actual $recordId
            foreach ($formData as $key => $value) {

                $newKey = preg_replace('/-0{1}$/', "-$recordId", $key);
                $formData[$newKey] = $value;
            }

            // Get keys of unchanged data
            $unchangedData = array_diff_key($formLabelData, $formData);

            // Only keep data for FEs that are used on the current form
            // When history record originates from another form that does not use the same FEs as the current form,
            // the values of the current form are set to '' although they were not changed
            $formData = OnArray::reorderArray($formLabelData, $formData);

            foreach ($formData as $key => $value) {
                $formData[$key] = array(
                    'value' => $value,
                    'name' => $key
                );
            }

            $formHistoryData[$formHistoryRecord[RECORD_HISTORY_CREATED]] = array(
                RECORD_HISTORY_FORM_DATA => $formData,
                RECORD_HISTORY_FE_USER => $formHistoryRecord[RECORD_HISTORY_FE_USER],
                'unchangedData' => $unchangedData,
            );
        }

        $formLabelData = OnArray::reorderArray($formLabelData, $formLabelData, true);
        $formHistoryData[RECORD_HISTORY_FORM_LABEL_DATA] = $formLabelData;
        $formHistoryData[RECORD_HISTORY_FORM_NAMES] = $formDataKeys;
        $formHistoryData[RECORD_HISTORY_FORM_ID] = $formId;

        return $formHistoryData;
    }

    /**
     * Based on the given SIP, create a new unique SIP by copying the relevant old params and taking the new recordId.
     *
     * @param array $sipArray
     * @param int $recordId
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function newRecordCreateSip(array $sipArray, $recordId) {

        $tmpParam = array();

        foreach ($sipArray as $key => $value) {
            switch ($key) {
                case SIP_SIP:
                case SIP_URLPARAM:
                case SIP_TABLE:
                    continue 2; // do not copy these params to the new SIP

                case SIP_RECORD_ID:
                    // set the new recordId
                    $tmpParam[SIP_RECORD_ID] = $recordId;
                    break;
                default:
                    // copy further vars stored in old SIP (form, maybe default values)
                    $tmpParam[$key] = $value;
                    break;
            }
        }

        // Construct fake urlparam
        $tmpUrlparam = OnArray::toString($tmpParam);

        // Create a SIP which has never been passed by URL - further processing might expect this to exist.
        $sip = store::getSipInstance()->queryStringToSip($tmpUrlparam, RETURN_SIP);
        $this->store->setVar(CLIENT_SIP, $sip, STORE_CLIENT);

        // Overwrite SIP Store
        $tmpParam[SIP_SIP] = $sip;
        $this->store->setStore($tmpParam, STORE_SIP, true);
    }


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

        $iconGearRelToApp = Path::urlExt('Resources/Public/icons/gear.svg');

        $code = <<<EOF
    <!-- Modal -->
    <div class="modal fade" id="qfqModal101" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                    <h4 class="modal-title" id="qfqModalTitle101">Loading Document</h4>
                </div>
                <div class="modal-body" style="text-align: center;">
                    <img class="qfq-icon-gear glyphicon-spin" src="$iconGearRelToApp" alt="gear">
                    <p id="qfqModalText101">Document is being generated. Please wait.</p>
                </div>
                <div class="modal-footer">
                    <p>In progress..</p>
                </div>
            </div>
        </div>
    </div>
EOF;

        return $code;
    }

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

        $code = <<<EOF
            <script type="text/javascript">
                $(function () {
            
            
                    $('.qfq-dnd-sort').each(function() {
                        var dndObject = new QfqNS.DragAndDrop($(this));
                        dndObject.makeSortable();
                    });
            
                    var zoni = new QfqNS.DragAndDrop($('.qfq-dnd'));
                    zoni.makeBasketCase();
            
            
                });
            </script>
EOF;

        return $code;

    }

    /**
     * @param array $restId
     * @param array $restForm
     * @return array|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
     */
    public function rest(array $restId, array $restForm) {

        // Copy 'id' from REST Url to STORE_CLIENT. Naming is generic with '_idX'
        $ii = 1;
        foreach ($restId as $val) {
            $this->store::setVar(CLIENT_REST_ID . $ii, $val, STORE_CLIENT);
            $ii++;
        }

        // Copy 'form' from REST Url to STORE_CLIENT. Naming is generic with '_formX'
        $ii = 1;
        foreach ($restForm as $val) {
            $this->store::setVar(CLIENT_REST_FORM . $ii, $val, STORE_CLIENT);
            $ii++;
        }

        $this->store::setVar(SIP_FORM, end($restForm), STORE_SIP);
        $this->store::setVar(SIP_RECORD_ID, end($restId), STORE_SIP);

        return $this->doForm(FORM_REST);

    }

    /**
     * export Form to file, if loaded record is a Form/FormElement
     *
     * @param $formFileName
     * @param string $formModeNew
     * @param $formFileNameDelete
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function formAsFileAfterSave($formFileName, string $formModeNew, $formFileNameDelete): void {
        if ($formFileName !== null) {
            switch ($formModeNew) {
                case FORM_SAVE:
                case FORM_DRAG_AND_DROP:
                    FormAsFile::exportForm($formFileName, $this->dbArray[$this->dbIndexQfq]);
                    break;
                case FORM_DELETE:
                    if (TABLE_NAME_FORM_ELEMENT === ($this->formSpec[F_TABLE_NAME] ?? '')) {
                        FormAsFile::exportForm($formFileName, $this->dbArray[$this->dbIndexQfq]);
                    } else {
                        FormAsFile::deleteFormFile($formFileName, $this->dbArray[$this->dbIndexQfq], 'Form was deleted using form-editor.');
                    }
                    break;
            }
        }
        // delete old form file if form name was changed
        if (isset($formFileNameDelete)) {
            FormAsFile::deleteFormFile($formFileNameDelete, $this->dbArray[$this->dbIndexQfq], "Form was renamed to: '$formFileName'.");
        }
    }

    /**
     * Save report history if changes are given.
     *
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \DbException
     * @throws \UserReportException
     */
    private function saveHistory() {
        $uid = $this->store::getVar(T3DATA_UID, STORE_SIP);
        $beUser = $this->store::getVar(TYPO3_BE_USER, STORE_SIP);
        $beUserUid = $this->store::getVar(TYPO3_BE_USER_UID, STORE_SIP);
        $ttContentOld = $this->dbArray[$this->dbIndexT3]->getBodyText($uid, false);
        $isFile = $this->store->getVar(REPORT_SAVE_FILE, STORE_SIP . STORE_ZERO, SANITIZE_ALLOW_DIGIT);

        // If some of the following values are not given.
        if (empty($uid) || empty($beUser) || !isset($_POST[T3DATA_BODYTEXT])) {
            throw new \UserFormException("Some data are missed to save history (uid, beUser, bodytext).", ERROR_DB_HISTORY_DATA);
        }
        // Set payload values in readable typo3 array format. Show only made changes in history
        $payload = array();

        // Old report from file needs to be fetched for correct history save
        $oldBodytext = $ttContentOld[T3DATA_BODYTEXT];
        if (intval($isFile) === 1) {
            $absoluteReportFilePath = ReportAsFile::parseFileKeyword($ttContentOld[COLUMN_BODYTEXT]);
            if ($absoluteReportFilePath === null) {
                throw new \UserReportException(json_encode([
                    ERROR_MESSAGE_TO_USER => "No report file defined.",
                    ERROR_MESSAGE_TO_DEVELOPER => "The keyword '" . TOKEN_REPORT_FILE . "' is not present in the typo3 content element with id $uid"]),
                    ERROR_FORM_NOT_FOUND);
            }
            $oldBodytext = ReportAsFile::read_report_file($absoluteReportFilePath);
        }

        if (strcmp($_POST[T3DATA_BODYTEXT], $oldBodytext)) {
            $payload[T3DATA_NEW_RECORD][T3DATA_BODYTEXT] = $_POST[T3DATA_BODYTEXT];
            $payload[T3DATA_OLD_RECORD][T3DATA_BODYTEXT] = $oldBodytext;
        }
        if (strcmp($_POST[T3DATA_HEADER], $ttContentOld[T3DATA_HEADER])) {
            $payload[T3DATA_NEW_RECORD][T3DATA_HEADER] = $_POST[T3DATA_HEADER];
            $payload[T3DATA_OLD_RECORD][T3DATA_HEADER] = $ttContentOld[T3DATA_HEADER];
        }
        if (strcmp($_POST[T3DATA_SUBHEADER], $ttContentOld[T3DATA_SUBHEADER])) {
            $payload[T3DATA_NEW_RECORD][T3DATA_SUBHEADER] = $_POST[T3DATA_SUBHEADER];
            $payload[T3DATA_OLD_RECORD][T3DATA_SUBHEADER] = $ttContentOld[T3DATA_SUBHEADER];
        }

        // Needed values for history record: tstamp, actiontype, usertype, userid, originaluserid, recuid, tablename, history_data, workspace
        $dataHistory = [time(), 2, 'BE', $beUserUid, 0, $uid, 'tt_content', json_encode($payload), 0];

        // Only save history if changes are given
        if (!empty($payload)) {
            $this->dbArray[$this->dbIndexT3]->setHistoryRecord($dataHistory);
        }
    }

    /**
     * Get and set default values for FEs from FE.parameter.defaultValue or from db default value.
     *
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \DbException
     * @throws \UserReportException
     */

    private function setFeDefaultValues(): void {

        $feValues = $this->store->getStore(STORE_FORM);
        $dbDefaultValues = $this->store->getStore(STORE_TABLE_DEFAULT);

        // Check each FE
        foreach ($this->feSpecNative as $fe) {
            $feName = $fe[FE_NAME];
            // If $feValues[$feName] if given and empty
            if (isset($feValues[$feName]) && ($feValues[$feName] === '' || $feValues[$feName] === 0)) {
                if (isset($fe[FE_DEFAULT_VALUE])) {
                    // Is there a custom default value configured (prio over DB scheme default value)
                    $defaultValue = $fe[FE_DEFAULT_VALUE];
                } elseif (isset($dbDefaultValues[$feName]) && $dbDefaultValues[$feName] !== null) {
                    // Take DB scheme default value
                    $defaultValue = $dbDefaultValues[$feName];
                } else {
                    // no default: skip
                    continue;
                }
                $this->store->setVar($feName, $defaultValue, STORE_FORM);
            }
        }
    }

    /** Handle chat json data.
     *
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \DbException
     * @throws \UserReportException
     */
    private function doChat($mode = CHAT_MODE_LOAD): array {
        // Get currently existing chat data
        $messageIdList = $this->store::getVar(CHAT_CLIENT_MESSAGE_ID_LIST, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX);
        $loadMode = $this->store::getVar(CHAT_CLIENT_LOAD_MODE, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX);
        $elementModeJson = $this->store::getVar(CHAT_CLIENT_ELEMENT_MODE, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALL);
        $messageId = $this->store::getVar(CHAT_MESSAGE_ID, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX);
        $chatRoom = $this->store::getVar(CHAT_CLIENT_ROOM_FLAG, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX);
        $chatRoom = $chatRoom === 'true';

        $elementMode = json_decode($elementModeJson, true);
        $chatConfig = $this->store::getVar(CHAT_CONFIG_SIP, STORE_SIP . STORE_EMPTY, SANITIZE_ALLOW_ALL);
        $chatConfig = json_decode($chatConfig, true);

        switch ($mode) {
            case CHAT_MODE_SAVE:
                $json = Chat::saveChatMessage($chatConfig, $elementMode, $this->store, $this->dbArray[$this->dbIndexQfq]);
                break;
            case CHAT_MODE_TAG_DONE:
                $json = Chat::setDone($chatConfig, $this->dbArray[$this->dbIndexQfq], $this->store);
                break;
            case CHAT_MODE_TOOLBAR_LOAD:
                $json = Chat::createToolbarConfig($chatConfig, $messageId, $this->dbArray[$this->dbIndexQfq], $this->store, $chatRoom);
                break;
            case CHAT_MODE_TAG_ADD:
                $json = Chat::setTag($chatConfig, $this->dbArray[$this->dbIndexQfq], $this->store);
                break;
            case CHAT_MODE_TAG_DELETE:
                $json = Chat::setTag($chatConfig, $this->dbArray[$this->dbIndexQfq], $this->store, $mode);
                break;
            default:
                $json = Chat::createChat($chatConfig, $this->dbArray[$this->dbIndexQfq], $messageIdList, $loadMode, $this->store);
        }

        return $json;
    }

    /** Build changed elements and store their new html in response
     *
     *
     * @param $data
     * @param $build
     * @return void
     */
    private function buildChangedElements(&$data, $save, $build): void {
        // Check for dynamically changed elements
        // Currently only for multi form deleteButtons used
        if (!empty($save->changedElements[API_CHANGED_ELEMENTS_DELETE_BUTTONS])) {
            if (!is_array($data)) {
                $data = array();
            }

            $data[API_CHANGED_ELEMENTS] = array();

            foreach ($save->changedElements[API_CHANGED_ELEMENTS_DELETE_BUTTONS] as $key => $value) {
                $deleteRender = '';

                $strDefault = TOKEN_ACTION_DELETE . ':' . TOKEN_ACTION_DELETE_AJAX
                    . '|' . TOKEN_GLYPH . ':' . GLYPH_ICON_DELETE
                    . '|' . TOKEN_SIP . '|' . TOKEN_BOOTSTRAP_BUTTON . $deleteRender
                    . '|' . TOKEN_URL_PARAM . ':form=' . $this->formSpec[F_NAME] . '&r=' . $value['id'] . '&' . FLAG_MULTIFORM . '=true'
                    . '|' . TOKEN_TOOL_TIP . ':Delete';

                $save->changedElements[API_CHANGED_ELEMENTS_DELETE_BUTTONS][$key][API_CHANGED_ELEMENTS_HTML] = $build->getNewLink($strDefault);
            }

            $data[API_CHANGED_ELEMENTS][API_CHANGED_ELEMENTS_DELETE_BUTTONS] = $save->changedElements[API_CHANGED_ELEMENTS_DELETE_BUTTONS];
        }
    }

    protected function updateIncludePath(): void {
        // Cached variable to make sure the path is updated only once
        static $includePathUpdated = false;

        if (!$includePathUpdated) {
            $includePathUpdated = set_include_path(
                    get_include_path() . PATH_SEPARATOR . '../../Resources/Private/Classes/'
                ) !== false;
        }
    }

    /**
     * Get filter counts for faceted filtering
     *
     * Executes the provided sql1 with current filter parameters (from STORE_CLIENT)
     * and returns counts for each option. The sql1 is passed directly from the SIP
     * to avoid a DB lookup on each count refresh.
     *
     * @param string $sql1 The SQL query for counting (passed directly from SIP for efficiency)
     * @return array Counts array: {"value1": count1, "value2": count2, ...} or {"total": count}
     * @throws \DbException
     */
    public function getFilterCounts(string $sql1): array {
        if (empty($sql1)) {
            return [];
        }

        // Evaluate sql1 with current parameters (from STORE_RECORD) and execute
        // ROW_REGULAR returns results as array
        $rows = $this->evaluate->parse($sql1, ROW_REGULAR);

        // Build counts array based on result structure
        $counts = [];

        if (empty($rows)) {
            return $counts;
        }

        $firstRow = $rows[0];
        $keys = array_keys($firstRow);
        $columnCount = count($keys);

        // Single column - return as total count (for text inputs)
        // Works with both "SELECT COUNT(*)" and "SELECT COUNT(*) AS count"
        if ($columnCount == 1) {
            $counts[INPUT_FILTER_COUNT_TOTAL] = (int)$firstRow[$keys[0]];
        }
        // Multiple columns with 'count' - return counts per value (for select/checkbox)
        // Expected format: id, label, count (consistent with QFQ convention)
        else if (array_key_exists(COLUMN_COUNT, $firstRow) && $columnCount >= 2) {
            // Use 'id' column as key (QFQ standard), fallback to first column
            $valueKey = array_key_exists(COLUMN_ID, $firstRow) ? COLUMN_ID : $keys[0];

            foreach ($rows as $row) {
                $value = $row[$valueKey];
                $counts[$value] = (int)$row[COLUMN_COUNT];
            }
        }

        return $counts;
    }

    /**
     * Get filter counts for multiple filters in a single batch request
     *
     * @param array $requestData Request data with 'countSips' and 'filterParams'
     * @return array Counts grouped by paramName: {paramName: {value: count, ...}, ...}
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function getFilterCountsBatch(array $requestData): array {
        $countSips = $requestData[INPUT_FILTER_COUNT_SIPS] ?? [];
        $filterParams = $requestData[INPUT_FILTER_PARAMS] ?? [];

        // Store filter params in STORE_RECORD for sql1 evaluation
        foreach ($filterParams as $key => $value) {
            $this->store::setVar($key, $value, STORE_RECORD);
        }

        $sip = $this->store->getSipInstance() ?? new Sip();
        $result = [];

        foreach ($countSips as $paramName => $sipToken) {
            try {
                // Decrypt SIP to get stored variables
                $sipVars = $sip->getVarsFromSip($sipToken);
                $sql1 = $sipVars[FE_SQL1] ?? '';

                if (!empty($sql1)) {
                    $result[$paramName] = $this->getFilterCounts($sql1);
                }
            } catch (\Exception $e) {
                // Skip invalid SIPs
                $result[$paramName] = [];
            }
        }

        return $result;
    }

    /**
     * Validate input filter parameters against allowed values from registry SIP
     *
     * Registry SIP contains: {'paramName': [allowedValues] or null}
     * - Keys = expected params
     * - Values = allowed values (array) or null (text field, no restriction)
     *
     * @throws \UserReportException If a filter value is not in the allowed list
     */
    private function validateInputFilterParams(): void {
        // Get allowed values directly from STORE_SIP (part of combined SIP)
        $allowedValuesJson = $this->store::getVar(INPUT_FILTER_ALLOWED_VALUES, STORE_SIP);
        if (empty($allowedValuesJson)) {
            return; // No filter validation required
        }

        $allowedValuesMap = json_decode($allowedValuesJson, true) ?: [];

        if (empty($allowedValuesMap)) {
            return; // No params to validate
        }

        // Validate each expected param (keys of allowedValuesMap)
        foreach ($allowedValuesMap as $paramName => $allowed) {
            $paramValue = $this->store::getVar($paramName, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX);

            // Skip empty values
            if ($paramValue === false || $paramValue === '') {
                continue;
            }

            // Null = text field, skip validation
            if ($allowed === null) {
                continue;
            }

            if (!is_array($allowed)) {
                continue; // Invalid format, skip
            }

            // Validate parameter value against allowed values (support multi-select)
            $values = explode(',', $paramValue);
            foreach ($values as $value) {
                if (!in_array($value, $allowed)) {
                    throw new \UserReportException(INPUT_FILTER_VALIDATION_ERROR . ': ' . $paramName, ERROR_INVALID_VALUE);
                }
            }
        }
    }
}