<?php
/**
 * OpenVPN | L2TP Bot - Centralized Configuration
 * 
 * This file is the single source of truth for all configuration.
 * Both bot and miniapp should use this file.
 * 
 * Environment Variables:
 * - If .env file exists in project root, values will be loaded from there
 * - Otherwise, fallback to hardcoded values below
 */
declare(strict_types=1);

// Mbstring is optional on some shared hosts. The project should not fatal if it is missing.
if (!function_exists('mb_internal_encoding')) {
    function mb_internal_encoding($encoding = null) { return 'UTF-8'; }
}
if (!function_exists('mb_strlen')) {
    function mb_strlen($string, $encoding = null) { return strlen((string)$string); }
}
if (!function_exists('mb_substr')) {
    function mb_substr($string, $start, $length = null, $encoding = null) { return $length === null ? substr((string)$string, (int)$start) : substr((string)$string, (int)$start, (int)$length); }
}
if (!function_exists('mb_strtolower')) {
    function mb_strtolower($string, $encoding = null) { return strtolower((string)$string); }
}
if (!function_exists('mb_strpos')) {
    function mb_strpos($haystack, $needle, $offset = 0, $encoding = null) { return strpos((string)$haystack, (string)$needle, (int)$offset); }
}
if (!function_exists('mb_stripos')) {
    function mb_stripos($haystack, $needle, $offset = 0, $encoding = null) { return stripos((string)$haystack, (string)$needle, (int)$offset); }
}
mb_internal_encoding('UTF-8');

// ═══════════════════════════════════════════════════════════════════════
// Load Environment Variables from .env (if exists)
// ═══════════════════════════════════════════════════════════════════════

/**
 * Load .env file and populate $_ENV
 */
function loadEnv(): void {
    $envFile = __DIR__ . '/../.env';
    
    if (!file_exists($envFile)) {
        return; // No .env file, defaults below will be used
    }
    
    $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    
    foreach ($lines as $line) {
        $line = trim($line);
        
        // Skip comments and empty lines
        if (empty($line) || $line[0] === '#') {
            continue;
        }
        
        // Parse KEY=VALUE
        if (strpos($line, '=') !== false) {
            list($key, $value) = explode('=', $line, 2);
            $key = trim($key);
            $value = trim($value);
            
            // Remove quotes if present
            $value = trim($value, '"\'');
            
            // Store in $_ENV
            $_ENV[$key] = $value;
            putenv("$key=$value");
        }
    }
}

/**
 * Get environment variable with fallback
 */
function env(string $key, $default = null) {
    return $_ENV[$key] ?? getenv($key) ?: $default;
}

// Load .env file first
loadEnv();

// Set timezone from env or default
date_default_timezone_set(env('TIMEZONE', 'Asia/Tehran'));

// ═══════════════════════════════════════════════════════════════════════
// Bot & Admin Configuration (from .env or fallback)
// ═══════════════════════════════════════════════════════════════════════

define('BOT_TOKEN',    env('BOT_TOKEN', '')); // required in .env
define('BOT_USERNAME', env('BOT_USERNAME', '@gemacc_bot'));

define('ADMIN_IDS',      env('ADMIN_IDS', '5516198479'));  // required: comma-separated numeric Telegram admin IDs  // production admins  // required: comma-separated numeric Telegram admin IDs
define('ADMIN_USERNAME', env('ADMIN_USERNAME', '@anyconnect_admin'));
define('ADMIN_NAME',     env('ADMIN_NAME', 'GEM ACC Admin'));

// ═══════════════════════════════════════════════════════════════════════
// Database Configuration (from .env or fallback)
// ═══════════════════════════════════════════════════════════════════════

define('DB_HOST',    env('DB_HOST', 'localhost'));
define('DB_NAME',    env('DB_NAME', 'gemaccir_369'));
define('DB_USER',    env('DB_USER', 'gemaccir_369'));
define('DB_PASS',    env('DB_PASS', 'Alik.1997a'));
define('DB_CHARSET', env('DB_CHARSET', 'utf8mb4'));

// ═══════════════════════════════════════════════════════════════════════
// Shop & Support Configuration (from .env or fallback)
// ═══════════════════════════════════════════════════════════════════════

define('SHOP_NAME',       env('SHOP_NAME', 'GEM ACC'));
define('CHANNEL_NAME',    env('CHANNEL_NAME', env('SHOP_NAME', 'GEM ACC')));
define('SUPPORT_GROUP',   env('SUPPORT_GROUP', '')); // configurable from admin settings; empty disables group button
define('SUPPORT_LINK',    env('ADMIN_USERNAME', '')); // configurable via .env
define('DOMAIN',          env('DOMAIN', 'gemacc.ir'));
define('MINIAPP_URL',     env('MINIAPP_URL', 'https://' . DOMAIN . '/telegram-bot/public/mini_app/'));
define('CARD_NUMBER',     env('DEFAULT_CARD_NUMBER', '6104331062907218')); // Fallback card number
define('CURRENCY_SUFFIX', ' تومان');
define('OPENVPN_CONFIG_URL', env('OPENVPN_CONFIG_URL', 'https://laksho1.com'));
define('L2TP_SERVER', env('L2TP_SERVER', 's8.laksho1.com'));
define('TELEGRAM_SECRET_TOKEN', env('TELEGRAM_SECRET_TOKEN', env('WEBHOOK_SECRET', '')));
define('SWAPWALLET_API_KEY', env('SWAPWALLET_API_KEY', ''));


// ═══════════════════════════════════════════════════════════════════════
// OpenVPN/L2TP System Constants (from .env or fallback)
// ═══════════════════════════════════════════════════════════════════════

define('MAX_SEATS_PER_CREDENTIAL', (int)env('MAX_SEATS_PER_CREDENTIAL', 15));   // حداکثر صندلی هر اکانت
define('SELL_WINDOW_1M_DAYS',      (int)env('SELL_WINDOW_1M_DAYS', 2));        // پنجره فروش یک‌ماهه (روز)
define('SELL_WINDOW_1Y_DAYS',      (int)env('SELL_WINDOW_1Y_DAYS', 10));       // پنجره فروش یک‌ساله (روز)
define('CAPACITY_WARNING_SEATS',   (int)env('CAPACITY_WARNING_SEATS', 10));    // هشدار وقتی صندلی >= این عدد

// ═══════════════════════════════════════════════════════════════════════
// API Endpoint
// ═══════════════════════════════════════════════════════════════════════

define('API', 'https://api.telegram.org/bot' . BOT_TOKEN . '/');

// ═══════════════════════════════════════════════════════════════════════
// Core Functions
// ═══════════════════════════════════════════════════════════════════════

/**
 * Database connection (singleton with persistent connection)
 */
function db(): PDO {
    static $pdo;
    if ($pdo) return $pdo;
    
    $dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET;
    $pdo = new PDO($dsn, DB_USER, DB_PASS, [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
        PDO::ATTR_PERSISTENT         => true,  // اتصال پایدار برای سرعت بیشتر
        PDO::ATTR_TIMEOUT            => 5,     // تایم‌اوت 5 ثانیه
    ]);
    
    // تنظیمات بهینه MySQL
    $pdo->exec("SET SESSION wait_timeout = 28800");
    $pdo->exec("SET SESSION interactive_timeout = 28800");

    // Runtime compatibility repair is expensive on shared hosting because it performs
    // many SHOW/CREATE/ALTER checks. Migrations must be used for normal deploys.
    // Enable only for emergency repair: GEMACC_RUNTIME_SCHEMA_REPAIR=1
    if ((string)env('GEMACC_RUNTIME_SCHEMA_REPAIR', '0') === '1') {
        gemaccEnsureCoreSchema($pdo);
    }

    return $pdo;
}

function gemaccCoreColumnExists(PDO $pdo, string $table, string $column): bool {
    try {
        $st = $pdo->prepare("SHOW COLUMNS FROM `$table` LIKE ?");
        $st->execute([$column]);
        return (bool)$st->fetch();
    } catch (Throwable $e) { return false; }
}

function gemaccCoreTableExists(PDO $pdo, string $table): bool {
    try {
        $st = $pdo->prepare("SHOW TABLES LIKE ?");
        $st->execute([$table]);
        return (bool)$st->fetchColumn();
    } catch (Throwable $e) { return false; }
}

function gemaccCoreExecSilent(PDO $pdo, string $sql): void {
    try { $pdo->exec($sql); }
    catch (Throwable $e) { error_log('[GEMACC_SCHEMA] ' . $e->getMessage() . ' SQL=' . $sql); }
}

function gemaccCoreAddColumn(PDO $pdo, string $table, string $column, string $definition): void {
    if (gemaccCoreTableExists($pdo, $table) && !gemaccCoreColumnExists($pdo, $table, $column)) {
        gemaccCoreExecSilent($pdo, "ALTER TABLE `$table` ADD COLUMN `$column` $definition");
    }
}

