<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 4/17/17
 * Time: 11:32 AM
 *
 * Check: CODING.md > Download
 */

namespace IMATHUZH\Qfq\Core\Report;

use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Exception\Thrower;
use IMATHUZH\Qfq\Core\Form\Chat;
use IMATHUZH\Qfq\Core\Helper\DownloadPage;
use IMATHUZH\Qfq\Core\Helper\DownloadProgress;
use IMATHUZH\Qfq\Core\Helper\HelperFile;
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\Sanitize;
use IMATHUZH\Qfq\Core\Helper\Support;
use IMATHUZH\Qfq\Core\QuickFormQuery;
use IMATHUZH\Qfq\Core\Store\Session;
use IMATHUZH\Qfq\Core\Store\Sip;
use IMATHUZH\Qfq\Core\Store\Store;
use React\Dns\Config\Config;
use UserFormException;

/**
 * Class Download
 *
 * Documentation: PROTOCOL.md >> Download
 *
 * Param: i=1..n
 *   <i>_mode=direct | html2pdf
 *   <i>_id=<pageId>
 *   <i>_<key i>=<value i>
 *
 * @package qfq
 */
class Download {

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

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

    /**
     * @var Database
     */
    private $db = null;

    /**
     * @var Html2Pdf
     */
    private $html2pdf = null;

    /**
     * @var string Filename where to write download Information
     */
    private $downloadDebugLogAbsolute = '';

    /**
     * @var string DOWNLOAD_OUTPUT_FORMAT_RAW | DOWNLOAD_OUTPUT_FORMAT_JSON
     */
    private $outputFormat = DOWNLOAD_OUTPUT_FORMAT_RAW;

    /**
     * @var array Cache parameters: [DOWNLOAD_CACHE_STATUS, DOWNLOAD_CACHE_PATH]
     */
    private $cacheParam = array();
    /**
     * @var array
     */
    private $storeSystem = array();
    /**
     * @var string
     */
    private $dbIndexData;
    /**
     * @var string
     */
    private $dbIndexQfq;
    /**
     * @var string
     */
    private $dbIndexFilePond;
    /**
     * @var Database[]
     */
    private $dbArray;

