<?php

namespace IMATHUZH\Qfq\Core\Report;

use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Database\DatabaseManager;
use IMATHUZH\Qfq\Core\Parser\KVPairListParser;
use IMATHUZH\Qfq\Core\Store\Store;
use IMATHUZH\Qfq\Core\Store\T3Info;

/**
 * Encapsulates the SQL report caching (key building, hit/write, helpers).
 * One instance lives per Report instance (created in the Report constructor).
 */
class ReportCache {

    private Database $dbQfq;
    private Store $store;
    private ?int $dbStartTime = null;

    /** Memoizes which cache directories were already created (avoids repeated is_dir/mkdir per level/row). */
    private array $cacheDirCreated = [];

    /** True once the cache-debug overlay setup-script was emitted; only the first box carries it. */
    private bool $cacheDebugJsEmitted = false;

    /** Memoizes the tt_content tstamp per content element uid (avoids one query per cached level). */
    private array $ttContentTstamp = [];

    public function __construct(Database $dbQfq, Store $store) {
        $this->dbQfq = $dbQfq;
        $this->store = $store;
    }

    /**
     * Build the per-level cache state (key hash, file path, dependency info) for a cached report level.
     * Returns null when caching is not active for this level (no QFQ session cookie).
     *
     * @param string $cacheKeyParam   raw value of the 'cache' keyword ('' = defaults only)
     * @param string $fullLevel       e.g. '10.20'
     * @param bool   $parentCacheActive  true if an ancestor level is already cached (nested cache => error)
     * @return array|null  cacheState array (see REPORT_CACHE_* keys) or null if caching is inactive
     */
    public function prepare(string $cacheKeyParam, string $fullLevel, bool $parentCacheActive): ?array {
        // Gate: caching only with a QFQ session cookie present (avoids a shared cross-visitor bucket).
        $sessCookie = $this->store->getVar(CLIENT_COOKIE_QFQ, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX);
        if ($sessCookie === '') {
            return null;
        }
        // Nested cache detection: cache inside a cached parent is not allowed.
        if ($parentCacheActive) {
            throw new \UserReportException(
                "Nested cache detected in level '$fullLevel'. Cache inside a cached parent level is not allowed.",
                ERROR_UNKNOWN_TOKEN
            );
        }

        // Start timing early so the cache-hit/-write measurement includes hash building + lookups.
        $cacheStart = hrtime(true);

        $ttContentUid = $this->store->getVar(TYPO3_TT_CONTENT_UID, STORE_TYPO3);
        $pageUid      = $this->store->getVar(TYPO3_PAGE_ID, STORE_TYPO3);
        $feUserName   = $this->store->getVar(SESSION_FE_USER, STORE_USER);
        $defaultHashParam = [
            'ttcontentUid' => $ttContentUid,
            'pageUid'      => $pageUid,
            'fullLevel'    => $fullLevel,
            'cookieQfq'    => $sessCookie,
            'feUser'       => $feUserName,
        ];

        $pairs = $this->parsePairs($cacheKeyParam);
        $atFragment = $this->buildAtFragment($pairs);

        $removedRKeys = [];
        $removedHashKeys = [];
        $cacheStoreRecord = true;
        $stringRaw = $this->buildRawString($pairs, $atFragment, $removedRKeys, $removedHashKeys, $cacheStoreRecord);

        // Remove excluded hash params (negated cookieQfq / feUser).
        if (!empty($removedHashKeys)) {
            $defaultHashParam = array_diff_key($defaultHashParam, $removedHashKeys);
        }

        // Conversion to hash.
        $hashParam = implode('|', $defaultHashParam);
        $fullCacheKey = $stringRaw . '|' . $hashParam;
        $contentHash = hash('xxh3', $fullCacheKey);

        // tt_content tstamp is a separate invalidation axis (report definition edit) baked into the filename.
        $tstamp = $this->getTtContentTstamp($ttContentUid);

        // Cache file path (folder structure). ttcontentUid/pageUid may have been negated away.
        if (array_key_exists('ttcontentUid', $defaultHashParam) && array_key_exists('pageUid', $defaultHashParam)) {
            $cacheFilePath = REPORT_CACHE_PATH . $pageUid . '-' . $ttContentUid . '/' . $tstamp . '-' . $contentHash . '.cache';
        } else {
            $cacheFilePath = REPORT_CACHE_PATH . $tstamp . '-' . $contentHash . '.cache';
        }

        return [
            REPORT_CACHE_START                => $cacheStart,
            REPORT_CACHE_FILE_PATH            => $cacheFilePath,
            REPORT_CACHE_PAIRS                => $pairs,
            REPORT_CACHE_STORE_RECORD         => $cacheStoreRecord,
            REPORT_CACHE_REMOVED_R_KEYS       => $removedRKeys,
            REPORT_CACHE_FULL_CACHE_KEY       => $fullCacheKey,
            REPORT_CACHE_R_STORE_BEFORE_CACHE => [],   // filled in by the Report before the level's SQL runs
        ];
    }

