<?php
/**
 * AcaiAPI — Data access layer
 *
 * Optimized replacement for CocoDB.
 * Uses PDO (via Db singleton), prepared statements, JOIN-based relation
 * loading, and batch upload fetching.
 *
 * Public write API is compatible with CocoDB: insert / update / delete.
 * Public read API (get) is added in later phases.
 */
class AcaiAPI
{
    // ── In-process caches ──────────────────────────────────────────────
    private static $schemaCache            = [];
    private static $queryCaches            = [];   // list/query relation results
    private static $requestGetCache        = [];   // unconditional per-request deduplication

    // ── Public caches (mirror CocoDB interface) ───────────────────────
    public static $getCaches               = [];   // in-memory result cache for get()
    public static $allowedTranslateFields  = null; // DISTINCT fieldName from traducciones
    public static $pluginsConfig           = [];   // aux_plg_config cache

    // ── Configuration ─────────────────────────────────────────────────
    public static $defaultRelationsDepth   = 2;
    public static $storeDebugData          = false;
    public static $debugData               = [];
    public static $trackData               = [];
    public static $backTracePoint          = null;
    public static $noCacheURIS             = [];
    public static $noCacheTABLES           = [];

    // ── Cache flags (mirror CocoDB interface) ─────────────────────────
    public static $redis                    = null;
    public static $redis_expireTime         = 0;
    public static $force_load_cache         = false;
    public static $force_redis              = false;
    public static $force_redis_module       = false; // consumed by external cache layer
    public static $force_redis_html         = false; // consumed by external cache layer
    public static $force_json_cache_uploads = false;