    /**
     * @param bool|false $phpUnit
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function __construct($phpUnit = false) {

        #TODO: rewrite $phpUnit to: "if (!defined('PHPUNIT_QFQ')) {...}"
        $this->session = Session::getInstance($phpUnit);
        $this->store = Store::getInstance('', $phpUnit);
        $this->db = new Database();
        $this->storeSystem = $this->store->getStore(STORE_SYSTEM);
        $this->html2pdf = new Html2Pdf($this->storeSystem, $phpUnit);

        // Filepond uses per default qfq index db or a custom one.
        $this->dbIndexData = $this->storeSystem[SYSTEM_DB_INDEX_DATA] ?? '';
        $this->dbIndexQfq = $this->storeSystem[SYSTEM_DB_INDEX_QFQ] ?? '';
        $this->dbIndexFilePond = $this->dbIndexQfq;
        $dbIndexSip = $this->store->getVar(TOKEN_DB_INDEX, STORE_SIP . STORE_EMPTY);
        if (in_array($dbIndexSip, [$this->dbIndexData, $this->dbIndexQfq])) {
            $this->dbIndexFilePond = $dbIndexSip;
        }
        $this->dbArray[$this->dbIndexFilePond] = new Database($this->dbIndexFilePond);

        if (Support::findInSet(SYSTEM_SHOW_DEBUG_INFO_DOWNLOAD, $this->storeSystem[SYSTEM_SHOW_DEBUG_INFO] ?? '')) {
            $this->downloadDebugLogAbsolute = Path::absoluteSqlLogFile();
        }

        // Initialize cache parameters
        $this->cacheParam[DOWNLOAD_CACHE_STATUS] = DOWNLOAD_CACHE_STATUS_NO;
        $this->cacheParam[DOWNLOAD_CACHE_PATH] = '';
    }

    /**
     * Concatenate all named files to one PDF file. Return name of new full PDF.
     * If a source file is an image, it will be converted to a PDF.
     *
     * @param array $files Array of source files
     * @param array $filesCleanLater Array of temporarily created files
     *
     * @return string  - fileName of concatenated file
     * @throws \CodeException
     * @throws \DownloadException
     * @throws \UserFormException
     */
    private function concatFilesToPdf(array $files, array &$filesCleanLater) {

        // Remove empty entries. Might happen if there was no upload
        $files = OnArray::removeEmptyElementsFromArray($files);


        // Check that all files are of type 'application/pdf'
        foreach ($files as $key => $filename) {
            // Check that all files exist and are readable
            if (!is_readable($filename)) {
                if ($this->storeSystem[SYSTEM_MISSING_FILE_MODE] === SYSTEM_MISSING_FILE_MODE_DUMMY
                    || ($this->storeSystem[SYSTEM_MISSING_FILE_MODE] === SYSTEM_MISSING_FILE_MODE_AUTO && $this->storeSystem[SYSTEM_FLAG_PRODUCTION] === 'no')) {
                    $files[$key] = QFQ_DUMMY_PDF_PATH;
                    $filename = QFQ_DUMMY_PDF_PATH;
                } else {
                    throw new \DownloadException("Error reading file $filename. Not found or no permission", ERROR_DOWNLOAD_FILE_NOT_READABLE);
                }
            }

            $mimetype = mime_content_type($filename);

            // If file is an image, convert to PDF
            if (substr($mimetype, 0, 6) == 'image/') {
                $tmpName = $this->doImgToPdf($filename);
                $filesCleanLater[] = $tmpName; // remember to purge later.
                $files[$key] = $tmpName; // replace original reference against temporary one.
                $mimetype = 'application/pdf';
            }

            if ($mimetype != 'application/pdf') {
                throw new \DownloadException("Error concat file $filename. Mimetype 'application/pdf' expected, got: $mimetype", ERROR_DOWNLOAD_UNEXPECTED_MIME_TYPE);
            }
        }

        if (count($files) === 0) {
            return '';
        }

        if (count($files) == 1) {
            if ($this->cacheParam[DOWNLOAD_CACHE_STATUS] === DOWNLOAD_CACHE_STATUS_CREATE) {
                rename($files[0], $this->cacheParam[DOWNLOAD_CACHE_PATH]);
                $files[0] = $this->cacheParam[DOWNLOAD_CACHE_PATH];
            }
            return $files[0];
        }

        $files = OnArray::arrayEscapeshellarg($files);
        $inputFiles = implode(' ', $files);
        if (trim($inputFiles) == '') {
            throw new \DownloadException('No files to concatenate.', ERROR_DOWNLOAD_NO_FILES);
        }

        // Need to create a separate result file, even if it is just a single file (#6929)
        if ($this->cacheParam[DOWNLOAD_CACHE_STATUS] === DOWNLOAD_CACHE_STATUS_CREATE) {
            $concatFile = $this->cacheParam[DOWNLOAD_CACHE_PATH];
        } else {
            $concatFile = HelperFile::tempnam();
        }

        if (false === $concatFile) {
            throw new \DownloadException('Error creating output file.', ERROR_DOWNLOAD_CREATE_NEW_FILE);
        }

//        $cmd = "pdftk $inputFiles cat output $concatFile 2>&1";  # Outdated. Hard to install on Ubuntu 18. Fails for recent PDFs.
//        $cmd = "qpdf --empty --pages $inputFiles -- $concatFile 2>&1"; # Fails to merge identical files, if they contain references.
        $cmd = ($this->storeSystem[SYSTEM_CMD_PDFUNITE] ?? '') . " $inputFiles $concatFile 2>&1"; // Based on poppler. URLs are preserved. Orientation and size are preserved.

        if ($this->downloadDebugLogAbsolute != '') {
            Logger::logMessage("Download: $cmd", $this->downloadDebugLogAbsolute);
        }

        $rc = $this->concatPdfFilesPdfUnite($cmd, $output);

        if ($rc != 0) {
            throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Failed to merge PDF file",
                    ERROR_MESSAGE_TO_DEVELOPER => "CMD: " . $cmd . "<br>RC: $rc<br>Output: " . implode("<br>", $output)])
                , ERROR_DOWNLOAD_MERGE_FAILED);
        }

        return $concatFile;
    }

    /**
     * Convert an image to PDF
     *
     * @param $fileImage
     * @throws \DownloadException
     */
    private function doImgToPdf($fileImage) {

        $filePdf = HelperFile::tempnam();
        if (false === $filePdf) {
            throw new \DownloadException('Error creating output file.', ERROR_DOWNLOAD_CREATE_NEW_FILE);
        }

        if (!is_readable($fileImage)) {
            if ($this->storeSystem[SYSTEM_MISSING_FILE_MODE] === SYSTEM_MISSING_FILE_MODE_DUMMY) {
                $fileImage = QFQ_DUMMY_PNG_PATH;
            } else {
                throw new \DownloadException("Error reading file $fileImage. Not found or no permission", ERROR_DOWNLOAD_FILE_NOT_READABLE);
            }
        }

        // img2pdf --pagesize A4 -o out.pdf *.jpg
        $cmd = ($this->storeSystem[SYSTEM_CMD_IMG2PDF] ?? '') . ' --pagesize A4 -o ' . $filePdf . ' ' . escapeshellarg($fileImage) . ' 2>&1';

        if ($this->downloadDebugLogAbsolute != '') {
            Logger::logMessage("Download: $cmd", $this->downloadDebugLogAbsolute);
        }

        exec($cmd, $rcOutput, $rc);
        if ($rc != 0) {
            $msgAdd = stripos($rcOutput[0] ?? '', 'img2pdf: not found') ? "Command not found 'img2pdf'" : "";

            throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Failed to convert image to PDF. $msgAdd",
                    ERROR_MESSAGE_TO_DEVELOPER => "CMD: $cmd<br>RC: $rc<br>Output: " . implode("<br>", $rcOutput)])
                , ERROR_DOWNLOAD_CONVERT_FAILED);
        }

        return $filePdf;
    }

    /**
     * Do the merge command.
     * Fixing part is highly customized to pdfunite: parsing of reported problematic files.
     * If for any reason the command fails: check if the reason
     * a) is 'unencrypted files'. If 'yes': try to decrypt them with qpdf.
     *        Try1: qpdf --decrypt '$backup' '$file'
     *        Try2: gs -sDEVICE=pdfwrite -dNOPAUSE -sOutputFile="$file" -- "$backup"
     * b) is 'Syntax Error: Gen inside xref table too large (bigger than INT_MAX)': try convert PDF2PS and PS2PDF
     *       Try1: pdf2ps $file $file.ps; ps2pdf $file.ps $file
     *
     * After one antempt to fix, try merge again.
     * Try to merge, decrypt and fix as long as there are encrypted files.
     *
     * @param $cmd
     * @param $rcOutput
     * @return mixed
     * @throws \DownloadException
     * @throws \UserFormException
     */
    private
    function concatPdfFilesPdfUnite($cmd, &$rcOutput) {
        $last = '';
        $rcOutput = '-';

        // Try to merge the PDFs as long as a problematic PDF has been repaired. Check this by comparing the last and the current output.
        while ($last != $rcOutput) {
            $last = $rcOutput; // Remember last

            // Merge:
            exec($cmd, $rcOutput, $rc);

            if ($rc == 0) {
                break; // skip rest if everything is fine
            }

            // Possible output on error:
            //   "Unimplemented Feature: Could not merge encrypted files ('ct.18.06.092-097.pdf')"
            //   "Could not merge damaged documents ('offerFinal_Light_Cycler_Offerte_2.pdf')"
            $lineAll = implode(',', $rcOutput);
            $cmdFailed = "CMD Merge: " . $cmd . "<br>RC: $rc<br>Output: " . implode("<br>", $rcOutput) . '<br>';
            $fix = false;

            // Searching for line "Unimplemented Feature: Could not merge encrypted files ('file.pdf')"
            // Skip error message until problematic file is named.
            $line = strstr($lineAll, "Unimplemented Feature: Could not merge encrypted files (");
            if (false !== $line) {

                $arr = explode("'", $line, 3);
                if (!empty($arr[1]) && file_exists($arr[1])) {
                    $file = $arr[1]; // problematic file

                    // Create a backup file if none already exist
                    $backup = $this->doBackupFilePerDay($file);

                    // Try 1: via 'qpdf --decrypt'
                    $cmdQpdf = ($this->storeSystem[SYSTEM_CMD_QPDF] ?? '') . " --decrypt '$backup' '$file' 2>&1"; // Try to decrypt file
                    exec($cmdQpdf, $outputQpdf, $rcQpdf);

                    $fix = $rcQpdf == 0;
                    if (!$fix) {

                        // Try 2: via 'gs -sDEVICE=pdfwrite'
                        $cmdGs = ($this->storeSystem[SYSTEM_CMD_GS] ?? '') . " -sDEVICE=pdfwrite -dNOPAUSE -sOutputFile=\"$file\" -- \"$backup\" 2>&1";
                        exec($cmdGs, $rcOutputGs, $rcGs);

                        $fix = $rcGs == 0;
                        if (!$fix) {
                            $line = implode(',', $rcOutputGs);
                            $msgUser = "Failed to decrypt PDF";
                            $msgDeveloper = $cmdFailed . "CMD Fix: " . $cmdGs . "<br>RC: $rcGs<br>Output: " . implode("<br>", $rcOutputGs);
                            $this->revertFixAndThrowException($msgUser, $msgDeveloper, $file, $backup);
                        }
                    }
                }
                // Check for 'Gen inside xref table too large' (Ticket 13722, 13731: pdf2ps >> ps2pdf)
            }

            $line = strstr($lineAll, "Syntax Error: Gen inside xref table too large (bigger than INT_MAX)");
            if (!$fix && false !== $line) {
                // Skip error message until problematic file is named.
                $line = strstr($line, "Could not merge damaged documents (");
                $arr = explode("'", $line, 3);
                if (!empty($arr[1]) && file_exists($arr[1])) {
                    $file = $arr[1]; // problematic file

                    // Create a backup file if none already exist
                    $backup = $this->doBackupFilePerDay($file);

                    // PDF2PS
                    $cmdFix = ($this->storeSystem[SYSTEM_CMD_PDF2PS] ?? '') . " $file $file.ps";
                    exec($cmdFix, $rcOutputFix, $rcFix);
                    $fix = $rcFix == 0;
                    if (!$fix) {
                        $line = implode(',', $rcOutputFix);
                        $msgUser = "Failed to merge PDF and also fix try failed on converting source PDF to PS.";
                        $msgDeveloper = $cmdFailed . "CMD Fix: " . $cmdFix . "<br>RC: $rcFix<br>Output: " . implode("<br>", $rcOutputFix);
                        $this->revertFixAndThrowException($msgUser, $msgDeveloper, $file, $backup);
                    }

                    // PS2PDF
                    $cmdFix = ($this->storeSystem[SYSTEM_CMD_PS2PDF] ?? '') . " $file.ps $file";
                    exec($cmdFix, $rcOutputFix, $rcFix);
                    $fix = $rcFix == 0;
                    if (!$fix) {
                        $line = implode(',', $rcOutputFix);
                        $msgUser = "Failed to merge PDF and also fix try failed on converting temporary PS to PDF.";
                        $msgDeveloper = $cmdFailed . "CMD Fix: " . $cmdFix . "<br>RC: $rcFix<br>Output: " . implode("<br>", $rcOutputFix);
                        $this->revertFixAndThrowException($msgUser, $msgDeveloper, $file, $backup);
                    }

                    HelperFile::unlink($file . '.ps');
                }
            }

            if (!$fix) {
                throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Merge PDF file failed.",
                        ERROR_MESSAGE_TO_DEVELOPER => $cmdFailed])
                    , ERROR_DOWNLOAD_MERGE_FAILED);
            }
        }
        return $rc;
    }

    /**
     * @param $msgUser
     * @param $msgDeveloper
     * @param $file
     * @param $backup
     * @return mixed
     * @throws \DownloadException
     * @throws \UserFormException
     */
    private
    function revertFixAndThrowException($msgUser, $msgDeveloper, $file, $backup) {
        if ($backup != '' && $file != '') {
            // command failed: restore origfile in case the $file has been destroyed.
            HelperFile::copy($backup, $file);
        }

        throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => $msgUser,
                ERROR_MESSAGE_TO_DEVELOPER => $msgDeveloper])
            , ERROR_DOWNLOAD_MERGE_FAILED);
    }

    /**
     * If there is already a Backup file from today: do nothing.
     * If there is no Backup file from today: duplicate the named file to the backup filename.
     * In general, it's meant that the original filename will be replaced by a fixed one. This should lead to have
     * only one copy of the original.
     *
     * @param $file
     * @return string
     * @throws \UserFormException
     */
    private
    function doBackupFilePerDay($file) {

        // Create a backup file: only one per day!
        $backup = $file . date('.Y-m-d');
        if (!file_exists($backup)) {
            HelperFile::copy($file, $backup);
        }

        return ($backup);
    }

    /**
     * Get the mimetype of $filename and store them in $rcMimetype.
     *
     * @param string $pathFileName
     * @param string $outputFilename
     * @param string $rcMimetype
     *
     * @return string possible updated $outputFilename, according the mimetype.
     */
    private
    function targetFilenameExtension($pathFileName, $outputFilename, &$rcMimetype) {

        if ($pathFileName != '' && file_exists($pathFileName)) {

            $rcMimetype = mime_content_type($pathFileName);
        }
        return $outputFilename;
    }

    /**
     * Set header type and output $filename. Be careful not to send any additional characters.
     *
     * @param $file
     * @param $outputFilename
     * @param $downloadMode
     * @throws \DownloadException
     */
    private function outputFile($file, $outputFilename, $downloadMode) {

        // Falls wir im JSON-Output sind, wird dies wie gehabt abgearbeitet.
        $json = '';
        $flagJson = ($this->getOutputFormat() === DOWNLOAD_OUTPUT_FORMAT_JSON);

        if (str_contains($outputFilename, FILE_NAME_UNIQUE_NAME_VAR_SANITIZED)) {
            // Check for any added prefix or suffix
            $pos = strpos($outputFilename, FILE_NAME_UNIQUE_NAME_VAR_SANITIZED);
            $prefix = substr($outputFilename, 0, $pos) ?? '';
            $start = $pos + strlen(FILE_NAME_UNIQUE_NAME_VAR_SANITIZED);
            $suffix = substr($outputFilename, $start);;

            $outputFilename = HelperFile::base64DecodeUniqueFileName($file);

            $fileInfo = pathinfo($outputFilename);
            $outputFilename = $prefix . $fileInfo['filename'] . $suffix . '.' . $fileInfo['extension'];
        } else {
            $outputFilename = HelperFile::base64DecodeUniqueFileName($outputFilename);
        }

        // Hier nehmen wir an, dass $mimeType schon definiert oder ermittelt wurde.
        // Für den TEXT-Modus setzen wir ihn explizit auf MIME_TYPE_TEXT_PLAIN
        if ($downloadMode === DOWNLOAD_MODE_TEXT) {
            $mimeType = MIME_TYPE_TEXT_PLAIN;
        }

        $outputFilename = $this->targetFilenameExtension($file, $outputFilename, $mimeType);
        $outputFilename = Sanitize::safeFilename($outputFilename); // be sure that there are no problematic chars in the filename. E.g. MacOS X don't like spaces for downloads.

        if ($flagJson) {
            if (false === ($json = json_encode([JSON_TEXT => file_get_contents($file)]))) {
                throw new \DownloadException(json_encode(
                    [ERROR_MESSAGE_TO_USER => 'Error converting to JSON',
                        ERROR_MESSAGE_TO_DEVELOPER => "json_last_error()=" . json_last_error() . ", File=" . $file]), ERROR_DOWNLOAD_JSON_CONVERT);
            }
            $length = strlen($json);
            $mimeType = 'application/json';
        } else {
            $length = filesize($file);
        }

        // For single downloads via M:file: application/json breaks JupyterNotebooks (.ipynb) and other files with json data.
        if ($downloadMode == DOWNLOAD_MODE_FILE && $mimeType == 'application/json') {
            // Set mimetype "application/octet-stream" to force 'save as' dialog and not to show inline
            $mimeType = "application/octet-stream";
        }

        // Set headers
        header("Content-Type: $mimeType");
        header("Content-Length: $length");

        // For TEXT downloads, we typically want an "attachment" header to force the download.
        header("Content-Length: $length");
        if (!$flagJson) {
            // If defined as 'attachment': PDFs are not shown inside the browser (if user configured that). Instead, always a 'save as'-dialog appears (Chrome, FF)
            header("Content-Disposition: attachment; filename=$outputFilename");

        } else {
            header("Content-Disposition: inline; filename=\"$outputFilename\"; name=\"$outputFilename\"");

        }
        header("Content-Length: $length");
        header("Pragma: no-cache");
        header("Expires: 0");

        if ($flagJson) {
            print $json;
        } else {
            readfile($file);
        }
        exit;
    }


    /**
     * @param string $uid
     * @param array $urlParam
     * @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, array $urlParam) {
        $bodyTextArr = $this->db->getBodytext($uid);

        // Copy $urlParam to STORE_SIP
        foreach ($urlParam as $key => $paramValue) {
            $this->store->setVar($key, $paramValue, STORE_SIP);
        }

        $qfq = new QuickFormQuery($bodyTextArr, false, false);
        return $qfq->process();

    }

    /**
     * Interprets $element and fetches corresponding content, either as a file or the content in a variable.
     *
     * @param string $element - U:id=myExport&r=12, u:http://www.nzz.ch/issue?nr=21, F:fileadmin/sample.pdf
     *
     * @param string $downloadMode - DOWNLOAD_MODE_EXCEL | ....
     * @param string $rcData - With $downloadMode=DOWNLOAD_MODE_EXCEL, this contains the rendered code from the given T3 page.
     * @return string filename - already ready or fresh exported. Fresh exported needs to be deleted later.
     * @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 getElement($element, $downloadMode, &$rcData) {

        $filename = '';
        $rcArgs = array();
        $rcSipEncode = false;

        $arr = explode(':', $element, 2);
        if (count($arr) != 2) {
            $possibleReason = ($element === '') ? 'If this is a download link, did you forget to include s:1?' : '';
            throw new \DownloadException("Missing parameter for '$element'. $possibleReason", ERROR_MISSING_REQUIRED_PARAMETER);
        }

        $token = $arr[0];
        $value = $arr[1];

        if ($token === TOKEN_DOWNLOAD && $downloadMode === DOWNLOAD_MODE_TEXT) {
            $filename = $value;
            return $filename;
        }
        if ($token === TOKEN_UID || $token === TOKEN_SOURCE) { // extract uid
            $uidParsed = OnString::parseUidToken($value);
            $uid = $uidParsed['name'];
            $value = $uidParsed['paramString']; // additional params
        }

        switch ($token) {
            case TOKEN_URL:
            case TOKEN_URL_PARAM:
            case TOKEN_PAGE:
            case TOKEN_UID:
                // Fake download mode pdf if saveZip is used
                if ($downloadMode === DOWNLOAD_MODE_SAVE_ZIP) {
                    $downloadMode = DOWNLOAD_MODE_PDF;
                }

                $urlParam = OnString::splitParam($value, $rcArgs, $rcSipEncode);
                $urlParamString = KeyValueStringParser::unparse($urlParam, '=', '&');
                if ($rcSipEncode) {
                    $sip = new Sip();
                    $urlParamString = $sip->queryStringToSip($urlParamString, RETURN_URL);
                }

                if ($downloadMode === DOWNLOAD_MODE_TEXT) {
                    try {

                        // Choose the data based on the token type
                        $data = ($token === TOKEN_UID)
                            ? $this->getEvaluatedBodytext($uid, $rcArgs)
                            : $value;

                        // Create a temporary filename and open the file for writing
                        $filename = HelperFile::tempnam() . '.txt';
                        $tmpFile = fopen($filename, "w");
                        if ($tmpFile === false) {
                            throw new UserFormException(
                                json_encode([
                                    ERROR_MESSAGE_TO_USER => "Cannot create file: $filename",
                                    ERROR_MESSAGE_TO_DEVELOPER => 'fopen() failed for temporary file creation',
                                    ERROR_MESSAGE_HTTP_STATUS => 'HTTP/1.0 500 Internal Server Error'
                                ]),
                                ERROR_REPORT_FAILED_ACTION
                            );
                        }

                        // Write the data to the temporary file
                        if (fwrite($tmpFile, $data) === false) {
                            fclose($tmpFile);
                            throw new UserFormException(
                                json_encode([
                                    ERROR_MESSAGE_TO_USER => "Cannot write data to file: $filename",
                                    ERROR_MESSAGE_TO_DEVELOPER => 'fwrite() failed for temporary file',
                                    ERROR_MESSAGE_HTTP_STATUS => 'HTTP/1.0 500 Internal Server Error'
                                ]),
                                ERROR_REPORT_FAILED_ACTION
                            );
                        }
                        fclose($tmpFile);
                        register_shutdown_function(function () use ($filename) {
                            if (file_exists($filename)) {
                                unlink($filename);
                            }
                        });
                        return $filename;
                    } catch (UserFormException $e) {
                        // Optionally: log the exception before rethrowing it.
                        throw $e;
                    }
                }

                if ($downloadMode == DOWNLOAD_MODE_EXCEL) {
                    if ($token === TOKEN_UID) {
                        $rcData = $this->getEvaluatedBodytext($uid, $urlParam);
                    } else {
                        $baseUrl = $this->storeSystem[SYSTEM_BASE_URL] ?? '';
                        $rcData = DownloadPage::getContent($urlParamString, $baseUrl);
                    }
                } else {
                    if ($token === TOKEN_UID) {
                        // create tmp html document with bodytext
                        $htmlText = $this->getEvaluatedBodytext($uid, $urlParam);
                        $tmpFilename = HelperFile::tempnam() . '.html';

                        $tmpFile = fopen($tmpFilename, "w") or die('Cannot create file:  ' . $tmpFilename);
                        fwrite($tmpFile, $htmlText);
                        fclose($tmpFile);

                        $rcArgsString = KeyValueStringParser::unparse($rcArgs, '=', '&');
                        $url = Support::mergeUrlComponents('', $tmpFilename, $rcArgsString);
                        $filename = $this->html2pdf->page2pdf($token, $url);
                        HelperFile::cleanTempFiles([$tmpFilename]);

                    } else {
                        $filename = $this->html2pdf->page2pdf($token, $value, $downloadMode);
                    }
                }
                break;

            case TOKEN_FILE:
                if (!file_exists($value)) {
                    throw new \DownloadException(json_encode([
                        ERROR_MESSAGE_TO_USER => "File not found: $value",
                        ERROR_MESSAGE_TO_DEVELOPER => "File: $value"
                    ]), ERROR_IO_FILE_NOT_FOUND);
                }
                $filename = $value;
                break;
            case TOKEN_FILE_DEPRECATED:
            default:
                throw new \DownloadException('Unknown token: "' . $token . '"', ERROR_UNKNOWN_TOKEN);
                break;
        }

        return $filename;
    }

    /**
     * Creates a ZIP Files of all given $files
     *
     * @param array $files
     *
     * @return string ZIP filename - has to be deleted later.
     * @throws \DownloadException
     */
    private function zipFiles(array $files, $downloadMode = DOWNLOAD_MODE_ZIP) {

        if ($this->cacheParam[DOWNLOAD_CACHE_STATUS] === DOWNLOAD_CACHE_STATUS_CREATE) {
            $zipFile = $this->cacheParam[DOWNLOAD_CACHE_PATH];
        } else {
            $zipFile = HelperFile::tempnam();
        }

        if (false === $zipFile) {
            throw new \DownloadException("Error creating output file.", ERROR_DOWNLOAD_CREATE_NEW_FILE);
        }

        $zip = new \ZipArchive();

        if ($zip->open($zipFile, \ZipArchive::CREATE) !== true) {
            throw new \DownloadException("Error creating/opening new empty zip file: $zipFile", ERROR_IO_OPEN);
        }

        $len = strlen(TMP_FILE_PREFIX);
        $ii = 1;
        $extension = '';
        // If download mode saveZip is given, then set extension for these on the fly generated pdfs
        if ($downloadMode === DOWNLOAD_MODE_SAVE_ZIP) {
            $extension = '.pdf';
        }
        foreach ($files as $item) {
            // a) $item: fileadmin/dog.jpg, b) $item: fileamdmin/doc.jpg:directory1/directory2/dog.jpg
            $arr = explode(PARAM_TOKEN_DELIMITER, $item);
            $filename = $arr[0] ?? '';
            // Check if a new target path/filename is specified. if not, extract remove preceeding path.
            $localName = (($arr[1] ?? '') == '') ? substr($filename, strrpos($filename, '/') + 1) : $arr[1];

            // If the final filename is a QFQ tempfile and still contains TMP_FILE_PREFIX: remove the prefix and replace it by 'file-?'
            if (substr($localName, 0, $len) == TMP_FILE_PREFIX) {
                $localName = 'file-' . $ii . $extension;
                $ii++;
            }

            $zip->addFile($filename, Sanitize::safeFilename($localName, false, true));
        }
        $zip->close();

        return $zipFile;
    }

    /**
     * Check $param for 'source:.<function name>&arg1=val1&arg2=val2,....'.
     * For each found, expand it  by fire the given QFQ function with the arguments.
     * Be aware, the result might contain again a 'source:..' definition ... do it recursively.
     *
     * Returns a string withut any 'source:' definition.
     *
     * @param string $param
     * @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 checkAndExpandSource($param) {

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

        $final = '';

        // $param = 'F:file.pdf|uid:123&pId=22|p:htmlcontent&appId=1|source:myFunction&accId=33'
        $elements = explode(PARAM_DELIMITER, $param);

        // $elements = [ 'F:file.pdf', 'uid:123&pId=22;, 'p:htmlcontent&appId=1', 'source:myFunction&accId=33' ]
        foreach ($elements as $element) {
            // E.g.: $element = 'source:myFunction&accId=33' >>
            $arr = explode(PARAM_TOKEN_DELIMITER, $element, 2);

            // Check for 'source:...'  - $arr[0] = 'source'
            if (0 === strcmp($arr[0], TOKEN_SOURCE)) {

                // $arr[1] = 'myFunction&accId=33&grId=44'
                $args = explode('&', $arr[1], 2);
                $urlParam = KeyValueStringParser::parse($args[1] ?? '', '=', '&');

                // Return a list of
                $element = $this->checkAndExpandSource($this->getEvaluatedBodytext($args[0], $urlParam));
                if ($element == '') {
                    continue;
                }
            }

            $final .= '|' . $element;
        }

        return substr($final, 1);
    }

    /**
     * $vars[DOWNLOAD_EXPORT_FILENAME] - Optional. '<new filename>'
     * $vars[DOWNLOAD_MODE] - Optional.  file | pdf | excel | thumbnail | monitor - default is a) 'file' in case of only one or b) 'pdf' in case of multiple sources.
     * HTML to PDF | Excel
     *   <i>_id=<Typo3 pageId>
     *   <i>_<key>=<value i>
     * Direct
     *   <i>_file=<filename>
     *
     * @param array $vars [ DOWNLOAD_EXPORT_FILENAME, DOWNLOAD_MODE, SIP_DOWNLOAD_PARAMETER ]
     *
     * @param string $outputMode OUTPUT_MODE_DIRECT | OUTPUT_MODE_FILE | OUTPUT_MODE_COPY_TO_FILE
     * @return string            Filename of the generated file. The filename only points to a real existing filename with  $outputMode=OUTPUT_MODE_FILE | OUTPUT_MODE_COPY_TO_FILE
     * @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 doElements(array $vars, $outputMode, $downloadProgress = null) {
        $action = $this->store::getVar(FILE_ACTION, STORE_SIP);
        $srcFiles = array();
        $filesCleanLater = array();

        $workDir = Path::absoluteApp();

        if ($action == FILE_ACTION_DOWNLOAD) {
            $downloadMode = DOWNLOAD_MODE_FILE;
            $table = $this->store::getVar(SIP_TABLE, STORE_SIP);
            $uploadId = $_GET[UPLOAD_ID];
            // Get pathFileName from database
            $sql = "SELECT pathFileName FROM `$table` WHERE id = ?";
            $result = $this->dbArray[$this->dbIndexFilePond]->sql($sql, ROW_EXPECT_1, [$uploadId]);
            $vars[SIP_DOWNLOAD_PARAMETER] = 'F:' . $result[UPLOAD_PATH_FILE_NAME];
        } else {
            $downloadMode = $vars[DOWNLOAD_MODE];
            HelperFile::chdir($workDir);
        }

        if ($downloadMode == DOWNLOAD_MODE_MONITOR) {
            $monitor = new Monitor();

            return $monitor->dump($vars[TOKEN_L_FILE], $vars[TOKEN_L_TAIL], $vars[TOKEN_L_APPEND]);
        }

        if ($downloadMode == DOWNLOAD_MODE_THUMBNAIL) {
            // Fake $vars control array.
            $pathFilenameThumbnail = $this->doThumbnail($vars[SIP_DOWNLOAD_PARAMETER]);
            $downloadMode = DOWNLOAD_MODE_FILE;
            $vars[SIP_DOWNLOAD_PARAMETER] = TOKEN_FILE . ':' . $pathFilenameThumbnail;
        }

        // Check and expand 'source:...'
        $elements = explode(PARAM_DELIMITER, $this->checkAndExpandSource($vars[SIP_DOWNLOAD_PARAMETER]));

        // Check if cached token is given and handle files with cache folder
        if (isset($vars[DOWNLOAD_PARAM_CACHE])) {
            $this->cacheParam = $this->checkForValidCachedFile($vars, $elements);
        }

        // If cached file is valid, use existing path for download else create new file.
        if ($this->cacheParam[DOWNLOAD_CACHE_STATUS] == DOWNLOAD_CACHE_STATUS_VALID) {
            $filename = $this->cacheParam[DOWNLOAD_CACHE_PATH];
        } else {

            //Modal Download count Files for displaying progress
            $counter = 0;
            if ($downloadProgress !== null) {
                $start = hrtime(true);
                $downloadProgress->writeProgressJson(count($elements), $downloadMode);
            }

            // Get all files / content
            $tmpData = array();
            foreach ($elements as $element) {
                $counter++;
                $data = '';
                $srcFiles[] = $this->getElement($element, $downloadMode, $data);

                //update download Progress (Modal) (counter = current File)
                if ($downloadProgress !== null) {
                    $downloadProgress->updateProgressJson(DOWNLOAD_MODAL_STATUS_PREPARING, $counter);
                }

                if (!empty($data)) {
                    $tmpData[] = $data;
                }
            }

            // Export, Concat File(s)
            switch ($downloadMode) {
                case DOWNLOAD_MODE_ZIP:
                case DOWNLOAD_MODE_SAVE_ZIP:
                    $filename = $this->zipFiles($srcFiles, $downloadMode);
                    if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
                        $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename) . ".zip";
                    }
                    break;

                case DOWNLOAD_MODE_EXCEL:
                    $excel = new Excel();
                    $filename = $excel->process($srcFiles, $tmpData, $this->cacheParam);

                    if (empty($filename) || !file_exists($filename)) {
                        if ($this->storeSystem[SYSTEM_MISSING_FILE_MODE] === SYSTEM_MISSING_FILE_MODE_DUMMY
                            || ($this->storeSystem[SYSTEM_MISSING_FILE_MODE] === SYSTEM_MISSING_FILE_MODE_AUTO && $this->storeSystem[SYSTEM_FLAG_PRODUCTION] === 'no')) {

                            $vars[DOWNLOAD_EXPORT_FILENAME] = QFQ_DUMMY_EXCEL_PATH;
                            $filename = QFQ_DUMMY_EXCEL_PATH;
                        } else {
                            throw new \DownloadException(json_encode(
                                [ERROR_MESSAGE_TO_USER => 'New created Excel file is broken.',
                                    ERROR_MESSAGE_TO_DEVELOPER => "File: '$filename'"]), ERROR_IO_READ_FILE);
                        }


                    }

                    if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
                        if (HelperFile::isQfqTemp($filename)) {
                            $vars[DOWNLOAD_EXPORT_FILENAME] = DOWNLOAD_OUTPUT_FILENAME . ".xlsx";
                        } else {
                            $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename);
                        }
                    }
                    break;

                case DOWNLOAD_MODE_FILE:
                    if ($this->cacheParam[DOWNLOAD_CACHE_STATUS] === DOWNLOAD_CACHE_STATUS_CREATE) {
                        copy($srcFiles[0], $this->cacheParam[DOWNLOAD_CACHE_PATH]);
                        $filename = $this->cacheParam[DOWNLOAD_CACHE_PATH];
                    } else {
                        $filename = $srcFiles[0];
                    }
                    if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
                        $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename);
                    }
                    break;

                case DOWNLOAD_MODE_PDF:
                case DOWNLOAD_MODE_QFQPDF:

                    $filename = $this->concatFilesToPdf($srcFiles, $filesCleanLater);

                    // try to find a meaningful filename
                    if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
                        if (count($srcFiles) > 1) {
                            $vars[DOWNLOAD_EXPORT_FILENAME] = DOWNLOAD_OUTPUT_FILENAME . ".pdf";
                        } else {
                            if (HelperFile::isQfqTemp($filename)) {
                                $vars[DOWNLOAD_EXPORT_FILENAME] = DOWNLOAD_OUTPUT_FILENAME . ".pdf";
                            } else {
                                $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename);
                            }
                        }
                    }
                    break;
                case DOWNLOAD_MODE_TEXT:
                    // Falls mehrere Elemente vorhanden sind, werden deren Inhalte zusammengeführt.
                    if (count($srcFiles) > 1) {
                        $content = '';
                        foreach ($srcFiles as $file) {
                            if (file_exists($file)) {
                                $content .= file_get_contents($file) . "\n\n";
                            }
                        }
                        $filename = HelperFile::tempnam(); // erzeugt einen temporären Pfad
                        // Hier speichern wir den zusammengeführten Text in einer Datei.
                        file_put_contents($filename, $content);
                    } else {
                        $filename = $srcFiles[0];
                    }
                    if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
                        $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename);
                    }
                    break;
                default:
                    throw new \DownloadException("Unknown download mode: $downloadMode", ERROR_UNKNOWN_MODE);
                    break;
            }
        }

        if ($filename != '' && !file_exists($filename)) {
            // Last check for Dummy File Default
            if ($this->storeSystem[SYSTEM_MISSING_FILE_MODE] === SYSTEM_MISSING_FILE_MODE_DUMMY
                || ($this->storeSystem[SYSTEM_MISSING_FILE_MODE] === SYSTEM_MISSING_FILE_MODE_AUTO && $this->storeSystem[SYSTEM_FLAG_PRODUCTION] === 'no')) {
                // Check if dummy file exists
                if (file_exists(QFQ_DUMMY_PDF_PATH)) {
                    // Return a dummy.pdf (type) should not matter since it a dummy
                    $filename = QFQ_DUMMY_PDF_PATH;
                    $vars[DOWNLOAD_EXPORT_FILENAME] = QFQ_DUMMY_PDF_PATH;
                } else {
                    throw new \DownloadException(json_encode([
                        ERROR_MESSAGE_TO_USER => 'Fallback file missing',
                        ERROR_MESSAGE_TO_DEVELOPER => 'Missing dummy.pdf at expected path'
                    ]), ERROR_IO_FILE_EXIST);
                }

            } else {
                throw new \DownloadException(json_encode(
                    [ERROR_MESSAGE_TO_USER => 'Can\'t read file',
                        ERROR_MESSAGE_TO_DEVELOPER => "File: $filename"]), ERROR_IO_FILE_EXIST);
            }
        }

        switch ($outputMode) {

            case OUTPUT_MODE_FILE:
                // At least for sendmail with dynamic PDF created from URL: unlink temporary files later, not now.
                break;

            case OUTPUT_MODE_COPY_TO_FILE:
                HelperFile::copy($filename, $vars[DOWNLOAD_EXPORT_FILENAME]);
                if ($this->cacheParam[DOWNLOAD_CACHE_STATUS] == DOWNLOAD_CACHE_STATUS_NO) {
                    $filesCleanLater[] = $filename;
                }
                break;

            case OUTPUT_MODE_DIRECT:
                // if the format is prepFile then
                if ($this->getOutputFormat() === DOWNLOAD_MODAL_PREPARE_FILE) {
                    $sipToken = $_GET[CLIENT_SIP];
                    $sip = new Sip();
                    $sipVars = $sip->getVarsFromSip($sipToken);
                    $sipVars[SIP_SIP] = $sipToken;
                    $sipVars[DOWNLOAD_MODAL_FILE_PATH] = $filename;
                    $sipVars[DOWNLOAD_MODAL_FILE_NAME] = $vars[DOWNLOAD_EXPORT_FILENAME];
                    $sip->updateSipToSession($sipVars);

                    header('Content-Type: application/json');
                    echo json_encode([
                        API_ANSWER_STATUS_SUCCESS => true,
                        DOWNLOAD_MODE => $downloadMode,
                        DOWNLOAD_MODAL_GET_JOBID => $downloadProgress->getJobId(),
                        CLIENT_SIP => $sipToken,
                    ]);
                    //Write Progress timing into json file
                    $end = hrtime(true);
                    $nanoSeconds = $end - $start;
                    $progressTime = $nanoSeconds / 1e9;
                    $downloadProgress->writeTimingJson($progressTime, $downloadMode);
                    if ($downloadProgress !== null) {
                        $downloadProgress->updateProgressJson(DOWNLOAD_MODAL_STATUS_COMPLETE, $counter);
                    }

                } else {
                    $this->outputFile($filename, $vars[DOWNLOAD_EXPORT_FILENAME], $downloadMode);
                    if ($this->cacheParam[DOWNLOAD_CACHE_STATUS] == DOWNLOAD_CACHE_STATUS_NO) {
                        $filesCleanLater[] = $filename;
                    }
                }
                $filename = '';
                break;

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

        HelperFile::cleanTempFiles($filesCleanLater);

        return $filename;
    }

    /**
     * @param string $urlParam
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function doThumbnail($urlParam) {

        $thumbnail = new Thumbnail();
        $pathFilenameThumbnail = $thumbnail->process($urlParam, THUMBNAIL_VIA_DOWNLOAD);

        return $pathFilenameThumbnail;
    }

    /**
     * Retrieve SQL query from QFQ config by token name extracted from PATH_INFO.
     * Token is looked up in the parsed sqlDirect INI config.
     *
     * @return array   //  [ 'sql' -> 'SELECT ...', 'error' -> 'Record not found', 'params' -> ['123'] ]
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function getDirectDownloadSql() {
        // Read token from PATH_INFO: d.php/tokenName/ID
        $pathInfo = $this->store->getVar('PATH_INFO', STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX);
        $pathInfo = Sanitize::sanitize(urldecode($pathInfo), SANITIZE_ALLOW_ALNUMX);
        $parts = OnString::splitPathToArray($pathInfo);

        if (empty($parts)) {
            throw new \DownloadException(json_encode([ERROR_MESSAGE_TO_USER => 'Missing token',
                    ERROR_MESSAGE_TO_DEVELOPER => "No token found in PATH_INFO"
                ])
                , ERROR_MISSING_VALUE);
        }

        $token = array_shift($parts);

        // Parse sqlDirect INI textarea on demand
        $parsed = parse_ini_string(
            $this->storeSystem[SYSTEM_SQL_DIRECT_DOWNLOAD] ?? '',
            false,
            INI_SCANNER_RAW
        );
        if (!is_array($parsed)) {
            $parsed = [];
        }

        $arr[DIRECT_DOWNLOAD_SQL] = $parsed[$token] ?? '';

        if ($arr[DIRECT_DOWNLOAD_SQL] == '') {
            throw new \DownloadException(json_encode([ERROR_MESSAGE_TO_USER => 'Missing SQL',
                    ERROR_MESSAGE_TO_DEVELOPER => "No SQL query defined for token '$token' in sqlDirect config"
                ])
                , ERROR_MISSING_VALUE);
        }

        // Error message: token-specific or global fallback
        $arr[DIRECT_DOWNLOAD_ERROR] = $parsed[$token . '.error']
            ?? $this->storeSystem[SYSTEM_SQL_DIRECT_DOWNLOAD . 'Error']
            ?? '';
        $arr[DIRECT_DOWNLOAD_PARAMS] = $parts; // remaining path segments as SQL params

        return $arr;
    }

    /**
     * @return int[]
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function getDirectDownloadModeDetails() {

        $arr = $this->getDirectDownloadSql();
        $param = $arr[DIRECT_DOWNLOAD_PARAMS];

        // In case there are more question mark than parameter, duplicate the last parameter until enough parameter filled.
        $last = end($param);
        $questionMark = substr_count($arr[DIRECT_DOWNLOAD_SQL], '?');
        while ($questionMark > count($param)) {
            $param[] = $last;
        }

        if ($arr[DIRECT_DOWNLOAD_ERROR] == '') {
            $arr[DIRECT_DOWNLOAD_ERROR] = 'Key for download not found.';
        }
        // Get cmd which defines the download
        try {
            $param = $this->db->sql($arr[DIRECT_DOWNLOAD_SQL], ROW_EXPECT_1, $param);
        } catch (\DbException $e) {
            throw new \DownloadException(json_encode([
                ERROR_MESSAGE_TO_USER => $arr[DIRECT_DOWNLOAD_ERROR],
                ERROR_MESSAGE_TO_DEVELOPER => $arr[DIRECT_DOWNLOAD_ERROR] . ' - ' . $e->getMessage()
            ]), ERROR_DB_COUNT_DO_NOT_MATCH);
        }
        // In case there are more than one column: implode
        $cmd = implode('', $param);
        // Auto-prepend download token if missing (e.g. 'F:path' → 'd|F:path', 'F:path|s:0' → 'd|F:path|s:0')
        if (!preg_match('/(^|[|])' . TOKEN_DOWNLOAD . '[|:]/', $cmd)) {
            $cmd = TOKEN_DOWNLOAD . '|' . $cmd;
        }
        // Use the link class only to reuse the parsing code of the download element.
        $link = new Link(Store::getSipInstance());
        $s = $link->renderLink($cmd, 'r:8|s:1');

        // Retrieve the generated vars
        $vars = Store::getSipInstance()->getVarsFromSip($s);
        $vars[SIP_DOWNLOAD_PARAMETER] = base64_decode($vars[SIP_DOWNLOAD_PARAMETER]);
        $vars[SIP_SIP] = $s;

        // If no explicit output name given (d|F:...), decode B64 filenames for a readable download name.
        if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
            $vars[DOWNLOAD_EXPORT_FILENAME] = HelperFile::base64DecodeUniqueFileName($vars[SIP_DOWNLOAD_PARAMETER]);
        }

        return $vars;
    }

    /**
     * Process download as requested in $vars. Output is either directly send to the browser, or a file which has to be deleted later.
     *
     * @param string|array $vars - If $config is an array, take it, else get values from STORE_SIP
     * @param string $outputMode OUTPUT_MODE_DIRECT | OUTPUT_MODE_FILE
     *
     * @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 process($vars, $outputMode = OUTPUT_MODE_DIRECT, $modalDownload = false, $downloadProgress = null) {

        if ($modalDownload === DOWNLOAD_MODAL_DOWNLOAD_FILE) {
            $downloadMode = $_GET[DOWNLOAD_MODE] ?? false;

            if (!$downloadMode) {
                throw new \DownloadException(json_encode([
                    ERROR_MESSAGE_TO_USER => 'Download failed',
                    ERROR_MESSAGE_TO_DEVELOPER => 'Missing GET parameter: ' . DOWNLOAD_MODE]),
                    ERROR_MISSING_REQUIRED_PARAMETER);
            }

            $sipVars = $this->store->getStore(STORE_SIP);
            $filePath = $sipVars[DOWNLOAD_MODAL_FILE_PATH] ?? false;
            $fileName = $sipVars[DOWNLOAD_MODAL_FILE_NAME] ?? false;

            if (!$filePath || !$fileName) {
                throw new \DownloadException(json_encode([
                    ERROR_MESSAGE_TO_USER => 'Download failed',
                    ERROR_MESSAGE_TO_DEVELOPER => 'Missing file path or file name in SIP store for modal download.']),
                    ERROR_MISSING_REQUIRED_PARAMETER);
            }
            if (!file_exists($filePath)) {
                throw new \DownloadException(json_encode([
                    ERROR_MESSAGE_TO_USER => 'File not found',
                    ERROR_MESSAGE_TO_DEVELOPER => "Requested file does not exist: $filePath"]),
                    ERROR_IO_FILE_NOT_FOUND);
            }

            $this->outputFile($filePath, $fileName, $downloadMode);
            HelperFile::cleanTempFiles([$filePath]);
            // Exit early since every thing else was done in previous step -
            return;
        }
        if (isset($_GET[DOWNLOAD_MODE]) && $_GET[DOWNLOAD_MODE] == MULTI_UPLOAD_API_MODE_DOWNLOAD) {
            $this->doDownloadFormElementMulti();
            return;
        }
        if (!is_array($vars)) {

            $vars = $this->store->getStore(STORE_SIP);

            // Check if something exists in store extra for new upload path after save form
            $sipDownloadKey = $this->store->getVar(UPLOAD_SIP_DOWNLOAD_KEY, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX);
            $storeExtra = $this->store->getVar($sipDownloadKey, STORE_EXTRA);
            if (isset($storeExtra[SIP_DOWNLOAD_PARAMETER])) {
                $vars[SIP_DOWNLOAD_PARAMETER] = $storeExtra[SIP_DOWNLOAD_PARAMETER];
                $vars[DOWNLOAD_EXPORT_FILENAME] = '';
            }

            if ($vars === array()) {
                // No SIP >> this seems to be a DirectDownloadMode
                $vars = $this->getDirectDownloadModeDetails();
            }
        }

        //Chat-Attachment Download (early return)
        if (isset($vars[DOWNLOAD_MODE]) && $vars[DOWNLOAD_MODE] == DOWNLOAD_MODE_CHAT_ATTACHMENT) {
            Chat::downloadAttachment($vars);
            return;
        }

        $this->setOutputFormat(empty($vars[DOWNLOAD_OUTPUT_FORMAT]) ? DOWNLOAD_OUTPUT_FORMAT_RAW : $vars[DOWNLOAD_OUTPUT_FORMAT]);
        if ($modalDownload) {
            $this->setOutputFormat($modalDownload);
        }
        return $this->doElements($vars, $outputMode, $downloadProgress);
    }

    /**
     * @param $outputFormat
     */
    private function setOutputFormat($outputFormat) {
        $this->outputFormat = $outputFormat;
    }

    /**
     * @return string - DOWNLOAD_OUTPUT_FORMAT_RAW | DOWNLOAD_OUTPUT_FORMAT_JSON
     */
    public function getOutputFormat() {
        return $this->outputFormat;
    }

    /**
     * Check for a cached file and set cache parameters. Check includes modified date and existing file.
     *
     * - If not exist, create SYSTEM_CACHE_DIR_SECURE.
     * - Compute name of cachePathFilename.
     * - Check if cachePathFilename is missing or outdated and has to be created: $vars[DOWNLOAD_CACHE_STATUS] = DOWNLOAD_CACHE_STATUS_NEW.
     *
     * @param array $vars
     * @param array $elements
     * @return array
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function checkForValidCachedFile(array $vars, array $elements) {

        $cacheDirSecure = $this->storeSystem[SYSTEM_CACHE_DIR_SECURE] ?? '';
        HelperFile::createPathRecursive(Path::absoluteApp($cacheDirSecure));

        $md5filename = md5($vars[SIP_DOWNLOAD_PARAMETER] . $vars[DOWNLOAD_MODE]);
        $pathToCachedFile = Path::absoluteApp($cacheDirSecure,
            TMP_FILE_PREFIX . $md5filename . '.' . $this->getFileExtensionFromDownloadMode($vars[DOWNLOAD_MODE], $elements));

        $cacheParam[DOWNLOAD_CACHE_PATH] = $pathToCachedFile;
        $cacheParam[DOWNLOAD_CACHE_STATUS] = DOWNLOAD_CACHE_STATUS_CREATE; // Default: assume cache is invalid

        if (!file_exists($pathToCachedFile)) {
            return $cacheParam;
        }

        // Check Sources
        $cachedFileModifiedUnix = filemtime($pathToCachedFile);
        foreach ($elements as $element) {
            // Only process 'F:' sources
            $arr = explode(PARAM_TOKEN_DELIMITER, $element);
            if ($arr[0] === TOKEN_FILE) {
                $pathToFile = Path::absoluteApp($arr[1] ?? '');
                if (filemtime($pathToFile) > $cachedFileModifiedUnix) {
                    return $cacheParam;
                }
            }
        }

        // Invalidation check on a single, timezone-independent basis: Unix epoch.
        // filemtime() is absolute; the DB DATETIME is converted via UNIX_TIMESTAMP() (MySQL session tz), hardcoded timestamps via strtotime() (PHP tz).
        $arr = explode(',', $vars[DOWNLOAD_PARAM_CACHE]);
        foreach ($arr as $item) {
            // If there is at least one '/': this is a database query of the form: tablename/id[/column]
            $args = explode('/', $item, 3);
            if (count($args) > 1) {
                //Database query
                if (empty($args[0]) || empty($args[1])) {
                    Thrower::userReportException('Broken cache parameter', 'Problem in \'' . $item . '\' of definition \'' .
                        $vars[DOWNLOAD_PARAM_CACHE] . '\'. Expected string: [timestamp]|[tablename/id[/column]][,...]');
                }
                $column = $args[2] ?? 'modified';
                // UNIX_TIMESTAMP() yields the absolute epoch (MySQL session tz aware), directly comparable to filemtime().
                $sql = 'SELECT UNIX_TIMESTAMP(`' . $column . '`) AS modifiedUnix FROM `' . $args[0] . '` WHERE `id` = ?';
                $result = $this->db->sql($sql, ROW_EXPECT_1, [$args[1]], 'No/too many record found: ' . $sql . ' [' . $args[1] . '].');
                if ($result['modifiedUnix'] > $cachedFileModifiedUnix) {
                    return $cacheParam;
                }

            } else {
                // Hardcoded timestamp string: interpret in PHP's timezone, compare as Unix epoch.
                if (strtotime($item) > $cachedFileModifiedUnix) {
                    return $cacheParam;
                }
            }
        }

        $cacheParam[DOWNLOAD_CACHE_STATUS] = DOWNLOAD_CACHE_STATUS_VALID;

        return $cacheParam;
    }

    /**
     * Depending on $mode, returns the corresponding filename extension.
     * If nothing is suitable, return ''.
     *
     * @param string $mode
     * @param array $sources
     * @return string
     */
    private function getFileExtensionFromDownloadMode($mode, array $sources) {

        switch ($mode) {
            case DOWNLOAD_MODE_PDF:
            case DOWNLOAD_MODE_QFQPDF:
                return 'pdf';
            case DOWNLOAD_MODE_ZIP:
            case DOWNLOAD_MODE_SAVE_ZIP:
                return 'zip';
            case DOWNLOAD_MODE_EXCEL:
                return 'xlsx';
            case DOWNLOAD_MODE_FILE:
                // Assumption: maybe the first source gives us an hint about wished extension.
                // Cut off source qualifier like 'F:'
                $arr = explode(':', $sources[0] ?? '');
                return pathinfo($arr[1] ?? '', PATHINFO_EXTENSION);
            case DOWNLOAD_MODE_TEXT:
                return DOWNLOAD_MODE_TEXT;
            default:
                throw new \DownloadException('Unknown download mode: ' . $mode, ERROR_UNKNOWN_MODE);
        }
    }

    /**
     * Process a download request for a multi-upload form element.
     *
     * This function retrieves the downloads array from the session, identifies the file to download
     * based on a GET parameter index, validates the file's availability and status, and then outputs
     * the file for direct download.
     *
     * @throws UserFormException
     * @throws \DownloadException
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserReportException
     * @throws \InfoException
     */
    private function doDownloadFormElementMulti(): void {
        // Retrieve Existing Session Data
        $downloadsArray = onString::extractUploadsFromSipString($this->store->getVar(SIP_URLPARAM, STORE_SIP));
        $downloadIndex = $_GET[MULTI_UPLOAD_DOWNLOAD_FILE_INDEX] ?? false;
        $file = [];

        // Check for a valid index and a downloads array.
        if ($downloadIndex !== false && $downloadsArray) {
            $download = $downloadsArray[$downloadIndex];

            // Check if the download represents a new (temporary) upload.
            if (isset($download[MULTI_UPLOAD_NEW_UPLOAD_FLAG]) && $download[MULTI_UPLOAD_NEW_UPLOAD_FLAG] === 1) {
                // For new uploads, extend the temporary filename to include a cached extension.
                $file[MULTI_UPLOAD_DOWNLOAD_FILE_PATH] = Support::extendFilename($download[FILES_TMP_NAME], UPLOAD_CACHED);;
                $file[MULTI_UPLOAD_DOWNLOAD_FILE_NAME] = $download[FILES_NAME];
            } else {
                // For existing uploads, use the full file path already stored.
                $file[MULTI_UPLOAD_DOWNLOAD_FILE_PATH] = $download[MULTI_UPLOAD_FILE_FULL_PATH];
                $file[MULTI_UPLOAD_DOWNLOAD_FILE_NAME] = $download[FILES_NAME];
            }
        } else {
            throw new \DownloadException(json_encode(
                [ERROR_MESSAGE_TO_USER => 'Can\'t read file',
                    ERROR_MESSAGE_TO_DEVELOPER => "File: could not be found in Downloads array with index: " . $downloadIndex]));
        }

        // If the file has been flagged for deletion, disallow the download.
        if (isset($download[MULTI_UPLOAD_DELETE_FLAG]) && $download[MULTI_UPLOAD_DELETE_FLAG] === 1) {
            throw new \InfoException('File has already been flagged for deletion and cant be downloaded.');
        }

        // Check that the file path is not empty and that the file actually exists.
        if ($file[MULTI_UPLOAD_DOWNLOAD_FILE_PATH] != '' && !file_exists($file[MULTI_UPLOAD_DOWNLOAD_FILE_PATH])) {
            throw new \DownloadException(json_encode(
                [ERROR_MESSAGE_TO_USER => 'Can\'t read file',
                    ERROR_MESSAGE_TO_DEVELOPER => "File: " . $file[MULTI_UPLOAD_DOWNLOAD_FILE_PATH]]), ERROR_IO_FILE_EXIST);
        }


        $this->outputFile($file[MULTI_UPLOAD_DOWNLOAD_FILE_PATH], $file[MULTI_UPLOAD_DOWNLOAD_FILE_NAME], OUTPUT_MODE_DIRECT);
    }
}