    /**
     * Parse the cache parameter into a store=>values map. Empty value => no dependency stores.
     * Repeated store segments are merged (e.g. 'R:a|R:b' === 'R:a,b').
     *
     * @param string $cacheKeyParam
     * @return array
     */
    private function parsePairs(string $cacheKeyParam): array {
        $kvParser = new KVPairListParser('|', ':');
        $pairs = [];
        if ($cacheKeyParam !== '') {
            foreach ($kvParser->iterate($cacheKeyParam) as $store => $values) {
                $pairs[$store] = isset($pairs[$store]) ? $pairs[$store] . ',' . $values : $values;
            }
        }
        return $pairs;
    }

    /**
     * Batch-fetch UPDATE_TIME for all '@'-store tables in ONE query and build the (sorted, deduped)
     * dependency fragment: 'TableA:<ts>,TableB:<ts>'. NULL UPDATE_TIME falls back to the DB start time.
     *
     * @param array $pairs
     * @return string  the '@' fragment, or '' if no '@' tables are requested
     */
    private function buildAtFragment(array $pairs): string {
        if (!isset($pairs[REPORT_CACHE_TABLE_KEY])) {
            return '';
        }

        // Clean the table list: trim, skip empty; negation ('!') is not supported for the '@' store.
        $atTables = [];
        foreach (array_map('trim', explode(',', $pairs[REPORT_CACHE_TABLE_KEY])) as $t) {
            if ($t === '') {
                continue;
            }
            if (str_starts_with($t, '!')) {
                throw new \UserReportException("Negation is not allowed for cache table dependencies ('$t'). A table that is not needed should simply be omitted from the list, not removed with '!'.", ERROR_SQL_CACHE_UNKNOWN_STORE_KEY);
            }
            $atTables[] = $t;
        }
        if (empty($atTables)) {
            return '';
        }

        // Single data DB assumed (multi-DB setup not yet supported).
        $dbName = $this->store->getVar(SYSTEM_DB_NAME_DATA, STORE_SYSTEM);
        $placeholders = implode(',', array_fill(0, count($atTables), '?'));
        $sql = 'SELECT TABLE_NAME, UPDATE_TIME FROM information_schema.TABLES '
            . 'WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (' . $placeholders . ') '
            . 'ORDER BY TABLE_NAME';
        $rows = $this->dbQfq->sql($sql, ROW_REGULAR, array_merge([$dbName], $atTables));

        // Detect missing tables: case-insensitive set of returned (canonical) names, then diff.
        $found = [];
        foreach ($rows as $row) {
            $found[strtolower($row['TABLE_NAME'])] = true;
        }
        $missing = [];
        foreach ($atTables as $t) {
            if (!isset($found[strtolower($t)])) {
                $missing[] = $t;
            }
        }
        if (!empty($missing)) {
            throw new \UserReportException(
                "cache parameter: table(s) '" . implode("', '", $missing) . "' not found in database '$dbName'",
                ERROR_SQL_CACHE_UNKNOWN_PARAMETER
            );
        }

        // Build fragment from the sorted result rows: canonical name + UPDATE_TIME (or DB-start fallback on NULL).
        $parts = [];
        foreach ($rows as $row) {
            $ts = $row['UPDATE_TIME'] ?? $this->getDbStartTime();
            $parts[] = $row['TABLE_NAME'] . ':' . $ts;
        }
        return implode(',', $parts);
    }