    // ═══════════════════════════════════════════════════════════════════
    // PUBLIC WRITE API
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Insert one or more records into a table.
     *
     * @param string       $table     Table name (without prefix)
     * @param array        $records   Single record or list of records
     * @param array        $functions Associative [fieldName => callable] applied before coercion
     * @param array        $options   See option list below
     * @return int  Number of inserted records, or last inserted ID if return_last_id
     *
     * Options:
     *   prefix                 string   Table prefix override (default: $TABLE_PREFIX)
     *   ignoreSchema           bool     Skip schema validation — trust all fields as-is
     *   forceNum               bool     Allow caller to set the num (PK) manually
     *   preSaveTempId          string   Link pre-uploaded files/translations to the new record
     *   generate_category_metadata bool Rebuild category tree metadata after insert
     *   return_last_id         bool     Return the last auto-increment ID instead of row count
     *   dieBeforeQuery         bool     Dump SQL + params and die (debug)
     */
    static function insert($table, $records, $functions = [], $options = [])
    {
        self::$requestGetCache = [];
        global $TABLE_PREFIX;

        if (!isset($records[0])) $records = [$records];

        $prefix       = isset($options['prefix'])       ? $options['prefix']       : $TABLE_PREFIX;
        $ignoreSchema = !empty($options['ignoreSchema']);
        $forceNum     = !empty($options['forceNum']);

        $schema = [];
        if (!$ignoreSchema) {
            $schema = self::schema($table);
            if (!$schema) self::error(['error' => "Tabla no encontrada: $table"]);
        }

        $db     = Db::getInstance();
        $lastId = 0;
        $count  = 0;

        foreach ($records as $record) {
            list($setParts, $params, $uploadFields) = self::buildSetClause(
                $record, $schema, $functions, $ignoreSchema, true, $forceNum
            );

            if (empty($setParts)) continue;

            $sql = "INSERT INTO `{$prefix}{$table}` SET " . implode(', ', $setParts);

            if (!empty($options['dieBeforeQuery'])) {
                self::error(['info' => $sql, 'params' => $params]);
            }

            $result = $db->execute($sql, $params ? $params : null);
            if ($result === false) {
                self::error(['error' => "Error al insertar en `$table`"]);
                continue;
            }

            $count++;
            $row    = $db->fetch('SELECT LAST_INSERT_ID() AS id');
            $lastId = ($row && isset($row['id'])) ? (int) $row['id'] : 0;

            // Register each upload in the uploads table
            foreach ($uploadFields as $fieldName => $urlPaths) {
                foreach ($urlPaths as $i => $urlPath) {
                    if (!$urlPath) continue;
                    self::insert('uploads', [
                        'urlPath'     => $urlPath,
                        'filePath'    => realpath(__DIR__ . '/../../../' . $urlPath),
                        'fieldName'   => $fieldName,
                        'recordNum'   => $lastId,
                        'tableName'   => $table,
                        'createdTime' => date('Y-m-d H:i:s'),
                        'order'       => time() + $i,
                        'width'       => 640,
                        'height'      => 480,
                    ], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
                }
            }
        }

        // Link uploads/traducciones that were pre-uploaded before the record existed
        if (!empty($options['preSaveTempId'])) {
            $db->execute(
                "UPDATE `{$prefix}uploads`
                    SET recordNum = ?, preSaveTempId = ''
                  WHERE tableName = ? AND preSaveTempId = ?",
                [$lastId, $table, $options['preSaveTempId']]
            );
            $db->execute(
                "UPDATE `{$prefix}traducciones`
                    SET recordNum = ?, preSaveTempId = ''
                  WHERE tableName = ? AND preSaveTempId = ?",
                [$lastId, $table, $options['preSaveTempId']]
            );
        }

        if (!empty($options['generate_category_metadata'])) {
            self::updateCategoryMetadata($table, $prefix);
        }

        return !empty($options['return_last_id']) ? $lastId : $count;
    }

    /**
     * Update records in a table.
     *
     * @param string       $table     Table name (without prefix)
     * @param array        $records   Single record or list of records
     * @param string|array $where     WHERE condition (same formats as buildWhere)
     * @param array        $functions Associative [fieldName => callable]
     * @param array        $options   See option list below
     * @return int  Number of updated records
     *
     * Options (in addition to insert options):
     *   delete_old_uploads   bool       Delete existing uploads for each field before inserting new ones
     *   insert_new_uploads   bool       Insert new upload records after update
     *   delete_upload_nums   int|int[]  Delete specific upload records by their num
     */
    static function update($table, $records, $where, $functions = [], $options = [])
    {
        self::$requestGetCache = [];
        global $TABLE_PREFIX;

        if (!isset($records[0])) $records = [$records];

        $prefix       = isset($options['prefix'])       ? $options['prefix']       : $TABLE_PREFIX;
        $ignoreSchema = !empty($options['ignoreSchema']);

        $schema = [];
        if (!$ignoreSchema) {
            $schema = self::schema($table);
            if (!$schema) self::error(['error' => "Tabla no encontrada: $table"]);
        }

        $db          = Db::getInstance();
        $whereParams = [];
        $whereSql    = self::buildWhere($where, $whereParams);

        if ($whereSql === null || $whereSql === '') return 0;

        $count = 0;

        foreach ($records as $record) {
            $lastNum = isset($record['num']) ? $record['num'] : null;

            list($setParts, $setParams, $uploadFields) = self::buildSetClause(
                $record, $schema, $functions, $ignoreSchema, false, false
            );

            if (empty($setParts)) continue;

            $sql       = "UPDATE `{$prefix}{$table}` SET " . implode(', ', $setParts)
                       . " WHERE " . $whereSql;
            $allParams = array_merge($setParams, $whereParams);

            if (!empty($options['dieBeforeQuery'])) {
                self::error(['info' => $sql, 'params' => $allParams]);
            }

            $result = $db->execute($sql, $allParams ? $allParams : null);
            if ($result === false) {
                self::error(['error' => "Error al actualizar `$table`"]);
                continue;
            }
            $count++;

            // Delete uploads by specific num list
            if ($lastNum && !empty($options['delete_upload_nums'])) {
                $numsToDelete = (array) $options['delete_upload_nums'];
                $ph = implode(',', array_fill(0, count($numsToDelete), '?'));
                $db->execute(
                    "DELETE FROM `{$prefix}uploads` WHERE num IN ($ph)",
                    $numsToDelete
                );
            }

            foreach ($uploadFields as $fieldName => $urlPaths) {
                // Remove all existing uploads for this field+record before inserting
                if ($lastNum && !empty($options['delete_old_uploads'])) {
                    $db->execute(
                        "DELETE FROM `{$prefix}uploads`
                          WHERE fieldName = ? AND recordNum = ? AND tableName = ?",
                        [$fieldName, $lastNum, $table]
                    );
                }

                if ($lastNum && !empty($options['insert_new_uploads'])) {
                    foreach ($urlPaths as $i => $urlPath) {
                        if (!$urlPath) continue;
                        self::insert('uploads', [
                            'urlPath'     => $urlPath,
                            'filePath'    => realpath(__DIR__ . '/../../../' . $urlPath),
                            'fieldName'   => $fieldName,
                            'recordNum'   => $lastNum,
                            'tableName'   => $table,
                            'createdTime' => date('Y-m-d H:i:s'),
                            'order'       => time() + $i,
                            'width'       => 640,
                            'height'      => 480,
                        ], [], ['ignoreSchema' => true, 'prefix' => $prefix]);
                    }
                }
            }
        }

        if (!empty($options['generate_category_metadata'])) {
            self::updateCategoryMetadata($table, $prefix);
        }

        return $count;
    }

    /**
     * Delete records from a table.
     *
     * @param string       $table   Table name (without prefix)
     * @param string|array $where   WHERE condition
     * @param array        $options prefix, dieBeforeQuery
     * @return bool
     */
    static function delete($table, $where, $options = [])
    {
        self::$requestGetCache = [];
        global $TABLE_PREFIX;

        $prefix  = isset($options['prefix']) ? $options['prefix'] : $TABLE_PREFIX;
        $params  = [];
        $whereSql = self::buildWhere($where, $params);

        if (!$whereSql) return false;

        $sql = "DELETE FROM `{$prefix}{$table}` WHERE {$whereSql}";

        if (!empty($options['dieBeforeQuery'])) {
            self::error(['info' => $sql, 'params' => $params]);
        }

        $db = Db::getInstance();
        return $db->execute($sql, $params ? $params : null) !== false;
    }


    // ═══════════════════════════════════════════════════════════════════
    // PUBLIC READ API
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Fetch records from one or more tables.
     *
     * @param string       $table   Table name, or comma-separated list for multi-table
     * @param string|array $where   WHERE condition — string (raw SQL) or array (structured)
     * @param string|null  $order   ORDER BY clause
     * @param mixed        $limit   int | 'offset,limit' | ['limit','offset'|'page'|'perPage']
     * @param array        $options See defaults below
     * @return array  Records array, or [$metaArray, $records] if withMetas=true
     *
     * Options:
     *   debug              bool    Render HTML debug panel after query
     *   translates         string  Language code for automatic translation
     *   uploads            bool    Load uploads for each record (default true)
     *   useAbsoluteUrls    bool    Prefix https://host to urlPath in uploads
     *   groupBy            string  GROUP BY clause
     *   ignoreSchema       bool    Skip schema/relation/upload enrichment
     *   aggregates         array   Extra SELECT expressions e.g. ['COUNT(*) AS n']
     *   relations          bool|array  true=all, ['field1']= only those fields
     *   relationsDepth     int     Recursive relation depth (default 2; 0=none)
     *   redis              bool    Cache this query in Redis
     *   redis_expire       int     Redis TTL in seconds (default 60)
     *   onlyFields         array   Restrict SELECT to these columns
     *   withMetas          bool    Return [$meta, $records] with pagination info
     *   returnDataByKey    string  Index result by this field instead of numeric keys
     *   dieBeforeQuery     bool    Dump SQL and die (debug)
     *   prefix             string  Table prefix override
     */
    static function get($table, $where = null, $order = null, $limit = null, $options = [])
    {
        global $TABLE_PREFIX;

        // ── Option defaults ──────────────────────────────────────────────
        $defaults = [
            'debug'           => false,
            'translates'      => isset($_REQUEST['idioma']) ? $_REQUEST['idioma'] : null,
            'uploads'         => true,
            'useAbsoluteUrls' => false,
            'groupBy'         => null,
            'ignoreSchema'    => false,
            'aggregates'      => [],
            'relations'       => true,
            'redis'           => null,
            'onlyFields'      => null,
            'redis_expire'    => 60,
            'relationsDepth'  => self::$defaultRelationsDepth,
            'dieBeforeQuery'  => false,
            'prefix'          => $TABLE_PREFIX,
            'withMetas'       => false,
            'returnDataByKey' => false,
        ];
        foreach ($defaults as $k => $v) {
            if (!isset($options[$k])) $options[$k] = $v;
        }

        // CocoDB compat alias
        if (!empty($options['ignoreSchemas'])) $options['ignoreSchema'] = true;

        // Recursive calls bottom out at depth -1
        if ((int) $options['relationsDepth'] < 0) return [];

        // Redis force flags
        if (self::$force_redis) {
            if ($options['redis'] === null) $options['redis'] = true;
            if (self::$redis_expireTime)    $options['redis_expire'] = self::$redis_expireTime;
        }
        if ($options['redis']) self::initCache();

        $prefix = $options['prefix'];

        // ── Parse tables ─────────────────────────────────────────────────
        $tables      = array_values(array_map('trim', array_filter(explode(',', $table))));
        $schemas     = [];   // field-only schema (no separators)
        $fullSchemas = [];   // raw schema including meta-keys

        if (!$options['ignoreSchema']) {
            foreach ($tables as $i => $tbl) {
                $tblName = trim(explode(' ', $tbl)[0]);
                $sc = self::schema($tblName);
                if (empty($sc)) { unset($tables[$i]); continue; }

                if (!$order && isset($sc['listPageOrder'])) $order = $sc['listPageOrder'];

                $fullSchemas[$tblName] = $sc;
                $schemas[$tblName]     = array_filter($sc, function ($f) use ($options) {
                    if (!is_array($f) || !isset($f['type'])) return false;
                    if ($f['type'] === 'separator')                return false;
                    if (!$options['uploads'] && $f['type'] === 'upload') return false;
                    return true;
                });
            }
            $tables = array_values($tables);
        }

        if (empty($tables)) return [];

        // ── Build SELECT ──────────────────────────────────────────────────
        $select = $options['onlyFields'] ? (array) $options['onlyFields'] : [];

        if (empty($select)) {
            if (count($tables) === 1) {
                $select = ['*'];
            } else {
                foreach ($tables as $i => $tbl) {
                    $parts    = explode(' ', trim($tbl));
                    $tblName  = $parts[0];
                    $ref      = isset($parts[1]) ? $parts[1] : $prefix . $tblName;
                    if ($i === 0) {
                        $select[] = $ref . '.*';
                    } else {
                        if (!isset($schemas[$tblName])) continue;
                        $cols = array_keys($schemas[$tblName]);
                        $colExprs = array_map(function ($col) use ($ref) {
                            return $ref . '.' . $col . " AS '" . $ref . '.' . $col . "'";
                        }, $cols);
                        $select[] = implode(', ', $colExprs);
                    }
                }
            }
        }

        // Aggregates appended to SELECT
        if (!empty($options['aggregates'])) {
            foreach ((array) $options['aggregates'] as $agg) $select[] = $agg;
        }

        // ── Build FROM ────────────────────────────────────────────────────
        $fromParts = array_map(function ($tbl) use ($prefix) {
            $parts   = explode(' ', trim($tbl));
            $tblName = $parts[0];
            $alias   = isset($parts[1]) ? $parts[1] : null;
            return $alias
                ? '`' . $prefix . $tblName . '` ' . $alias
                : '`' . $prefix . $tblName . '`';
        }, $tables);
        $from = implode(', ', $fromParts);

        // ── Build WHERE ───────────────────────────────────────────────────
        $whereParams = [];
        $whereSql    = self::buildWhere($where, $whereParams);

        // ── Build LIMIT ───────────────────────────────────────────────────
        $metaLimit = 1000000;
        $limitSql  = '';
        if ($limit !== null && $limit !== '') {
            if (is_array($limit)) {
                if (isset($limit['perPage'])) $limit['limit'] = $limit['perPage'];
                if (!isset($limit['limit'])) self::error(['error' => 'Limit array requires a "limit" key']);
                if (isset($limit['page']) && !isset($limit['offset'])) {
                    $limit['offset'] = (max(1, (int) $limit['page']) - 1) * (int) $limit['limit'];
                }
                $metaLimit = (int) $limit['limit'];
                $limitSql  = isset($limit['offset'])
                    ? ((int) $limit['offset']) . ',' . ((int) $limit['limit'])
                    : (string) (int) $limit['limit'];
            } else {
                $limitSql = (string) $limit;
                $metaLimit = (strpos($limit, ',') !== false)
                    ? (int) explode(',', $limit)[1]
                    : (int) $limit;
            }
        }

        // ── Assemble SQL ──────────────────────────────────────────────────
        $sql = 'SELECT ' . implode(', ', $select) . ' FROM ' . $from;
        if ($whereSql)            $sql .= ' WHERE '    . $whereSql;
        if ($options['groupBy'])  $sql .= ' GROUP BY ' . $options['groupBy'];
        if ($order)               $sql .= ' ORDER BY ' . $order;
        $sqlCount = $sql; // used for COUNT(*) — before LIMIT
        if ($limitSql)            $sql .= ' LIMIT '    . $limitSql;

        if ($options['dieBeforeQuery']) self::error(['info' => $sql, 'params' => $whereParams]);

        // ── Cache read ────────────────────────────────────────────────────
        $hashSql = self::cacheHash(md5($sql . json_encode($whereParams) . json_encode($options)));
        if (array_key_exists($hashSql, self::$requestGetCache)) {
            return self::$requestGetCache[$hashSql];
        }
        if (self::shouldCache($table)) {
            if ($options['redis'] && ($cached = self::cacheGet($hashSql))) {
                return json_decode($cached, true);
            }
            if (self::$force_load_cache && isset(self::$getCaches[$hashSql])) {
                return json_decode(self::$getCaches[$hashSql], true);
            }
        }

        // ── Execute ───────────────────────────────────────────────────────
        $db          = Db::getInstance();
        $pdoParams   = $whereParams ? $whereParams : null;
        $microtime   = microtime(true);

        // COUNT only when caller explicitly requests pagination metadata
        $totalRecords = 0;
        if ($options['withMetas']) {
            $countSql = 'SELECT COUNT(*) AS n FROM ' . $from
                . ($whereSql ? ' WHERE ' . $whereSql : '')
                . ($options['groupBy'] ? ' GROUP BY ' . $options['groupBy'] : '');
            $countRow     = $db->fetch($countSql, $pdoParams);
            $totalRecords = $countRow ? (int) $countRow['n'] : 0;
        }

        $rows = $db->executeS($sql, $pdoParams);
        if (!is_array($rows)) $rows = [];

        // ── Hydrate records ───────────────────────────────────────────────
        $firstTableName = trim(explode(' ', $tables[0])[0]);
        $firstSchema    = isset($fullSchemas[$firstTableName]) ? $fullSchemas[$firstTableName] : [];

        // Phase 3: batch-load uploads once for the entire result set
        $uploadsIndex = [];
        if ($options['uploads'] && !empty($firstSchema) && !empty($rows)) {
            $uploadsIndex = self::batchLoadUploads($rows, $firstTableName, $firstSchema, $options, $prefix);
        }

        $records = [];
        foreach ($rows as $row) {
            // Inject uploads from pre-built index (Phase 4); pass even when empty so
            // upload fields are initialized to [] for records with no uploads (matches CocoDB).
            if ($options['uploads'] && !empty($firstSchema)) {
                self::injectUploads($row, $firstTableName, $firstSchema, $uploadsIndex);
            }

            // Resolve text/query relations, decode multitext, set post-process fields (Phases 3 & 5)
            if (!$options['ignoreSchema'] && !empty($firstSchema)) {
                self::hydrateRecord($row, $firstTableName, $firstSchema, $options, $prefix);
            }

            if ($options['returnDataByKey']) {
                $records[$row[$options['returnDataByKey']]] = $row;
            } else {
                $records[] = $row;
            }
        }

        // Batch-resolve list/table relations for the full result set — one IN query per field (Phase 3)
        if (!$options['ignoreSchema'] && !empty($firstSchema) && !empty($records)
            && $options['relations'] !== false
        ) {
            self::batchLoadRelations($records, $firstSchema, $options, $prefix);
        }

        // Apply translations only after uploads and recursive relations have been hydrated.
        // This allows AcaiAPI to resolve all required values in a few batched queries.
        if (!$options['ignoreSchema'] && !empty($records) && !empty($options['translates'])) {
            self::applyTranslations($records, $options['translates']);
        }

        // ── Debug ─────────────────────────────────────────────────────────
        if (self::$storeDebugData) {
            $entry = [
                'hora'    => date('Y-m-d H:i:s') . ' ' . microtime(),
                'query'   => $sql,
                'records' => $records,
                'time'    => microtime(true) - $microtime,
            ];
            if (self::$backTracePoint) $entry['backTracePoint'] = self::$backTracePoint;
            self::$debugData[] = $entry;
        }

        // ── Cache write ───────────────────────────────────────────────────
        if (self::shouldCache($table) && $hashSql) {
            $payload = json_encode($options['withMetas']
                ? [self::buildMeta($totalRecords, $metaLimit), $records]
                : $records
            );
            if ($options['redis']) {
                self::cacheSet($hashSql, $payload, $options['redis_expire']);
            } elseif (self::$force_load_cache) {
                self::$getCaches[$hashSql] = $payload;
            }
        }

        $result = $options['withMetas']
            ? [self::buildMeta($totalRecords, $metaLimit), $records]
            : $records;
        self::$requestGetCache[$hashSql] = $result;
        return $result;
    }

    /** Activate in-memory per-request result cache. */
    static function localCache()
    {
        self::$force_load_cache = true;
    }

    /** Activate Redis cache for all subsequent get() calls. */
    static function fullCache($expireTime = 60)
    {
        self::$force_redis      = true;
        self::$redis_expireTime = $expireTime;
        self::initCache();
    }

    /** Connect to Redis (idempotent). */
    static function initCache()
    {
        $redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
        $redisPort = (int)(getenv('REDIS_PORT') ?: 6379);

        if (!self::$redis) {
            self::$redis = new Redis();
            self::$redis->connect($redisHost, $redisPort);
        } elseif (!self::$redis->isConnected()) {
            self::$redis->connect($redisHost, $redisPort);
        }
    }

    /** Check if cache should be used for a given table name. */
    private static function shouldCache($table)
    {
        global $TABLE_PREFIX;
        if (!self::$noCacheTABLES) return true;
        $full = $TABLE_PREFIX . str_replace($TABLE_PREFIX, '', $table);
        return !in_array($full, self::$noCacheTABLES, true);
    }

    /** Namespaced hash key: host + hash + expireTime. */
    static function cacheHash($string)
    {
        return $_SERVER['HTTP_HOST'] . '_' . $string . '_' . self::$redis_expireTime;
    }

    static function cacheGet($hash)
    {
        if (!self::$redis || !self::$redis->isConnected()) return null;
        return self::$redis->exists($hash) ? self::$redis->get($hash) : null;
    }

    static function cacheSet($hash, $data, $expireTime = null)
    {
        if (!self::$redis || !self::$redis->isConnected()) return;
        if (!$expireTime) $expireTime = self::$redis_expireTime ?: 60;
        self::$redis->set($hash, $data);
        self::$redis->expire($hash, $expireTime);
    }

    /**
     * Returns true if the current URL matches any pattern in $noCacheURIS.
     */
    static function bloquedCacheByURL($url)
    {
        if (!self::$noCacheURIS) return false;
        foreach (self::$noCacheURIS as $pattern) {
            if ($url === $pattern) return true;
            if (@preg_match('/' . $pattern . '/i', $url)) return true;
        }
        return false;
    }

    /**
     * Replaces the security token in cached HTML with the current session token.
     */
    static function replaceHooksToken($html)
    {
        session_start();
        return preg_replace_callback(
            "/var hooksToken(\s)?=(\s)?[\'\"]([a-zA-Z0-9]+)[\'\"]\;/i",
            function ($m) {
                $token = sha1(session_id() . $_SERVER['HTTP_HOST']);
                return "var hooksToken = '$token'; console.log('⚡️ Render cached HTML ⚡️');";
            },
            $html
        );
    }

    // ── Debug / tracking ─────────────────────────────────────────────────

    /** Alias de cacheHash() para compatibilidad con CocoDB::cacheGenerateHash(). */
    static function cacheGenerateHash($string) {
        return self::cacheHash($string);
    }

    /** Renderiza el panel de debug (misma firma que CocoDB::showDebug()). */
    static function showDebug($formated = false, $index = -1) {
        if (!$formated) return json_encode(self::$debugData, JSON_PRETTY_PRINT);
        if (!self::$debugData) return '';
        $result = '<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">'
                . '<div class="container mx-auto border p-4 flex flex-col pb-2">';
        foreach (self::$debugData as $cont => $q) {
            if ($index > -1 && $cont != $index) continue;
            $result .= '<div class="w-full">'
                . '<div class="border p-2 bg-green-200 text-green-800 text-xs border-green-400 mb-2 break-word block overflow-auto">'
                . $q['hora'] . '<br>';
            if (self::$backTracePoint) $result .= self::$backTracePoint . '<br>';
            $result .= $q['query'] . '<br>' . $q['time'] . ' microsegundos<br>'
                . "<button onclick='this.parentNode.parentNode.querySelector(\".info\").classList.toggle(\"hidden\")' class='text-white py-1 px-2 mt-2 mr-1 bg-green-500 hover:bg-green-400 focus:outline-none inline-block'>Ver resultado</button>"
                . "<button onclick='this.parentNode.parentNode.querySelector(\".back\").classList.toggle(\"hidden\")' class='text-white py-1 px-2 mt-2 bg-blue-500 hover:bg-blue-400 focus:outline-none inline-block'>Ver backtrace</button>"
                . '</div>'
                . '<pre class="info hidden bg-gray-200 p-4 text-gray-600 whitespace-normal text-xs">'
                . str_replace('  ', '&nbsp;&nbsp;', nl2br(json_encode($q['records'], JSON_PRETTY_PRINT)))
                . '</pre>'
                . '<pre class="back hidden bg-blue-200 p-4 text-blue-800 whitespace-normal text-xs">'
                . str_replace('  ', '&nbsp;&nbsp;', nl2br(json_encode(debug_backtrace(), JSON_PRETTY_PRINT)))
                . '</pre></div>';
        }
        return $result . '</div>';
    }

    static function setBacktracePoint($string) {
        self::$backTracePoint = $string;
    }

    static function getTrackData() {
        return self::$trackData;
    }

    static function setTrackData($init = false, $type = null, $id = null, $data = []) {
        if ($init) {
            $pushedData = [
                'ip'        => $_SERVER['REMOTE_ADDR'],
                'timestamp' => round(floatval(microtime(true) * 1000), 4),
                'totalTime' => 0,
                'host'      => $_SERVER['HTTP_HOST'],
                'url'       => $_SERVER['REQUEST_URI'],
                'trackData' => [],
            ];
            self::$trackData[] = $pushedData;
        } else {
            $last = count(self::$trackData) - 1;
            $prevTimestamp = self::$trackData[$last]['timestamp'];
            $pushedData = [
                'timestamp'    => round((floatval(microtime(true) * 1000) - $prevTimestamp), 4),
                'type'         => $type,
                'id'           => $id,
                'transferKeys' => !isset($data[0])
                    ? array_keys($data)
                    : (is_array($data[0]) ? array_keys($data[0]) : ['undefined' => $data[0]]),
            ];
            self::$trackData[$last]['trackData'][] = $pushedData;
            self::$trackData[$last]['totalTime']    = $pushedData['timestamp'];
            $prevPercent = 0;
            foreach (self::$trackData[$last]['trackData'] as $c => $td) {
                $pct = ($td['timestamp'] * 100) / self::$trackData[$last]['totalTime'];
                self::$trackData[$last]['trackData'][$c]['percent']      = round($pct, 2);
                self::$trackData[$last]['trackData'][$c]['initPercent']  = $prevPercent;
                self::$trackData[$last]['trackData'][$c]['widthPercent'] = $pct - $prevPercent;
                $prevPercent = $pct;
            }
        }
        return $pushedData;
    }

    /**
     * Carga y cachea la configuración de plugins desde aux_plg_config.
     * Misma firma que CocoDB::getPluginsConfig().
     */
    static function getPluginsConfig($table, $where) {
        global $TABLE_PREFIX;
        if (!self::$pluginsConfig) {
            $db = Db::getInstance();
            $configTable = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table) ?: "aux_plg_config";
            $rows = $db->executeS("SELECT * FROM `".$configTable."` ORDER BY num DESC") ?: [];
            self::$pluginsConfig = [];
            foreach ($rows as $row) {
                if (!isset(self::$pluginsConfig[$row['plugin']])) {
                    self::$pluginsConfig[$row['plugin']] = $row;
                }
            }
        }
        $key = str_replace([' ', "'", '"', 'plugin='], '', strtolower(trim($where)));
        return isset(self::$pluginsConfig[$key]) ? self::$pluginsConfig[$key] : [];
    }