function gemaccEnsureCoreSchema(PDO $pdo): void {
    static $done = false;
    if ($done) return;
    $done = true;

    if (!gemaccCoreTableExists($pdo, 'payment_idempotency')) {
        gemaccCoreExecSilent($pdo, "CREATE TABLE IF NOT EXISTS payment_idempotency (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, request_id VARCHAR(128) NOT NULL, scope VARCHAR(40) NOT NULL, order_id INT UNSIGNED DEFAULT NULL, status VARCHAR(30) NOT NULL DEFAULT 'processing', response_json LONGTEXT DEFAULT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uniq_payment_request (user_id, request_id, scope), KEY idx_payment_order (order_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    }

    // Production reliability core tables. Main migrations still own schema changes;
    // this keeps webhook/API alive when the SQL file was imported partially.
    gemaccCoreExecSilent($pdo, "CREATE TABLE IF NOT EXISTS bot_user_states (user_id BIGINT NOT NULL PRIMARY KEY, mode VARCHAR(100) NOT NULL, payload_json LONGTEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, expires_at DATETIME NULL, KEY idx_state_expires (expires_at), KEY idx_state_mode (mode)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    gemaccCoreAddColumn($pdo, 'bot_user_states', 'mode', "VARCHAR(100) NOT NULL DEFAULT ''");
    gemaccCoreAddColumn($pdo, 'bot_user_states', 'payload_json', 'LONGTEXT NULL');
    gemaccCoreAddColumn($pdo, 'bot_user_states', 'created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP');
    gemaccCoreAddColumn($pdo, 'bot_user_states', 'expires_at', 'DATETIME NULL');
    gemaccCoreExecSilent($pdo, "CREATE TABLE IF NOT EXISTS telegram_update_queue (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, update_id BIGINT NULL, update_type VARCHAR(50) NOT NULL DEFAULT 'unknown', payload_json LONGTEXT NOT NULL, payload_hash CHAR(64) NOT NULL, status VARCHAR(30) NOT NULL DEFAULT 'PENDING', attempts INT NOT NULL DEFAULT 0, max_attempts INT NOT NULL DEFAULT 5, last_error TEXT NULL, received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, reserved_at DATETIME NULL, processed_at DATETIME NULL, failed_at DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uniq_tg_update_id (update_id), UNIQUE KEY uniq_tg_payload_hash (payload_hash), KEY idx_tg_status_received (status, received_at), KEY idx_tg_type (update_type)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    gemaccCoreExecSilent($pdo, "CREATE TABLE IF NOT EXISTS referral_rewards (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, referrer_id BIGINT NOT NULL, referred_user_id BIGINT NOT NULL, order_id BIGINT UNSIGNED NOT NULL, amount INT NOT NULL DEFAULT 0, status VARCHAR(30) NOT NULL DEFAULT 'COMPLETED', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(id), UNIQUE KEY uniq_referred_first_order (referred_user_id), KEY idx_referrer_created (referrer_id, created_at), KEY idx_order (order_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    gemaccCoreExecSilent($pdo, "CREATE TABLE IF NOT EXISTS referral_events (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, referrer_id BIGINT NULL, event_type VARCHAR(50) NOT NULL, meta_json LONGTEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(id), KEY idx_user_created (user_id, created_at), KEY idx_referrer_created (referrer_id, created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    gemaccCoreExecSilent($pdo, "CREATE TABLE IF NOT EXISTS resellers (user_id BIGINT NOT NULL PRIMARY KEY, discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0, fixed_discount INT NOT NULL DEFAULT 0, is_active TINYINT(1) NOT NULL DEFAULT 1, notes TEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_resellers_active (is_active)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    gemaccCoreAddColumn($pdo, 'users', 'referrer_id', 'BIGINT DEFAULT NULL');
    gemaccCoreAddColumn($pdo, 'users', 'last_referral_reward_at', 'DATETIME DEFAULT NULL');

    $cols = [
        'plan_categories' => [
            'service_type' => "VARCHAR(30) NOT NULL DEFAULT 'openvpn_l2tp'", 'service_name' => 'VARCHAR(100) DEFAULT NULL', 'months' => 'INT NOT NULL DEFAULT 1', 'traffic_type' => "VARCHAR(20) NOT NULL DEFAULT 'unlimited'", 'traffic_limit_gb' => 'INT UNSIGNED DEFAULT NULL', 'display_order' => 'INT NOT NULL DEFAULT 0', 'sort_order' => 'INT NOT NULL DEFAULT 0', 'deleted_at' => 'DATETIME DEFAULT NULL'
        ],
        'plans' => [
            'category_id' => 'INT UNSIGNED DEFAULT NULL', 'code' => 'VARCHAR(50) DEFAULT NULL', 'months' => 'INT NOT NULL DEFAULT 1', 'seats' => 'INT NOT NULL DEFAULT 1', 'user_count' => 'INT NOT NULL DEFAULT 1', 'protocol' => "VARCHAR(30) NOT NULL DEFAULT 'openvpn_l2tp'", 'service_name' => 'VARCHAR(100) DEFAULT NULL', 'traffic_type' => "VARCHAR(20) NOT NULL DEFAULT 'unlimited'", 'traffic_limit_gb' => 'INT UNSIGNED DEFAULT NULL', 'purchase_price' => 'INT NOT NULL DEFAULT 0', 'display_order' => 'INT NOT NULL DEFAULT 0', 'sort_order' => 'INT NOT NULL DEFAULT 0', 'deleted_at' => 'DATETIME DEFAULT NULL'
        ],
        'credentials' => [
            'username' => 'VARCHAR(128) DEFAULT NULL', 'config_text' => 'MEDIUMTEXT DEFAULT NULL', 'qr_code_file_id' => 'VARCHAR(255) DEFAULT NULL', 'qr_code_path' => 'VARCHAR(500) DEFAULT NULL', 'months' => 'INT NOT NULL DEFAULT 1', 'max_seats' => 'INT NOT NULL DEFAULT 1', 'used_seats' => 'INT NOT NULL DEFAULT 0', 'traffic_type' => "VARCHAR(20) NOT NULL DEFAULT 'unlimited'", 'traffic_limit_gb' => 'INT UNSIGNED DEFAULT NULL', 'assigned_user_id' => 'BIGINT DEFAULT NULL', 'assigned_order_id' => 'INT UNSIGNED DEFAULT NULL', 'first_connected_at' => 'TIMESTAMP NULL DEFAULT NULL', 'first_connection_at' => 'DATETIME DEFAULT NULL', 'expires_at' => 'DATETIME DEFAULT NULL', 'plan_id' => 'INT UNSIGNED DEFAULT NULL', 'protocol' => "VARCHAR(30) NOT NULL DEFAULT 'openvpn_l2tp'", 'sellable_until' => 'DATETIME DEFAULT NULL', 'sales_paused' => 'TINYINT(1) NOT NULL DEFAULT 0', 'is_sold' => 'TINYINT(1) NOT NULL DEFAULT 0', 'status' => "VARCHAR(30) NOT NULL DEFAULT 'available'", 'sold_to_user_id' => 'BIGINT DEFAULT NULL', 'order_id' => 'BIGINT DEFAULT NULL', 'sold_at' => 'DATETIME DEFAULT NULL', 'delivered_at' => 'DATETIME DEFAULT NULL', 'disabled_at' => 'DATETIME DEFAULT NULL', 'deleted_at' => 'DATETIME DEFAULT NULL', 'warning_5d_sent' => 'TINYINT(1) NOT NULL DEFAULT 0', 'capacity_warn_sent' => 'TINYINT(1) NOT NULL DEFAULT 0', 'created_by' => 'BIGINT DEFAULT NULL'
        ],
        'deliveries' => [
            'username' => 'VARCHAR(128) DEFAULT NULL', 'password' => 'VARCHAR(255) DEFAULT NULL', 'config_text' => 'MEDIUMTEXT DEFAULT NULL', 'qr_code_file_id' => 'VARCHAR(255) DEFAULT NULL', 'qr_code_path' => 'VARCHAR(500) DEFAULT NULL', 'service_type' => "VARCHAR(30) NOT NULL DEFAULT 'openvpn_l2tp'", 'traffic_type' => "VARCHAR(20) NOT NULL DEFAULT 'unlimited'", 'traffic_limit_gb' => 'INT UNSIGNED DEFAULT NULL', 'status' => "VARCHAR(30) NOT NULL DEFAULT 'delivered'"
        ],
        'orders' => [
            'plan_id' => 'INT UNSIGNED DEFAULT NULL', 'category_id' => 'INT UNSIGNED DEFAULT NULL', 'plan_code' => 'VARCHAR(50) DEFAULT NULL', 'plan_title' => 'VARCHAR(255) DEFAULT NULL', 'service_type' => "VARCHAR(30) NOT NULL DEFAULT 'openvpn_l2tp'", 'duration' => "VARCHAR(20) NOT NULL DEFAULT '1M'", 'months' => 'INT NOT NULL DEFAULT 1', 'requested_seats' => 'INT NOT NULL DEFAULT 1', 'traffic_type' => "VARCHAR(20) NOT NULL DEFAULT 'unlimited'", 'traffic_limit_gb' => 'INT UNSIGNED DEFAULT NULL', 'required_accounts' => 'INT NOT NULL DEFAULT 1', 'delivered_accounts' => 'INT NOT NULL DEFAULT 0', 'price' => 'INT NOT NULL DEFAULT 0', 'purchase_price' => 'INT NOT NULL DEFAULT 0', 'payment_method' => "VARCHAR(30) NOT NULL DEFAULT 'CARD'", 'payment_status' => "VARCHAR(30) NOT NULL DEFAULT 'pending'", 'crypto_payment_id' => 'BIGINT UNSIGNED DEFAULT NULL', 'delivery_status' => "VARCHAR(30) NOT NULL DEFAULT 'pending'", 'receipt_file_id' => 'VARCHAR(255) DEFAULT NULL', 'receipt_text' => 'TEXT DEFAULT NULL', 'activated_at' => 'DATETIME DEFAULT NULL', 'first_connection_at' => 'DATETIME DEFAULT NULL', 'expires_at' => 'DATETIME DEFAULT NULL', 'approved_at' => 'TIMESTAMP NULL DEFAULT NULL', 'rejected_at' => 'TIMESTAMP NULL DEFAULT NULL', 'delivered_at' => 'TIMESTAMP NULL DEFAULT NULL', 'reminder_29d_sent' => 'TINYINT(1) NOT NULL DEFAULT 0'
        ],
        'wallet_transactions' => [
            'payment_method' => "VARCHAR(30) DEFAULT 'card'", 'crypto_payment_id' => 'BIGINT UNSIGNED DEFAULT NULL', 'currency' => "VARCHAR(20) NOT NULL DEFAULT 'IRT'", 'receipt_file_id' => 'VARCHAR(255) DEFAULT NULL', 'receipt_text' => 'TEXT DEFAULT NULL', 'processed_by' => 'BIGINT DEFAULT NULL', 'processed_at' => 'TIMESTAMP NULL DEFAULT NULL'
        ],
        'crypto_payments' => [
            'wallet_transaction_id' => 'INT UNSIGNED DEFAULT NULL', 'payment_type' => "VARCHAR(30) NOT NULL DEFAULT 'order'", 'provider_order_id' => 'VARCHAR(255) DEFAULT NULL', 'payment_url' => 'TEXT DEFAULT NULL', 'receipt_file_id' => 'VARCHAR(255) DEFAULT NULL', 'receipt_text' => 'TEXT DEFAULT NULL', 'processed_by' => 'BIGINT DEFAULT NULL', 'processed_at' => 'DATETIME DEFAULT NULL', 'crypto_currency' => 'VARCHAR(20) DEFAULT NULL', 'network' => 'VARCHAR(50) DEFAULT NULL', 'wallet_address' => 'TEXT DEFAULT NULL', 'raw_request' => 'LONGTEXT DEFAULT NULL', 'raw_response' => 'LONGTEXT DEFAULT NULL', 'paid_at' => 'DATETIME DEFAULT NULL'
        ],
        'discount_codes' => [
            'payment_scope' => "VARCHAR(20) NOT NULL DEFAULT 'all'", 'broadcast_target' => 'VARCHAR(30) DEFAULT NULL', 'broadcast_message' => 'TEXT DEFAULT NULL', 'broadcast_file_id' => 'VARCHAR(255) DEFAULT NULL', 'broadcast_file_type' => 'VARCHAR(30) DEFAULT NULL'
        ],
        'tutorials' => [
            'service_type' => "VARCHAR(30) NOT NULL DEFAULT 'openvpn_l2tp'", 'media_file_id' => 'VARCHAR(255) DEFAULT NULL', 'media_type' => 'VARCHAR(30) DEFAULT NULL'
        ]
    ];
    foreach ($cols as $table => $map) {
        foreach ($map as $column => $definition) gemaccCoreAddColumn($pdo, $table, $column, $definition);
    }

    gemaccCoreExecSilent($pdo, "ALTER TABLE orders MODIFY status VARCHAR(30) NOT NULL DEFAULT 'PENDING'");
    gemaccCoreExecSilent($pdo, "ALTER TABLE credentials MODIFY duration VARCHAR(20) NOT NULL DEFAULT '1M'");
    gemaccCoreExecSilent($pdo, "ALTER TABLE plans MODIFY duration VARCHAR(20) NOT NULL DEFAULT '1M'");
}

/**
 * Check if user is admin
 */
function adminIds(): array {
    $rawList = [];
    $rawList[] = (string)(defined('ADMIN_IDS') ? ADMIN_IDS : env('ADMIN_IDS', ''));
    try {
        $pdo = db();
        foreach (['admin_id','admin_extra_ids'] as $key) {
            $st = $pdo->prepare("SELECT value FROM settings WHERE key_name=? LIMIT 1");
            $st->execute([$key]);
            $v = $st->fetchColumn();
            if ($v !== false) $rawList[] = (string)$v;
        }
    } catch (Throwable $e) {}
    $ids = [];
    foreach ($rawList as $raw) {
        foreach (preg_split('/[,\s]+/', (string)$raw) as $id) {
            $id = trim($id);
            if ($id !== '' && preg_match('/^\d+$/', $id)) $ids[] = $id;
        }
    }
    return array_values(array_unique($ids));
}

/**
 * Check admin status.
 * Production-safe behavior:
 * 1) ADMIN_IDS from .env always grants admin.
 * 2) users.is_admin=1 also grants admin, so imported admins do not lose access
 *    when ADMIN_IDS is missing/mistyped.
 */
function isAdmin($uid): bool {
    $uid = (string)(int)$uid;
    if ($uid === '0') return false;

    // ADMIN_IDS inside .env is the only absolute admin source.
    // A reseller must never become admin just because users.is_admin=1 was left from old data.
    if (in_array($uid, adminIds(), true)) return true;

    try {
        $pdo = db();
        try {
            $rs = $pdo->prepare('SELECT is_active FROM resellers WHERE user_id = ? LIMIT 1');
            $rs->execute([$uid]);
            if ((int)$rs->fetchColumn() === 1) return false;
        } catch (Throwable $ignored) {}

        $stmt = $pdo->prepare('SELECT is_admin FROM users WHERE id = ? LIMIT 1');
        $stmt->execute([$uid]);
        return (int)$stmt->fetchColumn() === 1;
    } catch (Throwable $e) {
        return false;
    }
}

/**
 * Format money with currency suffix
 */
function money(int $amount): string {
    return number_format($amount) . CURRENCY_SUFFIX;
}



/**
 * Telegram Bot API button style helper.
 * Scope: Telegram Bot reply_markup only. Mini App/Web App UI is not touched.
 *
 * Supported values:
 * - primary: blue, neutral/navigation/info actions
 * - success: green, purchase/payment/confirm/submit actions
 * - danger: red, cancellation/delete/reject/destructive actions
 *
 * The project sends Telegram requests as raw JSON in tg(), so the style field is preserved.
 * If the target Bot API/client rejects the new field, tg() will retry once after stripping style.
 */
function telegramButtonStyleFor(string $text = '', ?string $callbackData = null): string {
    $haystack = mb_strtolower($text . ' ' . (string)$callbackData, 'UTF-8');

    // Destructive/cancel actions first so they win over generic navigation/back words.
    $dangerKeywords = [
        'لغو', 'انصراف', 'حذف', 'پاک', 'رد', 'reject', 'delete', 'remove', 'cancel',
        'خروج', 'destructive', 'لغو پرداخت', 'حذف سفارش', 'remove_discount',
        'ADMIN_DELETE', 'DELETE_', 'REJECT_', 'CANCEL_', 'REMOVE_', 'EXIT'
    ];
    foreach ($dangerKeywords as $kw) {
        if ($kw !== '' && mb_stripos($haystack, mb_strtolower($kw, 'UTF-8'), 0, 'UTF-8') !== false) {
            return 'danger';
        }
    }

    $successKeywords = [
        'خرید', 'پرداخت', 'واریز', 'شارژ', 'افزایش موجودی', 'تایید', 'تأیید', 'ثبت',
        'ادامه', 'تمدید', 'اعمال', 'ذخیره', 'ساخت', 'ایجاد', 'اضافه', 'افزودن',
        'کارت به کارت', 'ارز دیجیتال', 'کیف پول', 'submit', 'confirm', 'approve',
        'buy', 'purchase', 'payment', 'pay', 'topup', 'top_up', 'renew', 'apply',
        'ADMIN_APPROVE', 'APPROVE_', 'ADD_', 'CREATE_', 'SAVE_', 'PAY_', 'BUY_'
    ];
    foreach ($successKeywords as $kw) {
        if ($kw !== '' && mb_stripos($haystack, mb_strtolower($kw, 'UTF-8'), 0, 'UTF-8') !== false) {
            return 'success';
        }
    }

    // Everything informational/navigation defaults to blue.
    return 'primary';
}

function telegramStyleButton(array $button): array {
    // Preserve callback_data, url, web_app and every existing field. Only add style when omitted.
    if (!isset($button['style']) && isset($button['text'])) {
        $button['style'] = telegramButtonStyleFor((string)$button['text'], isset($button['callback_data']) ? (string)$button['callback_data'] : null);
    }
    return $button;
}

function telegramStyleMarkup(array $markup): array {
    foreach (['keyboard', 'inline_keyboard'] as $keyboardKey) {
        if (!isset($markup[$keyboardKey]) || !is_array($markup[$keyboardKey])) {
            continue;
        }
        foreach ($markup[$keyboardKey] as $rowIndex => $row) {
            if (!is_array($row)) continue;
            foreach ($row as $buttonIndex => $button) {
                if (is_array($button)) {
                    $markup[$keyboardKey][$rowIndex][$buttonIndex] = telegramStyleButton($button);
                }
            }
        }
    }
    return $markup;
}

function telegramStripUnsupportedButtonFields(array $markup): array {
    $validButtonKeys = [
        'text'=>true, 'url'=>true, 'callback_data'=>true, 'web_app'=>true,
        'login_url'=>true, 'switch_inline_query'=>true,
        'switch_inline_query_current_chat'=>true, 'switch_inline_query_chosen_chat'=>true,
        'callback_game'=>true, 'pay'=>true,
        'request_contact'=>true, 'request_location'=>true, 'request_poll'=>true,
        'request_users'=>true, 'request_chat'=>true,
    ];

    foreach (['keyboard', 'inline_keyboard'] as $keyboardKey) {
        if (!isset($markup[$keyboardKey]) || !is_array($markup[$keyboardKey])) continue;
        foreach ($markup[$keyboardKey] as $rowIndex => $row) {
            if (!is_array($row)) continue;
            foreach ($row as $buttonIndex => $button) {
                if (!is_array($button)) continue;
                $clean = [];
                foreach ($button as $k => $v) {
                    if (isset($validButtonKeys[$k])) $clean[$k] = $v;
                }
                if (!isset($clean['text']) && isset($button['text'])) $clean['text'] = (string)$button['text'];
                $markup[$keyboardKey][$rowIndex][$buttonIndex] = $clean;
            }
        }
    }
    return $markup;
}

/**
 * Send Telegram API request (optimized for speed)
 */
function tg(string $method, array $params = []) {
    static $ch = null;
    
    // استفاده مجدد از اتصال CURL برای سرعت بیشتر
    if ($ch === null) {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST           => true,
            CURLOPT_CONNECTTIMEOUT => 3,  // تایم‌اوت سریع‌تر
            CURLOPT_TIMEOUT        => 10, // تایم‌اوت کلی کمتر
            CURLOPT_TCP_NODELAY    => true, // ارسال فوری بدون بافرینگ
            CURLOPT_TCP_KEEPALIVE  => 1,    // حفظ اتصال
            CURLOPT_FORBID_REUSE   => false,
            CURLOPT_FRESH_CONNECT  => false,
        ]);
    }
    
    curl_setopt($ch, CURLOPT_URL, API . $method);
    
    // Telegram API: when we send JSON, reply_markup must be an object/array, not a JSON string.
    // Older parts of this project pass json_encode($keyboard). Decode it here so reply keyboards show correctly.
    if (isset($params['reply_markup']) && is_string($params['reply_markup'])) {
        $decodedMarkup = json_decode($params['reply_markup'], true);
        if (json_last_error() === JSON_ERROR_NONE && is_array($decodedMarkup)) {
            $params['reply_markup'] = $decodedMarkup;
        }
    }

    // Apply semantic Telegram Bot button styles centrally. This preserves all existing actions and callback_data.
    if (isset($params['reply_markup']) && is_array($params['reply_markup'])) {
        $params['reply_markup'] = telegramStyleMarkup($params['reply_markup']);
    }

    $params = tgNormalizeMessagePayload($params);

    // Handle file uploads.
    // IMPORTANT: Telegram multipart requests still expect complex fields like
    // reply_markup as JSON strings. If we pass a PHP array together with CURLFile,
    // Telegram/PHP drops or breaks the inline keyboard. This caused broadcast
    // messages with image + text + buttons to arrive without the buttons.
    $localFileField = null;
    foreach (['photo','document','video','animation','audio','voice'] as $candidateField) {
        if (isset($params[$candidateField]) && is_string($params[$candidateField]) && is_file($params[$candidateField])) {
            $localFileField = $candidateField;
            break;
        }
    }
    if ($localFileField !== null) {
        $params[$localFileField] = new CURLFile($params[$localFileField]);
        foreach ($params as $field => $value) {
            if ($value instanceof CURLFile) continue;
            if (is_array($value) || is_object($value)) {
                $params[$field] = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            }
        }
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: multipart/form-data']);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
    } else {
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }
    
    $res = curl_exec($ch);
    if ($res === false) {
        error_log('TG_CURL_ERR: ' . curl_error($ch));
        return null;
    }
    
    $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    
    if ($code !== 200) {
        $j = json_decode($res, true);
        $desc = is_array($j) ? ($j['description'] ?? '') : '';

        // If this Bot API endpoint/client rejects the new button style field,
        // retry once with the old safe markup. This keeps the colorful menu where
        // supported, without killing /start on unsupported environments.
        if ($code === 400 && $desc && isset($params['reply_markup']) && is_array($params['reply_markup'])) {
            $isStyleMarkupError = stripos($desc, 'style') !== false
                || stripos($desc, 'unsupported') !== false
                || stripos($desc, 'unexpected field') !== false
                || stripos($desc, 'BUTTON_TYPE_INVALID') !== false
                || stripos($desc, 'reply markup') !== false
                || stripos($desc, 'keyboard') !== false;
            if ($isStyleMarkupError) {
                $retryParams = $params;
                $retryParams['reply_markup'] = telegramStripUnsupportedButtonFields($retryParams['reply_markup']);
                curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($retryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
                $retry = curl_exec($ch);
                if ($retry !== false) {
                    $retryCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
                    if ($retryCode === 200) {
                        return json_decode($retry, true);
                    }
                    error_log('TG_STYLE_FALLBACK_HTTP_' . $retryCode . ': ' . $retry);
                } else {
                    error_log('TG_STYLE_FALLBACK_CURL_ERR: ' . curl_error($ch));
                }
            }
        }

        // 400: پاسخ‌های تکراری/بی‌تغییر را لاگ نکنیم (هنگ/اسپم لاگ)
        if ($code === 400 && $desc) {
            if (strpos($desc, 'query is too old') !== false || strpos($desc, 'query ID is invalid') !== false) {
                return $j;
            }
            if (strpos($desc, 'message is not modified') !== false) {
                // محتوای پیام تغییری نکرده؛ این خطا عادی است
                return $j;
            }

            // اگر Markdown به‌خاطر کاراکترهای یوزرنیم/پسورد یا ** قدیمی خطا داد،
            // همان پیام را بدون parse_mode یک‌بار دیگر بفرست تا دکمه‌ها از کار نیفتند.
            $isParseError = stripos($desc, "can't parse entities") !== false
                || stripos($desc, 'parse') !== false
                || stripos($desc, 'entity') !== false;
            if ($isParseError && isset($params['parse_mode'])) {
                $retryParams = $params;
                unset($retryParams['parse_mode']);
                curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($retryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
                $retry = curl_exec($ch);
                if ($retry !== false) {
                    $retryCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
                    if ($retryCode === 200) {
                        return json_decode($retry, true);
                    }
                    error_log('TG_RETRY_HTTP_' . $retryCode . ': ' . $retry);
                } else {
                    error_log('TG_RETRY_CURL_ERR: ' . curl_error($ch));
                }
            }
        }

        // 403: کاربر ربات را بلاک کرده/حذف شده است؛ در ارسال‌های گروهی لاگ را شلوغ نکنیم
        if ($code === 403 && $desc) {
            if (strpos($desc, 'bot was blocked by the user') !== false || strpos($desc, 'user is deactivated') !== false) {
                return $j;
            }
        }

        error_log('TG_HTTP_' . $code . ': ' . $res);
    }
    
    return json_decode($res, true);
}

/**
 * Convert Gregorian date to Jalali (Shamsi)
 */
function jdate($format, $timestamp = null) {
    if ($timestamp === null) $timestamp = time();
    
    $g_year  = (int)date('Y', $timestamp);
    $g_month = (int)date('m', $timestamp);
    $g_day   = (int)date('d', $timestamp);
    
    $g_days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    $j_days_in_month = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
    
    $gy = $g_year - 1600;
    $gm = $g_month - 1;
    $gd = $g_day - 1;
    
    $g_day_no = 365 * $gy + floor(($gy + 3) / 4) - floor(($gy + 99) / 100) + floor(($gy + 399) / 400);
    
    for ($i = 0; $i < $gm; ++$i) {
        $g_day_no += $g_days_in_month[$i];
    }
    if ($gm > 1 && (($gy % 4 == 0 && $gy % 100 != 0) || ($gy % 400 == 0))) {
        $g_day_no++;
    }
    $g_day_no += $gd;
    
    $j_day_no = $g_day_no - 79;
    $j_np = floor($j_day_no / 12053);
    $j_day_no = $j_day_no % 12053;
    
    $jy = 979 + 33 * $j_np + 4 * floor($j_day_no / 1461);
    $j_day_no %= 1461;
    
    if ($j_day_no >= 366) {
        $jy += floor(($j_day_no - 1) / 365);
        $j_day_no = ($j_day_no - 1) % 365;
    }
    
    for ($i = 0; $i < 11 && $j_day_no >= $j_days_in_month[$i]; ++$i) {
        $j_day_no -= $j_days_in_month[$i];
    }
    $jm = $i + 1;
    $jd = $j_day_no + 1;
    
    $jmonths = ['', 'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
                'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
    
    if ($format == 'd/m') {
        $result = sprintf("%02d/%s", $jd, $jmonths[$jm]);
    } elseif ($format == 'Y/m/d') {
        $result = sprintf("%04d/%02d/%02d", $jy, $jm, $jd);
    } elseif ($format == 'Y-m-d H:i') {
        $hour = date('H', $timestamp);
        $minute = date('i', $timestamp);
        $result = sprintf("%04d/%02d/%02d %s:%s", $jy, $jm, $jd, $hour, $minute);
    } else {
        $result = sprintf("%02d/%s/%04d", $jd, $jmonths[$jm], $jy);
    }
    
    $english = ['0','1','2','3','4','5','6','7','8','9'];
    $persian = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
    
    return str_replace($english, $persian, $result);
}

/**
 * Settings cache (per-request)
 */
$_SETTINGS_CACHE = [];

/**
 * Get setting from database (with caching)
 */
function getSetting(string $key, $default = null) {
    global $_SETTINGS_CACHE;
    
    // کش در حافظه
    if (isset($_SETTINGS_CACHE[$key])) {
        return $_SETTINGS_CACHE[$key];
    }
    
    try {
        $pdo = db();
        $stmt = $pdo->prepare("SELECT value FROM settings WHERE key_name = ?");
        $stmt->execute([$key]);
        $val = $stmt->fetchColumn();
        $result = $val !== false ? $val : $default;
        
        $_SETTINGS_CACHE[$key] = $result;
        return $result;
    } catch (Exception $e) {
        return $default;
    }
}

/**
 * Save setting to database
 */
function setSetting(string $key, $value): bool {
    global $_SETTINGS_CACHE;
    
    try {
        $pdo = db();
        $stmt = $pdo->prepare("INSERT INTO settings (key_name, value, updated_at) 
                               VALUES (?, ?, NOW()) 
                               ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()");
        $result = $stmt->execute([$key, $value]);
        
        // بروزرسانی کش
        if ($result) {
            $_SETTINGS_CACHE[$key] = $value;
        }
        
        return $result;
    } catch (Exception $e) {
        error_log("setSetting error: " . $e->getMessage());
        return false;
    }
}

/**
 * Get card number from database (or fallback to constant)
 */
function getCardNumber(): string {
    return getSetting('card_number', CARD_NUMBER);
}

/**
 * Parse rotating bank cards from settings. Each line format:
 * card_number|owner|bank_name
 */
function gemaccParseBankCards(?string $raw = null): array {
    $raw = $raw === null ? getSetting('bank_cards', '') : $raw;
    $cards = [];
    foreach (preg_split('/\R+/u', (string)$raw) ?: [] as $line) {
        $line = trim($line);
        if ($line === '') continue;
        $parts = array_map('trim', explode('|', $line));
        $number = preg_replace('/[^0-9]/', '', $parts[0] ?? '');
        if (strlen($number) !== 16) continue;
        $cards[] = [
            'number' => $number,
            'owner'  => $parts[1] ?? getSetting('card_owner_name', ''),
            'bank'   => $parts[2] ?? '',
        ];
    }
    if (!$cards) {
        $number = preg_replace('/[^0-9]/', '', getSetting('card_number', CARD_NUMBER));
        if ($number !== '') $cards[] = ['number'=>$number, 'owner'=>getSetting('card_owner_name',''), 'bank'=>''];
    }
    return $cards;
}

function gemaccFormatCardNumber(string $number): string {
    $n = preg_replace('/[^0-9]/', '', $number);
    return trim(chunk_split($n, 4, ' '));
}

/**
 * Get next bank card for card-to-card payment. Rotates only when requested.
 */
function gemaccGetPaymentCard(bool $rotate = true): array {
    $cards = gemaccParseBankCards();
    if (!$cards) return ['number'=>'', 'owner'=>'', 'bank'=>''];
    $idx = max(0, (int)getSetting('bank_cards_index', '0'));
    $card = $cards[$idx % count($cards)];
    if ($rotate && count($cards) > 1) {
        setSetting('bank_cards_index', (string)(($idx + 1) % count($cards)));
    }
    return $card;
}


/**
 * Get duration label
 */
function getDurationLabel(string $duration): string {
    if (preg_match('/^(\d+)M$/', $duration, $m)) {
        $n = (int)$m[1];
        return $n === 1 ? 'یک‌ماهه' : $n . ' ماهه';
    }
    if ($duration === '1Y' || $duration === '12M') return 'یک‌ساله';
    return $duration;
}

/**
 * Notify all admins
 */
function notifyAdmins(string $text, ?array $keyboard = null): void {
    $admins = function_exists('adminIds') ? adminIds() : array_filter(array_map('trim', explode(',', ADMIN_IDS)));
    
    foreach ($admins as $adminId) {
        $params = [
            'chat_id'    => $adminId,
            'text'       => $text,
            'parse_mode' => 'Markdown',
        ];
        
        if ($keyboard) {
            $params['reply_markup'] = json_encode($keyboard, JSON_UNESCAPED_UNICODE);
        }
        
        tg('sendMessage', $params);
    }
}


/**
 * v27.43 - Central admin operation confirmation.
 * Sends a Telegram confirmation to all admins after sensitive admin actions
 * performed from the bot or the Mini App. Kept dependency-light so it can be
 * called safely from every endpoint.
 */
if (!function_exists('gemaccAdminActionNotify')) {
    function gemaccAdminActionNotify(string $title, array $details = [], ?int $actorId = null, ?array $keyboard = null): void {
        try {
            $esc = function($v): string {
                return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
            };
            $fmt = function($v) use ($esc): string {
                if (is_bool($v)) return $v ? 'بله' : 'خیر';
                if ($v === null) return '-';
                if (is_array($v) || is_object($v)) {
                    $v = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                }
                $v = trim((string)$v);
                if ($v === '') return '-';
                if (mb_strlen($v, 'UTF-8') > 700) $v = mb_substr($v, 0, 700, 'UTF-8') . '...';
                return $esc($v);
            };
            $maskKeys = ['password','pass','new_password','old_password','token','api_key','secret'];
            $actorLabel = $actorId ? ('<code>' . $esc((string)$actorId) . '</code>') : '-';
            if ($actorId) {
                try {
                    $pdo = function_exists('db') ? db() : null;
                    if ($pdo instanceof PDO) {
                        $st = $pdo->prepare("SELECT username, first_name, last_name FROM users WHERE id=? LIMIT 1");
                        $st->execute([$actorId]);
                        $u = $st->fetch(PDO::FETCH_ASSOC);
                        if ($u) {
                            $name = trim((string)($u['first_name'] ?? '') . ' ' . (string)($u['last_name'] ?? ''));
                            $un = trim((string)($u['username'] ?? ''));
                            $label = $name !== '' ? $name : ($un !== '' ? '@'.$un : (string)$actorId);
                            $actorLabel = $esc($label) . ' | <code>' . $esc((string)$actorId) . '</code>';
                        }
                    }
                } catch (Throwable $e) {}
            }
            $lines = [];
            $lines[] = "✅ <b>تأیید عملیات ادمین</b>";
            $lines[] = "🔔 <b>" . $esc($title) . "</b>";
            $lines[] = "👤 ادمین: " . $actorLabel;
            $lines[] = "🕒 زمان: <code>" . $esc(date('Y-m-d H:i:s')) . "</code>";
            foreach ($details as $k => $v) {
                $key = trim((string)$k);
                if ($key === '') continue;
                $low = strtolower($key);
                foreach ($maskKeys as $mk) {
                    if (strpos($low, $mk) !== false) { $v = '***'; break; }
                }
                $lines[] = "• <b>" . $esc($key) . ":</b> " . $fmt($v);
            }
            $text = implode("\n", $lines);
            $admins = function_exists('adminIds') ? adminIds() : array_filter(array_map('trim', explode(',', defined('ADMIN_IDS') ? ADMIN_IDS : '')));
            foreach ($admins as $adminId) {
                if (!$adminId) continue;
                $params = [
                    'chat_id' => (int)$adminId,
                    'text' => $text,
                    'parse_mode' => 'HTML',
                    'disable_web_page_preview' => true,
                ];
                if ($keyboard) $params['reply_markup'] = $keyboard;
                try { tg('sendMessage', $params); } catch (Throwable $e) { error_log('[admin_action_notify_send] '.$e->getMessage()); }
            }
            try {
                $pdo = function_exists('db') ? db() : null;
                if ($pdo instanceof PDO) {
                    $pdo->exec("CREATE TABLE IF NOT EXISTS admin_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, admin_id BIGINT NULL, action VARCHAR(100) NOT NULL, details LONGTEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
                    $cols = [];
                    try {
                        foreach ($pdo->query("SHOW COLUMNS FROM admin_logs") ?: [] as $c) $cols[(string)$c['Field']] = true;
                    } catch (Throwable $e) {}
                    $json = json_encode(['title'=>$title,'details'=>$details], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
                    if (isset($cols['admin_user_id'])) {
                        $st = $pdo->prepare("INSERT INTO admin_logs(admin_user_id, action, target_type, target_id, new_value, created_at) VALUES (?, 'admin_action_notify', 'admin_confirmation', ?, ?, NOW())");
                        $st->execute([$actorId ?: 0, mb_substr($title, 0, 64, 'UTF-8'), $json]);
                    } elseif (isset($cols['admin_id']) && isset($cols['details'])) {
                        $st = $pdo->prepare("INSERT INTO admin_logs(admin_id, action, details, created_at) VALUES (?, 'admin_action_notify', ?, NOW())");
                        $st->execute([$actorId ?: null, $json]);
                    }
                }
            } catch (Throwable $e) {}
        } catch (Throwable $e) {
            error_log('[admin_action_notify] '.$e->getMessage());
        }
    }
}


/**
 * v27.44 - Fake receipt warning workflow.
 * Adds a single, safe path used by both the bot callbacks and the Mini App.
 */
if (!function_exists('gemaccEnsureFakeReceiptSchema')) {
    function gemaccEnsureFakeReceiptSchema(PDO $pdo): void {
        $silent = function(string $sql) use ($pdo): void { try { $pdo->exec($sql); } catch (Throwable $e) { error_log('[fake_receipt_schema] '.$e->getMessage().' SQL='.$sql); } };
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'users', 'fake_receipt_warnings', 'INT NOT NULL DEFAULT 0'); else $silent("ALTER TABLE users ADD COLUMN fake_receipt_warnings INT NOT NULL DEFAULT 0");
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'users', 'last_fake_receipt_warning_at', 'DATETIME NULL'); else $silent("ALTER TABLE users ADD COLUMN last_fake_receipt_warning_at DATETIME NULL");
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'users', 'is_blocked', 'TINYINT(1) NOT NULL DEFAULT 0'); else $silent("ALTER TABLE users ADD COLUMN is_blocked TINYINT(1) NOT NULL DEFAULT 0");
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'users', 'blocked_reason', 'TEXT NULL'); else $silent("ALTER TABLE users ADD COLUMN blocked_reason TEXT NULL");
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'users', 'blocked_at', 'DATETIME NULL'); else $silent("ALTER TABLE users ADD COLUMN blocked_at DATETIME NULL");
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'orders', 'fake_receipt_warning_at', 'DATETIME NULL'); else $silent("ALTER TABLE orders ADD COLUMN fake_receipt_warning_at DATETIME NULL");
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'orders', 'fake_receipt_warning_by', 'BIGINT NULL'); else $silent("ALTER TABLE orders ADD COLUMN fake_receipt_warning_by BIGINT NULL");
        if (function_exists('gemaccCoreAddColumn')) gemaccCoreAddColumn($pdo, 'orders', 'fake_receipt_warning_no', 'INT NOT NULL DEFAULT 0'); else $silent("ALTER TABLE orders ADD COLUMN fake_receipt_warning_no INT NOT NULL DEFAULT 0");
        $silent("CREATE TABLE IF NOT EXISTS fake_receipt_warnings (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            user_id BIGINT NOT NULL,
            order_id BIGINT NOT NULL,
            admin_id BIGINT NULL,
            warning_number INT NOT NULL DEFAULT 1,
            user_blocked TINYINT(1) NOT NULL DEFAULT 0,
            message_sent TINYINT(1) NOT NULL DEFAULT 0,
            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY idx_user_created (user_id, created_at),
            KEY idx_order (order_id),
            KEY idx_admin (admin_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    }
}

if (!function_exists('gemaccOrderWarningUserMessage')) {
    function gemaccOrderWarningUserMessage(int $orderId, int $warningNo, bool $blocked): string {
        $base = "⚠️ اخطار فیش فیک\n\n" .
            "سفارش #{$orderId} به دلیل ارسال رسید/فیش نامعتبر کنسل شد.\n\n" .
            "شما در حال دریافت اخطار برای فیش فیک هستید. تعداد اخطارهای ثبت‌شده: {$warningNo}/3\n\n" .
            "لطفاً فقط رسید واقعی پرداخت را ارسال کنید. در صورت تکرار، حساب شما داخل ربات مسدود می‌شود.";
        if ($blocked) {
            $base .= "\n\n🚫 به دلیل رسیدن به ۳ اخطار، حساب شما داخل ربات مسدود شد. برای بررسی فقط از بخش پشتیبانی پیگیری کنید.";
        }
        return $base;
    }
}

if (!function_exists('gemaccMarkFakeReceipt')) {
    function gemaccMarkFakeReceipt(PDO $pdo, int $orderId, int $adminId = 0): array {
        gemaccEnsureFakeReceiptSchema($pdo);
        if ($orderId <= 0) return ['ok'=>false, 'error'=>'شناسه سفارش نامعتبر است'];
        try {
            if (!$pdo->inTransaction()) $pdo->beginTransaction();
            $st = $pdo->prepare("SELECT o.*, u.fake_receipt_warnings, u.is_blocked, u.first_name, u.username FROM orders o LEFT JOIN users u ON u.id=o.user_id WHERE o.id=? FOR UPDATE");
            $st->execute([$orderId]);
            $order = $st->fetch(PDO::FETCH_ASSOC);
            if (!$order) {
                if ($pdo->inTransaction()) $pdo->rollBack();
                return ['ok'=>false, 'error'=>'سفارش یافت نشد'];
            }
            $status = strtoupper((string)($order['status'] ?? ''));
            if (!in_array($status, ['PENDING','WAITING','QUEUED'], true)) {
                if ($pdo->inTransaction()) $pdo->rollBack();
                return ['ok'=>false, 'error'=>'این سفارش قبلاً پردازش شده و قابل ثبت اخطار فیش فیک نیست'];
            }
            $userId = (int)($order['user_id'] ?? 0);
            if ($userId <= 0) {
                if ($pdo->inTransaction()) $pdo->rollBack();
                return ['ok'=>false, 'error'=>'کاربر سفارش مشخص نیست'];
            }
            $currentWarnings = max(0, (int)($order['fake_receipt_warnings'] ?? 0));
            $warningNo = $currentWarnings + 1;
            $blocked = $warningNo >= 3;

            $pdo->prepare("UPDATE users SET fake_receipt_warnings=?, last_fake_receipt_warning_at=NOW(), is_blocked=IF(?=1,1,is_blocked), blocked_reason=IF(?=1,?,blocked_reason), blocked_at=IF(?=1,NOW(),blocked_at) WHERE id=?")
                ->execute([$warningNo, $blocked ? 1 : 0, $blocked ? 1 : 0, 'مسدودسازی خودکار به دلیل ۳ اخطار فیش فیک', $blocked ? 1 : 0, $userId]);

            $newStatus = 'CANCELLED';
            $pdo->prepare("UPDATE orders SET status=?, delivery_status='cancelled', payment_status='fake_receipt', fake_receipt_warning_at=NOW(), fake_receipt_warning_by=?, fake_receipt_warning_no=?, updated_at=NOW() WHERE id=?")
                ->execute([$newStatus, $adminId ?: null, $warningNo, $orderId]);
            try { $pdo->prepare("DELETE FROM waiting_queue WHERE order_id=?")->execute([$orderId]); } catch (Throwable $e) {}
            try { $pdo->prepare("UPDATE crypto_payments SET status='FAKE_RECEIPT', processed_by=?, processed_at=NOW() WHERE order_id=? AND status NOT IN ('PAID','FAILED','REJECTED','CANCELLED','FAKE_RECEIPT')")->execute([$adminId ?: null, $orderId]); } catch (Throwable $e) {}

            $refunded = false;
            if (strtoupper((string)($order['payment_method'] ?? '')) === 'WALLET' && (int)($order['price'] ?? 0) > 0) {
                try {
                    $pdo->prepare("UPDATE users SET balance=balance+? WHERE id=?")->execute([(int)$order['price'], $userId]);
                    $pdo->prepare("INSERT INTO wallet_transactions(user_id,amount,type,status,description,processed_by,processed_at) VALUES(?,?, 'REFUND','COMPLETED',?,?,NOW())")
                        ->execute([$userId, (int)$order['price'], 'استرداد خودکار سفارش کنسل‌شده بابت اخطار فیش فیک #'.$orderId, $adminId ?: null]);
                    $refunded = true;
                } catch (Throwable $e) { error_log('[fake_receipt_refund] '.$e->getMessage()); }
            }

            $pdo->prepare("INSERT INTO fake_receipt_warnings(user_id, order_id, admin_id, warning_number, user_blocked) VALUES(?,?,?,?,?)")
                ->execute([$userId, $orderId, $adminId ?: null, $warningNo, $blocked ? 1 : 0]);
            if ($pdo->inTransaction()) $pdo->commit();

            $message = gemaccOrderWarningUserMessage($orderId, $warningNo, $blocked);
            $sent = false;
            try {
                $res = tg('sendMessage', ['chat_id'=>$userId, 'text'=>$message]);
                $sent = is_array($res) ? !empty($res['ok']) : true;
            } catch (Throwable $e) { error_log('[fake_receipt_user_notify] '.$e->getMessage()); }
            try {
                if ($sent) {
                    $pdo->prepare("UPDATE fake_receipt_warnings SET message_sent=1 WHERE user_id=? AND order_id=? AND warning_number=? ORDER BY id DESC LIMIT 1")
                        ->execute([$userId, $orderId, $warningNo]);
                }
            } catch (Throwable $e) {}

            try {
                if (function_exists('gemaccAdminActionNotify')) {
                    gemaccAdminActionNotify('اخطار فیش فیک ثبت شد', [
                        'شماره سفارش' => $orderId,
                        'کاربر' => $userId,
                        'تعداد اخطار' => $warningNo . '/3',
                        'سفارش' => 'کنسل شد',
                        'کاربر مسدود شد' => $blocked ? 'بله' : 'خیر',
                        'پیام به کاربر' => $sent ? 'ارسال شد' : 'ناموفق',
                        'استرداد کیف پول' => $refunded ? 'انجام شد' : 'نداشت',
                    ], $adminId ?: null);
                }
            } catch (Throwable $e) {}

            return [
                'ok'=>true,
                'message'=>$blocked ? 'اخطار سوم ثبت شد؛ سفارش کنسل و کاربر مسدود شد.' : 'اخطار فیش فیک ثبت و سفارش کنسل شد.',
                'order_id'=>$orderId,
                'user_id'=>$userId,
                'warning_count'=>$warningNo,
                'blocked'=>$blocked,
                'notification_sent'=>$sent,
                'refunded'=>$refunded,
            ];
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            error_log('[gemaccMarkFakeReceipt] '.$e->getMessage());
            return ['ok'=>false, 'error'=>'خطا در ثبت اخطار فیش فیک: '.$e->getMessage()];
        }
    }
}


/**
 * Log error message to error_log
 */
if (!function_exists('logError')) {
    function logError(string $message): void {
        $timestamp = date('Y-m-d H:i:s');
        error_log("[{$timestamp}] {$message}");
    }
}



/** GEM ACC OpenVPN/L2TP additions */
if (!defined('OPENVPN_CONFIG_URL')) define('OPENVPN_CONFIG_URL', env('OPENVPN_CONFIG_URL', 'https://laksho1.com'));
if (!defined('L2TP_SERVER')) define('L2TP_SERVER', env('L2TP_SERVER', 's8.laksho1.com'));


function gemaccPersianDateTime($date, bool $withTime = true): string {
    if (!$date) return '(-)';
    $ts = is_numeric($date) ? (int)$date : strtotime((string)$date);
    if (!$ts) return '(-)';
    $d = jdate('d/m', $ts);
    if (!$withTime) return '(' . $d . ')';
    $h = (int)date('g', $ts);
    $m = date('i', $ts);
    $ampm = date('A', $ts) === 'AM' ? 'ق.ظ' : 'ب.ظ';
    $txt = $d . ' | ' . $h . ':' . $m . $ampm;
    $en = ['0','1','2','3','4','5','6','7','8','9'];
    $fa = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
    return '(' . str_replace($en, $fa, $txt) . ')';
}

function jalaliShortDate($date): string {
    if (!$date) return '(-)';
    $ts = is_numeric($date) ? (int)$date : strtotime((string)$date);
    if (!$ts) return '(-)';
    return '(' . jdate('d/m', $ts) . ')';
}


function tgHtmlEscape($value): string {
    return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function tgCode($value): string {
    return '<code>' . tgHtmlEscape((string)$value) . '</code>';
}

function tgBold($value): string {
    return '<b>' . tgHtmlEscape((string)$value) . '</b>';
}

function gemaccWindscribeNoticeText(string $username, string $password): string {
    return "⚠️ <b>لطفاً موارد زیر را با دقت مطالعه کنید</b> ⚠️\n"
        . "✅ فقط از قسمت Login (ورود) وارد اکانت شوید.\n"
        . "❌ به هیچ عنوان وارد بخش Account نشوید و اطلاعات اکانت (ایمیل، پسورد و...) را تغییر ندهید.\n"
        . "انجام این کار باعث از دست رفتن دسترسی و غیرفعال شدن اکانت خواهد شد.\n"
        . "❌ افزودن هرگونه دستگاه (Device) اضافی یا تغییر در تنظیمات اصلی اکانت نیز موجب مسدود شدن اکانت می‌شود.\n"
        . "🔹 <b>Username:</b>\n" . tgCode($username) . "\n"
        . "🔹 <b>Password:</b>\n" . tgCode($password) . "\n"
        . "⚠️ مسئولیت هرگونه تغییر در اطلاعات اکانت یا استفاده خارج از قوانین اعلام‌شده، بر عهده کاربر خواهد بود.";
}

function gemaccWindscribeNoticePlain(string $username, string $password): string {
    return "⚠️ لطفاً موارد زیر را با دقت مطالعه کنید ⚠️\n"
        . "✅ فقط از قسمت Login (ورود) وارد اکانت شوید.\n"
        . "❌ به هیچ عنوان وارد بخش Account نشوید و اطلاعات اکانت (ایمیل، پسورد و...) را تغییر ندهید.\n"
        . "انجام این کار باعث از دست رفتن دسترسی و غیرفعال شدن اکانت خواهد شد.\n"
        . "❌ افزودن هرگونه دستگاه (Device) اضافی یا تغییر در تنظیمات اصلی اکانت نیز موجب مسدود شدن اکانت می‌شود.\n"
        . "🔹 Username:\n" . $username . "\n"
        . "🔹 Password:\n" . $password . "\n"
        . "⚠️ مسئولیت هرگونه تغییر در اطلاعات اکانت یا استفاده خارج از قوانین اعلام‌شده، بر عهده کاربر خواهد بود.";
}


function tgNormalizeMessagePayload(array $params): array {
    if (isset($params['text']) && is_string($params['text'])) {
        // Telegram legacy Markdown does not support **bold**. Convert old project text to valid Markdown.
        if (($params['parse_mode'] ?? '') === 'Markdown') {
            $params['text'] = preg_replace('/\*\*(.*?)\*\*/su', '*$1*', $params['text']);
        }
    }
    return $params;
}

function vpnDeliveryText(array $credential, array $plan = []): string {
    $username = $credential['username'] ?? $credential['email'] ?? '';
    $password = $credential['password'] ?? '';
    $duration = $plan['duration_label'] ?? (isset($plan['duration']) ? getDurationLabel($plan['duration']) : '');
    $service = strtolower((string)($credential['service_type'] ?? $credential['protocol'] ?? $plan['service_type'] ?? $plan['protocol'] ?? 'openvpn_l2tp'));
    $code = strtolower((string)($plan['plan_code'] ?? $plan['code'] ?? ''));
    $title = strtolower((string)($plan['plan_title'] ?? $plan['title'] ?? ''));
    if ($service === 'v2ray' || str_contains($code, 'v2ray') || str_contains($code, 'v2_') || str_contains($title, 'v2ray') || str_contains($title, 'وی‌تو‌ری') || str_contains($title, 'ویتوری')) {
        $configText = trim((string)($credential['config_text'] ?? $plan['config_text'] ?? ''));
        $trafficType = strtolower((string)($credential['traffic_type'] ?? $plan['traffic_type'] ?? 'unlimited'));
        $trafficLimit = $credential['traffic_limit_gb'] ?? $plan['traffic_limit_gb'] ?? null;
        $trafficLabel = $trafficType === 'limited' ? ('حجمی ' . (int)$trafficLimit . ' گیگ') : 'نامحدود';
        $text  = "✅ <b>کانفیگ V2Ray شما با موفقیت تحویل شد</b>

";
        if ($duration) $text .= "⏳ <b>مدت اعتبار:</b> " . tgHtmlEscape($duration) . "
";
        $text .= "📦 <b>نوع سرویس:</b> " . tgHtmlEscape($trafficLabel) . "
";
        $text .= "━━━━━━━━━━━━━━━━━━━━
";
        $text .= "🔗 <b>متن کانفیگ:</b>
" . tgCode($configText !== '' ? $configText : 'کانفیگ در بخش فیلترشکن‌های من قابل مشاهده است') . "

";
        $text .= "📷 در صورت ثبت QR Code، تصویر آن نیز جداگانه برای شما ارسال می‌شود.
";
        $text .= "📚 آموزش اتصال از بخش آموزش V2Ray در دسترس است.";
        return $text;
    }
    if ($service === 'expressvpn' || str_contains($code, 'exp_') || str_contains($code, 'express') || str_contains($title, 'express') || str_contains($title, 'اکسپرس')) {
        $seats = max(1, (int)($credential['seats_allocated'] ?? $plan['requested_seats'] ?? $plan['seats'] ?? $plan['user_count'] ?? 1));
        $months = max(1, (int)($plan['months'] ?? 1));
        $days = $months * 30;
        $text  = "✅ <b>اشتراک ExpressVPN شما با موفقیت فعال شد</b>

";
        $text .= "📦 <b>پلن:</b> " . tgHtmlEscape($duration ?: ($months . ' ماهه')) . " | " . tgHtmlEscape($seats . ' کاربر') . "
";
        $text .= "⏳ <b>اعتبار:</b> " . tgHtmlEscape($days . ' روز') . "

";
        $text .= "━━━━━━━━━━━━━━━

";
        $text .= "👤 <b>Username</b>
" . tgCode($username) . "

";
        $text .= "🔑 <b>Password</b>
" . tgCode($password) . "

";
        $text .= "📋 با لمس روی کادرها قابل کپی هستند.

";
        $text .= "💡 آموزش اندروید، آیفون و ویندوز از بخش آموزش ExpressVPN در دسترس است.";
        return $text;
    }
    if ($service === 'windscribe' || str_contains($code, 'ws_') || str_contains($code, 'windscribe') || str_contains($title, 'windscribe') || str_contains($title, 'ویندسکرایب')) {
        return gemaccWindscribeNoticeText((string)$username, (string)$password);
    }
    $trafficType = strtolower((string)($credential['traffic_type'] ?? $plan['traffic_type'] ?? 'unlimited'));
    $trafficLimit = $credential['traffic_limit_gb'] ?? $plan['traffic_limit_gb'] ?? null;
    $trafficLabel = $trafficType === 'limited' ? ('حجمی ' . (int)$trafficLimit . ' گیگ') : 'نامحدود';
    $text  = "✅ <b>سرویس OpenVPN | L2TP شما فعال شد!</b>

";
    $text .= "━━━━━━━━━━━━━━━━━━━━
";
    $text .= "👤 <b>یوزرنیم:</b>
" . tgCode($username) . "

";
    $text .= "🔐 <b>پسورد:</b>
" . tgCode($password) . "

";
    if ($duration) $text .= "⏳ <b>مدت اعتبار:</b> " . tgHtmlEscape($duration) . "
";
    $text .= "📦 <b>نوع سرویس:</b> " . tgHtmlEscape($trafficLabel) . "
";
    $text .= "📦 <b>وضعیت:</b> سرویس شما تحویل و آماده استفاده است
";
    $text .= "🌐 <b>سرور L2TP:</b> " . tgCode(L2TP_SERVER) . "
";
    $text .= "📥 <b>دانلود کانفیگ OpenVPN:</b> " . tgHtmlEscape(OPENVPN_CONFIG_URL) . "
";
    $text .= "━━━━━━━━━━━━━━━━━━━━

";
    $text .= "📚 آموزش اندروید، آیفون و ویندوز از بخش آموزش در دسترس است.";
    return $text;
}


/**
 * v27.45 - Private message reply flow.
 * Adds a safe inline "reply to admin" button under every private admin message
 * and stores the conversation audit trail without exposing sensitive internals.
 */
if (!function_exists('gemaccEnsurePrivateReplySchema')) {
    function gemaccEnsurePrivateReplySchema(?PDO $pdo = null): void {
        try {
            $pdo = $pdo ?: (function_exists('db') ? db() : null);
            if (!$pdo instanceof PDO) return;
            $pdo->exec("CREATE TABLE IF NOT EXISTS admin_private_messages (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                admin_id BIGINT NULL,
                user_id BIGINT NOT NULL,
                direction VARCHAR(30) NOT NULL DEFAULT 'admin_to_user',
                message_text LONGTEXT NULL,
                telegram_message_id BIGINT NULL,
                telegram_file_id VARCHAR(255) NULL,
                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (id),
                KEY idx_user_created (user_id, created_at),
                KEY idx_admin_created (admin_id, created_at),
                KEY idx_direction_created (direction, created_at)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
        } catch (Throwable $e) { error_log('[private_reply_schema] '.$e->getMessage()); }
    }
}

if (!function_exists('gemaccPrivateReplyKeyboard')) {
    function gemaccPrivateReplyKeyboard(int $adminId): array {
        return [
            'inline_keyboard' => [
                [
                    ['text' => '✍️ پاسخ به ادمین', 'callback_data' => 'USER_REPLY_ADMIN_' . max(0, $adminId)]
                ]
            ]
        ];
    }
}

if (!function_exists('gemaccAdminReplyToUserKeyboard')) {
    function gemaccAdminReplyToUserKeyboard(int $userId): array {
        return [
            'inline_keyboard' => [
                [
                    ['text' => '✉️ پاسخ به این کاربر', 'callback_data' => 'ADMIN_USER_MSG_' . max(0, $userId)]
                ],
                [
                    ['text' => '👤 مشاهده کاربر', 'callback_data' => 'ADMIN_USER_' . max(0, $userId)]
                ]
            ]
        ];
    }
}

if (!function_exists('gemaccLogPrivateMessage')) {
    function gemaccLogPrivateMessage(int $adminId, int $userId, string $direction, ?string $messageText = null, ?int $telegramMessageId = null, ?string $telegramFileId = null): void {
        try {
            $pdo = function_exists('db') ? db() : null;
            if (!$pdo instanceof PDO) return;
            gemaccEnsurePrivateReplySchema($pdo);
            $stmt = $pdo->prepare("INSERT INTO admin_private_messages(admin_id,user_id,direction,message_text,telegram_message_id,telegram_file_id) VALUES(?,?,?,?,?,?)");
            $stmt->execute([$adminId ?: null, $userId, $direction, $messageText, $telegramMessageId ?: null, $telegramFileId ?: null]);
        } catch (Throwable $e) { error_log('[private_message_log] '.$e->getMessage()); }
    }
}

if (!function_exists('gemaccSendPrivateAdminMessageToUser')) {
    function gemaccSendPrivateAdminMessageToUser(int $userId, string $messageText, int $adminId): array {
        $messageText = trim($messageText);
        if ($userId <= 0) return ['ok'=>false, 'error'=>'شناسه کاربر نامعتبر است'];
        if ($messageText === '') return ['ok'=>false, 'error'=>'متن پیام خالی است'];
        $params = [
            'chat_id' => $userId,
            'text' => $messageText,
            'reply_markup' => gemaccPrivateReplyKeyboard($adminId),
            'disable_web_page_preview' => true,
        ];
        $res = tg('sendMessage', $params);
        $ok = is_array($res) ? !empty($res['ok']) : (bool)$res;
        $msgId = is_array($res) ? (int)($res['result']['message_id'] ?? 0) : 0;
        if ($ok) gemaccLogPrivateMessage($adminId, $userId, 'admin_to_user', $messageText, $msgId, null);
        return ['ok'=>$ok, 'telegram_response'=>$res, 'message_id'=>$msgId];
    }
}

if (!function_exists('gemaccPrivateReplyAdminTargets')) {
    function gemaccPrivateReplyAdminTargets(int $adminId): array {
        $targets = [];
        if ($adminId > 0 && function_exists('isAdmin') && isAdmin($adminId)) $targets[] = $adminId;
        if (!$targets) {
            $targets = function_exists('adminIds') ? array_map('intval', adminIds()) : [];
        }
        $targets = array_values(array_unique(array_filter($targets, fn($v) => (int)$v > 0)));
        return $targets;
    }
}

/**
 * v27.50 - Professional Support/Ticket + Admin Chat Center helpers.
 * Backward-compatible with v27.45 private messages and legacy support tables.
 */
if (!function_exists('gemaccAdminChatTableExists')) {
    function gemaccAdminChatTableExists(PDO $pdo, string $table): bool {
        try { $st = $pdo->prepare("SHOW TABLES LIKE ?"); $st->execute([$table]); return (bool)$st->fetchColumn(); }
        catch (Throwable $e) { return false; }
    }
}

if (!function_exists('gemaccAdminChatColumnExists')) {
    function gemaccAdminChatColumnExists(PDO $pdo, string $table, string $column): bool {
        try { $st = $pdo->prepare("SHOW COLUMNS FROM `" . str_replace('`','',$table) . "` LIKE ?"); $st->execute([$column]); return (bool)$st->fetchColumn(); }
        catch (Throwable $e) { return false; }
    }
}

if (!function_exists('gemaccAdminChatAddColumnIfMissing')) {
    function gemaccAdminChatAddColumnIfMissing(PDO $pdo, string $table, string $column, string $definition): void {
        try { if (!gemaccAdminChatColumnExists($pdo, $table, $column)) $pdo->exec("ALTER TABLE `" . str_replace('`','',$table) . "` ADD COLUMN `$column` $definition"); }
        catch (Throwable $e) { error_log('[admin_chat_add_column] '.$table.'.'.$column.' '.$e->getMessage()); }
    }
}

if (!function_exists('gemaccChatEsc')) {
    function gemaccChatEsc($v): string {
        return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }
}

if (!function_exists('gemaccAdminChatEnsureSchema')) {
    function gemaccAdminChatEnsureSchema(?PDO $pdo = null): void {
        try {
            $pdo = $pdo ?: (function_exists('db') ? db() : null);
            if (!$pdo instanceof PDO) return;
            if (function_exists('gemaccEnsurePrivateReplySchema')) gemaccEnsurePrivateReplySchema($pdo);

            $pdo->exec("CREATE TABLE IF NOT EXISTS admin_chat_notes (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                user_id BIGINT NOT NULL,
                admin_id BIGINT NULL,
                note_text TEXT NOT NULL,
                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (id),
                KEY idx_user_created (user_id, created_at),
                KEY idx_admin_created (admin_id, created_at)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");

            $pdo->exec("CREATE TABLE IF NOT EXISTS support_tickets (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                user_id BIGINT NOT NULL,
                subject VARCHAR(255) DEFAULT 'درخواست پشتیبانی',
                status VARCHAR(30) NOT NULL DEFAULT 'OPEN',
                priority VARCHAR(20) NOT NULL DEFAULT 'normal',
                category VARCHAR(60) NOT NULL DEFAULT 'support',
                assigned_admin_id BIGINT NULL,
                unread_admin_count INT NOT NULL DEFAULT 0,
                unread_user_count INT NOT NULL DEFAULT 0,
                last_message_at DATETIME NULL,
                closed_at DATETIME NULL,
                closed_by BIGINT NULL,
                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
                PRIMARY KEY (id),
                KEY idx_support_tickets_user (user_id),
                KEY idx_support_tickets_status (status),
                KEY idx_support_tickets_updated (updated_at),
                KEY idx_support_tickets_last (last_message_at)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
            foreach ([
                'subject' => "VARCHAR(255) DEFAULT 'درخواست پشتیبانی'",
                'status' => "VARCHAR(30) NOT NULL DEFAULT 'OPEN'",
                'priority' => "VARCHAR(20) NOT NULL DEFAULT 'normal'",
                'category' => "VARCHAR(60) NOT NULL DEFAULT 'support'",
                'assigned_admin_id' => "BIGINT NULL",
                'unread_admin_count' => "INT NOT NULL DEFAULT 0",
                'unread_user_count' => "INT NOT NULL DEFAULT 0",
                'last_message_at' => "DATETIME NULL",
                'closed_at' => "DATETIME NULL",
                'closed_by' => "BIGINT NULL",
                'created_at' => "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
                'updated_at' => "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
            ] as $col => $def) gemaccAdminChatAddColumnIfMissing($pdo, 'support_tickets', $col, $def);

            $pdo->exec("CREATE TABLE IF NOT EXISTS support_messages (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                ticket_id BIGINT UNSIGNED NOT NULL,
                sender_id BIGINT NOT NULL,
                sender_role VARCHAR(30) NOT NULL DEFAULT 'USER',
                message_text MEDIUMTEXT NULL,
                telegram_file_id VARCHAR(255) DEFAULT NULL,
                attachment_type VARCHAR(30) NULL,
                attachment_name VARCHAR(255) NULL,
                attachment_mime VARCHAR(160) NULL,
                attachment_path VARCHAR(500) NULL,
                is_admin_reply TINYINT(1) NOT NULL DEFAULT 0,
                is_read TINYINT(1) NOT NULL DEFAULT 0,
                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
                sent_at DATETIME NULL,
                PRIMARY KEY (id),
                KEY idx_support_messages_ticket (ticket_id),
                KEY idx_support_messages_sender (sender_id),
                KEY idx_support_messages_created (created_at),
                KEY idx_support_messages_read (is_read)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
            foreach ([
                'sender_id' => "BIGINT NOT NULL DEFAULT 0",
                'sender_role' => "VARCHAR(30) NOT NULL DEFAULT 'USER'",
                'message_text' => "MEDIUMTEXT NULL",
                'telegram_file_id' => "VARCHAR(255) NULL",
                'attachment_type' => "VARCHAR(30) NULL",
                'attachment_name' => "VARCHAR(255) NULL",
                'attachment_mime' => "VARCHAR(160) NULL",
                'attachment_path' => "VARCHAR(500) NULL",
                'is_admin_reply' => "TINYINT(1) NOT NULL DEFAULT 0",
                'is_read' => "TINYINT(1) NOT NULL DEFAULT 0",
                'created_at' => "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP",
                'sent_at' => "DATETIME NULL",
            ] as $col => $def) gemaccAdminChatAddColumnIfMissing($pdo, 'support_messages', $col, $def);

            try { $pdo->exec("UPDATE support_tickets SET last_message_at=updated_at WHERE last_message_at IS NULL"); } catch (Throwable $ignored) {}
        } catch (Throwable $e) { error_log('[admin_chat_schema] '.$e->getMessage()); }
    }
}

if (!function_exists('gemaccAdminChatUserLabel')) {
    function gemaccAdminChatUserLabel(array $row): string {
        $name = trim((string)($row['first_name'] ?? '') . ' ' . (string)($row['last_name'] ?? ''));
        if ($name !== '') return $name;
        $username = trim((string)($row['username'] ?? ''));
        return $username !== '' ? '@' . ltrim($username, '@') : 'کاربر ' . (string)($row['user_id'] ?? $row['id'] ?? '');
    }
}

if (!function_exists('gemaccSupportStatusLabel')) {
    function gemaccSupportStatusLabel(string $status): string {
        $s = strtoupper($status);
        return [
            'OPEN' => 'باز',
            'WAITING' => 'در انتظار پاسخ ادمین',
            'ANSWERED' => 'پاسخ داده‌شده',
            'CLOSED' => 'بسته‌شده',
        ][$s] ?? $status;
    }
}

if (!function_exists('gemaccSupportOpenTicketForUser')) {
    function gemaccSupportOpenTicketForUser(PDO $pdo, int $userId, string $subject = 'درخواست پشتیبانی'): int {
        gemaccAdminChatEnsureSchema($pdo);
        $st = $pdo->prepare("SELECT id FROM support_tickets WHERE user_id=? AND status IN ('OPEN','WAITING','ANSWERED') ORDER BY updated_at DESC, id DESC LIMIT 1");
        $st->execute([$userId]);
        $ticketId = (int)($st->fetchColumn() ?: 0);
        if ($ticketId > 0) return $ticketId;
        $st = $pdo->prepare("INSERT INTO support_tickets(user_id, subject, status, category, priority, unread_admin_count, unread_user_count, last_message_at, created_at, updated_at) VALUES(?, ?, 'OPEN', 'support', 'normal', 0, 0, NOW(), NOW(), NOW())");
        $st->execute([$userId, mb_substr(trim($subject) ?: 'درخواست پشتیبانی', 0, 240, 'UTF-8')]);
        return (int)$pdo->lastInsertId();
    }
}

if (!function_exists('gemaccSupportNormalizeAttachment')) {
    function gemaccSupportNormalizeAttachment(?string $fileId = null, array $attachment = []): array {
        $type = strtolower(trim((string)($attachment['type'] ?? $attachment['attachment_type'] ?? '')));
        $allowed = ['photo','document','video','animation','voice','audio','local_image','local_file'];
        if (!in_array($type, $allowed, true)) $type = $fileId ? 'photo' : '';
        return [
            'file_id' => trim((string)($attachment['file_id'] ?? $fileId ?? '')) ?: null,
            'type' => $type ?: null,
            'name' => mb_substr(trim((string)($attachment['name'] ?? $attachment['attachment_name'] ?? '')), 0, 250, 'UTF-8') ?: null,
            'mime' => mb_substr(trim((string)($attachment['mime'] ?? $attachment['attachment_mime'] ?? '')), 0, 150, 'UTF-8') ?: null,
            'path' => mb_substr(trim((string)($attachment['path'] ?? $attachment['attachment_path'] ?? '')), 0, 490, 'UTF-8') ?: null,
        ];
    }
}

if (!function_exists('gemaccSupportNotifyAdmins')) {
    function gemaccSupportNotifyAdmins(PDO $pdo, int $ticketId, int $userId, string $message, ?string $fileId = null, array $attachment = []): void {
        try {
            $a = gemaccSupportNormalizeAttachment($fileId, $attachment);
            $st = $pdo->prepare("SELECT username, first_name, last_name FROM users WHERE id=? LIMIT 1");
            $st->execute([$userId]);
            $u = $st->fetch(PDO::FETCH_ASSOC) ?: [];
            $label = gemaccAdminChatUserLabel(['user_id'=>$userId] + $u);
            $safeLabel = gemaccChatEsc($label);
            $fallback = $a['file_id'] || $a['path'] ? '📎 فایل/تصویر ارسالی کاربر' : 'پیام بدون متن';
            $safeMsg = gemaccChatEsc(mb_substr(trim($message) ?: $fallback, 0, 1000, 'UTF-8'));
            $txt = "🎫 <b>پیام جدید پشتیبانی</b>\n"
                . "👤 کاربر: <b>{$safeLabel}</b> | <code>{$userId}</code>\n"
                . "🧾 تیکت: <code>#{$ticketId}</code>\n"
                . "━━━━━━━━━━━━━━\n{$safeMsg}";
            $kb = ['inline_keyboard'=>[
                [['text'=>'💬 باز کردن چت', 'callback_data'=>'ADMIN_CHAT_VIEW_support_'.$userId]],
                [['text'=>'✍️ پاسخ سریع', 'callback_data'=>'ADMIN_CHAT_REPLY_support_'.$userId], ['text'=>'✅ بستن تیکت', 'callback_data'=>'ADMIN_CHAT_CLOSE_'.$ticketId.'_'.$userId]],
            ]];
            $admins = function_exists('adminIds') ? adminIds() : array_filter(array_map('trim', explode(',', defined('ADMIN_IDS') ? ADMIN_IDS : '')));
            foreach ($admins as $adminId) {
                if (!$adminId) continue;
                $params = [
                    'chat_id'=>(int)$adminId,
                    'parse_mode'=>'HTML',
                    'reply_markup'=>json_encode($kb, JSON_UNESCAPED_UNICODE),
                ];
                $method = 'sendMessage';
                $mediaValue = $a['file_id'];
                if (!$mediaValue && $a['path']) {
                    $abs = dirname(__DIR__) . '/' . ltrim((string)$a['path'], '/');
                    if (is_file($abs)) $mediaValue = $abs;
                }
                if ($mediaValue) {
                    $caption = mb_strlen(strip_tags($txt), 'UTF-8') > 1000 ? mb_substr(strip_tags($txt), 0, 950, 'UTF-8').'…' : $txt;
                    $params['caption'] = $caption;
                    switch ($a['type']) {
                        case 'video': $method='sendVideo'; $params['video']=$mediaValue; break;
                        case 'animation': $method='sendAnimation'; $params['animation']=$mediaValue; break;
                        case 'voice': $method='sendVoice'; $params['voice']=$mediaValue; break;
                        case 'audio': $method='sendAudio'; $params['audio']=$mediaValue; break;
                        case 'document': case 'local_file': $method='sendDocument'; $params['document']=$mediaValue; break;
                        default: $method='sendPhoto'; $params['photo']=$mediaValue; break;
                    }
                } else {
                    $params['text']=$txt;
                    $params['disable_web_page_preview']=true;
                }
                $res = tg($method, $params);
                if (!(is_array($res) && !empty($res['ok'])) && $method !== 'sendMessage') {
                    tg('sendMessage', [
                        'chat_id'=>(int)$adminId, 'text'=>$txt."\n\n⚠️ نمایش مستقیم فایل ناموفق بود؛ از داخل چت پشتیبانی باز کنید.",
                        'parse_mode'=>'HTML', 'reply_markup'=>json_encode($kb, JSON_UNESCAPED_UNICODE),
                    ]);
                }
            }
        } catch (Throwable $e) { error_log('[support_notify_admins] '.$e->getMessage()); }
    }
}

if (!function_exists('gemaccSupportAddUserMessageToTicket')) {
    function gemaccSupportAddUserMessageToTicket(PDO $pdo, int $userId, int $ticketId = 0, string $message = '', ?string $fileId = null, string $subject = 'درخواست پشتیبانی', array $attachment = []): array {
        if ($userId <= 0) return ['ok'=>false, 'error'=>'شناسه کاربر نامعتبر است'];
        $message = trim($message);
        $a = gemaccSupportNormalizeAttachment($fileId, $attachment);
        if ($message === '' && !$a['file_id'] && !$a['path']) return ['ok'=>false, 'error'=>'متن یا فایل پیام خالی است'];
        gemaccAdminChatEnsureSchema($pdo);
        $started = false;
        try {
            if (!$pdo->inTransaction()) { $pdo->beginTransaction(); $started = true; }
            $realTicketId = 0;
            if ($ticketId > 0) {
                $st = $pdo->prepare("SELECT id,status FROM support_tickets WHERE id=? AND user_id=? LIMIT 1 FOR UPDATE");
                $st->execute([$ticketId, $userId]);
                $t = $st->fetch(PDO::FETCH_ASSOC);
                if ($t && strtoupper((string)($t['status'] ?? '')) !== 'CLOSED') $realTicketId = (int)$t['id'];
            }
            if ($realTicketId <= 0) {
                $st = $pdo->prepare("SELECT id FROM support_tickets WHERE user_id=? AND status IN ('OPEN','WAITING','ANSWERED') ORDER BY COALESCE(last_message_at, updated_at, created_at) DESC, id DESC LIMIT 1 FOR UPDATE");
                $st->execute([$userId]);
                $realTicketId = (int)($st->fetchColumn() ?: 0);
            }
            if ($realTicketId <= 0) {
                $st = $pdo->prepare("INSERT INTO support_tickets(user_id, subject, status, category, priority, unread_admin_count, unread_user_count, last_message_at, created_at, updated_at) VALUES(?, ?, 'OPEN', 'support', 'normal', 0, 0, NOW(), NOW(), NOW())");
                $st->execute([$userId, mb_substr(trim($subject) ?: 'درخواست پشتیبانی', 0, 240, 'UTF-8')]);
                $realTicketId = (int)$pdo->lastInsertId();
            }
            $st = $pdo->prepare("INSERT INTO support_messages(ticket_id, sender_id, sender_role, message_text, telegram_file_id, attachment_type, attachment_name, attachment_mime, attachment_path, is_admin_reply, is_read, created_at, sent_at) VALUES(?, ?, 'USER', ?, ?, ?, ?, ?, ?, 0, 0, NOW(), NOW())");
            $st->execute([$realTicketId, $userId, $message, $a['file_id'], $a['type'], $a['name'], $a['mime'], $a['path']]);
            $msgId = (int)$pdo->lastInsertId();
            $pdo->prepare("UPDATE support_tickets SET status='WAITING', unread_admin_count=COALESCE(unread_admin_count,0)+1, last_message_at=NOW(), updated_at=NOW(), closed_at=NULL, closed_by=NULL WHERE id=?")->execute([$realTicketId]);
            if ($started) $pdo->commit();
            gemaccSupportNotifyAdmins($pdo, $realTicketId, $userId, $message, $a['file_id'], $a);
            return ['ok'=>true, 'ticket_id'=>$realTicketId, 'message_id'=>$msgId, 'message'=>'پیام پشتیبانی ثبت شد'];
        } catch (Throwable $e) {
            if ($started && $pdo->inTransaction()) $pdo->rollBack();
            error_log('[support_add_user_message_v52] '.$e->getMessage());
            return ['ok'=>false, 'error'=>$e->getMessage()];
        }
    }
}

if (!function_exists('gemaccSupportAddUserMessage')) {
    function gemaccSupportAddUserMessage(PDO $pdo, int $userId, string $message, ?string $fileId = null, string $subject = 'درخواست پشتیبانی', array $attachment = []): array {
        return gemaccSupportAddUserMessageToTicket($pdo, $userId, 0, $message, $fileId, $subject, $attachment);
    }
}

if (!function_exists('gemaccSupportAdminSendReply')) {
    function gemaccSupportAdminSendReply(PDO $pdo, int $adminId, int $userId, string $message): array {
        if ($userId <= 0) return ['ok'=>false, 'error'=>'شناسه کاربر نامعتبر است'];
        $message = trim($message);
        if ($message === '') return ['ok'=>false, 'error'=>'متن پاسخ خالی است'];
        gemaccAdminChatEnsureSchema($pdo);
        try {
            $ticketId = gemaccSupportOpenTicketForUser($pdo, $userId, 'چت پشتیبانی');
            $pdo->prepare("INSERT INTO support_messages(ticket_id, sender_id, sender_role, message_text, is_admin_reply, is_read, created_at, sent_at) VALUES(?, ?, 'ADMIN', ?, 1, 0, NOW(), NOW())")->execute([$ticketId, $adminId, $message]);
            $pdo->prepare("UPDATE support_tickets SET status='ANSWERED', unread_user_count=COALESCE(unread_user_count,0)+1, unread_admin_count=0, assigned_admin_id=?, last_message_at=NOW(), updated_at=NOW(), closed_at=NULL, closed_by=NULL WHERE id=?")->execute([$adminId ?: null, $ticketId]);
            try { $pdo->prepare("UPDATE support_messages SET is_read=1 WHERE ticket_id=? AND sender_role='USER'")->execute([$ticketId]); } catch (Throwable $ignored) {}
            $txt = "📩 پاسخ پشتیبانی:\n\n" . $message;
            $kb = ['inline_keyboard'=>[[['text'=>'✍️ پاسخ به پشتیبانی','callback_data'=>'SEND_SUPPORT_MESSAGE']]]];
            $res = tg('sendMessage', ['chat_id'=>$userId, 'text'=>$txt, 'reply_markup'=>json_encode($kb, JSON_UNESCAPED_UNICODE)]);
            $ok = is_array($res) ? !empty($res['ok']) : (bool)$res;
            return ['ok'=>$ok, 'ticket_id'=>$ticketId, 'telegram_response'=>$res];
        } catch (Throwable $e) { error_log('[support_admin_reply] '.$e->getMessage()); return ['ok'=>false, 'error'=>$e->getMessage()]; }
    }
}

if (!function_exists('gemaccSupportCloseTicket')) {
    function gemaccSupportCloseTicket(PDO $pdo, int $ticketId, int $adminId = 0, int $userId = 0): array {
        gemaccAdminChatEnsureSchema($pdo);
        try {
            if ($ticketId <= 0 && $userId > 0) {
                $st = $pdo->prepare("SELECT id FROM support_tickets WHERE user_id=? AND status IN ('OPEN','WAITING','ANSWERED') ORDER BY updated_at DESC, id DESC LIMIT 1");
                $st->execute([$userId]);
                $ticketId = (int)($st->fetchColumn() ?: 0);
            }
            if ($ticketId <= 0) return ['ok'=>false, 'error'=>'تیکت باز یافت نشد'];
            $st = $pdo->prepare("SELECT user_id,status FROM support_tickets WHERE id=? LIMIT 1");
            $st->execute([$ticketId]);
            $t = $st->fetch(PDO::FETCH_ASSOC);
            if (!$t) return ['ok'=>false, 'error'=>'تیکت یافت نشد'];
            $uid = (int)$t['user_id'];
            $pdo->prepare("UPDATE support_tickets SET status='CLOSED', unread_admin_count=0, unread_user_count=0, closed_at=NOW(), closed_by=?, updated_at=NOW() WHERE id=?")->execute([$adminId ?: null, $ticketId]);
            try { tg('sendMessage', ['chat_id'=>$uid, 'text'=>'✅ تیکت پشتیبانی شما بسته شد. اگر دوباره نیاز به کمک داشتید، از بخش پشتیبانی پیام جدید ارسال کنید.']); } catch (Throwable $ignored) {}
            return ['ok'=>true, 'ticket_id'=>$ticketId, 'user_id'=>$uid, 'message'=>'تیکت بسته شد'];
        } catch (Throwable $e) { error_log('[support_close_ticket] '.$e->getMessage()); return ['ok'=>false, 'error'=>$e->getMessage()]; }
    }
}



if (!function_exists('gemaccSupportMarkAllTicketsRead')) {
    function gemaccSupportMarkAllTicketsRead(PDO $pdo, int $adminId = 0): array {
        gemaccAdminChatEnsureSchema($pdo);
        try {
            $msgCount = (int)$pdo->query("SELECT COUNT(*) FROM support_messages WHERE COALESCE(is_read,0)=0")->fetchColumn();
            $ticketCount = (int)$pdo->query("SELECT COUNT(*) FROM support_tickets WHERE COALESCE(unread_admin_count,0)>0 OR COALESCE(unread_user_count,0)>0")->fetchColumn();
            $pdo->exec("UPDATE support_messages SET is_read=1");
            $pdo->exec("UPDATE support_tickets SET unread_admin_count=0, unread_user_count=0, updated_at=updated_at");
            return ['ok'=>true, 'messages'=>$msgCount, 'tickets'=>$ticketCount, 'message'=>'همه تیکت‌ها خوانده‌شده شدند'];
        } catch (Throwable $e) {
            error_log('[support_mark_all_read] '.$e->getMessage());
            return ['ok'=>false, 'error'=>$e->getMessage()];
        }
    }
}

if (!function_exists('gemaccSupportDeleteAllTickets')) {
    function gemaccSupportDeleteAllTickets(PDO $pdo, int $adminId = 0): array {
        gemaccAdminChatEnsureSchema($pdo);
        try {
            $ticketCount = (int)$pdo->query("SELECT COUNT(*) FROM support_tickets")->fetchColumn();
            $msgCount = (int)$pdo->query("SELECT COUNT(*) FROM support_messages")->fetchColumn();
            $pdo->beginTransaction();
            $pdo->exec("DELETE FROM support_messages");
            $pdo->exec("DELETE FROM support_tickets");
            $pdo->commit();
            try { $pdo->exec("ALTER TABLE support_messages AUTO_INCREMENT=1"); } catch (Throwable $ignored) {}
            try { $pdo->exec("ALTER TABLE support_tickets AUTO_INCREMENT=1"); } catch (Throwable $ignored) {}
            return ['ok'=>true, 'tickets'=>$ticketCount, 'messages'=>$msgCount, 'message'=>'همه تیکت‌ها پاک شدند'];
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            error_log('[support_delete_all] '.$e->getMessage());
            return ['ok'=>false, 'error'=>$e->getMessage()];
        }
    }
}

if (!function_exists('gemaccAdminChatRecent')) {
    function gemaccAdminChatRecent(string $type = 'all', int $limit = 30, string $q = ''): array {
        $pdo = function_exists('db') ? db() : null;
        if (!$pdo instanceof PDO) return [];
        gemaccAdminChatEnsureSchema($pdo);
        $type = in_array($type, ['all','private','support'], true) ? $type : 'all';
        $limit = max(1, min(100, $limit));
        $q = trim($q);
        $items = [];

        if (($type === 'all' || $type === 'private') && gemaccAdminChatTableExists($pdo, 'admin_private_messages')) {
            $sql = "SELECT p.user_id, u.username, u.first_name, u.last_name, u.is_blocked,
                           MAX(p.created_at) AS last_at,
                           COUNT(*) AS message_count,
                           SUM(CASE WHEN p.direction='user_to_admin' THEN 1 ELSE 0 END) AS incoming_count
                    FROM admin_private_messages p
                    LEFT JOIN users u ON u.id=p.user_id
                    GROUP BY p.user_id, u.username, u.first_name, u.last_name, u.is_blocked
                    ORDER BY last_at DESC LIMIT 120";
            $stmtPrivateChats = $pdo->query($sql);
            foreach (($stmtPrivateChats ? $stmtPrivateChats->fetchAll(PDO::FETCH_ASSOC) : []) as $r) {
                $hay = implode(' ', [(string)$r['user_id'], (string)($r['username'] ?? ''), (string)($r['first_name'] ?? ''), (string)($r['last_name'] ?? '')]);
                if ($q !== '' && mb_stripos($hay, $q, 0, 'UTF-8') === false) continue;
                $last = $pdo->prepare("SELECT message_text, direction FROM admin_private_messages WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT 1");
                $last->execute([(int)$r['user_id']]);
                $lm = $last->fetch(PDO::FETCH_ASSOC) ?: [];
                $r['type'] = 'private';
                $r['type_label'] = 'چت خصوصی';
                $r['ticket_id'] = null;
                $r['status'] = 'OPEN';
                $r['status_label'] = 'باز';
                $r['unread_count'] = (int)($r['incoming_count'] ?? 0);
                $r['last_message'] = (string)($lm['message_text'] ?? '');
                $r['last_direction'] = (string)($lm['direction'] ?? '');
                $r['user_label'] = gemaccAdminChatUserLabel($r);
                $items[] = $r;
            }
        }

        if (($type === 'all' || $type === 'support') && gemaccAdminChatTableExists($pdo, 'support_tickets') && gemaccAdminChatTableExists($pdo, 'support_messages')) {
            $sql = "SELECT t.id AS ticket_id, t.user_id, t.subject, t.status, t.priority, t.unread_admin_count, t.unread_user_count,
                           COALESCE(t.last_message_at, t.updated_at, t.created_at) AS last_at,
                           u.username, u.first_name, u.last_name, u.is_blocked,
                           COUNT(sm.id) AS message_count,
                           SUM(CASE WHEN COALESCE(sm.is_admin_reply,0)=0 OR sm.sender_role='USER' THEN 1 ELSE 0 END) AS incoming_count
                    FROM support_tickets t
                    LEFT JOIN support_messages sm ON sm.ticket_id=t.id
                    LEFT JOIN users u ON u.id=t.user_id
                    GROUP BY t.id, t.user_id, t.subject, t.status, t.priority, t.unread_admin_count, t.unread_user_count, last_at, u.username, u.first_name, u.last_name, u.is_blocked
                    ORDER BY last_at DESC LIMIT 180";
            $stmtSupportChats = $pdo->query($sql);
            foreach (($stmtSupportChats ? $stmtSupportChats->fetchAll(PDO::FETCH_ASSOC) : []) as $r) {
                $hay = implode(' ', [(string)$r['user_id'], (string)($r['username'] ?? ''), (string)($r['first_name'] ?? ''), (string)($r['last_name'] ?? ''), (string)($r['subject'] ?? '')]);
                if ($q !== '' && mb_stripos($hay, $q, 0, 'UTF-8') === false) continue;
                $last = $pdo->prepare("SELECT message_text, telegram_file_id, attachment_path, sender_role, is_admin_reply FROM support_messages WHERE ticket_id=? ORDER BY COALESCE(sent_at, created_at) DESC, id DESC LIMIT 1");
                $last->execute([(int)$r['ticket_id']]);
                $lm = $last->fetch(PDO::FETCH_ASSOC) ?: [];
                $r['type'] = 'support';
                $r['type_label'] = 'چت کاربران';
                $r['status_label'] = gemaccSupportStatusLabel((string)($r['status'] ?? 'OPEN'));
                $r['unread_count'] = (int)($r['unread_admin_count'] ?? 0);
                $r['incoming_count'] = (int)($r['unread_admin_count'] ?? 0);
                $r['last_message'] = trim((string)($lm['message_text'] ?? '')) ?: ((!empty($lm['telegram_file_id']) || !empty($lm['attachment_path'])) ? '📎 فایل/تصویر' : '');
                $r['last_direction'] = ((string)($lm['sender_role'] ?? '') === 'ADMIN' || (int)($lm['is_admin_reply'] ?? 0) === 1) ? 'admin_to_user' : 'user_to_admin';
                $r['user_label'] = gemaccAdminChatUserLabel($r);
                $items[] = $r;
            }
        }
        usort($items, fn($a,$b) => strtotime((string)($b['last_at'] ?? '1970-01-01')) <=> strtotime((string)($a['last_at'] ?? '1970-01-01')));
        return array_slice($items, 0, $limit);
    }
}

if (!function_exists('gemaccAdminChatMessages')) {
    function gemaccAdminChatMessages(string $type, int $userId, int $limit = 80): array {
        $pdo = function_exists('db') ? db() : null;
        if (!$pdo instanceof PDO || $userId <= 0) return [];
        gemaccAdminChatEnsureSchema($pdo);
        $type = in_array($type, ['all','private','support'], true) ? $type : 'all';
        $limit = max(1, min(200, $limit));
        $items = [];
        if (($type === 'all' || $type === 'private') && gemaccAdminChatTableExists($pdo, 'admin_private_messages')) {
            $st = $pdo->prepare("SELECT id, admin_id, user_id, direction, message_text, telegram_file_id, created_at, NULL AS ticket_id, 'private' AS source FROM admin_private_messages WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT ?");
            $st->bindValue(1, $userId, PDO::PARAM_INT); $st->bindValue(2, $limit, PDO::PARAM_INT); $st->execute();
            foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $m) { $m['source']='private'; $m['source_label']='چت خصوصی'; $items[] = $m; }
        }
        if (($type === 'all' || $type === 'support') && gemaccAdminChatTableExists($pdo, 'support_tickets') && gemaccAdminChatTableExists($pdo, 'support_messages')) {
            $st = $pdo->prepare("SELECT sm.id, sm.sender_id AS admin_id, t.user_id, sm.sender_role, sm.is_admin_reply, sm.message_text, sm.telegram_file_id, sm.attachment_type, sm.attachment_name, sm.attachment_mime, sm.attachment_path, COALESCE(sm.sent_at, sm.created_at) AS created_at, sm.ticket_id, t.status
                FROM support_messages sm JOIN support_tickets t ON t.id=sm.ticket_id WHERE t.user_id=? ORDER BY COALESCE(sm.sent_at, sm.created_at) DESC, sm.id DESC LIMIT ?");
            $st->bindValue(1, $userId, PDO::PARAM_INT); $st->bindValue(2, $limit, PDO::PARAM_INT); $st->execute();
            foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $m) {
                $m['source']='support'; $m['source_label']='چت پشتیبانی';
                $m['direction'] = ((string)($m['sender_role'] ?? '') === 'ADMIN' || (int)($m['is_admin_reply'] ?? 0) === 1) ? 'admin_to_user' : 'user_to_admin';
                $items[] = $m;
            }
            try { $pdo->prepare("UPDATE support_messages sm JOIN support_tickets t ON t.id=sm.ticket_id SET sm.is_read=1 WHERE t.user_id=? AND (sm.sender_role='USER' OR COALESCE(sm.is_admin_reply,0)=0)")->execute([$userId]); } catch (Throwable $ignored) {}
            try { $pdo->prepare("UPDATE support_tickets SET unread_admin_count=0 WHERE user_id=? AND status<>'CLOSED'")->execute([$userId]); } catch (Throwable $ignored) {}
        }
        usort($items, function($a,$b){ $ta=strtotime((string)($a['created_at'] ?? '1970-01-01')); $tb=strtotime((string)($b['created_at'] ?? '1970-01-01')); if($ta===$tb) return ((int)($a['id']??0)) <=> ((int)($b['id']??0)); return $ta <=> $tb; });
        return array_slice($items, -$limit);
    }
}

if (!function_exists('gemaccAdminChatSendReply')) {
    function gemaccAdminChatSendReply(string $type, int $adminId, int $userId, string $message): array {
        $pdo = function_exists('db') ? db() : null;
        if (!$pdo instanceof PDO) return ['ok'=>false, 'error'=>'DB unavailable'];
        $type = in_array($type, ['private','support'], true) ? $type : 'private';
        if ($type === 'support') return gemaccSupportAdminSendReply($pdo, $adminId, $userId, $message);
        if (function_exists('gemaccSendPrivateAdminMessageToUser')) {
            $res = gemaccSendPrivateAdminMessageToUser($userId, $message, $adminId);
            return ['ok'=>!empty($res['ok']), 'telegram_response'=>$res];
        }
        $res = function_exists('tg') ? tg('sendMessage', ['chat_id'=>$userId, 'text'=>$message]) : false;
        $ok = is_array($res) ? !empty($res['ok']) : (bool)$res;
        return ['ok'=>$ok, 'telegram_response'=>$res];
    }
}

// v27.55 — Telegram Stars pending-order auto cancellation helpers.
if (!function_exists('gemaccStarsPendingAutoCancelSeconds')) {
    function gemaccStarsPendingAutoCancelSeconds(): int {
        $raw = function_exists('getSetting') ? getSetting('stars_pending_auto_cancel_seconds', env('STARS_PENDING_AUTO_CANCEL_SECONDS', '15')) : env('STARS_PENDING_AUTO_CANCEL_SECONDS', '15');
        $sec = (int)$raw;
        return max(5, min(300, $sec));
    }
}

if (!function_exists('gemaccStarsEnsureAutoCancelColumns')) {
    function gemaccStarsEnsureAutoCancelColumns(PDO $pdo): void {
        try { gemaccCoreAddColumn($pdo, 'orders', 'stars_expires_at', 'DATETIME NULL AFTER receipt_text'); } catch (Throwable $e) {}
        try { gemaccCoreAddColumn($pdo, 'orders', 'stars_auto_cancel_notified_at', 'DATETIME NULL AFTER stars_expires_at'); } catch (Throwable $e) {}
    }
}

if (!function_exists('gemaccStarsRollbackDiscountUsage')) {
    function gemaccStarsRollbackDiscountUsage(PDO $pdo, int $orderId): void {
        try {
            $du = $pdo->prepare("SELECT discount_id FROM discount_usages WHERE order_id=?");
            $du->execute([$orderId]);
            $discountIds = array_map('intval', $du->fetchAll(PDO::FETCH_COLUMN));
            foreach ($discountIds as $did) {
                if ($did > 0) {
                    $pdo->prepare("UPDATE discount_codes SET used_count=GREATEST(used_count-1,0) WHERE id=?")->execute([$did]);
                }
            }
            $pdo->prepare("DELETE FROM discount_usages WHERE order_id=?")->execute([$orderId]);
        } catch (Throwable $ignored) {}
    }
}

if (!function_exists('gemaccStarsAutoCancelUserMessage')) {
    function gemaccStarsAutoCancelUserMessage(int $orderId, int $seconds): string {
        return "⭐ سفارش پرداخت با استارز شما لغو شد.\n\n" .
            "پرداخت سفارش #{$orderId} تا {$seconds} ثانیه تکمیل نشد، بنابراین سفارش به‌صورت خودکار لغو شد.\n" .
            "اگر قصد خرید دارید، لطفاً به صفحه پرداخت برگردید و دوباره سفارش را ثبت کنید.";
    }
}

if (!function_exists('gemaccStarsAutoCancelPendingOrder')) {
    function gemaccStarsAutoCancelPendingOrder(PDO $pdo, int $orderId, ?int $userId = null, bool $notifyUser = true, bool $requireExpired = true): array {
        if ($orderId <= 0) return ['ok'=>false, 'error'=>'شناسه سفارش نامعتبر است.'];
        gemaccStarsEnsureAutoCancelColumns($pdo);
        $seconds = gemaccStarsPendingAutoCancelSeconds();
        $pdo->beginTransaction();
        try {
            $sql = "SELECT * FROM orders WHERE id=? AND UPPER(COALESCE(payment_method,''))='STARS'";
            $params = [$orderId];
            if ($userId !== null && $userId > 0) { $sql .= " AND user_id=?"; $params[] = $userId; }
            $sql .= " FOR UPDATE";
            $st = $pdo->prepare($sql);
            $st->execute($params);
            $order = $st->fetch(PDO::FETCH_ASSOC);
            if (!$order) throw new RuntimeException('سفارش استارز یافت نشد.');

            $paymentStatus = strtolower((string)($order['payment_status'] ?? ''));
            $status = strtoupper((string)($order['status'] ?? ''));
            if ($paymentStatus === 'paid' || in_array($status, ['DELIVERED','APPROVED','PAID'], true)) {
                $pdo->commit();
                return ['ok'=>false, 'paid'=>true, 'message'=>'این سفارش پرداخت شده و لغو نمی‌شود.'];
            }
            if (in_array($status, ['CANCELLED','REJECTED','FAKE_RECEIPT'], true)) {
                $pdo->commit();
                return ['ok'=>true, 'already_cancelled'=>true, 'message'=>'این سفارش قبلاً بسته شده است.'];
            }

            if ($requireExpired) {
                $createdAt = strtotime((string)($order['created_at'] ?? 'now')) ?: time();
                $expiresAt = !empty($order['stars_expires_at']) ? strtotime((string)$order['stars_expires_at']) : ($createdAt + $seconds);
                if (time() < $expiresAt) {
                    $pdo->commit();
                    return ['ok'=>false, 'not_expired'=>true, 'expires_at'=>$expiresAt, 'message'=>'زمان لغو خودکار هنوز نرسیده است.'];
                }
            }

            gemaccStarsRollbackDiscountUsage($pdo, $orderId);
            $wasNotified = !empty($order['stars_auto_cancel_notified_at']);
            $pdo->prepare("UPDATE orders SET payment_status='cancelled', delivery_status='cancelled', status='CANCELLED', stars_auto_cancel_notified_at=IF(stars_auto_cancel_notified_at IS NULL,NOW(),stars_auto_cancel_notified_at), updated_at=NOW() WHERE id=?")
                ->execute([$orderId]);
            $pdo->commit();

            if ($notifyUser && !$wasNotified) {
                try {
                    $chatId = (int)($order['user_id'] ?? 0);
                    if ($chatId > 0 && function_exists('tgSendSafe')) {
                        tgSendSafe('sendMessage', [
                            'chat_id' => $chatId,
                            'text' => gemaccStarsAutoCancelUserMessage($orderId, $seconds),
                        ]);
                    }
                } catch (Throwable $ignored) {}
            }
            return ['ok'=>true, 'cancelled'=>true, 'message'=>'سفارش استارز لغو شد.'];
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            return ['ok'=>false, 'error'=>$e->getMessage()];
        }
    }
}

if (!function_exists('gemaccStarsAutoCancelExpired')) {
    function gemaccStarsAutoCancelExpired(PDO $pdo, int $limit = 25): int {
        gemaccStarsEnsureAutoCancelColumns($pdo);
        $seconds = gemaccStarsPendingAutoCancelSeconds();
        $limit = max(1, min(100, $limit));
        try {
            $sql = "SELECT id FROM orders
                    WHERE UPPER(COALESCE(payment_method,''))='STARS'
                      AND LOWER(COALESCE(payment_status,'')) NOT IN ('paid','completed','success')
                      AND status='PENDING'
                      AND COALESCE(stars_expires_at, DATE_ADD(created_at, INTERVAL {$seconds} SECOND)) <= NOW()
                    ORDER BY id ASC
                    LIMIT {$limit}";
            $ids = array_map('intval', $pdo->query($sql)->fetchAll(PDO::FETCH_COLUMN));
        } catch (Throwable $e) {
            error_log('[stars_auto_cancel_select] ' . $e->getMessage());
            return 0;
        }
        $n = 0;
        foreach ($ids as $id) {
            $res = gemaccStarsAutoCancelPendingOrder($pdo, $id, null, true, true);
            if (!empty($res['ok']) && !empty($res['cancelled'])) $n++;
        }
        return $n;
    }
}

if (!function_exists('gemaccStarsEnqueueAutoCancel')) {
    function gemaccStarsEnqueueAutoCancel(PDO $pdo, int $orderId, int $userId): void {
        $seconds = gemaccStarsPendingAutoCancelSeconds();
        try {
            $payload = json_encode(['order_id'=>$orderId, 'user_id'=>$userId, 'seconds'=>$seconds], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
            $dedupe = 'stars_auto_cancel_order_' . $orderId;
            $stmt = $pdo->prepare("INSERT INTO jobs(type,payload,available_at,dedupe_key,max_attempts)
                                   VALUES('stars_auto_cancel_order', ?, DATE_ADD(NOW(), INTERVAL {$seconds} SECOND), ?, 1)
                                   ON DUPLICATE KEY UPDATE payload=VALUES(payload), available_at=VALUES(available_at), status=IF(status='DONE','DONE','PENDING')");
            $stmt->execute([$payload ?: '{}', $dedupe]);
        } catch (Throwable $e) {
            error_log('[stars_auto_cancel_enqueue] ' . $e->getMessage());
        }
    }
}