    /**
     * Walk the parsed stores/keys and build the raw (pre-hash) dependency string. Handles negation:
     *  - R:!        => whole Record store excluded ($cacheStoreRecord = false)
     *  - R:!key     => single Record key excluded ($removedRKeys)
     *  - C:!cookieQfq / U:!feUser => default hash param excluded ($removedHashKeys)
     * The three negation results are returned via reference parameters.
     *
     * @param array  $pairs
     * @param string $atFragment        pre-built '@' fragment from buildAtFragment()
     * @param array  $removedRKeys      (out) excluded individual Record keys
     * @param array  $removedHashKeys   (out) excluded default hash params
     * @param bool   $cacheStoreRecord  (out) false if the whole Record store is excluded
     * @return string  the raw dependency string
     */
    private function buildRawString(array $pairs, string $atFragment, array &$removedRKeys, array &$removedHashKeys, bool &$cacheStoreRecord): string {
        $stringRaw = '';
        //Iterate through every piece from cache string like this |R:grId,pId|
        foreach ($pairs as $store => $values) {
            //Remove Parameter start with ! and track when !R is Removed
            if (str_starts_with($store, '!')) {
                throw new \UserReportException(
                    "Invalid negation '$store'. Only the following can be negated: the entire R-Store (R:!), single R-Store values (R:!xId), cookieQfq (C:!cookieQfq) or feUser (U:!feUser)",
                    ERROR_SQL_CACHE_UNKNOWN_PARAMETER);
            }

            if (!in_array($store, REPORT_CACHE_VALID_STORES)) {
                throw new \UserReportException(
                    "Unknown store '$store' in cache. Valid stores are: " . implode(', ', REPORT_CACHE_VALID_STORES),
                    ERROR_SQL_CACHE_UNKNOWN_STORE
                );
            }

            // '@' store: emit the pre-built (sorted, deduped) table-update fragment in one go;
            // skip the generic per-key handling below. Empty fragment (degenerate '@:') is dropped.
            if ($store === REPORT_CACHE_TABLE_KEY) {
                if ($atFragment === '') {
                    continue;
                }
                $stringRaw = ($stringRaw === '') ? ('@:' . $atFragment) : ($stringRaw . '|@:' . $atFragment);
                continue;
            }

            $stringRaw = ($stringRaw === '') ? $store : ($stringRaw . '|' . $store);
            $stringRaw .= ':';
            $keyArray = explode(',', $values);
            $parts = [];
            //Iterate through every values from a store variable from cache param
            foreach ($keyArray as $key) {
                if (str_starts_with($key, '!')) {
                    $cleanKey = ltrim($key, '!');
                    if ($store === STORE_RECORD) {
                        if ($cleanKey === '') {
                            // R: ! cleared out the entire Record Store
                            $cacheStoreRecord = false;
                        } else {
                            // R:!key Removes individual values from the Record Store
                            $removedRKeys[$cleanKey] = true;
                        }
                    } elseif ($store === STORE_CLIENT) {
                        if ($cleanKey === (CLIENT_COOKIE_QFQ)) {
                            $removedHashKeys[$cleanKey] = true;
                        } else {
                            throw new \UserReportException("Key '$key' is not removable in store '$store'. In store '" . STORE_CLIENT . "' only '" . CLIENT_COOKIE_QFQ . "' can be removed.", ERROR_SQL_CACHE_UNKNOWN_STORE_KEY);
                        }
                    } elseif ($store === STORE_USER) {
                        if ($cleanKey === (SESSION_FE_USER)) {
                            $removedHashKeys[$cleanKey] = true;
                        } else {
                            throw new \UserReportException("Key '$key' is not removable in store '$store'. In store '" . STORE_USER . "' only '" . SESSION_FE_USER . "' can be removed.", ERROR_SQL_CACHE_UNKNOWN_STORE_KEY);
                        }
                    } else {
                        throw new \UserReportException("Key '$key' is not removable in store '$store'.", ERROR_SQL_CACHE_UNKNOWN_STORE_KEY);
                    }
                    continue;
                }

                $key = trim($key);
                //Load Values from store ('@' store is handled in one batch before this loop)
                $value = $this->store->getVar($key, $store);

                //Concat values with ,
                $parts[] = $key . ':' . $value;
            }
            $stringRaw .= implode(',', $parts);
        }
        return $stringRaw;
    }

