<?php

declare(strict_types=1);

/**
 * CustomEmoji (T46-az + T46-ba) — admin-loaded custom-emoji packs + emoji menus.
 *
 * Owner directive (T46-az): «به بخش‌هایی که ادمین می‌تواند پیام ارسال کند قابلیت
 * ارسال ایموجی کاستوم اضافه شود — لینک پک وارد شود، کل پک یک بار برای همیشه لود
 * بماند و برای همه ادمین‌ها با منوی ارسال ایموجی قابل استفاده باشد.»
 *
 * Owner directive (T46-ba): «کنار پیام فرستادن‌ها دکمه ایموجی … نباید به شکل
 * ایموجی عادی باشد، خود پک اضافه شود؛ کل فایل‌های tgs داخل پک دانلود و به ترتیب
 * داخل پک اضافه شوند و داخل پنل به شکل واقعی‌شان (tgs) نمایش داده شوند.»
 *
 * Mechanics:
 *  - An admin pastes a pack link (t.me/addemoji/<NAME> or t.me/emoji/<NAME>)
 *    → getStickerSet resolves every sticker's custom_emoji_id → the WHOLE
 *    pack is stored in MySQL (emoji_packs / emoji_pack_items) IN PACK ORDER
 *    (pos column mirrors the set's own order) and stays loaded until an admin
 *    deletes it. Packs are GLOBAL — shared by all admins.
 *  - T46-ba: every sticker's REAL file is also downloaded (getFile → .tgs
 *    Lottie animation or static .webp) into admin/uploads/emoji_packs/<pack>/
 *    and stored in pack order. Downloads run in small background CHUNKS
 *    (panel picker drives them automatically; a 1000-emoji pack can never
 *    block a web request). The panel picker renders each emoji in its REAL
 *    form: TGS files play as live Lottie animations (self-hosted lottie-web),
 *    .webp files render as images; while a file is still downloading the
 *    base emoji shows as a graceful placeholder.
 *  - Insertion token: the panel picker inserts «⟦ce:<custom_emoji_id>⟧» at
 *    the cursor. CustomEmoji::apply() resolves each token to its EXACT pack
 *    emoji (<tg-emoji emoji-id="…">BASE</tg-emoji>) — the pack the admin
 *    picked is the pack that is sent, never a first-match guess. Typed base
 *    emojis still map through emojiMap() as before (bot menu drafts).
 *  - TelegramBot's markdown→HTML converter protects those tags (T46-az added
 *    the closing tag to the protection regex), so they survive the standard
 *    Markdown pipeline, and the plain-text fallback keeps the base emoji
 *    (never a broken message).
 *  - The panel always shows an emoji button next to every composer — even
 *    with zero packs loaded — and the same popup manages packs (add link /
 *    delete / download progress), so the whole feature is self-service from
 *    the panel. The bot-side menu (T46-az) keeps working on the same data.
 *
 * The database always stores the RAW text (base emojis after token
 * resolution) so conversation summaries, message editors and future
 * re-renders stay readable.
 *
 * Schema is created lazily here (same pattern as BroadcastEngine) so
 * Database.php stays untouched. No config.php changes, no new emoji slots —
 * every chrome icon goes through the existing emoji_id(N) pack.
 */

class CustomEmoji
{
    private const PER_PAGE = 20;     // emoji grid rows in the bot menu
    private const CHUNK_SIZE = 12;   // sticker files downloaded per chunk call
    private const DL_MAX_TRIES = 3;  // per-item download attempts before dl_state=2
    private const ITEM_CAP = 3000;   // hard cap for the panel items listing

    private static bool $schemaChecked = false;
    private static ?array $emojiMapCache = null;
    private static ?array $tokenMapCache = null;
    private static bool $pickerAssetsPrinted = false;

    // ── schema (lazy, additive) ─────────────────────────────────────────