    /**
     * Aplica traducciones a un registro completo.
     * Alias público de applyTranslations() para compatibilidad con
     * CocoDB::t_recursivo().
     */
    static function t_recursivo($record, $idx = null) {
        if (!is_array($record)) return $record;
        self::applyTranslations($record);
        return $record;
    }

    // ── Internal read helpers (stubs — expanded in Phases 3-5) ──────────

    // ═══════════════════════════════════════════════════════════════════
    // PHASE 4 — BATCH UPLOAD LOADING
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Loads all uploads for a result set in ONE query per upload field
     * (vs CocoDB's one query per record).
     *
     * Returns an index structured as:
     *   $index[$fieldName][$recordNum] = [upload_row, upload_row, ...]
     */
    private static function batchLoadUploads(array $rows, $tableName, array $schema, array $options, $prefix)
    {
        // Collect upload field names declared in schema
        $uploadFields = [];
        foreach ($schema as $fieldName => $field) {
            if (is_array($field) && isset($field['type']) && $field['type'] === 'upload') {
                $uploadFields[] = $fieldName;
            }
        }
        if (empty($uploadFields)) return [];

        // Collect all record nums from the result
        $nums = array_filter(array_column($rows, 'num'));
        if (empty($nums)) return [];

        $db         = Db::getInstance();
        $ph         = implode(',', array_fill(0, count($nums), '?'));
        $fieldPh    = implode(',', array_fill(0, count($uploadFields), '?'));
        $selectCols = 'num, `order`, tableName, fieldName, recordNum, filePath, urlPath, '
                    . 'info1, info2, info3, info4, info5, alt';

        $params = array_merge(array_values($nums), [$tableName], array_values($uploadFields));

        $uploads = $db->executeS(
            "SELECT $selectCols
               FROM `{$prefix}uploads`
              WHERE recordNum IN ($ph)
                AND tableName  = ?
                AND fieldName  IN ($fieldPh)
              ORDER BY `order` ASC",
            $params
        );

        if (empty($uploads)) return [];

        // Re-index for O(1) lookup
        $index = [];
        foreach ($uploads as $upload) {
            if ($options['useAbsoluteUrls'] && isset($upload['urlPath'])
                && strpos($upload['urlPath'], '/cms') === 0
            ) {
                $upload['urlPath'] = 'https://' . $_SERVER['HTTP_HOST'] . $upload['urlPath'];
            }
            $field  = $upload['fieldName'];
            $recNum = $upload['recordNum'];
            $index[$field][$recNum][] = $upload;
        }
        return $index;
    }

    /**
     * Injects upload arrays from the pre-built index into a single record row.
     * Each upload field becomes an array of upload objects (same as CocoDB).
     */
    private static function injectUploads(array &$row, $tableName, array $schema, array $uploadsIndex)
    {
        if (!isset($row['num'])) return;
        foreach ($schema as $fieldName => $field) {
            if (!is_array($field) || !isset($field['type']) || $field['type'] !== 'upload') continue;
            $row[$fieldName] = isset($uploadsIndex[$fieldName][$row['num']])
                ? $uploadsIndex[$fieldName][$row['num']]
                : [];
        }
    }


    // ═══════════════════════════════════════════════════════════════════
    // PHASE 3 — RELATION LOADING
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Resolves list relations, decodes multitext, applies translations,
     * and sets breadcrumb helpers on a single record.
     *
     * list/optionsType=text   → pure PHP, no DB
     * list/optionsType=query  → getEvalOutput() with per-query static cache
     * list/optionsType=table  → batch-loaded after the main loop (see batchLoadRelations)
     * multitext               → json_decode → _bd
     */
    private static function hydrateRecord(array &$row, $tableName, array $schema, array $options, $prefix)
    {
        global $TABLE_PREFIX;

        $row['tableName'] = $tableName;

        $relationsFilter  = $options['relations'];
        $relationsDepth   = (int) $options['relationsDepth'];

        foreach ($schema as $fieldName => $field) {
            if (!is_array($field) || !isset($field['type'])) continue;

            // Honour the `relations` option — skip fields not in the whitelist
            if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue;

            $rawValue = isset($row[$fieldName]) ? $row[$fieldName] : null;

            switch ($field['type']) {

                case 'list':
                    $optionsType = isset($field['optionsType']) ? $field['optionsType'] : '';

                    switch ($optionsType) {

                        case 'text':
                            // Parse inline option list: "value|Label\nvalue2|Label2"
                            $lines = array_filter(explode("\n", isset($field['optionsText']) ? $field['optionsText'] : ''));
                            $map   = [];
                            foreach ($lines as $line) {
                                $parts = explode('|', $line);
                                $map[trim($parts[0])] = isset($parts[1]) ? trim($parts[1]) : trim($parts[0]);
                            }
                            $resultDatas = ($rawValue !== null && $rawValue !== '')
                                ? explode("\t", $rawValue)
                                : [];
                            $row[$fieldName . '_bd'] = [];
                            foreach ($resultDatas as $rval) {
                                $rval = trim($rval);
                                if ($rval === '') continue;
                                $row[$fieldName . '_bd'][] = [
                                    'key'   => $rval,
                                    'value' => isset($map[$rval])
                                        ? t_var($map[$rval])
                                        : t_var($rval),
                                ];
                            }
                            break;

                        case 'query':
                            // Evaluates PHP/SQL expression from schema; result is cached statically
                            $query    = function_exists('getEvalOutput')
                                ? getEvalOutput(isset($field['optionsQuery']) ? $field['optionsQuery'] : '')
                                : (isset($field['optionsQuery']) ? $field['optionsQuery'] : '');
                            $cacheKey = md5($query);
                            if (!isset(self::$queryCaches[$cacheKey])) {
                                $db = Db::getInstance();
                                self::$queryCaches[$cacheKey] = $db->executeS($query) ?: [];
                            }
                            // Extract table name from the query for tableName field
                            $tableQuery = null;
                            if (preg_match('/FROM\s+(\S+)/i', $query, $m)) {
                                $tableQuery = str_replace($TABLE_PREFIX, '', trim($m[1]));
                            }
                            $result = array_values(array_filter(
                                self::$queryCaches[$cacheKey],
                                function ($rec) use ($rawValue) {
                                    $firstVal = reset($rec);
                                    return $rawValue == $firstVal;
                                }
                            ));
                            $row[$fieldName . '_bd'] = array_map(function ($rec) use ($tableQuery) {
                                if ($tableQuery) $rec['tableName'] = $tableQuery;
                                return $rec;
                            }, $result);
                            break;

                        case 'table':
                            // Resolved in batchLoadRelations() after the main loop.
                            // Only pre-set the key when relations will actually be loaded;
                            // with depth=0 CocoDB leaves these keys absent entirely.
                            if ($relationsDepth > 0 && !isset($row[$fieldName . '_bd'])) {
                                $row[$fieldName . '_bd'] = [];
                            }
                            break;
                    }
                    break;

                case 'multitext':
                    $row[$fieldName . '_bd'] = $rawValue !== null ? json_decode($rawValue, true) : null;
                    break;
            }
        }

        // ── Post-processing (Phase 5) ─────────────────────────────────────
        self::applyPostProcessing($row, $schema, $options);
    }

    /**
     * Batch-resolves all list/table relations for an entire result set.
     * Called from get() after the per-record loop so we can collect all
     * needed IDs first and issue one IN query per relation field.
     *
     * Mutates $records in place, populating fieldName_bd arrays.
     */
    private static function batchLoadRelations(
        array &$records,
        array $schema,
        array $options,
        $prefix
    ) {
        if (empty($records) || (int) $options['relationsDepth'] <= 0) return;

        $relationsFilter = $options['relations'];
        $subDepth = (int) $options['relationsDepth'] - 1;
        $definitions = [];
        $pools = [];

        // Group fields sharing the same related table and key in one IN query.
        foreach ($schema as $fieldName => $field) {
            if (!is_array($field)) continue;
            if (($field['type'] ?? null) !== 'list' || ($field['optionsType'] ?? '') !== 'table') continue;
            if (is_array($relationsFilter) && !in_array($fieldName, $relationsFilter, true)) continue;

            $relTable = $field['optionsTablename'] ?? null;
            $relValueField = $field['optionsValueField'] ?? 'num';
            $listType = $field['listType'] ?? 'pulldown';
            if (!$relTable) continue;

            $poolKey = $relTable . '|' . $relValueField;
            $definitions[$fieldName] = ['poolKey' => $poolKey, 'listType' => $listType];
            if (!isset($pools[$poolKey])) {
                $pools[$poolKey] = ['table' => $relTable, 'valueField' => $relValueField, 'ids' => [], 'byId' => []];
            }

            foreach ($records as $record) {
                $raw = $record[$fieldName] ?? '';
                $ids = $listType === 'pulldownMulti'
                    ? array_filter(explode("\t", (string) $raw))
                    : (($raw !== null && $raw !== '') ? [$raw] : []);
                foreach ($ids as $id) $pools[$poolKey]['ids'][(string) $id] = true;
            }
        }

        foreach ($pools as &$pool) {
            $ids = array_keys($pool['ids']);
            if (!$ids) continue;
            $related = self::get($pool['table'], $pool['valueField'] . ' IN (' . implode(',', $ids) . ')', null, null, [
                'relationsDepth' => $subDepth,
                'translates' => $options['translates'],
                'uploads' => $options['uploads'],
                'prefix' => $prefix,
            ]);
            foreach ($related as $relatedRecord) {
                if (array_key_exists($pool['valueField'], $relatedRecord)) {
                    $pool['byId'][(string) $relatedRecord[$pool['valueField']]] = $relatedRecord;
                }
            }
        }
        unset($pool);

        foreach ($definitions as $fieldName => $definition) {
            $byId = $pools[$definition['poolKey']]['byId'];
            foreach ($records as &$record) {
                $raw = $record[$fieldName] ?? '';
                if ($definition['listType'] === 'pulldownMulti') {
                    $ids = array_filter(explode("\t", (string) $raw));
                    if (!$ids) {
                        unset($record[$fieldName . '_bd']);
                        continue;
                    }
                    $record[$fieldName . '_bd'] = array_values(array_filter(array_map(
                        function ($id) use ($byId) { return $byId[(string) $id] ?? null; }, $ids
                    )));
                } else {
                    if ($raw === null || $raw === '') {
                        unset($record[$fieldName . '_bd']);
                        continue;
                    }
                    $record[$fieldName . '_bd'] = isset($byId[(string) $raw]) ? [$byId[(string) $raw]] : [];
                }
            }
            unset($record);
        }
    }


    // PHASE 5 — POST-PROCESSING (per record)
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Sets breadcrumb helper fields and applies translations.
     * Runs after relation/upload injection so all data is present.
     */
    private static function applyPostProcessing(array &$row, array $schema, array $options)
    {
        // Breadcrumb metadata
        $breadcrumbField = isset($schema['breadcrumbField']) ? $schema['breadcrumbField'] : '';
        $row['breadcrumbField'] = $breadcrumbField;

        if ($breadcrumbField === 'parentNum') {
            $row['optionsTablename']  = $row['tableName'];
            $row['optionsValueField'] = 'num';
        } elseif ($breadcrumbField && isset($schema[$breadcrumbField])) {
            $row['optionsTablename']  = isset($schema[$breadcrumbField]['optionsTablename'])
                ? $schema[$breadcrumbField]['optionsTablename'] : null;
            $row['optionsValueField'] = isset($schema[$breadcrumbField]['optionsValueField'])
                ? $schema[$breadcrumbField]['optionsValueField'] : null;
        }

        // Main display field for link/breadcrumb generation
        $row['mainFieldBreadcrumb'] = null;
        foreach (['name', 'title', 'titulo', 'nombre'] as $candidate) {
            if (!empty($row[$candidate])) {
                $row['mainFieldBreadcrumb'] = $row[$candidate];
                break;
            }
        }
        if ($row['mainFieldBreadcrumb'] === null) {
            // Fall back to first textfield in schema order
            foreach ($schema as $key => $field) {
                if (!is_array($field)) continue;
                if (isset($field['type']) && $field['type'] === 'textfield' && $key !== 'enlace') {
                    $row['mainFieldBreadcrumb'] = isset($row[$key]) ? $row[$key] : null;
                    break;
                }
            }
        }

    }

    /**
     * Applies translations to a fully hydrated record tree.
     *
     * Required keys are collected first and fetched in batches grouped by
     * language and table. The request-local maps also prevent repeated work
     * when nested get() calls return records later embedded in a parent query.
     */
    private static function applyTranslations(array &$record, $language = null)
    {
        global $TABLE_PREFIX;

        $language = $language ?: (isset($_REQUEST['idioma']) ? $_REQUEST['idioma'] : null);
        if (!$language) return;

        if (self::$allowedTranslateFields === null) {
            $db = Db::getInstance();
            $fields = $db->executeS("SELECT DISTINCT fieldName FROM `{$TABLE_PREFIX}traducciones`");
            self::$allowedTranslateFields = array_flip(array_column($fields ?: [], 'fieldName'));
        }

        // Spanish is the source content: parse it directly and never query
        // cms_traducciones, which only stores en/de/fr in this project.
        if ($language === 'es') {
            self::applyTranslationValues($record, $language, []);
            return;
        }

        static $knownKeys = [];
        static $translatedValues = [];

        $requests = [];
        self::collectTranslationRequests($record, $language, $requests);

        $missing = [];
        foreach ($requests as $key => $meta) {
            if (!isset($knownKeys[$key])) {
                $knownKeys[$key] = true;
                $missing[$key] = $meta;
            }
        }

        if ($missing) {
            $groups = [];
            foreach ($missing as $meta) {
                $groupKey = $meta['tableName'];
                $groups[$groupKey]['recordNums'][$meta['recordNum']] = true;
                $groups[$groupKey]['fieldNames'][$meta['fieldName']] = true;
            }

            $db = Db::getInstance();
            foreach ($groups as $tableName => $group) {
                $fieldNames = array_keys($group['fieldNames']);
                if (!$fieldNames) continue;

                foreach (array_chunk(array_keys($group['recordNums']), 400) as $recordNums) {
                    $recordPh = implode(',', array_fill(0, count($recordNums), '?'));
                    $fieldPh = implode(',', array_fill(0, count($fieldNames), '?'));
                    $params = array_merge([$language, $tableName], $recordNums, $fieldNames);

                    $rows = $db->executeS(
                        "SELECT num, tableName, recordNum, fieldName, uploadNum, fieldValue
                           FROM `{$TABLE_PREFIX}traducciones`
                          WHERE prefix = ?
                            AND tableName = ?
                            AND recordNum IN ($recordPh)
                            AND fieldName IN ($fieldPh)
                          ORDER BY num ASC",
                        $params
                    ) ?: [];

                    foreach ($rows as $row) {
                        $uploadNum = $row['tableName'] === 'uploads'
                            ? (int) $row['uploadNum']
                            : null;
                        $key = self::translationCacheKey(
                            $language,
                            $row['tableName'],
                            (int) $row['recordNum'],
                            $row['fieldName'],
                            $uploadNum
                        );
                        // Preserve the previous LIMIT 1 behaviour when duplicates exist.
                        if (!array_key_exists($key, $translatedValues)) {
                            $translatedValues[$key] = $row['fieldValue'];
                        }
                    }
                }
            }
        }

        self::applyTranslationValues($record, $language, $translatedValues);
    }

    /** Collects unique translation lookup keys from a nested record tree. */
    private static function collectTranslationRequests(array $node, $language, array &$requests)
    {
        foreach ($node as $fieldName => $value) {
            if (is_array($value)) {
                self::collectTranslationRequests($value, $language, $requests);
                continue;
            }
            if (!isset(self::$allowedTranslateFields[$fieldName])) continue;

            $meta = self::translationMeta($node, $fieldName);
            if (!$meta) continue;

            $key = self::translationCacheKey(
                $language,
                $meta['tableName'],
                $meta['recordNum'],
                $meta['fieldName'],
                $meta['uploadNum']
            );
            $requests[$key] = $meta;
        }
    }

    /** Builds the legacy translation identity for regular and upload records. */
    private static function translationMeta(array $record, $fieldName)
    {
        if (empty($record['num']) || empty($record['tableName'])) return null;

        if (!empty($record['urlPath']) || !empty($record['info1'])) {
            return [
                'tableName' => 'uploads',
                'recordNum' => (int) ($record['recordNum'] ?? 0),
                'fieldName' => $fieldName,
                'uploadNum' => (int) $record['num'],
            ];
        }

        return [
            'tableName' => $record['tableName'],
            'recordNum' => (int) $record['num'],
            'fieldName' => $fieldName,
            'uploadNum' => null,
        ];
    }

    /** Creates an unambiguous key for the request-local translation map. */
    private static function translationCacheKey($language, $tableName, $recordNum, $fieldName, $uploadNum)
    {
        return json_encode([$language, $tableName, (int) $recordNum, $fieldName, $uploadNum]);
    }

    /** Applies source or translated values recursively without further SQL. */
    private static function applyTranslationValues(array &$node, $language, array $translatedValues)
    {
        global $hashTraducciones;

        foreach ($node as $fieldName => &$value) {
            if (is_array($value)) {
                self::applyTranslationValues($value, $language, $translatedValues);
                continue;
            }
            if (!isset(self::$allowedTranslateFields[$fieldName])) continue;

            $translated = null;
            $hasTranslation = false;
            $meta = self::translationMeta($node, $fieldName);

            if ($language !== 'es' && $meta) {
                $key = self::translationCacheKey(
                    $language,
                    $meta['tableName'],
                    $meta['recordNum'],
                    $meta['fieldName'],
                    $meta['uploadNum']
                );
                if (array_key_exists($key, $translatedValues)) {
                    $translated = $translatedValues[$key];
                    $hasTranslation = true;
                }
            }

            $rawValue = $hasTranslation ? $translated : $value;
            if ($hasTranslation && function_exists('parsea_texto2')) {
                $rawValue = parsea_texto2($rawValue);
            }
            $value = class_exists('CocoParser')
                ? CocoParser::parsea_codigos_en_linea($rawValue)
                : $rawValue;

            // Keep the legacy per-request t() cache coherent for later direct calls.
            if ($meta) {
                $legacyHash = md5(
                    $meta['uploadNum'] !== null
                        ? $meta['uploadNum'] . $fieldName . 'uploads' . $language . json_encode($_REQUEST)
                        : $meta['recordNum'] . $fieldName . $meta['tableName'] . $language . json_encode($_REQUEST)
                );
                $hashTraducciones[$legacyHash] = $value;
            }

            if (isset($node[$fieldName . '_bd'])
                && is_string($value)
                && substr($value, 0, 1) === '['
                && substr($value, -1) === ']'
            ) {
                $node[$fieldName . '_bd'] = json_decode($value, true);
            }
        }
        unset($value);
    }

    /** Assembles the meta array returned with withMetas=true. */
    private static function buildMeta($totalRecords, $perPage)
    {
        return [
            'totalRecords' => $totalRecords,
            'perPage'      => $perPage,
            'totalPages'   => ceil($totalRecords / max(1, $perPage)),
        ];
    }


    // ═══════════════════════════════════════════════════════════════════
    // WRITE HELPERS (private)
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Builds the SET clause for an INSERT or UPDATE using positional PDO params.
     * Upload fields (type=upload) have no column in the main table and are
     * returned separately for post-processing.
     *
     * @return array  [$setParts, $params, $uploadFields]
     *                $setParts    string[]  e.g. ['`title` = ?', '`num` = NULL', ...]
     *                $params      mixed[]   positional values matching each `?`
     *                $uploadFields array    [fieldName => [urlPath, ...]]
     */
    private static function buildSetClause(
        array $record,
        array $schema,
        array $functions,
        $ignoreSchema,
        $isInsert,
        $forceNum
    ) {
        $now      = date('Y-m-d H:i:s');
        $user     = 1;
        if (class_exists('API') && isset(API::$user['num'])) {
            $user = (int) API::$user['num'] ?: 1;
        }

        $menuType = isset($schema['menuType']) ? $schema['menuType'] : '';

        $setParts     = [];
        $params       = [];
        $uploadFields = [];

        // ── Audit fields — always written, not overridable by the caller ──
        $setParts[] = '`updatedDate` = ?';      $params[] = $now;
        $setParts[] = '`updatedByUserNum` = ?'; $params[] = $user;

        // ── INSERT-only defaults ──────────────────────────────────────────
        if ($isInsert) {
            // Auto-increment num — use literal NULL; forceNum overrides
            if ($forceNum && isset($record['num'])) {
                $setParts[] = '`num` = ?';
                $params[]   = (int) $record['num'];
            } else {
                $setParts[] = '`num` = NULL';
            }

            if (!isset($record['createdDate'])) {
                $setParts[] = '`createdDate` = ?';
                $params[]   = $now;
            }
            if (!isset($record['createdByUserNum'])) {
                $setParts[] = '`createdByUserNum` = ?';
                $params[]   = $user;
            }

            // Category tree initialisation
            if ($menuType === 'category') {
                $catDefaults = [
                    'globalOrder'  => 0,
                    'siblingOrder' => 0,
                    'depth'        => 0,
                    'parentNum'    => 0,
                    'lineage'      => '',
                    'breadcrumb'   => '',
                ];
                foreach ($catDefaults as $field => $default) {
                    if (!isset($record[$field])) {
                        $setParts[] = "`$field` = ?";
                        $params[]   = $default;
                    }
                }
            }

            // Multi table drag-sort initialisation
            if ($menuType === 'multi' && !isset($record['dragSortOrder'])) {
                $setParts[] = '`dragSortOrder` = ?';
                $params[]   = time();
            }
        }

        // ── User-supplied fields ─────────────────────────────────────────
        // num is always protected unless forceNum is set
        $skip = $forceNum ? [] : ['num'];

        foreach ($record as $key => $value) {
            if (in_array($key, $skip, true)) continue;

            $schemaField = isset($schema[$key]) ? $schema[$key] : null;

            // When a schema is available, silently drop fields not declared in it
            if (!$ignoreSchema && $schema && $schemaField === null) continue;

            // Upload fields: no column in the main table — collected for later
            if ($schemaField && isset($schemaField['type']) && $schemaField['type'] === 'upload') {
                $vals = is_array($value) ? $value : array_filter([$value]);
                if ($vals) $uploadFields[$key] = array_values($vals);
                continue;
            }

            // Apply custom per-field callback, or schema-driven type coercion
            if (isset($functions[$key]) && is_callable($functions[$key])) {
                $value = $functions[$key]($value);
            } elseif (!$ignoreSchema && $schemaField) {
                $value = self::coerceValue($value, $schemaField);
            }

            if ($value === null) continue;
            if (is_array($value)) $value = json_encode($value);

            $setParts[] = "`$key` = ?";
            $params[]   = $value;
        }

        return [$setParts, $params, $uploadFields];
    }

    /**
     * Coerces a value to its correct database representation
     * based on the schema field definition.
     */
    private static function coerceValue($value, array $field)
    {
        $type = isset($field['type']) ? $field['type'] : '';

        switch ($type) {
            case 'checkbox':
                // Stored as tinyint 0/1
                return $value ? 1 : 0;

            case 'list':
                // Multi-select lists store values tab-delimited: \t1\t3\t7\t
                if (isset($field['listType']) && $field['listType'] === 'pulldownMulti'
                    && is_array($value)
                ) {
                    return "\t" . implode("\t", $value) . "\t";
                }
                break;

            case 'multitext':
                // Structured repeater stored as JSON
                if (is_array($value)) return json_encode($value);
                break;
        }

        return $value;
    }

    /**
     * Builds a parameterised WHERE clause compatible with CocoDB's parse_where().
     * Populates $params with positional PDO values.
     *
     * Input formats:
     *   null / ''                       → '' (no WHERE)
     *   'num=1 AND visible=1'           → passed through as raw SQL
     *   ['field' => 'value', ...]       → simple equality conditions (AND)
     *   [['column','value','operator',  → structured conditions
     *     'or','not','raw_key'], ...]
     *
     * Operators: =  !=  <  >  <=  >=  LIKE  IN  IS NULL
     * Flags:     or      → join to previous condition with OR (wraps in parentheses)
     *            not     → NOT prefix
     *            raw_key → skip backtick quoting on the column name
     *
     * @param  mixed  $where
     * @param  array  $params  Populated by reference
     * @return string|null  SQL fragment (no WHERE keyword), or null on invalid input
     */
    private static function buildWhere($where, array &$params = [])
    {
        if (!$where && $where !== '0') return '';
        if (is_string($where)) return $where;
        if (!is_array($where)) return '';

        $parts          = [];
        $links          = [];
        $addParenthesis = false;

        foreach ($where as $key => $w) {
            if (is_array($w)) {
                if (!isset($w['column']) || !array_key_exists('value', $w)) return null;

                $operator = strtoupper(isset($w['operator']) ? $w['operator'] : '=');
                $not      = !empty($w['not']) ? 'NOT ' : '';
                $link     = !empty($w['or']) ? 'OR' : 'AND';
                if (!empty($w['or'])) $addParenthesis = true;

                $col = empty($w['raw_key']) ? $w['column'] : '`' . $w['column'] . '`';

                switch ($operator) {
                    case 'LIKE':
                        $parts[]  = "$col {$not}LIKE ?";
                        $params[] = $w['value'];
                        break;

                    case 'IS NULL':
                        // IS NULL / IS NOT NULL take no parameter
                        $parts[] = $not ? "$col IS NOT NULL" : "$col IS NULL";
                        break;

                    case 'IN':
                        $vals = (array) $w['value'];
                        $ph   = implode(',', array_fill(0, count($vals), '?'));
                        $parts[] = "$col {$not}IN ($ph)";
                        foreach ($vals as $v) $params[] = $v;
                        break;

                    default:
                        if (!in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) {
                            $operator = '=';
                        }
                        // "col NOT = ?" is invalid SQL; wrap as NOT (col op ?) instead
                        if ($not && in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) {
                            $parts[] = "NOT ($col $operator ?)";
                        } else {
                            $parts[] = "$col $not$operator ?";
                        }
                        $params[] = $w['value'];
                }

                $links[] = $link;

            } else {
                // Simple associative key => value
                $parts[]  = "`$key` = ?";
                $params[] = $w;
                $links[]  = 'AND';
            }
        }

        if (empty($parts)) return '';

        // Join conditions: links[$i] is the separator BEFORE parts[$i]
        $sql = $parts[0];
        for ($i = 1, $n = count($parts); $i < $n; $i++) {
            $sql .= ' ' . $links[$i] . ' ' . $parts[$i];
        }

        return $addParenthesis ? "($sql)" : $sql;
    }


    // ═══════════════════════════════════════════════════════════════════
    // SCHEMA (private)
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Loads and caches the schema for a table.
     * loadSchema() strips TABLE_PREFIX internally, so both 'blog' and
     * 'cms_blog' resolve to the same file.
     */
    static function schema($table)
    {
        if (!isset(self::$schemaCache[$table])) {
            self::$schemaCache[$table] = loadSchema($table) ?: [];
        }
        return self::$schemaCache[$table];
    }


    // ═══════════════════════════════════════════════════════════════════
    // ERROR HANDLING
    // ═══════════════════════════════════════════════════════════════════

    static function error(array $data = [])
    {
        if (class_exists('API') && class_exists('ApiError')) {
            API::$die = true;
            API::error(new ApiError(json_encode($data)));
        } else {
            die(json_encode($data));
        }
    }


    // ═══════════════════════════════════════════════════════════════════
    // CATEGORY METADATA (adapted from CocoDB, uses PDO)
    // ═══════════════════════════════════════════════════════════════════

    /**
     * Rebuilds globalOrder / siblingOrder / depth / lineage / breadcrumb
     * for all records in a category-type table.
     */
    static function updateCategoryMetadata($table, $prefix = '')
    {
        global $TABLE_PREFIX;
        if (!$prefix) $prefix = $TABLE_PREFIX;

        $fullTable = $prefix . str_replace($prefix, '', $table);
        $schema    = self::schema($fullTable);

        if (!isset($schema['menuType']) || $schema['menuType'] !== 'category') return;

        $db   = Db::getInstance();
        $rows = $db->executeS("SELECT * FROM `$fullTable` ORDER BY globalOrder ASC");

        if (empty($rows)) return;

        $byNum     = [];
        $childNums = [];
        foreach ($rows as $row) {
            $byNum[$row['num']]                   = $row;
            $childNums[(int) $row['parentNum']][] = $row['num'];
        }

        self::_rebuildCategoryBranch([
            'branchParent' => 0,
            'records'      => &$byNum,
            'childNodes'   => $childNums,
        ]);

        foreach ($byNum as $num => $cat) {
            $db->execute(
                "UPDATE `$fullTable`
                    SET globalOrder  = ?,
                        siblingOrder = ?,
                        depth        = ?,
                        lineage      = ?,
                        breadcrumb   = ?
                  WHERE num = ?",
                [
                    $cat['globalOrder'],
                    $cat['siblingOrder'],
                    $cat['depth'],
                    $cat['lineage'],
                    $cat['breadcrumb'],
                    $num,
                ]
            );
        }
    }

    /**
     * Recursive depth-first traversal that assigns order/depth/lineage/breadcrumb.
     */
    private static function _rebuildCategoryBranch(array $args)
    {
        if (!isset($args['globalOrder'])) $args['globalOrder'] = 0;
        if (!isset($args['depth']))       $args['depth']       = 0;
        if (!isset($args['lineage']))     $args['lineage']     = ':';

        $children = isset($args['childNodes'][$args['branchParent']])
            ? $args['childNodes'][$args['branchParent']]
            : [];

        if (empty($children)) return;

        // Sort by current siblingOrder ascending
        $sortable = [];
        foreach ($children as $childNum) {
            $sortable[$childNum] = isset($args['records'][$childNum]['siblingOrder'])
                ? (int) $args['records'][$childNum]['siblingOrder']
                : 0;
        }
        asort($sortable);

        $siblingOrder = 0;
        foreach (array_keys($sortable) as $childNum) {
            $r = &$args['records'][$childNum];

            $r['globalOrder']  = ++$args['globalOrder'];
            $r['siblingOrder'] = ++$siblingOrder;
            $r['depth']        = $args['depth'];
            $r['lineage']      = $args['lineage'] . $childNum . ':';
            $r['breadcrumb']   = isset($args['breadcrumb'])
                ? $args['breadcrumb'] . ' : ' . (isset($r['name']) ? $r['name'] : '')
                : (isset($r['name']) ? $r['name'] : '');

            if (!empty($args['childNodes'][$childNum])) {
                self::_rebuildCategoryBranch([
                    'branchParent' => $childNum,
                    'globalOrder'  => &$args['globalOrder'],
                    'records'      => &$args['records'],
                    'childNodes'   => $args['childNodes'],
                    'depth'        => $args['depth'] + 1,
                    'lineage'      => $r['lineage'],
                    'breadcrumb'   => $r['breadcrumb'],
                ]);
            }
        }
    }
}