    /**
     * Read the tt_content tstamp for the given content element (drives the cache filename).
     *
     * @param int|string $ttContentUid
     * @return string
     */
    private function getTtContentTstamp($ttContentUid): string {
        if (!isset($this->ttContentTstamp[$ttContentUid])) {
            $t3DbName = $this->store->getVar(SYSTEM_DB_NAME_T3, STORE_SYSTEM);
            $dbT3 = DatabaseManager::getInstance()->getT3Db();
            $sql = 'SELECT tstamp FROM ' . $t3DbName . '.tt_content WHERE uid = ?';
            $result = $dbT3->sql($sql, ROW_EXPECT_1, [$ttContentUid]);
            $this->ttContentTstamp[$ttContentUid] = $result['tstamp'];
        }
        return $this->ttContentTstamp[$ttContentUid];
    }

    /**
     * Try to serve this level from its cache file. Returns the cached HTML (prefixed with an
     * optional BE-only debug box) on a hit, or null on a miss (no cache file yet). On a hit the
     * relevant Stores are restored so the following levels see the same state as during the
     * original render.
     *
     * @param array  $cacheState  per-level cache state from prepare()
     * @param string $levelLabel  resolved report level (alias or full level), for the debug box
     * @return string|null  cached HTML on hit, null on miss
     */
    public function tryRead(array $cacheState, string $levelLabel): ?string {
        $cacheFilePath = $cacheState[REPORT_CACHE_FILE_PATH];
        if (!file_exists($cacheFilePath)) {
            return null;
        }

        // Decode html and Store data from cache
        $contentLevel = json_decode(file_get_contents($cacheFilePath), true);
        if (!is_array($contentLevel)) {
            // Corrupt/partial/empty cache file (concurrent or aborted write, or purged
            // between check and read) -> treat as a miss and rebuild.
            return null;
        }

        $html = '';
        if (T3Info::beUserLoggedIn()) {
            $cacheMs = round((hrtime(true) - $cacheState[REPORT_CACHE_START]) / 1e+6, 2);
            $renderMs = $contentLevel[REPORT_CACHE_RENDER_TIME] ?? 0;
            $html .= $this->cacheDebugBox('Cache hit: ' . $cacheMs, '#2e7d32', $levelLabel, $cacheFilePath, 'ms, write: ' . $renderMs . ' ms');
        }

        if ($cacheState[REPORT_CACHE_STORE_RECORD] === true) {
            // Restore R-Store: merge only what the cached level contributed
            $cachedBefore = $contentLevel[REPORT_CACHE_R_STORE_BEFORE_CACHE] ?? [];
            $cachedAfter = $contentLevel[REPORT_CACHE_STORE][STORE_RECORD] ?? [];
            $currentStore = $this->store->getStore(STORE_RECORD);

            $changes = array_diff_assoc($cachedAfter, $cachedBefore);
            $merged = array_replace($currentStore, $changes);
            $this->store->setStore($merged, STORE_RECORD, true);
        }

        // Restore other stores (merge, preserve existing values from preceding levels)
        foreach ($contentLevel[REPORT_CACHE_STORE] as $store => $values) {
            if ($store === STORE_RECORD || $store === REPORT_CACHE_TABLE_KEY) {
                continue;
            }
            foreach ($values as $key => $value) {
                $this->store->setVar($key, $value, $store, false);
            }
        }

        return $html . $contentLevel['html'];
    }