    private static function ensureSchema(): bool
    {
        if (self::$schemaChecked) {
            return true;
        }
        try {
            $db = Database::getConnection();
            $db->exec("CREATE TABLE IF NOT EXISTS `emoji_packs` (
                `id` INT AUTO_INCREMENT PRIMARY KEY,
                `short_name` VARCHAR(64) NOT NULL,
                `title` VARCHAR(255) NOT NULL DEFAULT '',
                `added_by` BIGINT NULL DEFAULT NULL,
                `created_at` INT NOT NULL DEFAULT 0,
                UNIQUE INDEX `idx_short_name` (`short_name`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
            $db->exec("CREATE TABLE IF NOT EXISTS `emoji_pack_items` (
                `id` INT AUTO_INCREMENT PRIMARY KEY,
                `pack_id` INT NOT NULL,
                `base_emoji` VARCHAR(64) NOT NULL DEFAULT '',
                `custom_emoji_id` VARCHAR(64) NOT NULL,
                `pos` INT NOT NULL DEFAULT 0,
                INDEX `idx_pack` (`pack_id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
            // T46-ba: real sticker files — stored ON SERVER in pack order.
            self::ensureColumn($db, 'emoji_pack_items', 'file_id',
                "`file_id` VARCHAR(128) NULL DEFAULT NULL");
            self::ensureColumn($db, 'emoji_pack_items', 'file_unique_id',
                "`file_unique_id` VARCHAR(64) NULL DEFAULT NULL");
            self::ensureColumn($db, 'emoji_pack_items', 'is_animated',
                "`is_animated` TINYINT(1) NOT NULL DEFAULT 0");
            self::ensureColumn($db, 'emoji_pack_items', 'file_ext',
                "`file_ext` VARCHAR(8) NULL DEFAULT NULL");
            self::ensureColumn($db, 'emoji_pack_items', 'local_file',
                "`local_file` VARCHAR(191) NULL DEFAULT NULL");
            self::ensureColumn($db, 'emoji_pack_items', 'dl_state',
                "`dl_state` TINYINT(1) NOT NULL DEFAULT 0");
            self::ensureColumn($db, 'emoji_pack_items', 'dl_tries',
                "`dl_tries` TINYINT(3) NOT NULL DEFAULT 0");
            self::$schemaChecked = true;
            return true;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] schema init failed: ' . $e->getMessage());
            return false;
        }
    }

    /** Additive column guard (information_schema, current database only). */
    private static function ensureColumn(\PDO $db, string $table, string $column, string $ddl): void
    {
        $st = $db->prepare(
            "SELECT COUNT(*) FROM information_schema.COLUMNS
             WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c"
        );
        $st->execute([':t' => $table, ':c' => $column]);
        if ((int)$st->fetchColumn() === 0) {
            $db->exec("ALTER TABLE `{$table}` ADD COLUMN {$ddl}");
            error_log("[CustomEmoji] Added column {$table}.{$column}");
        }
    }

    // ── admin gate (any active admin row — packs are shared by all) ─────

    public static function isAdminChatId(int $userId): bool
    {
        if (!self::ensureSchema()) {
            return false;
        }
        try {
            $db = Database::getConnection();
            $st = $db->prepare("SELECT COUNT(*) FROM `admins` WHERE `user_id` = :uid AND `is_active` = 1");
            $st->execute([':uid' => $userId]);
            return (int)$st->fetchColumn() > 0;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] admin check failed: ' . $e->getMessage());
            return false;
        }
    }

    // ── base-emoji → custom-emoji-id map + text conversion ──────────────

    /**
     * base emoji → custom_emoji_id. Ordered by pack (id, pos) — the FIRST
     * pack that defines a base emoji wins duplicates. Per-request cache.
     */
    public static function emojiMap(): array
    {
        if (self::$emojiMapCache !== null) {
            return self::$emojiMapCache;
        }
        self::$emojiMapCache = [];
        if (!self::ensureSchema()) {
            return self::$emojiMapCache;
        }
        try {
            $db = Database::getConnection();
            $st = $db->query("SELECT i.`base_emoji`, i.`custom_emoji_id`
                FROM `emoji_pack_items` i
                JOIN `emoji_packs` p ON p.`id` = i.`pack_id`
                ORDER BY p.`id` ASC, i.`pos` ASC, i.`id` ASC");
            foreach ($st->fetchAll() as $row) {
                $base = (string)$row['base_emoji'];
                $id = (string)$row['custom_emoji_id'];
                if ($base === '' || $id === '' || isset(self::$emojiMapCache[$base])) {
                    continue;
                }
                self::$emojiMapCache[$base] = $id;
            }
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] map load failed: ' . $e->getMessage());
        }
        return self::$emojiMapCache;
    }

    /**
     * T46-ba: custom_emoji_id → base emoji, for resolving picker tokens.
     * The token carries the Telegram-stable custom_emoji_id, so a pack
     * re-sync (delete + reinsert of item rows) never breaks a token.
     */
    private static function tokenMap(): array
    {
        if (self::$tokenMapCache !== null) {
            return self::$tokenMapCache;
        }
        self::$tokenMapCache = [];
        if (!self::ensureSchema()) {
            return self::$tokenMapCache;
        }
        try {
            $st = Database::getConnection()->query(
                "SELECT `custom_emoji_id`, `base_emoji` FROM `emoji_pack_items`"
            );
            foreach ($st->fetchAll() as $row) {
                $id = (string)$row['custom_emoji_id'];
                $base = (string)$row['base_emoji'];
                if ($id !== '' && $base !== '' && !isset(self::$tokenMapCache[$id])) {
                    self::$tokenMapCache[$id] = $base;
                }
            }
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] token map load failed: ' . $e->getMessage());
        }
        return self::$tokenMapCache;
    }

    /**
     * Convert admin text for sending. Two passes, in this exact order:
     *  1) Typed base emojis → their pack's <tg-emoji> (legacy bot-menu path,
     *     first-pack-wins). Runs FIRST so the tokens below are the only
     *     source of exact-pack tags and nothing gets double-wrapped.
     *  2) Picker tokens «⟦ce:<custom_emoji_id>⟧» → that EXACT emoji's
     *     <tg-emoji> tag (base emoji as alt text). A token whose item no
     *     longer exists (pack deleted) is stripped silently.
     * Texts without pack emojis or tokens return unchanged (fast path).
     */
    public static function apply(string $text): string
    {
        if ($text === '') {
            return $text;
        }
        $map = self::emojiMap();
        if ($map !== []) {
            $keys = array_keys($map);
            // Longest byte-length first so VS16 / ZWJ variants win over bare chars.
            usort($keys, static fn(string $a, string $b): int => strlen($b) - strlen($a));
            $rx = '~(' . implode('|', array_map(static fn(string $k): string => preg_quote($k, '~'), $keys)) . ')~u';
            $text = (string)preg_replace_callback($rx, static function (array $m) use ($map): string {
                $id = $map[$m[0]] ?? '';
                if ($id === '' || !ctype_digit($id)) {
                    return $m[0];
                }
                return '<tg-emoji emoji-id="' . $id . '">' . $m[0] . '</tg-emoji>';
            }, $text);
        }
        if (strpos($text, "\u{27E6}ce:") !== false) {
            $tmap = self::tokenMap();
            $text = (string)preg_replace_callback(
                '~\x{27E6}ce:(\d{1,24})\x{27E7}~u',
                static function (array $m) use ($tmap): string {
                    $base = $tmap[$m[1]] ?? '';
                    if ($base === '') {
                        return ''; // pack item is gone — never leak the raw token
                    }
                    return '<tg-emoji emoji-id="' . $m[1] . '">' . $base . '</tg-emoji>';
                },
                $text
            );
        }
        return $text;
    }

    // ── pack storage ────────────────────────────────────────────────────

    /** t.me/addemoji/Name | t.me/emoji/Name | bare short name → name (or null). */
    public static function parsePackName(string $input): ?string
    {
        $input = trim($input);
        if (preg_match('~^(?:https?://)?t\.me/(?:addemoji|emoji)/([A-Za-z0-9_]+)/?$~iu', $input, $m)) {
            return $m[1];
        }
        if (preg_match('~^[A-Za-z0-9_]{3,64}$~', $input)) {
            return $input;
        }
        return null;
    }

    /** Server-side storage dir for a pack's real sticker files. */
    private static function packDir(string $shortName): string
    {
        $safe = preg_replace('/[^A-Za-z0-9_]/', '', $shortName);
        if ($safe === '') {
            $safe = 'pack';
        }
        return __DIR__ . '/../admin/uploads/emoji_packs/' . $safe;
    }

    /** Wipe a pack's downloaded files (re-sync / delete). */
    private static function removePackDir(string $shortName): void
    {
        $dir = self::packDir($shortName);
        if (!is_dir($dir)) {
            return;
        }
        foreach (glob($dir . '/*') ?: [] as $f) {
            if (is_file($f)) {
                @unlink($f);
            }
        }
        @rmdir($dir);
    }

    /**
     * Resolve a pack link via getStickerSet and (re)store the WHOLE pack.
     * Re-adding an existing short name re-syncs its items (idempotent).
     * T46-ba: every sticker's file_id / is_animated / file_unique_id is
     * stored too, and the REAL .tgs/.webp files are downloaded afterwards
     * in small chunks (downloadPendingChunk) — never inline (a 1000-emoji
     * pack must not time out a web request or a bot update).
     *
     * @return array{ok:bool,error?:string,updated?:bool,title?:string,count?:int,sample?:array,pack_id?:int}
     */
    public static function addPackFromLink(TelegramBot $bot, string $input, int $adminId): array
    {
        if (!self::ensureSchema()) {
            return ['ok' => false, 'error' => 'db'];
        }
        $name = self::parsePackName($input);
        if ($name === null) {
            return ['ok' => false, 'error' => 'bad_link'];
        }
        $res = $bot->send('getStickerSet', ['name' => $name]);
        if ($res === null || !isset($res->ok) || !$res->ok) {
            return ['ok' => false, 'error' => 'not_found'];
        }
        $set = $res->result ?? null;
        if ($set === null) {
            return ['ok' => false, 'error' => 'not_found'];
        }
        $stickers = isset($set->stickers) && is_array($set->stickers) ? $set->stickers : [];
        $items = [];
        foreach ($stickers as $i => $st) {
            $ceid = (string)($st->custom_emoji_id ?? '');
            $base = (string)($st->emoji ?? '');
            if ($ceid === '' || !ctype_digit($ceid) || $base === '') {
                continue;
            }
            $items[] = [
                'base' => $base,
                'id' => $ceid,
                'pos' => (int)$i, // pack order — picker renders in this order
                'file_id' => (string)($st->file_id ?? ''),
                'fuid' => (string)($st->file_unique_id ?? ''),
                'anim' => !empty($st->is_animated) ? 1 : 0,
            ];
        }
        if ($items === []) {
            // Regular sticker sets carry no custom_emoji_id — only emoji packs do.
            return ['ok' => false, 'error' => 'not_emoji_pack'];
        }
        $title = mb_substr(trim((string)($set->title ?? $name)), 0, 120);
        try {
            $db = Database::getConnection();
            $st = $db->prepare("SELECT `id` FROM `emoji_packs` WHERE `short_name` = :n");
            $st->execute([':n' => $name]);
            $row = $st->fetch();
            $updated = false;
            if ($row) {
                $packId = (int)$row['id'];
                $db->prepare("UPDATE `emoji_packs` SET `title` = :t WHERE `id` = :i")
                    ->execute([':t' => $title, ':i' => $packId]);
                $db->prepare("DELETE FROM `emoji_pack_items` WHERE `pack_id` = :i")
                    ->execute([':i' => $packId]);
                $updated = true;
                // Re-sync: old downloaded files are stale (item row ids change).
                self::removePackDir($name);
            } else {
                $ins = $db->prepare("INSERT INTO `emoji_packs` (`short_name`, `title`, `added_by`, `created_at`)
                    VALUES (:n, :t, :a, :c)");
                $ins->execute([':n' => $name, ':t' => $title, ':a' => $adminId, ':c' => time()]);
                $packId = (int)$db->lastInsertId();
            }
            $insItem = $db->prepare("INSERT INTO `emoji_pack_items`
                (`pack_id`, `base_emoji`, `custom_emoji_id`, `pos`, `file_id`, `file_unique_id`, `is_animated`, `dl_state`, `dl_tries`)
                VALUES (:p, :b, :c, :o, :f, :u, :a, 0, 0)");
            foreach ($items as $it) {
                $insItem->execute([
                    ':p' => $packId, ':b' => $it['base'], ':c' => $it['id'], ':o' => $it['pos'],
                    ':f' => $it['file_id'], ':u' => $it['fuid'], ':a' => $it['anim'],
                ]);
            }
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] pack store failed: ' . $e->getMessage());
            return ['ok' => false, 'error' => 'db'];
        }
        self::$emojiMapCache = null;
        self::$tokenMapCache = null;
        $sample = array_map(static fn(array $x): string => $x['base'], array_slice($items, 0, 10));
        return [
            'ok' => true, 'updated' => $updated, 'title' => $title,
            'count' => count($items), 'sample' => $sample, 'pack_id' => $packId,
        ];
    }

    /** @return list<array{id:int,short_name:string,title:string,created_at:int,items:int}> */
    public static function listPacks(): array
    {
        if (!self::ensureSchema()) {
            return [];
        }
        try {
            $db = Database::getConnection();
            $st = $db->query("SELECT p.`id`, p.`short_name`, p.`title`, p.`created_at`,
                    (SELECT COUNT(*) FROM `emoji_pack_items` i WHERE i.`pack_id` = p.`id`) AS `items`
                FROM `emoji_packs` p ORDER BY p.`id` ASC");
            return $st->fetchAll();
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] listPacks failed: ' . $e->getMessage());
            return [];
        }
    }

    /**
     * T46-ba: packs with real-file download progress — drives the panel
     * picker's tabs, progress badges and the auto-heal chunk loop.
     * @return list<array{id:int,short_name:string,title:string,items:int,dl_done:int,dl_failed:int}>
     */
    public static function panelPacks(): array
    {
        if (!self::ensureSchema()) {
            return [];
        }
        try {
            $db = Database::getConnection();
            $st = $db->query("SELECT p.`id`, p.`short_name`, p.`title`,
                    COUNT(i.`id`) AS `items`,
                    COALESCE(SUM(i.`dl_state` = 1), 0) AS `dl_done`,
                    COALESCE(SUM(i.`dl_state` = 2), 0) AS `dl_failed`
                FROM `emoji_packs` p
                LEFT JOIN `emoji_pack_items` i ON i.`pack_id` = p.`id`
                GROUP BY p.`id`, p.`short_name`, p.`title`
                ORDER BY p.`id` ASC");
            $out = [];
            foreach ($st->fetchAll() as $r) {
                $out[] = [
                    'id' => (int)$r['id'],
                    'short_name' => (string)$r['short_name'],
                    'title' => (string)$r['title'],
                    'items' => (int)$r['items'],
                    'dl_done' => (int)$r['dl_done'],
                    'dl_failed' => (int)$r['dl_failed'],
                ];
            }
            return $out;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] panelPacks failed: ' . $e->getMessage());
            return [];
        }
    }

    public static function countPacks(): int
    {
        return count(self::listPacks());
    }

    public static function deletePack(int $packId): bool
    {
        if (!self::ensureSchema()) {
            return false;
        }
        try {
            $db = Database::getConnection();
            $short = null;
            $st = $db->prepare("SELECT `short_name` FROM `emoji_packs` WHERE `id` = :pid");
            $st->execute([':pid' => $packId]);
            $row = $st->fetch();
            if ($row) {
                $short = (string)$row['short_name'];
            }
            $db->prepare("DELETE FROM `emoji_pack_items` WHERE `pack_id` = :pid")
                ->execute([':pid' => $packId]);
            $st = $db->prepare("DELETE FROM `emoji_packs` WHERE `id` = :pid");
            $st->execute([':pid' => $packId]);
            $ok = $st->rowCount() > 0;
            if ($ok && $short !== null) {
                self::removePackDir($short); // T46-ba: delete the real files too
            }
            self::$emojiMapCache = null;
            self::$tokenMapCache = null;
            return $ok;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] deletePack failed: ' . $e->getMessage());
            return false;
        }
    }

    // ── emoji listing ───────────────────────────────────────────────────

    /**
     * Flat emoji list ordered by pack (id, pos) — drives the bot menu grid.
     * @return list<array{id:int,base_emoji:string,custom_emoji_id:string,pack_title:string}>
     */
    public static function allEmojis(int $limit = 0): array
    {
        if (!self::ensureSchema()) {
            return [];
        }
        try {
            $db = Database::getConnection();
            $sql = "SELECT i.`id`, i.`base_emoji`, i.`custom_emoji_id`, p.`title` AS `pack_title`
                FROM `emoji_pack_items` i
                JOIN `emoji_packs` p ON p.`id` = i.`pack_id`
                ORDER BY p.`id` ASC, i.`pos` ASC, i.`id` ASC";
            if ($limit > 0) {
                $sql .= ' LIMIT ' . (int)$limit;
            }
            return $db->query($sql)->fetchAll();
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] allEmojis failed: ' . $e->getMessage());
            return [];
        }
    }

    public static function countEmojis(): int
    {
        if (!self::ensureSchema()) {
            return 0;
        }
        try {
            return (int)Database::getConnection()->query("SELECT COUNT(*) FROM `emoji_pack_items`")->fetchColumn();
        } catch (\Throwable $e) {
            return 0;
        }
    }

    public static function itemById(int $id): ?array
    {
        if (!self::ensureSchema()) {
            return null;
        }
        try {
            $db = Database::getConnection();
            $st = $db->prepare("SELECT `id`, `base_emoji`, `custom_emoji_id` FROM `emoji_pack_items` WHERE `id` = :i");
            $st->execute([':i' => $id]);
            $row = $st->fetch();
            return $row ?: null;
        } catch (\Throwable $e) {
            return null;
        }
    }

    /**
     * T46-ba: a pack's items IN PACK ORDER with real-file state — the panel
     * picker grid payload.
     * @return list<array{id:int,base_emoji:string,custom_emoji_id:string,pos:int,file_ext:?string,dl_state:int}>
     */
    public static function packItems(int $packId): array
    {
        if (!self::ensureSchema()) {
            return [];
        }
        try {
            $db = Database::getConnection();
            $st = $db->prepare("SELECT i.`id`, i.`base_emoji`, i.`custom_emoji_id`, i.`pos`,
                    i.`file_ext`, i.`dl_state`
                FROM `emoji_pack_items` i
                WHERE i.`pack_id` = :p
                ORDER BY i.`pos` ASC, i.`id` ASC
                LIMIT " . self::ITEM_CAP);
            $st->execute([':p' => $packId]);
            $out = [];
            foreach ($st->fetchAll() as $r) {
                $out[] = [
                    'id' => (int)$r['id'],
                    'base_emoji' => (string)$r['base_emoji'],
                    'custom_emoji_id' => (string)$r['custom_emoji_id'],
                    'pos' => (int)$r['pos'],
                    'file_ext' => $r['file_ext'] !== null ? (string)$r['file_ext'] : null,
                    'dl_state' => (int)$r['dl_state'],
                ];
            }
            return $out;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] packItems failed: ' . $e->getMessage());
            return [];
        }
    }

    // ── real sticker files (T46-ba) ─────────────────────────────────────

    /**
     * Download up to $max pending sticker files of one pack into
     * admin/uploads/emoji_packs/<pack>/ (real .tgs / .webp, pack order kept
     * via pos). Each .tgs is also cached gunzipped (.json) so the panel
     * picker can render the REAL Lottie animation without per-open CPU.
     * Designed to be called repeatedly (panel picker loop / re-open) until
     * done+failed == total.
     *
     * @return array{pack_id:int,total:int,done:int,failed:int,processed:int}
     */
    public static function downloadPendingChunk(TelegramBot $bot, int $packId, ?int $max = null): array
    {
        $max = $max ?? self::CHUNK_SIZE;
        $max = max(1, min(30, $max));
        $result = ['pack_id' => $packId, 'total' => 0, 'done' => 0, 'failed' => 0, 'processed' => 0];
        if (!self::ensureSchema()) {
            return $result;
        }
        try {
            $db = Database::getConnection();
            $st = $db->prepare("SELECT COUNT(*) AS `total`,
                    COALESCE(SUM(`dl_state` = 1), 0) AS `done`,
                    COALESCE(SUM(`dl_state` = 2), 0) AS `failed`
                FROM `emoji_pack_items` WHERE `pack_id` = :p");
            $st->execute([':p' => $packId]);
            $c = $st->fetch();
            if (!$c) {
                return $result;
            }
            $result['total'] = (int)$c['total'];
            $result['done'] = (int)$c['done'];
            $result['failed'] = (int)$c['failed'];

            $sel = $db->prepare("SELECT `id`, `file_id`, `is_animated` FROM `emoji_pack_items`
                WHERE `pack_id` = :p AND `dl_state` = 0 AND `file_id` <> ''
                ORDER BY `pos` ASC, `id` ASC LIMIT " . $max);
            $sel->execute([':p' => $packId]);
            $rows = $sel->fetchAll();
            if ($rows === []) {
                return $result;
            }
            $short = null;
            $ps = $db->prepare("SELECT `short_name` FROM `emoji_packs` WHERE `id` = :p");
            $ps->execute([':p' => $packId]);
            $pr = $ps->fetch();
            if (!$pr) {
                return $result;
            }
            $short = (string)$pr['short_name'];
            $dir = self::packDir($short);
            if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
                error_log('[CustomEmoji] cannot create pack dir: ' . $dir);
                return $result;
            }
            $upd = $db->prepare("UPDATE `emoji_pack_items`
                SET `local_file` = :lf, `file_ext` = :fx, `dl_state` = 1
                WHERE `id` = :i");
            $fail = $db->prepare("UPDATE `emoji_pack_items`
                SET `dl_tries` = `dl_tries` + 1, `dl_state` = IF(`dl_tries` + 1 >= " . self::DL_MAX_TRIES . ", 2, 0)
                WHERE `id` = :i");
            foreach ($rows as $r) {
                $itemId = (int)$r['id'];
                $result['processed']++;
                try {
                    $gf = $bot->send('getFile', ['file_id' => (string)$r['file_id']]);
                    $path = '';
                    if ($gf !== null && isset($gf->ok) && $gf->ok && isset($gf->result->file_path)) {
                        $path = (string)$gf->result->file_path;
                    }
                    if ($path === '') {
                        $fail->execute([':i' => $itemId]);
                        continue;
                    }
                    $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
                    if (!in_array($ext, ['tgs', 'webp', 'webm', 'png', 'jpg'], true)) {
                        $ext = !empty($r['is_animated']) ? 'tgs' : 'webp';
                    }
                    $bytes = $bot->getFileContents($path);
                    if ($bytes === null || strlen($bytes) < 32) {
                        $fail->execute([':i' => $itemId]);
                        continue;
                    }
                    $fname = 'item_' . $itemId . '.' . $ext;
                    if (@file_put_contents($dir . '/' . $fname, $bytes) === false) {
                        $fail->execute([':i' => $itemId]);
                        continue;
                    }
                    // Pre-render the Lottie JSON cache for .tgs so previews are cheap.
                    if ($ext === 'tgs') {
                        $json = @gzdecode($bytes);
                        if ($json === false || json_decode($json) === null) {
                            $fail->execute([':i' => $itemId]);
                            @unlink($dir . '/' . $fname);
                            continue;
                        }
                        @file_put_contents($dir . '/item_' . $itemId . '.json', $json);
                    }
                    $upd->execute([':lf' => $fname, ':fx' => $ext, ':i' => $itemId]);
                    $result['done']++;
                } catch (\Throwable $e) {
                    error_log('[CustomEmoji] item download failed #' . $itemId . ': ' . $e->getMessage());
                    $fail->execute([':i' => $itemId]);
                }
            }
            // Recount after the chunk.
            $st->execute([':p' => $packId]);
            $c = $st->fetch();
            if ($c) {
                $result['total'] = (int)$c['total'];
                $result['done'] = (int)$c['done'];
                $result['failed'] = (int)$c['failed'];
            }
            return $result;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] download chunk failed: ' . $e->getMessage());
            return $result;
        }
    }

    /**
     * T46-ba: the real preview payload for one item — a Lottie JSON string
     * for .tgs (gunzipped, cached on disk) or raw bytes for image formats.
     * @return array{kind:string,mime:string,body:string}|null
     */
    public static function preview(int $itemId): ?array
    {
        if (!self::ensureSchema()) {
            return null;
        }
        try {
            $db = Database::getConnection();
            $st = $db->prepare("SELECT i.`id`, i.`local_file`, i.`file_ext`, p.`short_name`
                FROM `emoji_pack_items` i JOIN `emoji_packs` p ON p.`id` = i.`pack_id`
                WHERE i.`id` = :i");
            $st->execute([':i' => $itemId]);
            $row = $st->fetch();
            if (!$row || $row['local_file'] === null) {
                return null;
            }
            $dir = self::packDir((string)$row['short_name']);
            $file = basename((string)$row['local_file']); // defensive: no traversal
            $full = $dir . '/' . $file;
            $ext = (string)$row['file_ext'];
            if ($ext === 'tgs') {
                $cache = $dir . '/item_' . (int)$row['id'] . '.json';
                if (is_file($cache) && filesize($cache) > 0) {
                    $json = (string)file_get_contents($cache);
                } elseif (is_file($full)) {
                    $json = @gzdecode((string)file_get_contents($full));
                    if ($json === false) {
                        return null;
                    }
                    @file_put_contents($cache, $json);
                } else {
                    return null;
                }
                return ['kind' => 'lottie', 'mime' => 'application/json', 'body' => $json];
            }
            if (in_array($ext, ['webp', 'png', 'jpg'], true) && is_file($full)) {
                $bytes = (string)file_get_contents($full);
                $mime = $ext === 'webp' ? 'image/webp' : ($ext === 'png' ? 'image/png' : 'image/jpeg');
                return ['kind' => 'image', 'mime' => $mime, 'body' => $bytes];
            }
            return null;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] preview failed #' . $itemId . ': ' . $e->getMessage());
            return null;
        }
    }

    // ── per-admin draft / state (bot_settings keys, same pattern as
    //    pending_support_reply_*) ─────────────────────────────────────────

    private static function draftKey(int $adminId): string
    {
        return 'ce_draft_' . $adminId;
    }

    public static function draftGet(int $adminId): string
    {
        return (string)(Database::getSetting(self::draftKey($adminId)) ?? '');
    }

    public static function draftSet(int $adminId, string $draft): void
    {
        Database::setSetting(self::draftKey($adminId), $draft);
    }

    public static function draftAppend(int $adminId, string $emoji): void
    {
        $cur = self::draftGet($adminId);
        Database::setSetting(self::draftKey($adminId), mb_substr($cur . $emoji, 0, 3600));
    }

    public static function draftPageGet(int $adminId): int
    {
        return max(1, (int)(Database::getSetting('ce_page_' . $adminId) ?? '1'));
    }

    public static function draftPageSet(int $adminId, int $page): void
    {
        Database::setSetting('ce_page_' . $adminId, (string)max(1, $page));
    }

    /** The in-flight admin reply (support ticket / withdrawal), if any. */
    public static function replyContext(int $adminId): ?array
    {
        $t = Database::getSetting('pending_support_reply_' . $adminId);
        if ($t !== null && $t !== '') {
            return ['kind' => 'support', 'id' => (int)$t];
        }
        $w = Database::getSetting('pending_wdraw_reply_' . $adminId);
        if ($w !== null && $w !== '') {
            return ['kind' => 'withdrawal', 'id' => (int)$w];
        }
        return null;
    }

    // ── bot menu views (plain text + inline keyboards) ──────────────────

    /**
     * The Telegram-style emoji menu: draft preview + grid of pack emojis
     * (5 per row, 20 per page, icon = the actual custom emoji) + actions.
     * Text is rendered as PLAIN text (parse mode '') — the draft can contain
     * arbitrary characters and must preview exactly.
     * @return array{text:string,kb:string}
     */
    public static function menuView(int $adminId): array
    {
        $total = self::countEmojis();
        $pages = max(1, (int)ceil($total / self::PER_PAGE));
        $page = min(max(1, self::draftPageGet($adminId)), $pages);
        $items = self::allEmojis();
        $slice = array_slice($items, ($page - 1) * self::PER_PAGE, self::PER_PAGE);
        $draft = self::draftGet($adminId);

        $text = 'ایموجی کاستوم';
        $text .= "\n\n";
        if ($draft !== '') {
            $text .= 'متن فعلی:' . "\n" . mb_substr($draft, 0, 700) . (mb_strlen($draft) > 700 ? ' …' : '');
        } else {
            $text .= 'هنوز ایموجی‌ای انتخاب نشده است.';
        }
        $text .= "\n\n" . 'برای افزودن، روی ایموجی‌ها بزنید؛ انتخاب‌ها به انتهای پاسخ اضافه می‌شوند.';
        if ($total === 0) {
            $text .= "\n\n" . 'هنوز هیچ پکی لود نشده — «افزودن پک» را بزنید و لینک پک را بفرستید.';
        }

        $kb = [];
        $grid = [];
        foreach ($slice as $it) {
            $btn = ['text' => (string)$it['base_emoji'], 'callback_data' => 'ce_t_' . (int)$it['id']];
            $ceid = (string)$it['custom_emoji_id'];
            if ($ceid !== '' && ctype_digit($ceid)) {
                $btn['icon_custom_emoji_id'] = $ceid;
            }
            $grid[] = $btn;
            if (count($grid) === 5) {
                $kb[] = $grid;
                $grid = [];
            }
        }
        if ($grid !== []) {
            $kb[] = $grid;
        }
        $nav = [];
        if ($page > 1) {
            $nav[] = ['text' => 'قبلی', 'callback_data' => 'ce_p_' . ($page - 1)];
        }
        $nav[] = ['text' => $page . ' / ' . $pages, 'callback_data' => 'noop'];
        if ($page < $pages) {
            $nav[] = ['text' => 'بعدی', 'callback_data' => 'ce_p_' . ($page + 1)];
        }
        if ($nav !== []) {
            $kb[] = $nav;
        }
        $kb[] = [
            ['text' => 'ارسال پاسخ', 'callback_data' => 'ce_send', 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(52)],
            ['text' => 'پاک کردن', 'callback_data' => 'ce_clr', 'icon_custom_emoji_id' => emoji_id(32)],
            ['text' => 'بستن', 'callback_data' => 'ce_close', 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(11)],
        ];
        $kb[] = [
            ['text' => 'افزودن پک', 'callback_data' => 'ce_add', 'icon_custom_emoji_id' => emoji_id(14)],
            ['text' => 'پک‌ها (' . self::countPacks() . ')', 'callback_data' => 'ce_m', 'icon_custom_emoji_id' => emoji_id(12)],
        ];
        return ['text' => $text, 'kb' => json_encode(['inline_keyboard' => $kb], JSON_UNESCAPED_UNICODE)];
    }

    /** Loaded-pack manager (shared by all admins). @return array{text:string,kb:string} */
    public static function packsView(): array
    {
        $packs = self::listPacks();
        $text = $packs === [] ? 'هنوز هیچ پکی لود نشده است.' : 'پک‌های لودشده:';
        $kb = [];
        foreach ($packs as $p) {
            $kb[] = [
                ['text' => mb_substr((string)$p['title'], 0, 28) . ' (' . (int)$p['items'] . ')', 'callback_data' => 'noop'],
                ['text' => 'حذف', 'callback_data' => 'ce_del_' . (int)$p['id'], 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)],
            ];
        }
        $kb[] = [['text' => 'افزودن پک', 'callback_data' => 'ce_add', 'icon_custom_emoji_id' => emoji_id(14)]];
        $kb[] = [['text' => 'بازگشت', 'callback_data' => 'ce_back', 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(11)]];
        return ['text' => $text, 'kb' => json_encode(['inline_keyboard' => $kb], JSON_UNESCAPED_UNICODE)];
    }

    /** «لینک پک را بفرستید» instructions. @return array{text:string,kb:string} */
    public static function addPackView(): array
    {
        $text = 'افزودن پک ایموجی' . "\n\n"
            . 'لینک پک را بفرستید؛ مثال:' . "\n"
            . 'https://t.me/addemoji/EmojiPackName' . "\n\n"
            . 'کل پک یک بار لود می‌شود و برای همیشه (تا وقتی خودتان حذفش کنید) برای همه ادمین‌ها قابل استفاده می‌ماند.'
            . "\n\n" . 'برای انصراف، دکمه زیر را بزنید.';
        $kb = json_encode(['inline_keyboard' => [
            [['text' => 'انصراف', 'callback_data' => 'ce_addx', 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(11)]],
        ]], JSON_UNESCAPED_UNICODE);
        return ['text' => $text, 'kb' => $kb];
    }

    /** Delete confirmation. @return array{text:string,kb:string} */
    public static function confirmDeleteView(int $packId): array
    {
        foreach (self::listPacks() as $p) {
            if ((int)$p['id'] === $packId) {
                $text = 'پک «' . mb_substr((string)$p['title'], 0, 60) . '» و همه ' . (int)$p['items'] . ' ایموجی‌اش حذف شود؟';
                $kb = json_encode(['inline_keyboard' => [
                    [
                        ['text' => 'بله، حذف کن', 'callback_data' => 'ce_dely_' . $packId, 'style' => 'danger', 'icon_custom_emoji_id' => emoji_id(32)],
                        ['text' => 'انصراف', 'callback_data' => 'ce_m', 'icon_custom_emoji_id' => emoji_id(11)],
                    ],
                ]], JSON_UNESCAPED_UNICODE);
                return ['text' => $text, 'kb' => $kb];
            }
        }
        return self::packsView();
    }

    // ── shared entry points used by the handlers / panel ────────────────

    /** Keyboard for the admin reply prompts: emoji-menu entry + main-menu back. */
    public static function replyStarterKeyboard(): string
    {
        $kb = [
            [['text' => 'ایموجی کاستوم', 'callback_data' => 'ce', 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(29)]],
            [makeButton('back_to_main', 'برگشت به منوی اصلی', 'back_to_main_menu', 'danger', 11)],
        ];
        return json_encode(['inline_keyboard' => $kb], JSON_UNESCAPED_UNICODE);
    }

    /**
     * Consumed from MessageHandler when ce_waiting_pack_<uid> is set: resolves
     * the pack link and replies with a live rendering preview (HTML mode).
     */
    public static function handlePackLinkMessage(TelegramBot $bot, int $adminId, string $text): void
    {
        if (!self::isAdminChatId($adminId)) {
            return;
        }
        $res = self::addPackFromLink($bot, $text, $adminId);
        if (!$res['ok']) {
            $msg = match ((string)($res['error'] ?? '')) {
                'bad_link' => 'لینک نامعتبر است. لینک پک را مثل https://t.me/addemoji/Name بفرستید یا دوباره «افزودن پک» را بزنید.',
                'not_found' => 'پک پیدا نشد یا خصوصی است. لینک را چک کنید و دوباره «افزودن پک» را بزنید.',
                'not_emoji_pack' => 'این پک، پک استیکر است نه ایموجی کاستوم. لینک یک پک ایموجی (addemoji) را بفرستید.',
                default => 'ذخیره‌سازی پک ناموفق بود. دوباره تلاش کنید.',
            };
            $bot->sendMessage($adminId, $msg, backToMainMenuKeyboard());
            return;
        }
        $sample = '';
        foreach ((array)($res['sample'] ?? []) as $b) {
            $sample .= (string)$b . ' ';
        }
        $htmlTitle = htmlspecialchars((string)$res['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $head = (string)($res['updated'] ?? false) ? 'به‌روزرسانی شد' : 'ذخیره شد';
        $text2 = 'پک «' . $htmlTitle . '» با ' . (int)$res['count'] . ' ایموجی ' . $head
            . ' و برای همه ادمین‌ها فعال است.' . "\n\n" . 'پیش‌نمایش:' . "\n" . $sample;
        // Convert the sample (and any mapped emoji) through the pack map so the
        // preview renders THIS pack, not the fixed config pack.
        $text2 = self::apply($text2);
        $kb = json_encode(['inline_keyboard' => [
            [['text' => 'منوی ایموجی', 'callback_data' => 'ce', 'style' => 'success', 'icon_custom_emoji_id' => emoji_id(29)]],
            [makeButton('back_to_main', 'برگشت به منوی اصلی', 'back_to_main_menu', 'danger', 11)],
        ]], JSON_UNESCAPED_UNICODE);
        $bot->sendMessage($adminId, $text2, $kb, 'HTML');
    }

    // ── panel picker bar (T46-ba) ───────────────────────────────────────

    /**
     * The ALWAYS-VISIBLE emoji button for panel compose forms. T46-ba owner
     * directive: «کنار پیام فرستادن‌ها دکمه ایموجی قرار داده شود» — the button
     * renders even with ZERO packs loaded (its popup then shows the pack
     * manager so the admin can paste a pack link right there). Clicking an
     * emoji in the popup inserts an exact-pack token «⟦ce:<id>⟧» at the
     * cursor of the first visible textarea in $textareaIds; send paths
     * resolve tokens via CustomEmoji::apply(). The popup renders each emoji
     * in its REAL form (animated TGS via self-hosted lottie-web / real .webp)
     * and manages packs (add link / delete / download progress).
     */
    public static function webPickerHtml(array $textareaIds): string
    {
        $ids = array_values(array_filter(array_map('strval', $textareaIds), static fn(string $s): bool => $s !== ''));
        if ($ids === []) {
            return '';
        }
        $targets = htmlspecialchars((string)json_encode($ids, JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8');
        $html = '<button type="button" class="ce-open-btn" data-ce-targets="' . $targets . '"'
            . ' aria-haspopup="dialog" aria-expanded="false" aria-label="منوی ایموجی" title="ایموجی کاستوم">'
            . '<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false">'
            . '<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>'
            . '<circle cx="8.5" cy="10" r="1.4" fill="currentColor"/>'
            . '<circle cx="15.5" cy="10" r="1.4" fill="currentColor"/>'
            . '<path d="M7.6 14.6c1.2 1.5 2.7 2.3 4.4 2.3s3.2-.8 4.4-2.3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>'
            . '</svg><span>ایموجی</span></button>';
        if (!self::$pickerAssetsPrinted) {
            self::$pickerAssetsPrinted = true;
            $v = '2';
            $html .= '<script src="assets/lottie.min.js?v=' . $v . '"></script>'
                . '<script src="assets/emoji-picker.js?v=' . $v . '"></script>';
        }
        return $html;
    }
}
