<?php

declare(strict_types=1);

/**
 * CustomEmoji (T46-az) — admin-loaded custom-emoji packs + reply emoji menu.
 *
 * Owner directive: «به بخش‌هایی که ادمین می‌تواند پیام ارسال کند قابلیت ارسال
 * ایموجی کاستوم اضافه شود — لینک پک وارد شود، کل پک یک بار برای همیشه لود
 * بماند و برای همه ادمین‌ها با منوی ارسال ایموجی قابل استفاده باشد.»
 *
 * 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) and stays
 *    loaded until an admin deletes it. Packs are GLOBAL — shared by all
 *    admins, exactly as requested.
 *  - CustomEmoji::apply($text) replaces every loaded pack's base emoji with
 *    <tg-emoji emoji-id="…">BASE</tg-emoji>. 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 Telegram-style emoji menu (draft + grid + pack manager) lives on the
 *    two bot-side admin reply flows (support ticket / withdrawal replies).
 *    The panel gets the same emojis as a picker bar (webPickerHtml) and its
 *    send paths run the same apply() conversion.
 *  - The database always stores the RAW text (base emojis) 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 PICKER_CAP = 80; // max emojis rendered in the panel bar

    private static bool $schemaChecked = false;
    private static ?array $emojiMapCache = null;
    private static bool $pickerJsPrinted = 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");
            self::$schemaChecked = true;
            return true;
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] schema init failed: ' . $e->getMessage());
            return false;
        }
    }

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

    /**
     * Replace every loaded pack's base emoji with a <tg-emoji> tag (base
     * emoji kept as alt text, so non-premium recipients still see the emoji).
     * Texts without pack emojis return unchanged (byte-identical fast path).
     */
    public static function apply(string $text): string
    {
        if ($text === '') {
            return $text;
        }
        $map = self::emojiMap();
        if ($map === []) {
            return $text;
        }
        $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';
        return (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);
    }

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

    /**
     * Resolve a pack link via getStickerSet and (re)store the WHOLE pack.
     * Re-adding an existing short name re-syncs its items (idempotent).
     *
     * @return array{ok:bool,error?:string,updated?:bool,title?:string,count?:int,sample?:array}
     */
    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];
        }
        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;
            } 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`)
                VALUES (:p, :b, :c, :o)");
            foreach ($items as $it) {
                $insItem->execute([':p' => $packId, ':b' => $it['base'], ':c' => $it['id'], ':o' => $it['pos']]);
            }
        } catch (\Throwable $e) {
            error_log('[CustomEmoji] pack store failed: ' . $e->getMessage());
            return ['ok' => false, 'error' => 'db'];
        }
        self::$emojiMapCache = 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];
    }

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

    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();
            $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]);
            self::$emojiMapCache = null;
            return $st->rowCount() > 0;
        } 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
     * AND the panel picker bar.
     * @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;
        }
    }

    // ── 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 ────────────────────────────────────────────────

    /**
     * Server-rendered emoji bar for panel compose forms. Tapping a chip
     * inserts the BASE emoji at the cursor of the first visible textarea in
     * $textareaIds (send paths convert it via CustomEmoji::apply()).
     * Returns '' when no pack is loaded — zero UI noise before the first pack.
     */
    public static function webPickerHtml(array $textareaIds): string
    {
        $items = self::allEmojis(self::PICKER_CAP);
        if ($items === []) {
            return '';
        }
        $ids = array_values(array_filter(array_map('strval', $textareaIds), static fn(string $s): bool => $s !== ''));
        if ($ids === []) {
            return '';
        }
        $jsIds = json_encode($ids, JSON_UNESCAPED_UNICODE);
        $html = '<div class="ce-picker" style="display:flex;flex-wrap:wrap;gap:4px;margin:6px 0 2px;align-items:center;">';
        foreach ($items as $it) {
            $e = (string)$it['base_emoji'];
            $title = htmlspecialchars((string)($it['pack_title'] ?? ''), ENT_QUOTES, 'UTF-8');
            $eJson = json_encode($e, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
            $html .= '<button type="button" class="btn btn-glass btn-xs" title="' . $title . '"'
                . ' onclick="ceInsertTa(' . $jsIds . ', ' . $eJson . ')"'
                . ' style="font-size:16px;line-height:1.2;padding:2px 7px;">' . $e . '</button>';
        }
        $html .= '</div>';
        if (!self::$pickerJsPrinted) {
            self::$pickerJsPrinted = true;
            $html .= '<script>'
                . 'function ceInsertTa(ids, ch){var ta=null;for(var i=0;i<ids.length;i++){var el=document.getElementById(ids[i]);if(el&&el.offsetParent!==null){ta=el;break;}}'
                . 'if(!ta){ta=document.getElementById(ids[0]);}if(!ta){return;}'
                . 'var s=ta.selectionStart||0,e=ta.selectionEnd||0;ta.value=ta.value.slice(0,s)+ch+ta.value.slice(e);'
                . 'var p=s+ch.length;ta.selectionStart=ta.selectionEnd=p;ta.focus();}'
                . '</script>';
        }
        return $html;
    }
}