    /**
     * Snapshot the R-Store before this level's SQL runs, so a future cache hit can diff what this
     * level contributed to the R-Store (counterpart to the diff-merge in tryRead). Per-level keys
     * negated via '!' are excluded from the baseline. No-op unless the cache key includes the
     * R-Store. Mutates $cacheState in place.
     *
     * @param array $cacheState  per-level cache state from prepare() (modified by reference)
     * @return void
     */
    public function snapshotRStore(array &$cacheState): void {
        if ($cacheState[REPORT_CACHE_STORE_RECORD] !== true) {
            return;
        }
        if (!empty($cacheState[REPORT_CACHE_REMOVED_R_KEYS])) {
            $recordStore = $this->store->getStore(STORE_RECORD);
            $cacheState[REPORT_CACHE_R_STORE_BEFORE_CACHE] = array_diff_key($recordStore, $cacheState[REPORT_CACHE_REMOVED_R_KEYS]);
        } else {
            $cacheState[REPORT_CACHE_R_STORE_BEFORE_CACHE] = $this->store->getStore(STORE_RECORD);
        }
    }

    /**
     * Write this level's content to its cache file (only the level's own content, not its siblings).
     * Collects the Stores that the cache key declared (R-Store and per-pair store values, '!'-negated
     * keys excluded) plus the system flags needed for post-processing, and persists them next to the
     * rendered html. Returns a BE-only "Cache write" debug box (or '' for non-BE users).
     *
     * @param array  $cacheState    per-level cache state from prepare()
     * @param string $contentLevel  rendered html of this level (the payload to cache)
     * @param string $levelLabel    resolved report level (alias or full level), for the debug box
     * @return string  debug box html on a BE session, otherwise ''
     */
    public function writeCacheFile(array $cacheState, string $contentLevel, string $levelLabel): string {
        $storeData = [];
        // Ensure the cache directory exists only once per folder (memoized across levels/parent rows).
        $cacheDir = dirname($cacheState[REPORT_CACHE_FILE_PATH]);
        if (!isset($this->cacheDirCreated[$cacheDir])) {
            if (!is_dir($cacheDir)) {
                mkdir($cacheDir, 0775, true);
            }
            $this->cacheDirCreated[$cacheDir] = true;
        }

        // Save entire R-Store Or remove value or save nothing
        if ($cacheState[REPORT_CACHE_STORE_RECORD] === true) {
            if (!empty($cacheState[REPORT_CACHE_REMOVED_R_KEYS])) {
                $recordStore = $this->store->getStore(STORE_RECORD);
                $storeData[STORE_RECORD] = array_diff_key($recordStore, $cacheState[REPORT_CACHE_REMOVED_R_KEYS]);
            } else {
                $storeData[STORE_RECORD] = $this->store->getStore(STORE_RECORD);
            }
        }

        // Save store values from report cache param
        foreach ($cacheState[REPORT_CACHE_PAIRS] as $store => $values) {
            if ($store === REPORT_CACHE_TABLE_KEY) {
                continue;
            }
            $valuesToArray = explode(',', $values);
            foreach ($valuesToArray as $key) {
                $key = trim($key);
                if (str_starts_with($key, '!')) {
                    continue;
                }
                $storeData[$store][$key] = $this->store->getVar($key, $store);
            }
        }

        // Save system flags needed for post-processing in QuickFormQuery::process()
        $downloadPopup = $this->store->getVar(SYSTEM_DOWNLOAD_POPUP, STORE_SYSTEM);
        if ($downloadPopup === DOWNLOAD_POPUP_REQUEST) {
            $storeData[STORE_SYSTEM][SYSTEM_DOWNLOAD_POPUP] = $downloadPopup;
        }
        $dragAndDropJs = $this->store->getVar(SYSTEM_DRAG_AND_DROP_JS, STORE_SYSTEM);
        if ($dragAndDropJs === 'true') {
            $storeData[STORE_SYSTEM][SYSTEM_DRAG_AND_DROP_JS] = $dragAndDropJs;
        }

        $renderTimeMs = round((hrtime(true) - $cacheState[REPORT_CACHE_START]) / 1e+6, 2);
        $payload = json_encode(['html' => $contentLevel, REPORT_CACHE_STORE => $storeData, REPORT_CACHE_RENDER_TIME => $renderTimeMs, REPORT_CACHE_R_STORE_BEFORE_CACHE => $cacheState[REPORT_CACHE_R_STORE_BEFORE_CACHE], REPORT_CACHE_FULL_CACHE_KEY => $cacheState[REPORT_CACHE_FULL_CACHE_KEY]]);
        $tmpPath = $cacheState[REPORT_CACHE_FILE_PATH] . '.' . uniqid('', true) . '.tmp';
        file_put_contents($tmpPath, $payload);
        rename($tmpPath, $cacheState[REPORT_CACHE_FILE_PATH]);

        $html = '';
        if (T3Info::beUserLoggedIn()) {
            $html = $this->cacheDebugBox('Cache write: ' . $renderTimeMs . 'ms', '#ef6c00', $levelLabel, $cacheState[REPORT_CACHE_FILE_PATH]);
        }
        return $html;
    }

