<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 1/28/16
 * Time: 8:05 AM
 */

namespace IMATHUZH\Qfq\Core\Helper;


use IMATHUZH\Qfq\Core\Exception\GenericException;
use IMATHUZH\Qfq\Core\Store\Store;
use IMATHUZH\Qfq\Core\Typo3\T3Handler;

const LONG_CURLY_OPEN = '#/+open+/#';
const LONG_CURLY_CLOSE = '#/+close+/#';

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

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

    /**
     * @param array $queryArray Empty or prefilled assoc array with url parameter
     * @param string $mode PARAM_T3_NO_ID, PARAM_T3_ALL
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function appendTypo3ParameterToArray(array &$queryArray, $mode = PARAM_T3_ALL) {

        self::$store = Store::getInstance();

        if ($mode === PARAM_T3_ALL) {
            $queryArray[CLIENT_PAGE_ID] = self::$store->getVar(TYPO3_PAGE_ID, STORE_TYPO3);
        }

        // TYPE
        $tmp = self::$store->getVar(TYPO3_PAGE_TYPE, STORE_TYPO3);
        if ($tmp !== false && $tmp != 0) {
            $queryArray[CLIENT_PAGE_TYPE] = $tmp;
        }

        // Language
        $tmp = self::$store->getVar(TYPO3_PAGE_LANGUAGE, STORE_TYPO3);
        if ($tmp !== false && $tmp != 0) {
            $queryArray[CLIENT_PAGE_LANGUAGE] = $tmp;
        }
    }

    /**
     * Check $uri if 'type' and/or 'L' is missing. If yes, append it.
     *
     * Do nothing if $uri is empty.
     * Do nothing if $uri is absolute (=starts with http)
     *
     * @param $uri
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function appendTypo3ParameterToUrl($uri) {

        self::$store = Store::getInstance();


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

        // Full URL: no processing
        $protocol = explode(':', $uri, 2);
        if ($protocol[0] == 'http' || $protocol[0] == 'https') {
            return $uri;
        }

        // Split by '?' and get first parameter name if exists
        $arr = KeyValueStringParser::parse($uri, '=', '?');
        $firstKey = array_keys($arr)[1] ?? null;

        // Split by '&' and get first parameter value if exists
        $arr = KeyValueStringParser::parse($uri, '=', '&');
        $firstValue = is_null($firstKey) ? null : array_values($arr)[0];

        $arrFinal = array();
        $flagFirst = true;

        // Iterate through array (split by '&') and add them to final array
        foreach ($arr as $key => $value) {
            if ($flagFirst) {

                // Add first parameter
                if (!is_null($firstKey)) {
                    $arrFinal[$firstKey] = $firstValue;
                }

                $flagFirst = false;

            } else {
                $arrFinal[$key] = $value;
            }
        }

        $type = self::$store->getVar(TYPO3_PAGE_TYPE, STORE_TYPO3);
        $language = self::$store->getVar(TYPO3_PAGE_LANGUAGE, STORE_TYPO3);
        $questionMarkNeeded = true;
        $anchor = '';

        if (strpos($uri, '?') !== false && T3Handler::typo3VersionGreaterEqual10()) {
            $questionMarkNeeded = false;
        }

        // Check for anchor
        if (strpos($uri, '#') !== false) {
            $anchorArray = explode('#', $uri, 2);

            // Assuming the structure of the URL is correct and the anchor comes at the end
            $uri = $anchorArray[0];
            $anchor = empty($anchorArray[1]) ? '' : '#' . $anchorArray[1];
        }

        // Check if 'type' needs to be added and at what position
        if ($type != 0 && $type !== false && !isset($arrFinal[CLIENT_PAGE_TYPE])) {
            if ($questionMarkNeeded) {
                $uri .= '?type=' . $type;
                $questionMarkNeeded = false;
            } else {
                $uri .= '&type=' . $type;
            }
        }

        // Check if 'L' needs to be added and at what position
        if ($language != 0 && $language !== false && !isset($arrFinal[CLIENT_PAGE_LANGUAGE])) {
            if ($questionMarkNeeded) {
                $uri .= '?L=' . $language;
            } else {
                $uri .= '&L=' . $language;
            }
        }

        return $uri . $anchor;
    }

    /**
     * Build the form log filename. Depending on $formLog=FORM_LOG_ALL one file for all BE_USER, or $formLog=FORM_LOG_SESSION one file per BE_USER.
     *
     * @param $formName
     * @param $formLogMode
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function getFormLogFileName($formName, $formLogMode) {

        self::$store = Store::getInstance();

        switch ($formLogMode) {
            case FORM_LOG_ALL:
                $perBeSession = '';
                break;
            case FORM_LOG_SESSION:
                $perBeSession = self::$store->getVar(TYPO3_BE_USER, STORE_TYPO3) . '.';
                if (empty($perBeSession)) {
                    throw new \UserFormException('formLog: no BE User logged in', ERROR_NO_BE_USER_LOGGED);
                }
                break;
            default:
                throw new \CodeException('Unknown mode: ' . $formLogMode, ERROR_UNKNOWN_TOKEN);
        }

        $filename = Path::absoluteLog() . '/' . $formName . "." . $perBeSession . "log";

        return sanitize::safeFilename($filename, false, true);
    }

    /**
     * Builds a urlencoded query string of an assoc array.
     *
     * @param array $queryArray
     *
     * @return string Querystring (e.g.: id=23&type=99
     */
    public static function arrayToQueryString(array $queryArray) {
        $items = array();

        // CR/Workaround for broken behaviour: LINK() class expects 'id' always as first parameter
        // Take care that Parameter 'id' is the first one in the array:
        if (isset($queryArray[CLIENT_PAGE_ID])) {
            $id = $queryArray[CLIENT_PAGE_ID];
            unset ($queryArray[CLIENT_PAGE_ID]);
            $queryArray = array_merge([CLIENT_PAGE_ID => $id], $queryArray);
        }

        foreach ($queryArray as $key => $value) {
            if (is_int($key)) {
                $items[] = urlencode($value);
            } else {
                $items[] = $key . '=' . urlencode($value);
            }
        }

        return implode('&', $items);
    }

    /**
     * Extract Tag(s) from $tag (eg: <div><input class="form-control">, might contain further attributes) and wrap it
     * around $value.
     * If $flagOmitEmpty==true && $value=='': return ''.
     *
     * @param string $tag
     * @param string $value
     * @param bool|false $omitIfValueEmpty
     *
     * @return string
     */
    public static function wrapTag($tag, $value, $omitIfValueEmpty = false) {

        $tag = trim($tag);

        if ($tag == '' || ($omitIfValueEmpty && $value == "")) {
            return $value;
        }

        $tagArray = explode('>', $tag, 2);
        if (count($tagArray) > 1 && $tagArray[1] != '') {
            $value = self::wrapTag($tagArray[1], $value, $omitIfValueEmpty);
            $tag = $tagArray[0] . '>';
        }

        // a) <div class="container-fluid">, b) <label>
        $arr = explode(' ', $tag);

        $tagPlain = (count($arr) === 1) ? substr($arr[0], 1, strlen($arr[0]) - 2) : substr($arr[0], 1);
        $closing = '</' . $tagPlain . '>';

        return $tag . $value . $closing;
    }

    /**
     * @param $glyphIcon
     * @param string $value
     * @return string
     */
    public static function renderGlyphIcon($glyphIcon, $value = '') {
        return Support::wrapTag("<span class='" . GLYPH_ICON . " $glyphIcon'>", $value);
    }

    /**
     * Removes '$tag' and closing $tag from $value, if they are the outermost.
     * E.g.  unWrapTag('<p>', '<p>Hello World</p>')    returns  'Hello World'
     *
     * @param string $tag
     * @param string $value
     *
     * @return string
     */
    public static function unWrapTag($tag, $value) {

        if ($tag == '' || $value == '') {
            return $value;
        }

        $lenTag = strlen($tag);
        $lenValue = strlen($value);

        if ($lenValue < $lenTag + $lenTag + 1) {
            return $value;
        }

        $closeTag = $tag[0] . '/' . substr($tag, 1);

        if (substr($value, 0, $lenTag) == $tag && substr($value, $lenValue - $lenTag - 1) == $closeTag) {
            $value = substr($value, $lenTag, $lenValue - $lenTag - $lenTag - 1);
        }

        return $value;
    }

    /**
     * Wraps some $inner fragment with a CSS styled $tooltipText . CSS is configured in 'Resources/Public/qfq-jqw.css'.
     *
     * Based on: http://www.w3schools.com/howto/howto_css_tooltip.asp
     *
     * @param string $htmlId
     * @param string $tooltipText
     *
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     */
    public static function doTooltip($htmlId, $tooltipText) {

        return "<img " . self::doAttribute('id', $htmlId) . " src='" . Path::urlExt(Path::EXT_TO_GFX_INFO_FILE) . "' title=\"" . htmlentities($tooltipText) . "\">";
    }

    /**
     * Format's an attribute: $type=$value. If $flagOmitEmpty==true && $value=='': return ''.
     * Escape double tick - assumes that attributes will always be enclosed by double ticks.
     * Add's a space at the end.
     *
     * @param string $type
     * @param string|array $value
     * @param bool $flagOmitEmpty true|false
     * @param string $modeEscape ESCAPE_WITH_BACKSLASH | ESCAPE_WITH_HTML_QUOTE
     *
     * @return string correctly formatted attribute. Space at the end.
     * @throws \CodeException
     */
    public static function doAttribute($type, $value, $flagOmitEmpty = true, $modeEscape = ESCAPE_WITH_HTML_QUOTE) {

        // several attributes might be given as an array - concat to a string
        if (is_array($value)) {
            $value = implode(' ', $value);
        }

        if ($flagOmitEmpty && trim($value) === "") {
            return '';
        }

        switch (strtolower($type)) {
            case 'size':
            case 'maxlength':
                // empty or '0' for attributes of type 'size' or 'maxlength' result in unusable input elements: skip this.
                if ($value === '' || $value == 0) {
                    return '';
                }
                break;
            // Bad idea to do urlencode on this place: it will convert ?, &, ... which are necessary for a proper URL.
            // Instead the value of a parameter needs to encode. Unfortunately, it's too late on this place.
//            case 'href':
//                $value = urlencode($value);
//                break;
            default:
                break;
        }

        $value = self::escapeDoubleTick(trim($value), $modeEscape);

        return $type . '="' . $value . '" ';
    }


    /**
     * Sanitizes a value before adding it to a given array.
     * This function is similar to doAttribute, but it does not return a string.
     * Instead it adds the element to the given array.
     *
     * @param string &$attributeArray reference to the array, to which the element will be pushed.
     * @param string $key
     * @param string|array $value
     * @param bool $flagOmitEmpty true|false
     * @param string $modeEscape ESCAPE_WITH_BACKSLASH | ESCAPE_WITH_HTML_QUOTE
     *
     *
     * @return string correctly formatted attribute. Space at the end.
     * @throws \CodeException
     */
    public static function addAttributeToArray(array &$attributeArray, string $key, $value, bool $flagOmitEmpty = true, string $modeEscape = ESCAPE_WITH_HTML_QUOTE): void {
        // several attributes might be given as an array - concat to a string
        if (is_array($value)) {
            $value = implode(' ', $value);
        }
        // Cut empty spaces
        $value = trim($value);
        if ($flagOmitEmpty && $value === "") return;

        // Special cases
        switch (strtolower($key)) {
            case 'size':
            case 'maxlength':
                // empty or '0' for attributes of type 'size' or 'maxlength' result in unsuable input elements: skip this.
                if ($value === '' || $value == 0) return;
                break;
            default:
                break;
        }

        $value = self::escapeDoubleTick($value, $modeEscape);

        $attributeArray[$key] = $value;
    }


    /**
     * Builds a string of XML attributes out of an associative array
     * @return string
     */
    public static function arrayToXMLAttributes(array $arr): string {
        $output = '';
        foreach ($arr as $key => $value) {
            $output .= $key . '="' . $value . '" ';
        }
        return $output;
    }

    /**
     * Escapes Double Ticks ("), which are not already escaped.
     * modeEscape: ESCAPE_WITH_BACKSLASH | ESCAPE_WITH_HTML_QUOTE
     *
     * TinyMCE: Encoding JS Attributes (keys & values) for TinyMCE needs to be encapsulated in '&quot;' instead of '\"'.
     *
     * @param string $str
     * @param string $modeEscape ESCAPE_WITH_BACKSLASH | ESCAPE_WITH_HTML_QUOTE
     *
     * @return string
     * @throws \CodeException
     */
    public static function escapeDoubleTick($str, $modeEscape = ESCAPE_WITH_BACKSLASH) {
        $newStr = '';

        for ($ii = 0; $ii < strlen($str); $ii++) {
            if ($str[$ii] === '"') {
                if ($ii === 0 || $str[$ii - 1] != '\\') {
                    switch ($modeEscape) {
                        case ESCAPE_WITH_BACKSLASH:
                            $newStr .= '\\';
                            break;
                        case ESCAPE_WITH_HTML_QUOTE:
                            $newStr .= '&quot;';
                            continue 2;
                        default:
                            throw new \CodeException('Unknown modeEscape=' . $modeEscape, ERROR_UNKNOWN_ESCAPE_MODE);
                    }
                }
            }
            $newStr .= $str[$ii];
        }

        return $newStr;
    }

    /**
     * Format's an attribute and inserts them at the beginning of the $htmlTag:
     * If $flagOmitEmpty==true && $value=='': insert nothing
     *
     * @param string $htmlTag with open and closing angle.
     * @param string $type
     * @param string|array $value
     * @param bool $flagOmitEmpty
     * @param string $modeEscape
     *
     * @return string correctly fomratted attribute. Space at the end.
     * @throws \CodeException
     */
    public static function insertAttribute($htmlTag, $type, $value, $flagOmitEmpty = true, $modeEscape = ESCAPE_WITH_BACKSLASH) {
        $htmlTag = trim($htmlTag);

        // e.g. '<div class=...' will be exploded to '<div' and 'class=...'
        $parts = explode(' ', $htmlTag, 2);
        if (count($parts) < 2) {
            if (strlen($htmlTag) < 3) {
                throw new \CodeException('HTML Token too short (<3 chars):' . $htmlTag, ERROR_HTML_TOKEN_TOO_SHORT);
            } else {
                $parts[0] = substr($htmlTag, 0, -1);
                $parts[1] = '>';
            }
        }

        $attr = self::doAttribute($type, $value, $flagOmitEmpty, $modeEscape);

        return $parts[0] . ' ' . $attr . $parts[1];
    }

    /**
     * Search for the parameter $needle in $haystack. The arguments have to be separated by ','.
     *
     * Returns false if not found, or index (starting with 0) of found place. Be careful: use unary operator to compare for 'false'
     *
     * @param string $needle
     * @param string $haystack
     *
     * @return boolean     true if found, else false
     */
    public static function findInSet($needle, $haystack) {
        $arr = explode(',', $haystack);

        return array_search($needle, $arr) !== false;
    }

    /**
     * Converts a dateTime String to the international format:
     * 1.2.79 > 1979-02-01 00:00:00
     * 01.02.13 3:24 >  1979-02-01 03:24:00
     * 1.2.1979 14:21:5 > 1979-02-01 14:21:05
     *
     * @param string $dateTimeString
     *
     * @return string
     * @throws \UserFormException
     */
    public static function dateTimeGermanToInternational($dateTimeString) {
        $dateRaw = '';
        $timeRaw = '';


//        const REGEXP_DATE_INT_ = '^\d{2,4}-\d{2}-\d{2}$';
//        const REGEXP_DATE_GER = '^\d{1,2}\.\d{1,2}\.\d{2}(\d{2})?$';
//        const REGEXP_TIME = '^\d{1,2}:\d{1,2}(:\d{1,2})?$';

        $tmpArr = explode(' ', $dateTimeString);
        switch (count($tmpArr)) {
            case 0:
                return '';

            case 1:
                if (strpos($tmpArr[0], ':') === false) {
                    $dateRaw = $tmpArr[0];
                } else {
                    $timeRaw = $tmpArr[0];
                }
                break;

            case 2:
                $dateRaw = $tmpArr[0];
                $timeRaw = $tmpArr[1];
                break;

            default:
                throw new \UserFormException('Date/time format not recognised.', ERROR_DATE_TIME_FORMAT_NOT_RECOGNISED);
                break;
        }

        if ($dateRaw === '' || $dateRaw === '0000-00-00' || $dateRaw === '00.00.0000') {
            $date = '0000-00-00';
        } else {
            // International format: YYYY-MM-DD

            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateRaw) === 1) {
                $date = $dateRaw;

                // German format: 1.1.01 - 11.12.1234
            } elseif (preg_match('/^\d{1,2}\.\d{1,2}\.\d{2}(\d{2})?$/', $dateRaw) === 1) {
                $tmpArr = explode('.', $dateRaw);

                if ($tmpArr[2] < 70) {
                    $tmpArr[2] = 2000 + $tmpArr[2];
                } elseif ($tmpArr[2] < 100) {
                    $tmpArr[2] = 1900 + $tmpArr[2];
                }
                $date = sprintf("%04d-%02d-%02d", $tmpArr[2], $tmpArr[1], $tmpArr[0]);

            } else {
                throw new \UserFormException('Date/time format not recognised.', ERROR_DATE_TIME_FORMAT_NOT_RECOGNISED);
            }
        }

        if ($timeRaw === '' || $timeRaw === '00:00:00') {
            $time = '00:00:00';
        } else {
            if (preg_match('/^\d{1,2}:\d{1,2}(:\d{1,2})?$/', $timeRaw) !== 1) {
                throw new \UserFormException('Date/time format not recognised.', ERROR_DATE_TIME_FORMAT_NOT_RECOGNISED);
            }

            $tmpArr = explode(':', $timeRaw);
            switch (count($tmpArr)) {
                case 2:
                    $time = sprintf("%02d:%02d:00", $tmpArr[0], $tmpArr[1]);
                    break;
                case 3:
                    $time = sprintf("%02d:%02d:%02d", $tmpArr[0], $tmpArr[1], $tmpArr[2]);
                    break;
                default:
                    throw new \UserFormException('Date/time format not recognised.', ERROR_DATE_TIME_FORMAT_NOT_RECOGNISED);
            }
        }

        return $date . ' ' . $time;
    }

    /**
     * @param string $type date | datetime | time
     * @param string $format FORMAT_DATE_INTERNATIONAL | FORMAT_DATE_GERMAN
     * @param string $timeIsOptional
     *
     * @return string
     * @throws \UserFormException
     */
    public static function dateTimeRegexp($type, $format, $timeIsOptional = '0') {
        if (mb_strlen($format) > 10) {
            $format = explode(' ', $format, 2)[0];
        }

        if (strtolower($format) == FORMAT_DATE_INTERNATIONAL) {
            // FORMAT_DATE_INTERNATIONAL: yyyy-mm-dd | 0000-00-00
            $date = '([0-9]{4}-([1-9]|0[1-9]|1[012])-([1-9]|0[1-9]|1[0-9]|2[0-9]|3[01])|0000-00-00)';
        } else {
            // dd.mm.yyyy | 00.00.0000
            $date = '(([1-9]|0[1-9]|1[0-9]|2[0-9]|3[01])\.([1-9]|0[1-9]|1[012])\.([0-9]{4}|[0-9]{2})|00\.00\.(00){1,2})';
        }

        // hh:mm[:ss]
        $time = '(0[0-9]|1[0-9]|2[0-3])(:[0-5][0-9]){1,2}';

        switch ($type) {
            case 'date':
                $pattern = $date;
                break;
            case 'datetime':
                if ($timeIsOptional == '1')
                    $pattern = $date . '( ' . $time . ')?';
                else
                    $pattern = $date . ' ' . $time;
                break;
            case 'time':
                $pattern = $time;
                break;
            default:
                throw new \UserFormException("Unknown mode: '$type'", ERROR_UNKNOWN_MODE);
        }

        // Special formats
//        $day = '([1-9]|0[1-9]|1[0-9]|2[0-9]|3[01])';
//        $month = '([1-9]|0[1-9]|1[012])';
//        $year = '([0-9]{4}|[0-9]{2})|00\.00\.(00){1,2})';
//
//        switch ($format) {
//            case 'dd':
//                $pattern = $day;
//                break;
//            case 'dd.mm':
//                $pattern = $day . '\.' . $month;
//                break;
//            case 'mm':
//                $pattern = $month;
//                break;
//            case 'mm.yyyy':
//                $pattern = $month . '\.' . $year;
//                break;
//            case 'yyyy':
//                $pattern = $year;
//                break;
//            default:
//                break;
//        }

        return '^' . $pattern . '$';
    }

    /**
     * Validates and reformats a date / datetime / time value to the requested $dateFormat.
     *
     * Handling overview:
     * - Zero-sentinel values ('', '0', '0000-00-00', '00:00:00', …) are routed through
     *   {@see dateTimeZero()} and respect $showZero.
     * - For STANDARD date formats ({@see FE_DATEPICKER_DATE_STANDARD} / {@see FE_DATEPICKER_DATE_DB})
     *   the date portion is parsed via {@see splitDateToArray()} and reformatted with sprintf.
     * - For NON-STANDARD date-only formats (e.g. 'MM.YYYY', 'YYYY') with $showTime === 0 the
     *   input is parsed via {@see createDateRegex()} (with a separator-normalizing fallback)
     *   and the output is built by substituting placeholders in $dateFormat.
     * - The time portion (when present in the input) is always formatted as HH:mm[:ss],
     *   independent of $dateFormat. A time portion inside $dateFormat is stripped — the
     *   time output is governed by $showTime / $showSeconds.
     *
     * @param string $dateTimeString Input value, e.g. '2025-05-19 14:30:00', '19.05.2025', '14:30'.
     * @param string $dateFormat     Target date format, e.g. 'DD.MM.YYYY', 'yyyy-mm-dd', 'MM.YYYY'.
     * @param string $showZero       '1' to keep zero-sentinel output, '0' to map zero values to ''.
     * @param string $showTime       '1' to keep the time portion, '0' to drop it.
     * @param string $showSeconds    '1' for HH:mm:ss, '0' for HH:mm.
     * @return string Reformatted value, or '' if $dateFormat is not recognized.
     *
     * @throws \UserFormException Propagated from {@see splitDateToArray()} on unparseable date strings.
     */
    public static function convertDateTime($dateTimeString, $dateFormat, $showZero, $showTime, $showSeconds) {
        $dateTimeString = trim($dateTimeString);

        // $dateFormat may carry a time portion (e.g. 'DD.MM.YYYY HH:mm'). Time output is governed
        // by $showTime / $showSeconds, so the time portion of the format string is irrelevant here.
        [$dateFormat] = self::splitFormatIntoDateAndTime($dateFormat);

        // Non-standard, date-only formats (e.g. 'MM.YYYY', 'YYYY') need the placeholder parser
        // so partial inputs are accepted. Standard formats fall through to the splitDateToArray
        // path so that zero-sentinel handling stays in effect.
        if ($showTime == 0 && !self::isStandardDateFormat($dateFormat)) {
            return self::formatPartialDate($dateTimeString, $dateFormat);
        }

        if (self::isZeroDateTime($dateTimeString)) {
            return self::dateTimeZero($dateFormat, $showZero, $showTime, $showSeconds);
        }

        [$givenDate, $givenTime, $hasBoth] = self::splitValueIntoDateAndTime($dateTimeString);

        $newDate = $givenDate !== '' ? self::formatStandardDate($givenDate, $dateFormat) : '';
        $newTime = $givenTime !== '' ? self::formatTime($givenTime, $showSeconds) : '';

        return $newDate . ($hasBoth ? ' ' : '') . $newTime;
    }

    /**
     * Split a format string into [datePart, timePart] on the first whitespace.
     * Example: 'DD.MM.YYYY HH:mm' → ['DD.MM.YYYY', 'HH:mm']; 'DD.MM.YYYY' → ['DD.MM.YYYY', ''].
     *
     * @param string $format
     * @return array{0:string,1:string}
     */
    private static function splitFormatIntoDateAndTime(string $format): array {
        $parts = explode(' ', $format, 2);
        return [$parts[0], $parts[1] ?? ''];
    }

    /**
     * @param string $format
     * @return bool True if $format is one of the standard date-only formats (case-insensitive).
     */
    private static function isStandardDateFormat(string $format): bool {
        $upper = strtoupper($format);
        return $upper === strtoupper(FE_DATEPICKER_DATE_STANDARD)
            || $upper === strtoupper(FE_DATEPICKER_DATE_DB);
    }

    /**
     * @param string $value
     * @return bool True if $value is any of the recognized zero-sentinel representations.
     */
    private static function isZeroDateTime(string $value): bool {
        return in_array($value, [
            '', '0',
            '0000-00-00 00:00:00', '0000-00-00',
            '00.00.0000 00:00:00', '00.00.0000',
            '00:00:00',
        ], true);
    }

    /**
     * Split a value string into [dateString, timeString, valueHadBothParts].
     * A single token containing ':' is treated as time-only; without ':' as date-only.
     * Anything that doesn't split into exactly one or two tokens collapses to ['', '', false].
     *
     * @param string $value
     * @return array{0:string,1:string,2:bool}
     */
    private static function splitValueIntoDateAndTime(string $value): array {
        $parts = explode(' ', $value);
        if (count($parts) === 2) {
            return [$parts[0], $parts[1], true];
        }
        if (count($parts) === 1) {
            return strpos($value, ':') !== false ? ['', $value, false] : [$value, '', false];
        }
        return ['', '', false];
    }

    /**
     * Format a parsed date string into one of the four standard date formats.
     * Returns '' for unknown $dateFormat (silent no-op, preserved for backwards compatibility).
     *
     * @param string $givenDate Date in either FORMAT_DATE_INTERNATIONAL or FORMAT_DATE_GERMAN.
     * @param string $dateFormat Target format (one of FORMAT_DATE_INTERNATIONAL[_QFQ] / FORMAT_DATE_GERMAN[_QFQ]).
     * @return string
     * @throws \UserFormException Propagated from {@see splitDateToArray()}.
     */
    private static function formatStandardDate(string $givenDate, string $dateFormat): string {
        if ($givenDate === '0') {
            $givenDate = '0000-00-00';
        }
        [$year, $month, $day] = self::splitDateToArray($givenDate);
        switch ($dateFormat) {
            case FORMAT_DATE_INTERNATIONAL:
            case FORMAT_DATE_INTERNATIONAL_QFQ:
                return sprintf('%04d-%02d-%02d', $year, $month, $day);
            case FORMAT_DATE_GERMAN:
            case FORMAT_DATE_GERMAN_QFQ:
                return sprintf('%02d.%02d.%04d', $day, $month, $year);
        }
        return '';
    }

    /**
     * Format a parsed time string into HH:mm or HH:mm:ss.
     *
     * @param string $givenTime
     * @param string|int $showSeconds '1' / 1 for HH:mm:ss, otherwise HH:mm.
     * @return string
     */
    private static function formatTime(string $givenTime, $showSeconds): string {
        if ($givenTime === '0') {
            $givenTime = '0:0:0';
        }
        $parts = explode(':', $givenTime);
        if (count($parts) < 3) {
            $parts[2] = 0;
        }
        return $showSeconds == 1
            ? sprintf('%02d:%02d:%02d', $parts[0], $parts[1], $parts[2])
            : sprintf('%02d:%02d', $parts[0], $parts[1]);
    }

    /**
     * Reformat a date string into a non-standard date-only format using placeholder substitution.
     *
     *  1. Tries to parse the input with a regex derived from $dateFormat itself ({@see createDateRegex()}).
     *  2. Falls back to splitting the input on '/', '.' or whitespace and inferring positions
     *     by part count (YYYY-MM-DD vs. partial month/year).
     *  3. Builds the output by replacing whichever placeholder tokens appear in $dateFormat
     *     ({@see FE_DATEPICKER_DAY}, {@see FE_DATEPICKER_MONTH}, {@see FE_DATEPICKER_YEAR},
     *     {@see FE_DATEPICKER_YEAR_SHORT}).
     *
     * If neither parser produces usable parts, $dateTimeString is returned unchanged.
     *
     * @param string $dateTimeString
     * @param string $dateFormat A non-standard, date-only format (e.g. 'MM.YYYY', 'YYYY').
     * @return string
     */
    private static function formatPartialDate(string $dateTimeString, string $dateFormat): string {
        $year = $month = $day = '';

        if (preg_match(self::createDateRegex($dateFormat), $dateTimeString, $matches)) {
            $year = $matches['year'] ?? '';
            $month = $matches['month'] ?? '';
            $day = $matches['day'] ?? '';
        } else {
            // Normalize alternative separators ("/", ".", whitespace) and infer from part count.
            $normalized = str_replace(['/', '.', ' '], '-', $dateTimeString);
            $parts = array_map('trim', explode('-', $normalized));
            if (count($parts) === 3 && strlen($parts[0]) === 4) {
                // YYYY-MM-DD shape (DB value passed through a partial-date FE).
                [$year, $month, $day] = $parts;
            } elseif (count($parts) >= 2) {
                // Assume MM-YYYY (day missing).
                $month = $parts[0];
                $year = $parts[1];
            } else {
                return $dateTimeString;
            }
        }

        $formatted = $dateFormat;
        if (strpos($dateFormat, FE_DATEPICKER_DAY) !== false && $day !== '') {
            $formatted = str_replace(FE_DATEPICKER_DAY, sprintf('%02d', $day), $formatted);
        }
        if (strpos($dateFormat, FE_DATEPICKER_MONTH) !== false && $month !== '') {
            $formatted = str_replace(FE_DATEPICKER_MONTH, sprintf('%02d', $month), $formatted);
        }
        if (strpos($dateFormat, FE_DATEPICKER_YEAR) !== false && $year !== '') {
            // 2-digit year: assume 21st century.
            if (strlen($year) === 2) {
                $year = '20' . $year;
            }
            $formatted = str_replace(FE_DATEPICKER_YEAR, sprintf('%04d', $year), $formatted);
        } elseif (strpos($dateFormat, FE_DATEPICKER_YEAR_SHORT) !== false && $year !== '') {
            // 4-digit year condensed to 2 digits.
            $shortYear = strlen($year) === 4 ? substr($year, 2) : $year;
            $formatted = str_replace(FE_DATEPICKER_YEAR_SHORT, sprintf('%02d', $shortYear), $formatted);
        }
        return $formatted;
    }


    /**
     * Generates a regex pattern from a date format string.
     *
     * This method replaces defined placeholders with named capturing groups:
     * - FE_DATEPICKER_YEAR:       "YYYY" → Four-digit year
     * - FE_DATEPICKER_YEAR_SHORT: "YY"   → Two-digit year
     * - FE_DATEPICKER_MONTH:      "MM"   → Two-digit month
     * - FE_DATEPICKER_DAY:        "DD"   → Two-digit day
     *
     * The format string is first escaped so that all special characters are treated as literals.
     * Then, the placeholders are replaced with the corresponding regex groups. The resulting
     * regex validates that the entire input string conforms exactly to the given format.
     *
     * **Examples:**
     *
     * - **Example 1:**
     *   *Input Format:* "DD-MM-YYYY"
     *   *Processing:*
     *     1. Escaping: "DD\-MM\-YYYY"
     *     2. Replacements:
     *        - "DD" becomes `(?P<day>\d{2})`
     *        - "MM" becomes `(?P<month>\d{2})`
     *        - "YYYY" becomes `(?P<year>\d{4})`
     *   *Output Regex:*
     *     `/^(?P<day>\d{2})-(?P<month>\d{2})-(?P<year>\d{4})$/`
     *
     * - **Example 2:**
     *   *Input Format:* "MM/YY"
     *   *Processing:*
     *     1. Escaping: "MM\/YY"
     *     2. Replacements:
     *        - "MM" becomes `(?P<month>\d{2})`
     *        - "YY" becomes `(?P<year>\d{2})`
     *   *Output Regex:*
     *     `/^(?P<month>\d{2})\/(?P<year>\d{2})$/`
     *
     * @param string $dateFormat The date format string, e.g., "DD-MM-YYYY" or "MM/YY".
     * @return string The generated regex pattern with start (^) and end ($) delimiters.
     */

    private static function createDateRegex($dateFormat) {
        // Ersetzungstabelle: Platzhalter => Regex-Pattern
        $replacements = [
            FE_DATEPICKER_YEAR => '(?P<year>\d{4})',
            FE_DATEPICKER_YEAR_SHORT => '(?P<year>\d{2})',
            FE_DATEPICKER_MONTH => '(?P<month>\d{2})',
            FE_DATEPICKER_DAY => '(?P<day>\d{2})'
        ];

        // Sortiere die Tokens nach Länge absteigend, um Überschneidungen (z. B. YYYY vs. YY) zu vermeiden.
        uksort($replacements, function ($a, $b) {
            return strlen($b) - strlen($a);
        });

        // Escapen des Format-Strings, damit Sonderzeichen als Literale behandelt werden.
        $regex = preg_quote($dateFormat, '/');

        // Ersetze dann die escaped Tokens durch die entsprechenden Regex-Gruppen.
        foreach ($replacements as $token => $pattern) {
            $regex = str_replace(preg_quote($token, '/'), $pattern, $regex);
        }
        return '/^' . $regex . '$/';
    }

    /**
     * Returns a representation of 0 in a chosen variant.
     *
     * @param string $dateFormat FORMAT_DATE_INTERNATIONAL | FORMAT_DATE_GERMAN
     * @param string $showZero
     * @param string $showTime '0' | '1'
     * @param string $showSeconds '0' | '1'
     *
     * @return string
     */
    private static function dateTimeZero($dateFormat, $showZero, $showTime, $showSeconds) {

        if ($showZero != '1') {
            return '';
        }

        // $dateFormat (INT/GER),  $showTime, $showSeconds
        $arr[0][0][0] = '0000-00-00';
        $arr[0][0][1] = '0000-00-00';
        $arr[0][1][0] = '0000-00-00 00:00';
        $arr[0][1][1] = '0000-00-00 00:00:00';
        $arr[1][0][0] = '00.00.0000';
        $arr[1][0][1] = '00.00.0000';
        $arr[1][1][0] = '00.00.0000 00:00';
        $arr[1][1][1] = '00.00.0000 00:00:00';

        $showFormat = ($dateFormat === FORMAT_DATE_INTERNATIONAL) ? 0 : 1;

        return $arr[$showFormat][$showTime][$showSeconds];
    }

    /**
     * Split date FORMAT_DATE_GERMAN | FORMAT_DATE_INTERNATIONAL to array with arr[0]=yyyy, arr[1]=mm, arr[2]=dd.
     *
     * @param string $dateString
     *
     * @return array
     * @throws \UserFormException
     */
    private static function splitDateToArray($dateString) {

        if (strpos($dateString, '-') === false) {
            // FORMAT_DATE_GERMAN
            $arr = explode('.', $dateString);
            if (count($arr) != 3) {
                throw new \UserFormException("Unexpected format for date: $dateString", ERROR_DATE_UNEXPECTED_FORMAT);
            }
            $tmp = $arr[0];
            $arr[0] = $arr[2];
            $arr[2] = $tmp;
        } else {
            // FORMAT_DATE_INTERNATIONAL
            $arr = explode('-', $dateString);
            if (count($arr) != 3) {
                throw new \UserFormException("Unexpected format for date: $dateString", ERROR_DATE_UNEXPECTED_FORMAT);
            }
        }

        // Adjust yy to yyyy. See https://dev.mysql.com/doc/refman/5.7/en/datetime.html > Dates containing two-digit year values ...
        if ($arr[0] < 100) {
            $add = ($arr[0] < 70) ? 2000 : 1900;
            $arr[0] = $add + $arr[0];
        }

        return $arr;
    }

    /**
     * Encrypt curly braces by an uncommon string. Helps to prevent unwanted actions on curly braces.
     *
     * @param string $text
     *
     * @return mixed
     */
    public static function encryptDoubleCurlyBraces($text) {
        $text = str_replace('{{', LONG_CURLY_OPEN, $text);
        $text = str_replace('}}', LONG_CURLY_CLOSE, $text);

        return $text;
    }

    /**
     * Decrypt curly braces by an uncommon string. Helps to prevent unwanted actions on curly braces
     *
     * @param string $text
     *
     * @return mixed
     */
    public static function decryptDoubleCurlyBraces($text) {

        if (!is_string($text)) {
            return $text;
        }

        $text = str_replace(LONG_CURLY_OPEN, '{{', $text);
        $text = str_replace(LONG_CURLY_CLOSE, '}}', $text);

        return $text;
    }

    /**
     * Creates a random string, starting with uniq microseconds timestamp.
     * After a discussion of ME & CR, the uniqid() should be sufficient to guarantee uniqueness.
     *
     * @param int $length Length of the required hash string
     *
     * @return string       A random alphanumeric hash
     */
    public static function randomAlphaNum($length) {
        $possible_characters = "abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
        $allChars = strlen($possible_characters);

        $string = uniqid();

        $ii = $length - strlen($string);

        while ($ii-- > 0) {
            $string .= substr($possible_characters, rand() % ($allChars), 1);
        }

        return ($string);
    }

    /**
     * Concatenate URL and Parameter. Depending on if there is a '?' in URL or not,  append the param with '?' or '&'..
     *
     * @param string $url
     * @param string|array $param
     *
     * @return string
     */
    public static function concatUrlParam($url, $param) {

        if (is_array($param)) {
            $param = implode('&', $param);
        }

        if ($param == '') {
            return $url;
        }

        $token = ((strpos($url, '?') === false) && (strpos($url, '&')) === false) ? '?' : '&';

        return $url . $token . $param;
    }

    /**
     * Concatenate $host, $path and $query. A given $host will always append a '/' if none is given.
     * End of  $path, there is no assumption to append '/' - the user needs full control.
     * $query: trailing 'index.php' will be skipped.
     *
     * If HOST and PATH is combined in a var, pass the value as $hostOrPath, leave $host empty.
     *
     * @param string $host
     * @param string $hostOrPath
     * @param string $query
     *
     * @return string
     * @throws \CodeException
     */
    public static function mergeUrlComponents($host, $hostOrPath, $query) {
        $url = '';
        $pageSlug = '';

        if ($host != '' && substr($host, -1, 1) != '/') {
            $host .= '/';
        }

        if ($host != '' && substr($hostOrPath, 0, 1) == '/') {
            $hostOrPath = substr($hostOrPath, 1);
        }

        if ($host != '' || $hostOrPath != '') {
            $url = $host . $hostOrPath;
        }

        if (substr($query, 0, 9) == 'index.php') {
            $query = substr($query, 9);
        } else if (!defined('PHPUNIT_QFQ')) { //ToDo adjust unit test for t3 v10
            $countHost = strlen($url);
            $queryTmp = substr($query, $countHost);
            $pageSlug = explode('?', $queryTmp, 2);
            $pageSlug = $pageSlug[0] ?? '';
            if (!empty($pageSlug)) {
                $pageSlugCount = strlen($pageSlug);
                $finalCount = strlen($url) + $pageSlugCount;
                $query = substr($query, $finalCount);
            }
        }

        //T3 v10 link is constructed differently
        if (false !== strpos(substr($query, 1), '?')) {
            throw new \CodeException('Found a "?" after the beginning of the query - this is forbidden', ERROR_BROKEN_PARAMETER);
        }

        if ($query != '') {
            if (T3Handler::typo3VersionGreaterEqual10()) {
                $url = $url . $pageSlug . '?' . $query;
            } else {
                $url = $url . '?' . $query;
            }
            $url = str_replace('??', '?', $url);
        }
        //Variante 1(T3 < v10): index.php?s=wt5reit45itt
        //Variante 2(T3 >= v10): ?s=grksan40nag04n
        return $url;
    }

    /**
     * Set Defaults for the current formElement.
     *
     * @param array $formElement
     * @param array $formSpec
     * @return array
     *
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function setFeDefaults(array $formElement, array $formSpec = array()): array {

        $store = Store::getInstance();

        // Some Defaults
        self::setIfNotSet($formElement, F_FE_INPUT_CLEAR_ME, $formSpec[F_FE_INPUT_CLEAR_ME] ?? '0');
        self::setIfNotSet($formElement, F_FE_MAX_IMAGE_DIMENSION, $formSpec[F_FE_MAX_IMAGE_DIMENSION] ?? '');
        self::setIfNotSet($formElement, FE_FILE_NAME_TO_LOWER, $formSpec[F_FILE_NAME_TO_LOWER] ?? '');
        self::setIfNotSet($formElement, FE_DATE_TIME_PICKER_TYPE, $formSpec[F_DATE_TIME_PICKER_TYPE] ?? '');
        self::setIfNotSet($formElement, UPLOAD_TYPE, $formSpec[UPLOAD_TYPE] ?? '');
        self::setIfNotSet($formElement, FE_ENCODE, FE_ENCODE_SPECIALCHAR);
        self::setIfNotSet($formElement, FE_CHECK_TYPE, SANITIZE_ALLOW_AUTO);
        self::setIfNotSet($formElement, FE_SHOW_SECONDS, '0');
        self::setIfNotSet($formElement, FE_TIME_IS_OPTIONAL, '0');
        self::setIfNotSet($formElement, FE_SHOW_ZERO, '0');
        self::setIfNotSet($formElement, FE_HIDE_ZERO, '0');
        self::setIfNotSet($formElement, FE_INDICATE_REQUIRED, '1', false, '1');
        self::setIfNotSet($formElement, FE_DATE_FORMAT, $store->getVar(SYSTEM_DATE_FORMAT, STORE_SYSTEM));
        self::setIfNotSet($formElement, FE_VALUE, '');
        self::setIfNotSet($formElement, FE_NOTE, '');
        self::setIfNotSet($formElement, FE_TG_INDEX, 0);
        self::setIfNotSet($formElement, FE_FORM_ID, 0);
        self::setIfNotSet($formElement, FE_SIZE, '');
        self::setIfNotSet($formElement, FE_MODE, FE_MODE_SHOW);


        self::setIfNotSet($formElement, FE_HTML_BEFORE);
        self::setIfNotSet($formElement, FE_HTML_AFTER);

        self::setIfNotSet($formElement, FE_DATA_REFERENCE, ($formElement[FE_NAME] == '' ? $formElement[FE_ID] : $formElement[FE_NAME]));

        self::setIfNotSet($formElement, FE_SUBRECORD_TABLE_CLASS, SUBRECORD_TABLE_CLASS_DEFAULT);

        if (isset($formSpec[F_BS_LABEL_COLUMNS])) {
            self::setIfNotSet($formElement, F_BS_LABEL_COLUMNS, $formSpec[F_BS_LABEL_COLUMNS], '');
            self::setIfNotSet($formElement, F_BS_INPUT_COLUMNS, $formSpec[F_BS_INPUT_COLUMNS], '');
            self::setIfNotSet($formElement, F_BS_NOTE_COLUMNS, $formSpec[F_BS_NOTE_COLUMNS], '');
        }

        self::setIfNotSet($formElement, FE_MODE_SQL);
        if ($formElement[FE_MODE_SQL] != '') {
            $formElement[FE_MODE] = $formElement[FE_MODE_SQL];
        }
        // Check FE_MODE
        switch ($formElement[FE_MODE]) {
            case FE_MODE_SHOW:
            case FE_MODE_READONLY:
            case FE_MODE_REQUIRED:
            case FE_MODE_HIDDEN:
                break;
            default:
                throw new \UserFormException("Invalid modeSql: '" . substr($formElement[FE_MODE], 0, 20) . "'", ERROR_UNKNOWN_MODE);
        }

        if (isset($formSpec[F_MODE_GLOBAL]) && $formElement[FE_TYPE] != FE_TYPE_PILL) {
            $formElement[FE_MODE] = self::applyFormModeToFormElement($formElement[FE_MODE], $formSpec[F_MODE_GLOBAL]);
        }

        // set typeAheadPedantic
        if (isset($formElement[FE_TYPEAHEAD_PEDANTIC]) && $formElement[FE_TYPEAHEAD_PEDANTIC] === '') {
            $formElement[FE_TYPEAHEAD_PEDANTIC] = '1'; // support legacy option of 'typeAheadPedantic' without a value
        }
        if (isset($formElement[FE_TYPEAHEAD_LDAP]) || isset($formElement[FE_TYPEAHEAD_SQL])) {
            self::setIfNotSet($formElement, FE_TYPEAHEAD_PEDANTIC, '1');
        }

        // Will be used to change dynamicUpdate behaviour
        if (isset($formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE])) {
            $formElement[FE_FLAG_ROW_OPEN_TAG]   = self::findInSet('row',    $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
            $formElement[FE_FLAG_ROW_CLOSE_TAG]   = self::findInSet('/row',   $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
            $formElement[FE_FLAG_LABEL_OPEN_TAG]  = self::findInSet('label',  $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
            $formElement[FE_FLAG_LABEL_CLOSE_TAG] = self::findInSet('/label', $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
            $formElement[FE_FLAG_INPUT_OPEN_TAG]  = self::findInSet('input',  $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
            $formElement[FE_FLAG_INPUT_CLOSE_TAG] = self::findInSet('/input', $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
            $formElement[FE_FLAG_NOTE_OPEN_TAG]   = self::findInSet('note',   $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
            $formElement[FE_FLAG_NOTE_CLOSE_TAG]  = self::findInSet('/note',  $formElement[FE_WRAP_ROW_LABEL_INPUT_NOTE]);
        } else {
            $formElement[FE_FLAG_ROW_OPEN_TAG]   = true;
            $formElement[FE_FLAG_ROW_CLOSE_TAG]   = false;
            $formElement[FE_FLAG_LABEL_OPEN_TAG]  = true;
            $formElement[FE_FLAG_LABEL_CLOSE_TAG] = true;
            $formElement[FE_FLAG_INPUT_OPEN_TAG]  = true;
            $formElement[FE_FLAG_INPUT_CLOSE_TAG] = true;
            $formElement[FE_FLAG_NOTE_OPEN_TAG]   = true;
            $formElement[FE_FLAG_NOTE_CLOSE_TAG]  = true;
        }

        self::setIfNotSet($formElement, FE_INPUT_EXTRA_BUTTON_INFO_CLASS, $store->getVar(FE_INPUT_EXTRA_BUTTON_INFO_CLASS, STORE_SYSTEM));

        // For specific FE hard coded 'checkType'
        switch ($formElement[FE_TYPE]) {
            case FE_TYPE_IMAGE_CUT:
                $formElement[FE_CHECK_TYPE] = SANITIZE_ALLOW_ALL;
                $formElement[FE_ENCODE] = FE_ENCODE_NONE;
                break;
            case FE_TYPE_ANNOTATE:
                $formElement[FE_CHECK_TYPE] = SANITIZE_ALLOW_ALL;
                break;
            default:
                break;
        }

        self::setIfNotSet($formElement, FE_MIN);
        self::setIfNotSet($formElement, FE_MAX);
        self::setIfNotSet($formElement, FE_DECIMAL_FORMAT);

        self::setIfNotSet($formElement, F_FE_DATA_PATTERN_ERROR);

        $typeSpec = $store->getVar($formElement[FE_NAME], STORE_TABLE_COLUMN_TYPES);
        self::adjustFeToColumnDefinition($formElement, $typeSpec);

        return $formElement;
    }

    /**
     * Adjusts several FE parameters using smart guesses based on the table column definition and other parameters.
     *
     * @param array $formElement
     * @param $typeSpec
     * @throws \UserFormException
     */
    public static function adjustFeToColumnDefinition(array &$formElement, $typeSpec) {

        self::adjustMaxLength($formElement, $typeSpec);
        self::adjustDecimalFormat($formElement, $typeSpec);

        // Make educated guesses about the desired $min, $max, $checkType, and $inputType

        // $typeSpec = 'tinyint(3) UNSIGNED NOT NULL' | 'int(11) NOT NULL'
        $arr = explode(' ', $typeSpec, 2);
        if (empty($arr[1])) {
            $sign = 'signed';
        } else {
            $arr = explode(' ', $arr[1], 2);
            $sign = $arr[0] == 'unsigned' ? 'unsigned' : 'signed';
        }

        $arr = explode('(', $typeSpec, 2);
        $token = $arr[0];

        # s: signed, u: unsigned.
        # s-min, s-max, s-checktype, u-min, u-max, u-checktype
        $control = [
            'tinyint' => [-128, 127, SANITIZE_ALLOW_NUMERICAL, 0, 255, SANITIZE_ALLOW_DIGIT],
            'smallint' => [-32768, 32767, SANITIZE_ALLOW_NUMERICAL, 0, 65535, SANITIZE_ALLOW_DIGIT],
            'mediumint' => [-8388608, 8388607, SANITIZE_ALLOW_NUMERICAL, 0, 16777215, SANITIZE_ALLOW_DIGIT],
            'int' => [-2147483648, 2147483647, SANITIZE_ALLOW_NUMERICAL, 0, 4294967295, SANITIZE_ALLOW_DIGIT],
            'bigint' => [-9223372036854775808, 9223372036854775807, SANITIZE_ALLOW_NUMERICAL, 0, 18446744073709551615, SANITIZE_ALLOW_DIGIT],
        ];

        $min = '';
        $max = '';
        $checkType = SANITIZE_ALLOW_ALNUMX;
        $inputType = '';
        $isANumber = true;

        switch ($formElement[FE_TYPE]) {
            case FE_TYPE_PASSWORD:
            case FE_TYPE_NOTE:
                $checkType = SANITIZE_ALLOW_ALL;
                break;

            case FE_TYPE_EDITOR:
            case FE_TYPE_TEXT:
                if ($formElement[FE_ENCODE] === FE_ENCODE_SPECIALCHAR || $formElement[FE_ENCODE] === FE_ENCODE_SINGLE_TICK)
                    $checkType = SANITIZE_ALLOW_ALL;
                break;
        }

        switch ($token) {
            case 'tinyint':
            case 'smallint':
            case 'mediumint':
            case 'int':
            case 'bigint':
                $inputType = HTML_INPUT_TYPE_NUMBER;
                $arr = $control[$token];
                if ($sign == 'signed') {
                    $min = $arr[0];
                    $max = $arr[1];
                    $checkType = $arr[2];
                } else {
                    $min = $arr[3];
                    $max = $arr[4];
                    $checkType = $arr[5];
                }
                break;

            case 'decimal':
            case 'float':
            case 'double':
                $checkType = SANITIZE_ALLOW_NUMERICAL;
                break;

            case 'bit':
                $inputType = HTML_INPUT_TYPE_NUMBER;
                $checkType = SANITIZE_ALLOW_DIGIT;
                break;

            default:
                $isANumber = false;
                break;
        }

        // Numbers don't need a maxLength because they are being handled by min/max and/or decimalFormat
        if ($isANumber) {

            if ($formElement[FE_TYPE] !== FE_TYPE_RADIO && $formElement[FE_TYPE] !== FE_TYPE_CHECKBOX) {
                $formElement[FE_MAX_LENGTH] = '';
            }
        }

        if (!empty($formElement[FE_TYPEAHEAD_SQL]) || !empty($formElement[FE_TYPEAHEAD_LDAP])) {
            $inputType = '';
            $checkType = SANITIZE_ALLOW_ALL;
        }

        // Set parameters if not set by user
        if ($formElement[FE_CHECK_TYPE] === SANITIZE_ALLOW_AUTO) {
            $formElement[FE_CHECK_TYPE] = $checkType;
        }

        // If nothing given, set default
        if ($formElement[FE_MIN] == '') {
            $formElement[FE_MIN] = $min;
        }

        // If nothing given, set default
        if ($formElement[FE_MAX] == '') {
            $formElement[FE_MAX] = $max;
        }

        if (empty($formElement[FE_INPUT_TYPE])) {
            $formElement[FE_INPUT_TYPE] = $inputType;
        }

        // If a  $formElement[FE_STEP] is given, the optional boundaries (FE_MIN / FE_MAX) have to be aligned to a multiple of $formElement[FE_STEP].
        if (!empty($formElement[FE_STEP])) {
            if (!empty($formElement[FE_MIN])) {
                $formElement[FE_MIN] = ceil($formElement[FE_MIN] / $formElement[FE_STEP]) * $formElement[FE_STEP];
            }
            if (!empty($formElement[FE_MAX])) {
                $formElement[FE_MAX] = floor($formElement[FE_MAX] / $formElement[FE_STEP]) * $formElement[FE_STEP];
            }
        }

        // If min or max is set and if there is the standard error text given, define a more detailed error text.
        if (($formElement[FE_MIN] != '' || $formElement[FE_MAX] != '') && ($formElement[F_FE_DATA_ERROR] ?? '') == F_FE_DATA_ERROR_DEFAULT) {
            $formElement[F_FE_DATA_ERROR] = F_FE_DATA_ERROR_DEFAULT . ' - allowed values: ' . $formElement[FE_MIN] . '...' . $formElement[FE_MAX];
        }
    }

    /**
     * Sets the decimalFormat of a FormElement based on the parameter definition and table.field definition
     * Affected FormElement fields: FE_DECIMAL_FORMAT
     *
     * @param array $formElement
     * @param $typeSpec
     * @throws \UserFormException
     */
    private static function adjustDecimalFormat(array &$formElement, $typeSpec) {

        if (isset($formElement[FE_DECIMAL_FORMAT])) {
            if ($formElement[FE_DECIMAL_FORMAT] === '') {
                // Get decimal format from column definition
                if ($typeSpec !== false) {
                    $fieldTypeInfoArray = preg_split("/[()]/", $typeSpec);
                    if ($fieldTypeInfoArray[0] === 'decimal')
                        $formElement[FE_DECIMAL_FORMAT] = $fieldTypeInfoArray[1];
                }
            } else {
                // Decimal format is defined in parameter field
                $isValidDecimalFormat = preg_match("/^[0-9]+,[0-9]+$/", $formElement[FE_DECIMAL_FORMAT]);
                if ($isValidDecimalFormat) {
                    $decimalFormatArray = explode(',', $formElement[FE_DECIMAL_FORMAT]);
                    $isValidDecimalFormat = $decimalFormatArray[0] >= $decimalFormatArray[1];
                }
                if (!$isValidDecimalFormat) {
                    throw new \UserFormException("Invalid decimalFormat: '" . $formElement[FE_DECIMAL_FORMAT] . "'", ERROR_INVALID_DECIMAL_FORMAT);
                }
            }
        }
    }

    /**
     * Calculates the maxLength of an input field, based on formElement type, formElement user definition and
     * table.field definition.
     * Affected formElement fields: FE_MAX_LENGTH
     *
     * @param array $formElement
     * @param $typeSpec
     */
    private static function adjustMaxLength(array &$formElement, $typeSpec) {

        // MIN( $formElement['maxLength'], table definition)
        // Skip fe type radio and checkbox - maxLength is used here for vertically/horizontally aligned radio buttons.
        if ($formElement[FE_TYPE] == FE_TYPE_RADIO || $formElement[FE_TYPE] == FE_TYPE_CHECKBOX) {
            return;
        }

        $maxLength = self::getColumnSize($typeSpec);

        $feMaxLength = false;
        switch ($formElement[FE_TYPE]) {
            case FE_TYPE_DATE:
                $feMaxLength = 10;
                break;
            case FE_TYPE_DATETIME:
                $feMaxLength = 19;
                break;
            case FE_TYPE_TIME:
                $feMaxLength = 8;
                break;
        }

        // In case there is no limit of the underlying table column, or a non primary table column, and the FE_TYPE is date/time.
        if ($maxLength === false && $feMaxLength !== false) {
            $maxLength = $feMaxLength;
        }

        // In case the underlying table column is not of type date/time, the respect user given value ($maxLength might be too high).
        if ($feMaxLength !== false && $maxLength !== false && $feMaxLength < $maxLength) {
            $maxLength = $feMaxLength;
        }

        // Physical maxlength given?
        if ($maxLength !== false) {
            // Custom maxLength given?
            if (isset($formElement[FE_MAX_LENGTH]) && is_numeric($formElement[FE_MAX_LENGTH]) && $formElement[FE_MAX_LENGTH] != 0) {
                // See #9221 - in case of typeahead (with key/value translation) the user should be capable to input longer text than column width.
                if (!(isset($formElement[FE_TYPEAHEAD_LDAP]) || isset($formElement[FE_TYPEAHEAD_SQL]))) {
                    if ($formElement[FE_MAX_LENGTH] > $maxLength) {
                        $formElement[FE_MAX_LENGTH] = $maxLength;
                    }
                }
            } else {
                // Take physical maxLength
                $formElement[FE_MAX_LENGTH] = $maxLength;
            }
        }
    }

    /**
     * Get column spec from table definition and parse size of it. If nothing defined, return false.
     *
     * @param $typeSpec
     * @return bool|int a) 'false' if there is no length definition, b) length definition, c)
     *                   date|time|datetime|timestamp use hardcoded length
     */
    public static function getColumnSize($typeSpec) {

        $matches = array();

        switch ($typeSpec) {
            case 'date': // yyyy-mm-dd
                return 10;
            case 'datetime': // yyyy-mm-dd hh:mm:ss
            case 'timestamp': // yyyy-mm-dd hh:mm:ss
                return 19;
            case 'time': // hh:mm:ss
                return 8;
            case 'tinytext':
            case 'tinyblob':
                return 255;
            case 'text':
            case 'blob':
                return 65535;
            case 'mediumtext':
            case 'mediumblob':
                return 16777215;
            case 'longtext':
            case 'longblob':
                return 4294967295;
            case 'inet4':
                return 4;
            case 'inet6':
                return 16;
            default:
                if (substr($typeSpec, 0, 4) === 'set(' || substr($typeSpec, 0, 5) === 'enum(') {
                    return self::maxLengthSetEnum($typeSpec);
                }
                break;
        }

        // e.g.: string(64) >> 64, enum('yes','no') >> false
        if (1 === preg_match('/\((.+)\)/', $typeSpec, $matches)) {
            // Check for 'decimal(5,3)' or 'decimal(5,3) unsigned'
            if (substr($typeSpec, 0, 8) === 'decimal(') {
                $cnt = 0;
                // $arr[0]=m, $arr[1]=d
                $arr = explode(',', $matches[1]);
                $tmp = $arr[1] ?? 0;
                if ($tmp > 0) {
                    $cnt += $tmp + 1;
                }
                if (strpos('unsigned', $typeSpec) !== false) {
                    // Without 'unsigned', a '-' (minus) might be given.
                    $cnt++;
                }
                return $arr[0] + $cnt;
            } else {

                if (is_numeric($matches[1]))
                    return (int)$matches[1];
            }
        }

        return false;
    }

    /**
     * Get the strlen of the longest element in enum('val1','val2',...,'valn')
     * or the maximum total length of a set('val1','val2',...,'valn')
     *
     * @param string $typeSpec
     *
     * @return int
     */
    private static function maxLengthSetEnum($typeSpec) {

        $isSet = substr($typeSpec, 0, 4) === 'set(';
        $startPos = $isSet ? 4 : 5;
        $max = 0;

        $valueList = substr($typeSpec, $startPos, strlen($typeSpec) - $startPos - 1);
        $valueArr = explode(',', $valueList);

        if ($isSet) { // set
            return strlen(implode(', ', $valueArr));
        } else { // enum
            foreach ($valueArr as $value) {
                $value = trim($value, "'");
                $len = strlen($value);
                if ($len > $max) {
                    $max = $len;
                }
            }

            return $max;
        }
    }

    /**
     * Depending on $formMode and $feMode, calculate a new $feMode.
     * - If $formMode='' : no change.
     * - If $formMode=F_MODE_READ_ONLY: set feMode=FE_MODE_READ_ONLY, for all visible elements.
     * - If $formMode=F_MODE_REQUIRED_OFF: set feMode=FE_MODE_SHOW, who have have FE_MODE_REQUIRE before.
     *
     * @param string $feMode FE_MODE_SHOW | FE_MODE_REQUIRED | FE_MODE_READONLY | FE_MODE_HIDDEN |
     * @param string $formMode '' | F_MODE_READONLY | F_MODE_REQUIRED_OFF
     *
     * @return array|string
     * @throws \CodeException
     */
    private static function applyFormModeToFormElement($feMode, $formMode) {

        if ($formMode == '') {
            return $feMode; //no change
        }

        switch ($feMode) {
            case FE_MODE_HIDDEN:
            case FE_MODE_READONLY:
                break;

            case FE_MODE_SHOW:
            case FE_MODE_REQUIRED:
                if ($formMode == F_MODE_READONLY) {
                    $feMode = FE_MODE_READONLY;
                } elseif ($formMode == F_MODE_REQUIRED_OFF && $feMode == FE_MODE_REQUIRED) {
                    $feMode = FE_MODE_SHOW_REQUIRED;
                }
                break;

            default:
                throw new \CodeException('Unknown mode: ' . $feMode, ERROR_UNKNOWN_MODE);
        }

        return $feMode;
    }

    /**
     * Check $arr, if there is an element $index. If not, set it to $value.
     * If  $overwriteThis!=false, replace the original value with $value, if $arr[$index]==$overwriteThis.
     *
     * @param array $arr
     * @param string $index
     * @param string $value
     * @param string|bool $overwriteThis If there is already something which is equal to $overwrite: take new default.
     * @param $missingValueMeans  false|0|1|...  If the $index is given but without a value (=''), set to $missingValueMeans (mostly =1)
     * @return mixed|string
     */
    public static function setIfNotSet(array &$arr, $index, $value = '', $overwriteThis = false, $missingValueMeans = false) {

        if (!isset($arr[$index])) {
            $arr[$index] = $value;
        }

        if ($overwriteThis !== false && $arr[$index] === $overwriteThis) {
            $arr[$index] = $value;
        }

        if ($missingValueMeans !== false && $arr[$index] == '') {
            $arr[$index] = $missingValueMeans;
        }
        return $arr[$index];
    }

    /**
     * Append $extend to $filename
     *
     * @param string $filename
     * @param string $extend
     *
     * @return string
     */
    public static function extendFilename($filename, $extend) {
        return $filename . $extend;
    }

    /**
     * Join $path and $file.
     * If $mode=FILE_PRIORITY and $file starts with '/' (=absolute), the $path is skipped.
     *
     *
     * @param string $path
     * @param string $file
     * @param string $mode FILE_PRIORITY | PATH_FILE_CONCAT
     * @return string
     */
    public static function joinPath($path, $file, $mode = FILE_PRIORITY) {

        if ($file == '') {
            return $path;
        }

        if ($path == '') {
            return $file;
        }

        if ($file[0] == DIRECTORY_SEPARATOR) {

            if ($mode == FILE_PRIORITY) {
                return $file; // absolute
            } else {
                if ($path != '') {
                    $file = substr($file, 1);
                }
            }
        }

        if (substr($path, -1) == DIRECTORY_SEPARATOR) {
            return $path . $file; // SEPARATOR already inside
        }

        return $path . DIRECTORY_SEPARATOR . $file;
    }

    /**
     * @param $srcFile
     * @param $pathFileName
     * @param bool $overwrite
     * @param bool|int $chmodDir , 'false' if not change
     * @throws \CodeException
     * @throws \UserFormException
     */
    public static function moveFile($srcFile, $pathFileName, $overwrite, $chmodDir = false) {

        if (file_exists($pathFileName)) {
            if ($overwrite) {
                HelperFile::unlink($pathFileName);
            } else {
                throw new \UserFormException(json_encode(
                    [ERROR_MESSAGE_TO_USER => 'Copy upload failed - file already exist',
                        ERROR_MESSAGE_TO_DEVELOPER => 'File: ' . $pathFileName]), ERROR_IO_FILE_EXIST);
            }
        }

        HelperFile::mkDirParent($pathFileName, $chmodDir);

        // Do not use 'rename' - might cause trouble if src and dest are on different filesystems.
        HelperFile::copy($srcFile, $pathFileName);

        HelperFile::unlink($srcFile);
    }

    /**
     * Convert 'false' and '<empty string>' to '0'.
     *
     * @param $val
     *
     * @return string
     */
    public static function falseEmptyToZero($val) {
        return ($val == '' || $val == false) ? '0' : $val;
    }

    /**
     * If there is an element $key in array $arr and if that is empty (acts as a switch) or not equal '0': return true, else false
     *
     * @param array $arr
     * @param string $key
     * @return bool  true: if $key exists and a) is empty or b) =='1'
     */
    public static function isEnabled(array $arr, $key) {
        if (!array_key_exists($key, $arr)) {
            return false;
        }

        if ($arr[$key] === '' || $arr[$key] !== '0') {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Check if the string starts with the comment sign - return an empty string.
     * Check if the string starts with an escaped comment sign - strip the escape character.
     * Check if the string starts or ends with an 'escape space' - strip the escape character.
     *
     * @param string $str
     *
     * @return string
     */
    public static function handleEscapeSpaceComment($str) {

        $str = trim($str);
        if ($str == '') {
            return '';
        }
        // Skip comments.
        if ($str[0] == '#') {
            $str = ''; // It's necessary to create an empty entry - E.g. Form.title will not exist if is a comment, but later processing expects that there is an string.
        } else {
            switch (substr($str, 0, 2)) {
                case '\#':
                case '\ ':
                    $str = substr($str, 1);
                    break;
                default:
                    break;
            }

            if (substr($str, -1) == '\\') {
                $str = substr($str, 0, strlen($str) - 1);
            }
        }

        return $str;
    }

    /**
     * TODO: as soon as we don't support PHP 5.6.0 anymore, this local implemention can be removed.
     * Workaround for PHP < 5.6.0: there is no ldap_escape() - use this code instead.
     *
     * @param string $subject The subject string
     * @param string $ignore Set of characters to leave untouched
     * @param int $flags Any combination of LDAP_ESCAPE_* flags to indicate the
     *                        set(s) of characters to escape.
     *
     * @return string
     **/
    public static function ldap_escape($subject, $ignore = '', $flags = 0) {

        if (function_exists('ldap_escape')) {

            return ldap_escape($subject, $ignore ?? '', $flags);

        } else {

//            define('LDAP_ESCAPE_FILTER', 0x01);
//            define('LDAP_ESCAPE_DN',     0x02);

            static $charMaps = array(
                LDAP_ESCAPE_FILTER => array('\\', '*', '(', ')', "\x00"),
                LDAP_ESCAPE_DN => array('\\', ',', '=', '+', '<', '>', ';', '"', '#'),
            );
            // Pre-process the char maps on first call
            if (!isset($charMaps[0])) {
                $charMaps[0] = array();
                for ($i = 0; $i < 256; $i++) {
                    $charMaps[0][chr($i)] = sprintf('\\%02x', $i);;
                }
                for ($i = 0, $l = count($charMaps[LDAP_ESCAPE_FILTER]); $i < $l; $i++) {
                    $chr = $charMaps[LDAP_ESCAPE_FILTER][$i];
                    unset($charMaps[LDAP_ESCAPE_FILTER][$i]);
                    $charMaps[LDAP_ESCAPE_FILTER][$chr] = $charMaps[0][$chr];
                }
                for ($i = 0, $l = count($charMaps[LDAP_ESCAPE_DN]); $i < $l; $i++) {
                    $chr = $charMaps[LDAP_ESCAPE_DN][$i];
                    unset($charMaps[LDAP_ESCAPE_DN][$i]);
                    $charMaps[LDAP_ESCAPE_DN][$chr] = $charMaps[0][$chr];
                }
            }
            // Create the base char map to escape
            $flags = (int)$flags;
            $charMap = array();
            if ($flags & LDAP_ESCAPE_FILTER) {
                $charMap += $charMaps[LDAP_ESCAPE_FILTER];
            }
            if ($flags & LDAP_ESCAPE_DN) {
                $charMap += $charMaps[LDAP_ESCAPE_DN];
            }
            if (!$charMap) {
                $charMap = $charMaps[0];
            }
            // Remove any chars to ignore from the list
            $ignore = (string)$ignore;
            for ($i = 0, $l = strlen($ignore); $i < $l; $i++) {
                unset($charMap[$ignore[$i]]);
            }
            // Do the main replacement
            $result = strtr($subject, $charMap);
            // Encode leading/trailing spaces if LDAP_ESCAPE_DN is passed
            if ($flags & LDAP_ESCAPE_DN) {
                if ($result[0] === ' ') {
                    $result = '\\20' . substr($result, 1);
                }
                if ($result[strlen($result) - 1] === ' ') {
                    $result = substr($result, 0, -1) . '\\20';
                }
            }

            return $result;
        }
    }

    /**
     * @param $mode // MODE_ENCODE|MODE_DECODE|MODE_NONE
     * @param $data
     * @return string
     * @throws \UserFormException
     */
    public static function htmlEntityEncodeDecode($mode, $data) {

        switch ($mode) {
            case MODE_ENCODE:
                $data = htmlspecialchars($data, ENT_QUOTES);
                $data = str_replace('{{', '&lbrace;&lbrace;', $data);
                $data = str_replace('}}', '&rbrace;&rbrace;', $data);
                break;
            case MODE_ENCODE_ALL:
                $data = htmlentities($data, ENT_QUOTES);
                break;
            case MODE_DECODE:
                $data = htmlspecialchars_decode($data, ENT_QUOTES);
                $data = str_replace('&lbrace;&lbrace;', '{{', $data);
                $data = str_replace('&rbrace;&rbrace;', '}}', $data);


                break;
            case MODE_NONE:
                break;
            default:
                throw new \UserFormException('Unknown mode=' . $mode, ERROR_UNKNOWN_MODE);
        }

        return $data;
    }

    /**
     * Converts a human-readable size to bytes. Accepts both PHP-ini style ('5M')
     * and intuitive suffixes ('5MB', '5 mb'). Whitespace and casing are ignored.
     * Returns 0 for empty/invalid input so callers can treat it as "no limit".
     *
     * @param $size_str
     * @return int
     */
    public static function returnBytes($size_str) {

        $size_str = trim((string)$size_str);
        if ($size_str === '') {
            return 0;
        }

        if (!preg_match('/^(\d+(?:\.\d+)?)\s*([KMGT]?)B?$/i', $size_str, $m)) {
            return 0;
        }

        $num = (float)$m[1];
        switch (strtoupper($m[2])) {
            case 'T':
                return (int)($num * 1099511627776);
            case 'G':
                return (int)($num * 1073741824);
            case 'M':
                return (int)($num * 1048576);
            case 'K':
                return (int)($num * 1024);
            default:
                return (int)$num;
        }
    }

    /**
     * Executes the Command in $cmd
     * RC: if RC==0 Returns Output, else 'RC - Output'
     *
     * @param string $cmd : command to start
     *
     * @param int $rc
     * @return string The content that is displayed on the website
     */
    public static function qfqExec($cmd, &$rc = 0) {

        exec($cmd, $arr, $rc);

        $output = implode('<br>', $arr);
        if ($rc != 0) {
            $output = "[rc=$rc] $output";
        }

        return $output;
    }

    /**
     * @param string $prefix
     * @return string
     */
    public static function uniqIdQfq($prefix) {

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

        return uniqid();
    }

    /**
     * Get formModeGlobal from STORE_USER, STORE_SIP or directly from $mode.
     *
     * @param $mode
     * @return array|string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function getFormModeGlobal($mode) {

        self::$store = Store::getInstance();

        $formModeGlobal = self::$store->getVar(F_MODE_GLOBAL, STORE_USER);

        if ($formModeGlobal === '' || $formModeGlobal === false) {
            $formModeGlobal = self::$store->getVar(F_MODE_GLOBAL, STORE_SIP);
        }

        if ($formModeGlobal === '' || $formModeGlobal === false) {
            $formModeGlobal = $mode;
        }

        return $formModeGlobal;
    }

    /**
     * Set QFQ Error Handler.
     * Should not be active if T3 code runs.
     */
    public static function setQfqErrorHandler() {
        set_error_handler("\\IMATHUZH\\Qfq\\Core\\Exception\\ErrorHandler::exception_error_handler");
    }

    /**
     * Check if protected folder is accessible. Set flag correctly to actual status. Notification will be shown in formEditor.qfqr.
     *
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function checkProtectedFolderSecured() {
        self::$store = Store::getInstance();
        $pathPage = self::$store->getVar(SYSTEM_BASE_URL, STORE_SYSTEM);
        $cmdWget = self::$store->getVar(SYSTEM_CMD_WGET, STORE_SYSTEM);
        $path = $pathPage . Path::APP_TO_FILEADMIN_PROTECTED . '/qfqProject/conf/qfq.json';
        $resultCode = null;

        // Create htaccess file to protect folder if not exists before check happened.
        // htaccess works only with apache server
        if (strpos(php_sapi_name(), 'apache') !== false) {
            $htaccess = Path::APP_TO_FILEADMIN_PROTECTED . '/.htaccess';
            if (!file_exists($htaccess)) {
                $file = fopen($htaccess, 'w');
                fwrite($file, 'Deny from all');
                fclose($file);
            }
        }
        exec($cmdWget . " " . $path, $access, $resultCode);

        // Check if file in protected folder can be accessed
        if ($resultCode === 0) {
            throw new \CodeException("Protected folder is not protected from outside.", ERROR_UNPROTECTED_FOLDER);
        }

        // Check if wget command can not be executed
        if ($resultCode === 126 || $resultCode === 127) {
            throw new \CodeException("Invalid wget configuration. Maybe wget command is incorrect or not supported.", ERROR_INVALID_WGET_CMD);
        }
    }

    /**
     * Check if $value is Enclosed by {{}}
     * @param $value
     * @return bool
     */
    public static function isEnclosedWithBraces($value): bool {
        $trimmedValue = trim($value); // Remove leading and trailing whitespace
        return str_starts_with($trimmedValue, '{{') && str_ends_with($trimmedValue, '}}');
    }

    /**
     * Validate that the required actions are enclosed with braces.
     *
     * @param array $fe
     * @param array $actions
     * @throws \UserFormException
     */
    public static function validateFeActions(array $fe, array $actions): void {
        foreach ($actions as $action) {
            if (!empty($fe[$action]) && !self::isEnclosedWithBraces($fe[$action])) {
                $store = Store::getInstance();
                // Set FE information to Store system, so it can be correctly referenced in exception
                $store->setVar(SYSTEM_FORM_ELEMENT_ID, $fe[FE_ID], STORE_SYSTEM);
                $store->setVar(SYSTEM_FORM_ELEMENT, $fe[FE_ID] . ' / ' . $fe[FE_NAME], STORE_SYSTEM);
                throw new \UserFormException(json_encode([
                        ERROR_MESSAGE_TO_DEVELOPER => sprintf("The action '%s' is not enclosed by {{ }} and will not execute.\nCODE:\n%s = %s", $action, $action, $fe[$action]
                        ), ERROR_MESSAGE_TO_USER => "An internal form error occurred. Please contact support or your administrator."])
                );
            }
        }
    }

    /**
     * Wraps an uncaught \Throwable into a GenericException.
     *
     * Should be used in catch-all exception handlers to ensure consistent output and logging.
     *
     * Always wraps the original message and sets:
     *   - ERROR_MESSAGE_TO_USER: Generic safe error message
     *   - ERROR_MESSAGE_TO_DEVELOPER: Original exception message
     *   - ERROR_MESSAGE_HTTP_STATUS: HTTP/1.1 500
     *
     * Usage:
     *
     *   try {
     *       // risky code
     *   } catch (\Throwable $e) {
     *       $wrapped = self::wrapUnexpectedException($e);
     *       echo $wrapped->formatException();
     *   }
     *
     * @param \Throwable $e The uncaught exception to be wrapped.
     * @return GenericException A wrapped exception ready for formatException().
     */
    public static function wrapUnexpectedException(\Throwable $e) {
        try {
            return new \IMATHUZH\Qfq\Core\Exception\GenericException(json_encode([
                ERROR_MESSAGE_TO_USER => 'An unexpected error occurred.',
                ERROR_MESSAGE_TO_DEVELOPER => $e->getMessage(),
                ERROR_MESSAGE_HTTP_STATUS => HTTP_500_SERVER_ERROR
            ]), 500, $e);
        } catch (\Throwable $error) {
            // Fallback if GenericException can't be loaded
            return new \Exception('Failed to wrap exception: ' . $e->getMessage(), 500, $e);
        }
    }

    /**
     * Check if the URL is reachable and not redirected to a typo3 login page.
     *
     * @param $url
     * @param $data
     * @return bool
     */
    public static function curlAndCheck($url, $data): bool {
        $status = false;

        // Execute the curl command
        $options = [
            CURL_HTTP => [
                CURL_METHOD => CURL_METHOD_POST,
                CURL_HEADER => CURL_HEADER_DEFAULT,
                CURL_CONTENT => $data,
                CURL_IGNORE_ERRORS => true
            ]
        ];

        $context = stream_context_create($options);
        $result = file_get_contents($url, false, $context);

        // Check HTTP status code
        if (isset($http_response_header)) {
            foreach ($http_response_header as $header) {
                if (preg_match('#HTTP/[0-9\.]+\s+([0-9]+)#', $header, $matches)) {
                    $statusCode = intval($matches[1]);
                    break;
                }
            }

            // Check if we got a successful status code
            $status = ($statusCode >= 200 && $statusCode < 300);

        }

        return $status;
    }

    /**
     * Checks if the PHP is called via Webserver or Commandline.
     *
     * @return bool
     */
    public static function is_cli() {
        return (php_sapi_name() === 'cli' || defined('STDIN'));
    }
}