<?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\Session;
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 = [];

    /** 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 on a hit, or null on a
     * miss (no cache file yet). On a hit a BE-only cache event is recorded for the Dev Panel and 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 cache event
     * @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;
        }

        $cacheMs = round((hrtime(true) - $cacheState[REPORT_CACHE_START]) / 1e+6, 2);
        $this->recordCacheEvent(REPORT_CACHE_EVENT_TYPE_HIT, $cacheMs, $levelLabel, $cacheFilePath);

        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 $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. Records a BE-only "Cache write" event for the Dev Panel and returns ''.
     *
     * @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 cache event
     * @return string  always '' (the previous debug-box html was replaced by a session event)
     */
    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]);

        $this->recordCacheEvent(REPORT_CACHE_EVENT_TYPE_WRITE, $renderTimeMs, $levelLabel, $cacheState[REPORT_CACHE_FILE_PATH]);
        return '';
    }

    /**
     * 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;
    }

    /**
     * Record a report cache event (hit or write) for the Dev Panel's ReportCache tab.
     * BE-only: front-end visitors never populate the session buffer.
     *
     * @param string $type   REPORT_CACHE_EVENT_TYPE_HIT | REPORT_CACHE_EVENT_TYPE_WRITE
     * @param float  $ms      Duration in milliseconds (read time on hit, render time on write).
     * @param string $level   Resolved report level (alias or full level).
     * @param string $file    Cache file path.
     * @return void
     */
    private function recordCacheEvent(string $type, float $ms, string $level, string $file): void {
        if (!T3Info::beUserLoggedIn()) {
            return;
        }
        Session::fillReportCacheEvent($type, $ms, $level, $file);
    }
}