    /**
     * Lazy getter for the DB server start time (unix timestamp), used as a fallback when
     * information_schema returns NULL for a table's UPDATE_TIME. This keeps a NULL out of the
     * cache hash and prevents stale caches across a DB restart (a restart resets UPDATE_TIME to NULL).
     *
     * @return int
     */
    public function getDbStartTime(): int {
        if ($this->dbStartTime === null) {
            $sql = "SELECT UNIX_TIMESTAMP() - VARIABLE_VALUE AS dbStart "
                . "FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME = 'Uptime'";
            $row = $this->dbQfq->sql($sql, ROW_EXPECT_1);
            $this->dbStartTime = (int)$row['dbStart'];
        }
        return $this->dbStartTime;
    }

    /**
     * Build a BE-only cache debug box (hit/write). Every box moves itself into one shared,
     * bottom-right stacking container, so multiple caches on a page list vertically instead
     * of overlapping. The box (and the container, once empty) auto-removes after 10s.
     *
     * @param string $label         e.g. 'Cache hit: 2.1' / 'Cache write: 48ms'
     * @param string $color         background color (hit/write differ for quick scanning)
     * @param string $levelLabel    resolved report level (alias or full level)
     * @param string $cacheFilePath
     * @param string $mutedTime     optional muted suffix, e.g. 'ms, write: 48 ms'
     * @return string
     */
    public function cacheDebugBox(string $label, string $color, string $levelLabel, string $cacheFilePath, string $mutedTime = ''): string {
        $shortPath = mb_strlen($cacheFilePath) > 15 ? '…' . mb_substr($cacheFilePath, -15) : $cacheFilePath;

        // Visible (styled) text: label [, muted write-time] | level | shortened path
        $text = htmlspecialchars($label);
        if ($mutedTime !== '') {
            $text .= ', <span style="color:rgba(255,255,255,.6)">' . htmlspecialchars($mutedTime) . '</span>';
        }
        $text .= htmlspecialchars(' | ' . $levelLabel . ' | ' . $shortPath);

        // Plain-text twin for the clipboard: same line, but the FULL untruncated path and no markup
        $copyLine = $label . ($mutedTime !== '' ? ', ' . $mutedTime : '') . ' | ' . $levelLabel . ' | ' . $cacheFilePath;

        $box = '<div title="' . htmlspecialchars($cacheFilePath, ENT_QUOTES) . '"'
            . ' data-copy="' . htmlspecialchars($copyLine, ENT_QUOTES) . '"'
            . ' style="background:' . $color . ';color:white;padding:4px 8px;font:12px/1.4 monospace;'
            . 'border-radius:3px;box-shadow:0 1px 4px rgba(0,0,0,.3);">' . $text . '</div>';

        // First box emits the full overlay (container + toolbar + ONE auto-hide timer);
        // every following box only appends itself -> keeps the markup small.
        if (!$this->cacheDebugJsEmitted) {
            $this->cacheDebugJsEmitted = true;
            return $box . $this->cacheDebugSetupScript();
        }
        return $box . '<script>(function(){var l=document.getElementById("qfqCacheDebugList");'
            . 'if(l)l.appendChild(document.currentScript.previousElementSibling);})();</script>';
    }

    /**
     * One-time JS that builds the fixed cache-debug overlay: a sticky toolbar (copy / pause / close)
     * plus a scrollable message list, and a single timer that hides the whole overlay after a delay.
     * Idempotent on DOM level (re-uses an existing overlay) so multiple qfq elements share one box.
     *
     * @return string
     */
    private function cacheDebugSetupScript(): string {
        $btn = 'background:transparent;border:0;color:#fff;cursor:pointer;font:14px/1;padding:2px 4px;';
        return '<script>(function(){'
            . 'var c=document.getElementById("qfqCacheDebug"),list;'
            . 'if(c){list=document.getElementById("qfqCacheDebugList");}'
            . 'else{'
            .   'c=document.createElement("div");c.id="qfqCacheDebug";'
            .   'c.style.cssText="position:fixed;bottom:5px;right:20px;z-index:9999;display:flex;flex-direction:column;max-width:45vw;max-height:90vh;gap:4px;";'
            .   'var tb=document.createElement("div");'
            .   'tb.style.cssText="flex-shrink:0;display:flex;gap:4px;background:#333;padding:2px 4px;border-radius:3px;";'
            .   'list=document.createElement("div");list.id="qfqCacheDebugList";'
            .   'list.style.cssText="flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:4px;";'
            .   'var mk=function(t,h){var b=document.createElement("button");b.textContent=t;b.title=h;b.style.cssText="' . $btn . '";return b;};'
            .   'var copyBtn=mk("⧉","Copy all messages"),pauseBtn=mk("⏸","Stop auto-hide"),closeBtn=mk("✕","Close");'
            .   'closeBtn.style.display="none";'
            .   'copyBtn.onclick=function(){'
            .     'function fb(x){var a=document.createElement("textarea");a.value=x;a.style.cssText="position:fixed;opacity:0";document.body.appendChild(a);a.select();try{document.execCommand("copy");}catch(e){}a.remove();}'
            .     'var t=[].map.call(list.children,function(m){return m.getAttribute("data-copy");}).join("\n");'
            .     'if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(t).catch(function(){fb(t);});}else{fb(t);}'
            .     'var o=copyBtn.textContent;copyBtn.textContent="✓";setTimeout(function(){copyBtn.textContent=o;},1000);'
            .   '};'
            .   'pauseBtn.onclick=function(){clearTimeout(c._timer);pauseBtn.style.display="none";closeBtn.style.display="";};'
            .   'closeBtn.onclick=function(){c.remove();};'
            .   'tb.appendChild(copyBtn);tb.appendChild(pauseBtn);tb.appendChild(closeBtn);'
            .   'c.appendChild(tb);c.appendChild(list);document.body.appendChild(c);'
            .   'c._timer=setTimeout(function(){c.remove();},' . REPORT_CACHE_DEBUG_TIMEOUT_MS . ');'
            . '}'
            . 'if(list)list.appendChild(document.currentScript.previousElementSibling);'
            . '})();</script>';
    }
}