<?php
require __DIR__ . '/config.php';

// Try Composer autoload if present
$__autoload_path = __DIR__ . '/vendor/autoload.php';
if (file_exists($__autoload_path)) { require $__autoload_path; }

// PSR-4 fallback autoloader (works without Composer)
spl_autoload_register(function($class){
    if (strpos($class, 'App\\') === 0) {
        $rel = substr($class, 4);
        $rel = str_replace('\\', '/', $rel); // داخل رشته یک بک‌اسلش کافیست
        $file = __DIR__ . '/lib/' . $rel . '.php';
        if (file_exists($file)) { require $file; }
    }
});

use App\Telegram;
use App\Utils;
use App\Zohal;
use App\Excel;
use App\Store;
use App\Parser;
use App\Render;
use App\Health;

function isAdmin($user_id): bool { return in_array(intval($user_id), ADMIN_IDS, true); }
function labelFromFilename(string $filename): string { return preg_replace('/\.[^.]+$/', '', basename($filename)); }

// ------------------------------
// Pending channel post (for inline "واریزی/برگشتی/هر دو" confirm)
// ------------------------------
function pendingDir(): string {
    $d = DATA_DIR . '/_pending';
    if (!is_dir($d)) { @mkdir($d, 0775, true); }
    return $d;
}

function savePendingPost(string $token, array $payload): void {
    $path = rtrim(pendingDir(), '/') . '/' . $token . '.json';
    @file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}

function loadPendingPost(string $token): ?array {
    $path = rtrim(pendingDir(), '/') . '/' . $token . '.json';
    if (!is_file($path)) return null;
    $raw = @file_get_contents($path);
    $j = $raw ? json_decode($raw, true) : null;
    return is_array($j) ? $j : null;
}

function deletePendingPost(string $token): void {
    $path = rtrim(pendingDir(), '/') . '/' . $token . '.json';
    if (is_file($path)) @unlink($path);
}

// ------------------------------
// Metadata هر دیتاست (مثل نوع واریزی/برگشتی) — data/<label>/meta.json
// ------------------------------
function datasetMetaPath(string $label): string {
    return Store::datasetDir($label) . '/meta.json';
}

function loadDatasetMeta(string $label): array {
    $p = datasetMetaPath($label);
    if (!is_file($p)) return [];
    $j = json_decode((string)@file_get_contents($p), true);
    return is_array($j) ? $j : [];
}

function saveDatasetMeta(string $label, array $meta): void {
    $dir = Store::datasetDir($label);
    if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
    $old = loadDatasetMeta($label);
    @file_put_contents(datasetMetaPath($label), json_encode(array_merge($old, $meta), JSON_UNESCAPED_UNICODE));
}

/** «شامل X واریزی و Y برگشتی» — بخش‌های صفر حذف می‌شوند */
function countsLine(array $counts): string {
    $v = (int)($counts['v'] ?? 0);
    $b = (int)($counts['b'] ?? 0);
    $parts = [];
    if ($v > 0) $parts[] = "{$v} واریزی";
    if ($b > 0) $parts[] = "{$b} برگشتی";
    return empty($parts) ? '' : ('شامل ' . implode(' و ', $parts));
}

function codesFromFinalSheet(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, int $startRow = 0): array {
    if ($startRow <= 0) $startRow = defined('FINAL_BANK_START_ROW') ? FINAL_BANK_START_ROW : 7;
    $col = defined('FINAL_DESC_COL') ? FINAL_DESC_COL : 'I';
    $codes = [];
    $seen = [];
    $max = $sheet->getHighestRow();
    for ($r = $startRow; $r <= $max; $r++) {
        $v = $sheet->getCell($col.$r)->getCalculatedValue();
        $v = is_null($v) ? '' : trim((string)$v);
        if ($v === '') continue;
        // سلول ممکن است «#123 واریزی» یا فقط «واریزی» باشد — فقط عدد کد را بردار
        if (!preg_match('/#?\s*(\d{3,})/u', Utils::toEnglishDigits($v), $mm)) continue;
        $norm = $mm[1];
        if (isset($seen[$norm])) continue;
        $seen[$norm] = true;
        $codes[] = '#'.$norm;
    }
    return $codes;
}

// Like codesFromFinalSheet(), but returns codes for EACH non-empty row (no de-duplication)
// so we can map them to rendered PNG pages.
function codesFromFinalSheetRows(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet, int $startRow = 0): array {
    if ($startRow <= 0) $startRow = defined('FINAL_BANK_START_ROW') ? FINAL_BANK_START_ROW : 7;
    $col = defined('FINAL_DESC_COL') ? FINAL_DESC_COL : 'I';
    $codes = [];
    $max = $sheet->getHighestRow();
    for ($r = $startRow; $r <= $max; $r++) {
        $v = $sheet->getCell($col.$r)->getCalculatedValue();
        $v = is_null($v) ? '' : trim((string)$v);
        if ($v === '') continue;
        // ردیف بدون کد هم جای‌گذار می‌گیرد تا با صفحات رندرشده هم‌تراز بماند
        if (preg_match('/#?\s*(\d{3,})/u', Utils::toEnglishDigits($v), $mm)) {
            $codes[] = '#'.$mm[1];
        } else {
            $codes[] = 'بدون کد';
        }
    }
    return $codes;
}

function buildCodesCaption(string $type, array $codes): string {
    $joiner = defined('AUTO_POST_CODES_JOINER') ? AUTO_POST_CODES_JOINER : ' ، ';
    if ($type === 'v') $prefix = (defined('AUTO_POST_PREFIX_VARIZI') ? AUTO_POST_PREFIX_VARIZI : 'واریزی کد های:');
    elseif ($type === 'b') $prefix = (defined('AUTO_POST_PREFIX_BARGASHTI') ? AUTO_POST_PREFIX_BARGASHTI : 'برگشتی کد های:');
    else $prefix = (defined('AUTO_POST_PREFIX_BOTH') ? AUTO_POST_PREFIX_BOTH : 'واریزی یا برگشتی کد های:');

    $list = implode($joiner, $codes);
    return $list !== '' ? ($prefix . "\n" . $list) : $prefix;
}

function codesToLines(array $codes, int $perLine = 25): array {
    $joiner = defined('AUTO_POST_CODES_JOINER') ? AUTO_POST_CODES_JOINER : ' ، ';
    $out = [];
    $buf = [];
    foreach ($codes as $c) {
        $c = trim((string)$c);
        if ($c === '') continue;
        $buf[] = $c;
        if (count($buf) >= $perLine) {
            $out[] = implode($joiner, $buf);
            $buf = [];
        }
    }
    if (!empty($buf)) $out[] = implode($joiner, $buf);
    return $out;
}

function typeLabel(string $type): string {
    if ($type === 'v') return 'واریزی';
    if ($type === 'b') return 'برگشتی';
    return 'هر دو';
}

function sendCodesToChannel($channel_id, string $type, array $codes): void {
    if (empty($codes)) {
        Telegram::sendMessage($channel_id, (defined('AUTO_POST_PREFIX_BOTH') ? AUTO_POST_PREFIX_BOTH : 'گزارش کدها'));
        return;
    }
    if ($type === 'v') $prefix = (defined('AUTO_POST_PREFIX_VARIZI') ? AUTO_POST_PREFIX_VARIZI : 'واریزی کد های:');
    elseif ($type === 'b') $prefix = (defined('AUTO_POST_PREFIX_BARGASHTI') ? AUTO_POST_PREFIX_BARGASHTI : 'برگشتی کد های:');
    else $prefix = (defined('AUTO_POST_PREFIX_BOTH') ? AUTO_POST_PREFIX_BOTH : 'واریزی یا برگشتی کد های:');

    $lines = codesToLines($codes, defined('AUTO_POST_CODES_PER_LINE') ? (int)AUTO_POST_CODES_PER_LINE : 25);
    sendLongMessage($channel_id, $prefix, $lines);
}

// If $pageMeta + $rowCodes are provided, bot will send page-related codes as a message right after each photo.
// $counts = ['v'=>N,'b'=>M] → زیر کپشن هر عکس «شامل N واریزی و M برگشتی» می‌آید.
function sendPhotosPaged($chat_id, array $pngs, string $label, string $type = 'a', array $pageMeta = [], array $rowCodes = [], array $counts = []): bool {
    $pngs = array_values(array_filter($pngs, fn($p) => is_string($p) && $p !== '' && is_file($p)));
    if (empty($pngs)) return false;
    $total = count($pngs);
    $tlabel = typeLabel($type);
    $cline = countsLine($counts);
    for ($i = 0; $i < $total; $i++) {
        // User asked: do NOT show filename (label) under images.
        // Keep captions minimal to avoid Telegram caption limits.
        $cap = "🖼 {$tlabel} — صفحه " . ($i+1) . "/{$total}";
        if ($cline !== '') $cap .= "\n" . $cline;
        Telegram::sendPhoto($chat_id, $pngs[$i], $cap);

        // Send codes for this page right under the photo (as a separate message)
        if (!empty($pageMeta) && !empty($rowCodes) && isset($pageMeta[$i])) {
            $off = (int)($pageMeta[$i]['offset'] ?? ($i * (defined('FINAL_PHOTO_MAX_ROWS') ? (int)FINAL_PHOTO_MAX_ROWS : 40)));
            $rows = (int)($pageMeta[$i]['rows'] ?? 0);
            if ($rows <= 0) continue;
            $slice = array_slice($rowCodes, $off, $rows);
            if (empty($slice)) continue;

            // Telegram message limit ~4096 chars. We'll chunk safely.
            $lines = codesToLines($slice, defined('AUTO_POST_CODES_PER_LINE') ? (int)AUTO_POST_CODES_PER_LINE : 25);
            $prefix = "کدهای صفحه " . ($i+1) . "/{$total}:";
            sendLongMessage($chat_id, $prefix, $lines);
        }
    }
    return true;
}


function currentJobId(): ?string {
    return $GLOBALS['APP_JOB_ID'] ?? null;
}

/**
 * برچسب یک رکورد رد‌شده برای گزارش «کدهای حذف‌شده».
 *
 * رکورد بدون کد محصول هم باید گزارش شود؛ قبلاً همهٔ مسیرهای رد پشت
 * if (!empty($rec['product_code'])) بودند و رکورد بی‌کد بی‌صدا ناپدید می‌شد.
 * چنین رکوردی با نام/شبا/کارت/مبلغ شناسانده می‌شود تا قابل پیگیری باشد.
 */
function skippedRecordLabel(array $rec): string {
    $code = trim((string)($rec['product_code'] ?? ''));
    if ($code !== '') return $code;

    $parts = [];
    $name = trim((string)($rec['name'] ?? ''));
    if ($name !== '') $parts[] = $name;
    if (!empty($rec['iban'])) {
        $parts[] = strtoupper((string)$rec['iban']);
    } elseif (!empty($rec['card'])) {
        $parts[] = '****' . substr(Utils::onlyDigits((string)$rec['card']), -4);
    }
    if (($rec['amount_toman'] ?? null) !== null) {
        $parts[] = number_format((int)$rec['amount_toman']) . ' تومان';
    }
    if (!empty($rec['message_id'])) $parts[] = 'msg#' . $rec['message_id'];

    return '(بدون کد) ' . ($parts ? implode(' — ', $parts) : 'رکورد ناشناس');
}

function handleJsonUpload(array $file, array $chat, $message_id, ?string $job_id = null): void {
    $chat_id = $chat['id'];
    clearAmountWait($chat_id); // اگر فلو قبلی نیمه‌کاره مانده، state انتظار بازه پاک شود
    $file_name = $file['file_name'] ?? ('data_' . time() . '.json');
    $origLabel = labelFromFilename($file_name);
    $label = sanitizeLabel($origLabel);
    if ($label !== $origLabel) {
        Telegram::sendMessage($chat_id, "ℹ️ نام فایل شامل کاراکتر غیرمجاز بود؛ لیبل دیتاست شد: <code>{$label}</code>");
    }
    $dirs = Store::ensureDatasetDirs($label);

    // Save uploaded JSON in a global archive folder (unique per job) + latest.json
    $jobId = $job_id ?: (date('Ymd_His') . '_m' . intval($message_id) . '_' . bin2hex(random_bytes(3)));
    $uploadsDir = Store::ensureGlobalUploadsDir();
    $safeOriginal = preg_replace('/[^A-Za-z0-9._-]+/u', '_', basename($file_name));
    if ($safeOriginal === '' || $safeOriginal === '_') { $safeOriginal = 'data.json'; }
    if (strtolower(pathinfo($safeOriginal, PATHINFO_EXTENSION)) !== 'json') { $safeOriginal .= '.json'; }
    $uniqueName = $jobId . '_' . $safeOriginal;
    $uniquePath = rtrim($uploadsDir, '/') . '/' . $uniqueName;
    $latestPath = rtrim($uploadsDir, '/') . '/latest.json';

    $ok = Telegram::downloadFileById($file['file_id'], $uniquePath);
    if (!$ok) {
        Telegram::sendMessage($chat_id, '⚠️ دانلود فایل JSON از تلگرام ناموفق بود.');
        return;
    }
    @copy($uniquePath, $latestPath);

    $tmp = $dirs['raw'] . '/' . $uniqueName;
    @copy($uniquePath, $tmp);

    try {
        $records = Parser::parseTelegramJsonToRecords($tmp);
    } catch (\RuntimeException $e) {
        Telegram::sendMessage($chat_id, "⚠️ خطایی در پردازش فایل JSON رخ داد.\n\n<pre>" . htmlspecialchars($e->getMessage()) . "</pre>");
        return;
    }

    $skipped = []; // [['code'=>..., 'text'=>...], ...] — بعداً فیلتر و به $skippedCodes تبدیل می‌شود
    $rows = [];
    $walletFailures = 0; // چند رکورد فقط به‌خاطر خالی بودن کیف پول زحل رد شدند

    // Pre-resolve card → IBAN batch + cache
    $needCards = [];
    foreach ($records as $rec) {
        if (!empty($rec['has_kyc'])) continue;
        if (empty($rec['iban']) && !empty($rec['card'])) {
            $card = Utils::onlyDigits($rec['card']);
            if (strlen($card) === 16) $needCards[] = $card;
        }
    }
    $cardMap = $needCards ? Zohal::cardToIBANBatchCached($needCards) : [];

    foreach ($records as $rec) {
        if (!empty($rec['has_kyc'])) {
            $reason = !empty($rec['kyc_status']) ? ' (احراز: ' . $rec['kyc_status'] . ')' : ' (احراز تاییدنشده)';
            $skipped[] = ['code' => $rec['product_code'] ?? '', 'text' => skippedRecordLabel($rec) . $reason];
            continue;
        }
        $amount_toman = $rec['amount_toman'] ?? null;
        $amount_rial = ($amount_toman !== null) ? $amount_toman * 10 : null;

        $iban = $rec['iban'];
        $bankName = $rec['bank_name'] ?? null;
        $zibilName = null; // نام صاحب حساب از استعلام زحل (فقط برای رکوردهای مبتنی بر کارت)
        if (!$iban && !empty($rec['card'])) {
            $card = Utils::onlyDigits($rec['card']);
            if (strlen($card) === 16) {
                $z = $cardMap[$card] ?? Zohal::cardToIBAN($card);
                if (!empty($z['success']) && !empty($z['iban'])) {
                    $iban     = strtoupper($z['iban']);
                    $bankName = $z['bankName'] ?? null;
                    $zibilName = isset($z['name']) && $z['name'] !== '' ? trim($z['name']) : null;
                }
                else {
                    // پاسخ واقعی زحل باید در گزارش بیاید، وگرنه معلوم نیست کارت
                    // خراب بوده یا فقط استعلام لحظه‌ای شکست خورده.
                    $zErr = trim((string)($z['error'] ?? ''));
                    $tail = ' (عدم تبدیل کارت به شبا' . ($zErr !== '' ? ' — ' . $zErr : '')
                          . ' | کارت: ****' . substr($card, -4) . ')';
                    $skipped[] = ['code' => $rec['product_code'] ?? '', 'text' => skippedRecordLabel($rec) . $tail];
                    // کمبود موجودی کیف پول اصلاً مشکلِ کارت نیست: اگر وسط دسته
                    // اتفاق بیفتد بقیهٔ کارت‌ها هم رد می‌شوند. جدا شمرده می‌شود
                    // تا در گزارش پررنگ هشدار داده شود و با خطای کارت قاطی نشود.
                    if (!empty($z['wallet'])) $walletFailures++;
                    continue;
                }
            } else { $skipped[] = ['code' => $rec['product_code'] ?? '', 'text' => skippedRecordLabel($rec) . ' (شماره کارت نامعتبر: ' . strlen($card) . ' رقم)']; continue; }
        }
        if (empty($iban) || $amount_rial === null || $amount_rial <= 0) {
            if ($amount_rial === null)     $reason = ' (مبلغ نامشخص)';
            elseif ($amount_rial <= 0)     $reason = ' (مبلغ نامعتبر)';
            else                           $reason = ' (بدون شبا/کارت)';
            $skipped[] = ['code' => $rec['product_code'] ?? '', 'text' => skippedRecordLabel($rec) . $reason];
            continue;
        }
        // نام: اولویت با پیام تلگرام (در صورتی که معتبر باشد)، اگر خالی/نامعتبر بود از API زیبال، اگر هیچکدام نبود «مشتری»
        $rawName = trim((string)($rec['name'] ?? ''));
        $invalidNames = ['—', '-', '–', 'نامشخص', 'استعلام ناموفق', 'null', 'none'];
        if ($rawName !== '' && !in_array($rawName, $invalidNames, true)) {
            $resolvedName = $rawName;
        } else {
            $resolvedName = $zibilName ?? '';
        }
        // حذف کاراکترهای صفر-عرض (ZWNJ و مشابه) که سیستم‌های بانکی reject می‌کنند
        $resolvedName = preg_replace('/[\x{200B}\x{200C}\x{200D}\x{200E}\x{200F}\x{FEFF}]/u', '', $resolvedName);
        $resolvedName = trim(preg_replace('/\s+/u', ' ', $resolvedName));
        if ($resolvedName === '' || in_array($resolvedName, $invalidNames, true)) { $resolvedName = 'مشتری'; }

        // بانک: اگر در پیام تلگرام خط‌تیره یا نامشخص بود، از استعلام زیبال بردار
        $rawBank = trim((string)($rec['bank_name'] ?? ''));
        if ($rawBank !== '' && !in_array($rawBank, $invalidNames, true) && strpos($rawBank, 'استعلام ناموفق') === false) {
            $resolvedBank = $rawBank;
        } else {
            $resolvedBank = trim((string)($bankName ?? ''));
        }

        $rows[] = [
            'amount_rial' => $amount_rial,
            'iban'        => $iban,
            'name'        => $resolvedName,
            'code'        => $rec['product_code'] ?? '',
            'bank'        => $resolvedBank,
        ];
    }

    // ── قانون: کدِ مشکل‌دار نباید وارد فایل واریز شود ────────────────────────
    // واحد این قانون «کد» است، نه «رکورد». یک کد می‌تواند چند پیام در کانال
    // داشته باشد (اصلاحیه، ارسال مجدد، یا چند واریز). اگر حتی یکی از آن‌ها شبا
    // نداشت، کارتش به شبا تبدیل نشد، یا احراز هویتش مشکل داشت، کل آن کد از
    // خروجی کنار می‌رود — نه فقط همان یک رکورد.
    //
    // fail-closed: اگر دربارهٔ یک کد شک هست، پول نمی‌رود. برعکسش (واریز به
    // کسی که احراز نشده) قابل برگشت نیست.
    $blockedCodes = [];
    foreach ($skipped as $s) {
        $c = Utils::normalizeProductCode((string)$s['code']);
        if ($c !== '') $blockedCodes[$c] = true;
    }

    $skippedCodes = array_column($skipped, 'text');
    $pulledCodes  = []; // کدهایی که رکورد سالمشان هم به‌خاطر رکورد خرابِ هم‌کد بیرون رفت

    if ($blockedCodes) {
        $kept = [];
        foreach ($rows as $r) {
            $c = Utils::normalizeProductCode((string)($r['code'] ?? ''));
            if ($c !== '' && isset($blockedCodes[$c])) {
                $pulledCodes[$c] = true;
                $skippedCodes[]  = '#' . $c . ' (رکورد دیگری با همین کد مشکل داشت — کل کد از فایل واریز خارج شد)';
                continue;
            }
            $kept[] = $r;
        }
        $rows = $kept;
    }
    // array_keys کلیدهای عددی را int برمی‌گرداند؛ برای گزارش رشته لازم است.
    $pulledCodes = array_map('strval', array_keys($pulledCodes));

    // Build base XLS (no header) for later /p merge — always from ALL rows
    $spread = Excel::newSpreadsheet();
    $sheet = $spread->getActiveSheet();
    $sheet->setTitle('base');
    $rowNum = 1;
    foreach ($rows as $r) {
        Excel::setRowValues($sheet, $rowNum, [$r['amount_rial'], $r['iban'], '', $r['name'], $r['code']], 'A');
        $rowNum++;
    }
    $basePath = $dirs['excel_base'] . '/' . $label . '.xls';
    Excel::saveXls($spread, $basePath);
    Telegram::sendDocument($chat_id, $basePath, "(نسخه اکسل داخلی برای ادغام /p)");

    if (empty($rows)) {
        if (!empty($skippedCodes)) {
            $logPath = $dirs['logs'] . '/' . date('Ymd_His') . '_skipped_codes.txt';
            file_put_contents($logPath, implode("\n", $skippedCodes));
            foreach (ADMIN_IDS as $admin) {
                Telegram::sendMessage($admin, "کدهای حذف‌شده (در خروجی نیامدند):\n" . implode("\n", $skippedCodes));
            }
        }
        Telegram::sendMessage($chat_id, '⚠️ هیچ رکورد قابل پردازشی در فایل JSON یافت نشد.');
        return;
    }

    // --- Ask type first (واریزی/برگشتی)، بعد گام ۰ فیلتر مبلغ ---
    $token = substr(bin2hex(random_bytes(8)), 0, 12);

    // خلاصه‌ی مبالغ برای راهنمایی ادمین
    $minT = null; $maxT = null; $sumR = 0;
    foreach ($rows as $r) {
        $t = (int)($r['amount_rial'] / 10);
        if ($minT === null || $t < $minT) $minT = $t;
        if ($maxT === null || $t > $maxT) $maxT = $t;
        $sumR += (int)$r['amount_rial'];
    }

    $summary  = "✅ فایل پارس شد — <b>" . count($rows) . " رکورد</b>\n";
    $summary .= "کمترین مبلغ: " . number_format((int)$minT) . " تومان\n";
    $summary .= "بیشترین مبلغ: " . number_format((int)$maxT) . " تومان\n";
    $summary .= "جمع کل: " . number_format($sumR) . " ریال (" . number_format((int)($sumR/10)) . " تومان)\n";

    $msgText  = $summary;
    $msgText .= "\n🏷 <b>این دسته واریزی است یا برگشتی؟</b>\n<i>(در فیلد توضیحات فایل‌های بانکی کنار کد نوشته می‌شود)</i>";

    $kb = ['inline_keyboard' => [[
        ['text' => '💸 واریزی',  'callback_data' => 'jty:' . $token . ':v'],
        ['text' => '↩️ برگشتی', 'callback_data' => 'jty:' . $token . ':b'],
    ]]];
    $sent  = Telegram::sendMessage($chat_id, $msgText, null, 'HTML', $kb);
    $msgId = $sent['result']['message_id'] ?? 0;

    savePendingPost($token, [
        'created_at'       => time(),
        'type'             => 'bank_select',
        'step'             => 'qt',
        'ptype'            => null,
        'summary'          => $summary,
        'msg_id'           => $msgId,
        'chat_id'          => $chat_id,
        'label'            => $label,
        'dirs'             => $dirs,
        'rows'             => $rows,
        'skipped_codes'    => $skippedCodes,
        'pulled_codes'     => $pulledCodes,
        'wallet_failures'  => $walletFailures,
        'amount_filter'    => null,
        'amount_excluded'  => [],
        'bank_list'        => [],
        'bank_counts'      => [],
        'selected_banks'   => [],
        'selected_outputs' => [],
    ]);
}

// ------------------------------------------------------------------
// گام ۰ (فیلتر مبلغ) — بعد از انتخاب نوع دسته نمایش داده می‌شود
// ------------------------------------------------------------------
function askAmountFilterStep($chat_id, string $token, array $payload): void {
    $summary  = (string)($payload['summary'] ?? '');
    $typeWord = (($payload['ptype'] ?? 'v') === 'b') ? 'برگشتی' : 'واریزی';

    $msgText  = $summary;
    $msgText .= "🏷 نوع دسته: <b>{$typeWord}</b>\n";
    $msgText .= "\n💰 <b>گام ۰:</b> فیلتر مبلغ می‌خواهی؟";

    $payload['step'] = 'q0';
    savePendingPost($token, $payload);

    $kb = ['inline_keyboard' => [
        [['text' => '➡️ بدون فیلتر مبلغ', 'callback_data' => 'afil_no:' . $token]],
        [['text' => '🔢 تعیین بازه مبلغ',  'callback_data' => 'afil_set:' . $token]],
    ]];

    $done = false;
    if (!empty($payload['msg_id'])) {
        try {
            Telegram::editMessageText($chat_id, $payload['msg_id'], $msgText, 'HTML', $kb);
            $done = true;
        } catch (\Throwable $e) {}
    }
    if (!$done) {
        $sent = Telegram::sendMessage($chat_id, $msgText, null, 'HTML', $kb);
        if (!empty($sent['result']['message_id'])) {
            $payload['msg_id'] = $sent['result']['message_id'];
            savePendingPost($token, $payload);
        }
    }
}

// ------------------------------------------------------------------
// گام ۱ (انتخاب بانک) — از روی ردیف‌های فعلی payload ساخته می‌شود
// (بعد از فیلتر مبلغ، لیست بانک‌ها و تعدادها دوباره محاسبه می‌شوند)
// ------------------------------------------------------------------
function askBankSelectionStep($chat_id, string $token, array $payload, bool $editExisting = true): void {
    $rows = $payload['rows'] ?? [];

    $bankCountMap = [];
    foreach ($rows as $r) {
        $bn = ($r['bank'] ?? '') !== '' ? $r['bank'] : 'نامشخص';
        $bankCountMap[$bn] = ($bankCountMap[$bn] ?? 0) + 1;
    }
    $bankList = array_keys($bankCountMap);
    sort($bankList, SORT_STRING);
    $sortedCounts = [];
    foreach ($bankList as $bn) { $sortedCounts[$bn] = $bankCountMap[$bn]; }

    $allIdxs = range(0, max(0, count($bankList) - 1));
    if (empty($bankList)) $allIdxs = [];

    $bankLines = '';
    foreach ($bankList as $bn) {
        $bankLines .= "\n  • {$bn}: " . $sortedCounts[$bn] . ' رکورد';
    }

    $filterNote = '';
    if (!empty($payload['amount_filter']) && is_array($payload['amount_filter'])) {
        [$fmin, $fmax] = $payload['amount_filter'];
        $exCnt = count($payload['amount_excluded'] ?? []);
        $filterNote = "\n💰 فیلتر مبلغ: " . number_format((int)$fmin) . " تا " . number_format((int)$fmax) . " تومان"
                    . ($exCnt > 0 ? " — <b>{$exCnt} رکورد خارج از بازه</b>" : '') . "\n";
    }

    $msgText  = "✅ <b>" . count($rows) . " رکورد</b> در خروجی\n";
    $msgText .= $filterNote;
    $msgText .= "\nبانک‌های موجود:{$bankLines}\n";
    $msgText .= "\n🏦 <b>گام ۱:</b> کدام بانک‌ها در خروجی باشند؟\n<i>(همه انتخاب شده‌اند — هر کدام را که نمی‌خواهی برداشته کن)</i>";

    $payload['step']           = 'q1';
    $payload['bank_list']      = $bankList;
    $payload['bank_counts']    = $sortedCounts;
    $payload['selected_banks'] = $allIdxs;
    savePendingPost($token, $payload);

    $kb = buildBankSelectionKeyboard($token, $bankList, $allIdxs);

    $done = false;
    if ($editExisting && !empty($payload['msg_id'])) {
        try {
            Telegram::editMessageText($chat_id, $payload['msg_id'], $msgText, 'HTML', $kb);
            $done = true;
        } catch (\Throwable $e) {}
    }
    if (!$done) {
        $sent = Telegram::sendMessage($chat_id, $msgText, null, 'HTML', $kb);
        if (!empty($sent['result']['message_id'])) {
            $payload['msg_id'] = $sent['result']['message_id'];
            savePendingPost($token, $payload);
        }
    }
}

// ------------------------------------------------------------------
// فیلتر مبلغ: state انتظار برای متن ادمین + پارس بازه
// ------------------------------------------------------------------
function amountWaitPath($chat_id): string {
    return rtrim(pendingDir(), '/') . '/awaitamt_' . intval($chat_id) . '.json';
}
function setAmountWait($chat_id, string $token): void {
    @file_put_contents(amountWaitPath($chat_id), json_encode(['token' => $token, 'ts' => time()]));
}
function getAmountWait($chat_id): ?string {
    $p = amountWaitPath($chat_id);
    if (!is_file($p)) return null;
    $j = json_decode((string)@file_get_contents($p), true);
    return is_array($j) && !empty($j['token']) ? (string)$j['token'] : null;
}
function clearAmountWait($chat_id): void {
    $p = amountWaitPath($chat_id);
    if (is_file($p)) @unlink($p);
}

/**
 * پارس بازه مبلغ (تومان) از متن ادمین.
 * فرمت‌های قابل قبول: «100000 5000000» ، «100,000-5,000,000» ، «۱۰۰۰۰۰ تا ۵۰۰۰۰۰۰»
 * خروجی: [min, max] به تومان یا null
 */
function parseAmountRangeToman(string $text): ?array {
    $t = Utils::toEnglishDigits($text);
    $t = str_replace(['تا', '،'], ' ', $t);
    if (!preg_match_all('/\d[\d,\.]*/u', $t, $m) || count($m[0]) < 2) return null;
    $nums = [];
    foreach ($m[0] as $raw) {
        $n = (int)preg_replace('/\D+/', '', $raw);
        if ($n > 0 || $raw === '0') $nums[] = $n;
    }
    if (count($nums) < 2) return null;
    $min = min($nums[0], $nums[1]);
    $max = max($nums[0], $nums[1]);
    if ($max <= 0) return null;
    return [$min, $max];
}

/**
 * اعمال فیلتر مبلغ روی payload: ردیف‌های خارج بازه به amount_excluded می‌روند.
 * $min/$max به تومان.
 */
function applyAmountFilter(array $payload, int $min, int $max): array {
    $kept = [];
    $excluded = [];
    foreach (($payload['rows'] ?? []) as $r) {
        $toman = (int)(($r['amount_rial'] ?? 0) / 10);
        if ($toman >= $min && $toman <= $max) {
            $kept[] = $r;
        } else {
            $excluded[] = [
                'code'  => (string)($r['code'] ?? ''),
                'bank'  => (string)($r['bank'] ?? ''),
                'toman' => $toman,
            ];
        }
    }
    $payload['rows']            = $kept;
    $payload['amount_filter']   = [$min, $max];
    $payload['amount_excluded'] = $excluded;
    return $payload;
}



// -------------------------------------------------------
// Detect bank statement XLS format by header row content
// Returns format config including render settings for PNG
// -------------------------------------------------------
function detectBankStatementFormat(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sh): array {
    // Bank Melli: header at row 6 — A6='شبا / حساب مقصد', H6='وضعیت'
    $a6 = trim((string)$sh->getCell('A6')->getValue());
    $h6 = trim((string)$sh->getCell('H6')->getValue());
    if (mb_strpos($a6, 'شبا') !== false || mb_strpos($h6, 'وضعیت') !== false) {
        return [
            'bank'           => 'melli',
            'header_row'     => 6,
            'data_start'     => 7,
            'col_iban'       => 'A',
            'col_name'       => 'B',
            'col_amount'     => 'C',
            'col_type'       => 'E',
            'col_status'     => 'H',
            'col_code_read'  => null,
            'col_code_write' => 'I',
            // render: پیش‌فرض Render.php (C..I، header=6)
            'render_cols'    => null,
            'render_weights' => null,
        ];
    }
    // Bank Shahr / Saman: header at row 7
    $a7 = trim((string)$sh->getCell('A7')->getValue());
    $b7 = trim((string)$sh->getCell('B7')->getValue());
    if (mb_strpos($a7, 'ردیف') !== false) {
        // Shahr: B7='شبای مبدا'  →  IBAN dest in C
        if (mb_strpos($b7, 'مبدا') !== false) {
            return [
                'bank'           => 'shahr',
                'header_row'     => 7,
                'data_start'     => 8,
                'col_iban'       => 'C',
                'col_name'       => 'D',
                'col_amount'     => 'E',
                'col_type'       => null,
                'col_status'     => 'H',
                'col_code_read'  => 'J',
                'col_code_write' => 'I',
                // render: E=مبلغ، G=تاریخ ثبت، H=ثبت شده، I=کد
                'render_cols'    => ['E', 'G', 'H', 'I'],
                'render_weights' => [230, 260, 160, 220],
            ];
        }
        // Saman: B7='شبا مقصد'  →  IBAN dest in B
        if (mb_strpos($b7, 'شبا') !== false) {
            return [
                'bank'           => 'saman',
                'header_row'     => 7,
                'data_start'     => 8,
                'col_iban'       => 'B',
                'col_name'       => 'C',
                'col_amount'     => 'D',
                'col_type'       => null,
                'col_status'     => 'H',
                'col_code_read'  => 'J',
                'col_code_write' => 'I',
                // render: D=مبلغ، G=تاریخ ثبت، H=ثبت شده، I=کد
                'render_cols'    => ['D', 'G', 'H', 'I'],
                'render_weights' => [230, 260, 160, 220],
            ];
        }
    }
    return ['bank' => 'unknown'];
}

function handleMergeCommand(array $message, ?string $label): void {
    $chat = $message['chat']; $chat_id = $chat['id']; $from_id = $message['from']['id'] ?? 0;
    if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ دسترسی ندارید.'); return; }
    if (empty($message['document'])) { Telegram::sendMessage($chat_id, '⚠️ لطفاً فایل گزارش را با کپشن /p (یا /p LABEL) بفرستید.'); return; }

    $doc = $message['document'];
    $fileName = $doc['file_name'] ?? 'report.xls';
    $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
    if ($ext !== 'xls' && $ext !== 'xlsx') {
        Telegram::sendMessage($chat_id, '⚠️ فرمت گزارش باید .xls یا .xlsx باشد.');
        return;
    }

    // با لیبل: حالت قدیمی (تطبیق با فایل پایه). بدون لیبل: پردازش مستقل از خود گزارش.
    $useBase = ($label !== null && $label !== '');
    if ($useBase) {
        if (!isValidLabel($label)) { Telegram::sendMessage($chat_id, '⚠️ لیبل نامعتبر است.'); return; }
        $basePath = Store::datasetDir($label) . '/excel/base/' . $label . '.xls';
        if (!is_file($basePath)) { Telegram::sendMessage($chat_id, '⚠️ فایل پایه پیدا نشد: ' . $label . '.xls'); return; }
    } else {
        $label = sanitizeLabel(labelFromFilename($fileName));
    }

    $dirs = Store::ensureDatasetDirs($label);
    $safeName = preg_replace('/[^A-Za-z0-9._-]+/u', '_', $fileName);
    $upPath = $dirs['uploads'] . '/' . date('Ymd_His') . '_p_' . $safeName;

    $ok = Telegram::downloadFileById($doc['file_id'], $upPath);
    if (!$ok || !is_file($upPath) || filesize($upPath) < 1000) {
        Telegram::sendMessage($chat_id, '⚠️ دانلود فایل گزارش ناموفق بود.');
        return;
    }

    performMerge((int)$chat_id, $label, $upPath, $useBase);
}

function performMerge(int $chat_id, string $label, string $upPath, bool $useBase = true): void {
    $dirs = Store::ensureDatasetDirs($label);

    // نوع پیش‌فرض دیتاست (برای ملی که توضیحات را echo نمی‌کند، یا ردیف‌های بدون توضیح)
    $meta = loadDatasetMeta($label);
    $defaultType = (($meta['ptype'] ?? 'v') === 'b') ? 'b' : 'v';

    // Build lookup maps from base XLS:  amount|iban → [code,...]  &  amount|normName → [code,...]
    $mapIban = [];
    $mapName = [];
    if ($useBase) {
    $basePath = $dirs['excel_base'] . '/' . $label . '.xls';
    if (!is_file($basePath)) { Telegram::sendMessage($chat_id, '⚠️ فایل پایه پیدا نشد: ' . $label . '.xls'); return; }
    $base = \PhpOffice\PhpSpreadsheet\IOFactory::load($basePath);
    $bs = $base->getActiveSheet();
    $baseMax = $bs->getHighestRow();
    for ($r = 1; $r <= $baseMax; $r++) {
        $amount   = Utils::parseIrrCell($bs->getCell('A'.$r)->getCalculatedValue());
        $iban     = strtoupper(trim((string)$bs->getCell('B'.$r)->getValue()));
        $nameBase = trim((string)$bs->getCell('D'.$r)->getValue());
        $code     = Utils::normalizeProductCode(trim((string)$bs->getCell('E'.$r)->getValue()));
        if ($amount <= 0 || $code === '') continue;

        if ($iban !== '') {
            $key = $amount . '|' . strtoupper(preg_replace('/\s+/', '', $iban));
            if (!isset($mapIban[$key])) $mapIban[$key] = [];
            $mapIban[$key][] = $code;
        }
        $nKey = $amount . '|' . Utils::normalizeName($nameBase);
        if ($nKey !== ($amount . '|')) {
            if (!isset($mapName[$nKey])) $mapName[$nKey] = [];
            $mapName[$nKey][] = $code;
        }
    }
    } // end if ($useBase)

    // Load report + detect format
    $report = \PhpOffice\PhpSpreadsheet\IOFactory::load($upPath);
    $sh = $report->getActiveSheet();
    $maxRow = $sh->getHighestRow();

    $fmt = detectBankStatementFormat($sh);

    if ($fmt['bank'] === 'unknown') {
        Telegram::sendMessage($chat_id, '⚠️ فرمت فایل گزارش بانک شناخته نشد. فایل باید از بانک ملی، سامان یا شهر باشد.');
        return;
    }

    Telegram::sendMessage($chat_id, '🏦 فرمت بانک <b>' . $fmt['bank'] . '</b> شناخته شد. در حال پردازش...');

    // بانک ملی کد را در گزارش echo نمی‌کند؛ بدون فایل پایه کدها پیدا نمی‌شوند
    if ($fmt['bank'] === 'melli' && !$useBase) {
        Telegram::sendMessage($chat_id, '⚠️ گزارش بانک ملی توضیحات/کد را برنمی‌گرداند؛ بدون لیبل، کد محصول‌ها پیدا نمی‌شوند. بهتر است با کپشن /p LABEL بفرستی.');
    }

    $rowsToDelete    = [];
    $failedLines     = [];
    $failedRetryLines= [];
    $processedRows   = 0;
    $cntV = 0; // تعداد واریزی موفق
    $cntB = 0; // تعداد برگشتی موفق

    for ($r = $fmt['data_start']; $r <= $maxRow; $r++) {
        $dest        = trim((string)$sh->getCell($fmt['col_iban']   . $r)->getValue());
        $iban        = strtoupper($dest);
        $amount      = Utils::parseIrrCell($sh->getCell($fmt['col_amount'] . $r)->getCalculatedValue());
        $status      = trim((string)$sh->getCell($fmt['col_status'] . $r)->getValue());
        $nameRep     = trim((string)$sh->getCell($fmt['col_name']   . $r)->getValue());
        $transferType= $fmt['col_type']
            ? trim((string)$sh->getCell($fmt['col_type'] . $r)->getValue())
            : '';

        if ($iban === '' && $amount <= 0 && $status === '') continue;
        $processedRows++;

        $code = '';
        $rowType = $defaultType;

        // 1. کد + نوع از ستون بانک (شهر/سامان توضیحات CSV ما «کد + واریزی/برگشتی» را echo می‌کنند)
        if (!empty($fmt['col_code_read'])) {
            $raw = trim((string)$sh->getCell($fmt['col_code_read'] . $r)->getValue());
            if ($raw !== '') {
                // بانک‌ها حروف فارسی را عربی برمی‌گردانند (واریزی → واريزي)؛ اول نرمال کن
                $nn = str_replace(['ي', 'ى', 'ك'], ['ی', 'ی', 'ک'], $raw);
                if (mb_strpos($nn, 'برگشت') !== false)      $rowType = 'b';
                elseif (mb_strpos($nn, 'واریز') !== false)  $rowType = 'v';
                if (preg_match('/(\d{3,})/u', Utils::toEnglishDigits($raw), $mm)) {
                    $code = $mm[1];
                }
            }
        }

        // 2. اگه کد پیدا نشد، match با base XLS (IBAN+مبلغ)
        if ($code === '' && $amount > 0) {
            if (Utils::isIban($iban)) {
                $key = $amount . '|' . strtoupper(preg_replace('/\s+/', '', $iban));
                if (isset($mapIban[$key]) && count($mapIban[$key]) > 0) {
                    $code = array_shift($mapIban[$key]);
                }
            }
            // درون‌بانکی یا بدون شبا: match با نام+مبلغ
            if ($code === '' &&
                (!Utils::isIban($iban) || ($transferType !== '' && mb_strpos($transferType, 'درون') !== false))) {
                $nKey = $amount . '|' . Utils::normalizeName($nameRep);
                if (isset($mapName[$nKey]) && count($mapName[$nKey]) > 0) {
                    $code = array_shift($mapName[$nKey]);
                }
            }
        }

        // نوشتن کد + نوع (واریزی/برگشتی) در ستون استاندارد (I) — برای همه بانک‌ها یکسان
        // برای ردیف بدون کد هم نوع نوشته می‌شود تا ستون شرح همیشه پر باشد
        $rowTypeWord = ($rowType === 'b') ? 'برگشتی' : 'واریزی';
        $descText = ($code !== '')
            ? ('#' . Utils::normalizeProductCode($code) . ' ' . $rowTypeWord)
            : $rowTypeWord;
        $sh->setCellValue($fmt['col_code_write'] . $r, $descText);

        $isSuccess = Utils::isSuccessStatus($status, $fmt['bank']);

        if ($isSuccess) {
            if ($rowType === 'b') $cntB++; else $cntV++;
        } else {
            $rowsToDelete[] = $r;
            $failedLines[]  = $dest . "\t" . $amount . "\t" . ($code !== '' ? ('#'.Utils::normalizeProductCode($code)) : '#?');
            $dest2 = Utils::isIban($dest) ? strtoupper(preg_replace('/\s+/', '', $dest)) : $dest;
            $failedRetryLines[] = ((string)$amount) . ',' . $dest2 . ',,' . Utils::sanitizeCsvField($nameRep);
        }
    }

    // حذف ردیف‌های ناموفق (از پایین به بالا تا شیفت ردیف مشکل نسازه)
    rsort($rowsToDelete);
    foreach ($rowsToDelete as $rr) {
        $sh->removeRow($rr, 1);
    }

    $failedCount  = count($failedLines);
    $successCount = max(0, $processedRows - $failedCount);

    // ── Normalize شیت برای شهر/سامان قبل از ذخیره و رندر ──────────────────
    if ($fmt['bank'] !== 'melli') {
        $maxRowNow = $sh->getHighestRow();
        for ($r = $fmt['data_start']; $r <= $maxRowNow; $r++) {
            $sv = trim((string)$sh->getCell($fmt['col_status'] . $r)->getValue());
            if ($sv !== '') {
                // وضعیت همه ردیف‌های باقی‌مانده (موفق) → «ثبت شده»
                $sh->setCellValue($fmt['col_status'] . $r, 'موفق');
            }
        }
        // تغییر عنوان ستون تاریخ → «تاریخ ثبت»
        $sh->setCellValue('G' . $fmt['header_row'], 'تاریخ ثبت');
        // تغییر عنوان ستون کد → «شرح تراکنش» (مطابق بانک ملی)
        $sh->setCellValue($fmt['col_code_write'] . $fmt['header_row'], 'شرح تراکنش');
    }
    // ────────────────────────────────────────────────────────────────────────

    // ذخیره فایل نهایی
    $finalPath = $dirs['excel_final'] . '/' . $label . '.xls';
    $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xls($report);
    $writer->save($finalPath);
    Telegram::sendDocument($chat_id, $finalPath, '✅ فایل نهایی آماده شد: ' . $label . '.xls');

    // نوع کلی گزارش برای کپشن‌ها/متن کانال
    $counts = ['v' => $cntV, 'b' => $cntB];
    $overallType = ($cntV > 0 && $cntB > 0) ? 'a' : (($cntB > 0) ? 'b' : 'v');

    // رندر PNG — برای شهر/سامان ستون‌ها و header row متفاوته
    $pngPaths = [];
    $pageMeta = [];
    if (defined('FINAL_PHOTO_ENABLED') && FINAL_PHOTO_ENABLED) {
        $pngPrefix = $dirs['images'] . '/' . $label;
        $renderOpts = [
            'max_rows'           => FINAL_PHOTO_MAX_ROWS,
            'watermark_text'     => FINAL_PHOTO_WATERMARK_TEXT,
            'watermark_opacity'  => FINAL_PHOTO_WATERMARK_OPACITY,
            'watermark_angle'    => FINAL_PHOTO_WATERMARK_ANGLE,
            'width'              => FINAL_PHOTO_WIDTH,
            'height'             => FINAL_PHOTO_HEIGHT,
            'header_row'         => $fmt['header_row'],
            'data_start'         => $fmt['data_start'],
        ];
        if (!empty($fmt['render_cols']))    $renderOpts['cols']        = $fmt['render_cols'];
        if (!empty($fmt['render_weights'])) $renderOpts['col_weights'] = $fmt['render_weights'];
        $r = Render::finalXlsToPngPages($finalPath, $pngPrefix, $renderOpts);
        if (!empty($r['ok'])) {
            $pngPaths = $r['pngs'] ?? [];
            $pageMeta = $r['meta'] ?? [];
            if (!empty($pngPaths)) {
                sendPhotosPaged($chat_id, $pngPaths, $label, $overallType, [], [], $counts);
            }
        } else {
            foreach (ADMIN_IDS as $admin) {
                Telegram::sendMessage($admin, '⚠️ ساخت تصویر نهایی ناموفق بود: ' . ($r['error'] ?? 'unknown'));
            }
        }
    }

    // ارسال به کانال — نوع از توضیحات/متادیتا مشخص است؛ فقط تایید ارسال پرسیده می‌شود
    if (defined('AUTO_POST_CHANNEL_ENABLED') && AUTO_POST_CHANNEL_ENABLED && defined('AUTO_POST_CHANNEL_ID')) {
        $codes    = codesFromFinalSheet($sh, $fmt['data_start']);
        $rowCodes = codesFromFinalSheetRows($sh, $fmt['data_start']);

        $hasPhoto = !empty($pngPaths);
        $ask = defined('AUTO_POST_CHANNEL_ASK_TYPE') ? (bool)AUTO_POST_CHANNEL_ASK_TYPE : true;

        if ($ask) {
            $token = substr(bin2hex(random_bytes(8)), 0, 12);
            savePendingPost($token, [
                'created_at' => time(),
                'type'       => 'chan_confirm',
                'ptype'      => $overallType,
                'counts'     => $counts,
                'chat_id'    => $chat_id,
                'channel_id' => AUTO_POST_CHANNEL_ID,
                'pngs'       => $pngPaths,
                'png'        => (!empty($pngPaths) ? $pngPaths[0] : ''),
                'xls'        => $finalPath,
                'codes'      => $codes,
                'row_codes'  => $rowCodes,
                'page_meta'  => $pageMeta,
                'has_photo'  => $hasPhoto,
                'label'      => $label,
            ]);
            $kb = ['inline_keyboard' => [[
                ['text' => '📢 ارسال به کانال', 'callback_data' => 'ppost:' . $token . ':y'],
                ['text' => '❌ ارسال نشود',     'callback_data' => 'ppost:' . $token . ':n'],
            ]]];
            $cline = countsLine($counts);
            Telegram::sendMessage($chat_id, "✅ گزارش آماده شد" . ($cline !== '' ? " — <b>{$cline}</b>" : '') . ". به کانال ارسال شود؟", null, 'HTML', $kb);
        } else {
            sendCodesToChannel(AUTO_POST_CHANNEL_ID, $overallType, $codes);
            if ($hasPhoto) {
                sendPhotosPaged(AUTO_POST_CHANNEL_ID, $pngPaths, $label, $overallType, $pageMeta, $rowCodes, $counts);
            } else {
                Telegram::sendDocument(AUTO_POST_CHANNEL_ID, $finalPath, "📎 فایل گزارش ({$label})");
            }
            if (defined('AUTO_POST_CHANNEL_SEND_XLS') && AUTO_POST_CHANNEL_SEND_XLS) {
                Telegram::sendDocument(AUTO_POST_CHANNEL_ID, $finalPath, "📎 فایل اکسل: {$label}.xls");
            }
        }
    }

    // گزارش ناموفق‌ها
    if (!empty($failedLines)) {
        $txtPath = $dirs['txt'] . '/' . $label . '_FAILED.txt';
        file_put_contents($txtPath, implode("\n", $failedLines));
        sendLongMessage($chat_id, "❌ موارد غیرموفق (برای واریز دستی):", $failedLines);
        Telegram::sendDocument($chat_id, $txtPath, 'لیست غیرموفق‌ها');
        if (!empty($failedRetryLines)) {
            $retryPath = $dirs['txt'] . '/' . $label . '_FAILED_RETRY.txt';
            $retryContent = implode("\n", $failedRetryLines);
            if (defined('TXT_USE_UTF8_BOM') && TXT_USE_UTF8_BOM) { $retryContent = "\xEF\xBB\xBF" . $retryContent; }
            file_put_contents($retryPath, $retryContent);
            Telegram::sendDocument($chat_id, $retryPath, "📄 فایل TXT برای اقدام مجدد: <b>{$label}_FAILED_RETRY.txt</b>");
        }
    } else {
        Telegram::sendMessage($chat_id, '✅ همه تراکنش‌ها موفق بودند؛ موردی برای واریز دستی نیست.');
    }
}



function sendLongMessage($chat_id, string $title, array $lines): void {
    if (empty($lines)) return;
    $chunks = [];
    $cur = $title . "\n";
    foreach ($lines as $ln) {
        $ln = trim((string)$ln);
        if ($ln === '') continue;
        // Telegram message limit ~4096 chars; keep safe margin
        if (mb_strlen($cur . $ln . "\n", 'UTF-8') > 3600) {
            $chunks[] = $cur;
            $cur = $title . "\n";
        }
        $cur .= $ln . "\n";
    }
    if (trim($cur) !== trim($title)) $chunks[] = $cur;
    foreach ($chunks as $c) {
        Telegram::sendMessage($chat_id, $c);
    }
}

function handleList($chat_id, $from_id = 0) {
    if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ فقط ادمین.'); return; }
    $list = Store::listDatasets();
    if (!$list) { Telegram::sendMessage($chat_id, '📂 دیتاستی وجود ندارد.'); return; }
    $out = "📁 دیتاست‌ها:\n";
    foreach ($list as $label) {
        $base = DATA_DIR . '/' . $label . '/excel/base/' . $label . '.xls';
        $final = DATA_DIR . '/' . $label . '/excel/final/' . $label . '.xls';
        $txt   = DATA_DIR . '/' . $label . '/txt/' . $label . '.txt';
        $out .= '• ' . $label . '  ' . (is_file($txt) ? '📄txt' : '') . ' ' . (is_file($base) ? '🧱base' : '') . ' ' . (is_file($final) ? '✅final' : '') . "\n";
    }
    Telegram::sendMessage($chat_id, $out);
}

function handleFinal($chat_id, $from_id, $label) {
    if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ فقط ادمین.'); return; }
    if (!isValidLabel($label)) { Telegram::sendMessage($chat_id, '⚠️ لیبل نامعتبر است.'); return; }
    $path = DATA_DIR . '/' . $label . '/excel/final/' . $label . '.xls';
    if (is_file($path)) Telegram::sendDocument($chat_id, $path, $label . '.xls');
    else Telegram::sendMessage($chat_id, '⚠️ فایل نهایی موجود نیست.');
}

function isValidLabel($label): bool {
    // «.» و «..» (و هر لیبل فقط-نقطه) ممنوع — وگرنه /rm .. کل پروژه را پاک می‌کند
    return (bool)preg_match('/^[A-Za-z0-9._-]+$/', $label) && trim((string)$label, '.') !== '';
}

/** لیبل را به کاراکترهای مجاز محدود می‌کند؛ اگر چیزی باقی نماند، لیبل تاریخ‌دار می‌سازد. */
function sanitizeLabel(string $label): string {
    $label = Utils::toEnglishDigits(trim($label));
    $label = preg_replace('/[^A-Za-z0-9._-]+/u', '-', $label);
    $label = trim(preg_replace('/-{2,}/', '-', (string)$label), '-.');
    // لیبل خیلی کوتاه (مثلاً وقتی نام فایل تماماً فارسی بوده) → لیبل تاریخ‌دار
    if ($label === '' || strlen($label) < 3 || !isValidLabel($label)) {
        $label = 'DS-' . date('Ymd-His');
    }
    return $label;
}
function rrmdir($dir) { if (is_dir($dir)) { foreach (scandir($dir) as $o){ if($o=='.'||$o=='..') continue; $p=$dir.'/'.$o; if(is_dir($p)) rrmdir($p); else @unlink($p);} @rmdir($dir);} elseif (is_file($dir)) @unlink($dir); }
function handleRemove($chat_id, $from_id, $label) {
    if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ فقط ادمین.'); return; }
    if (!isValidLabel($label)) { Telegram::sendMessage($chat_id, '⚠️ لیبل نامعتبر است.'); return; }
    $base = DATA_DIR . '/' . $label;
    if (!is_dir($base)) { Telegram::sendMessage($chat_id, '⚠️ دیتاستی با این نام پیدا نشد.'); return; }
    rrmdir($base);
    Telegram::sendMessage($chat_id, "🗑 دیتاست «{$label}» حذف شد.");
}

// ------------------------------------------------------------------
// منوی مدیریت دیتاست‌ها (/rm بدون آرگومان): لیست + حذف با تایید
// ------------------------------------------------------------------
const RM_MENU_PER_PAGE = 15;

function buildRmMenuKeyboard(string $token, array $datasets, int $page): array {
    $perPage = RM_MENU_PER_PAGE;
    $total   = count($datasets);
    $pages   = max(1, (int)ceil($total / $perPage));
    $page    = max(0, min($page, $pages - 1));

    $rows = [];
    $slice = array_slice($datasets, $page * $perPage, $perPage, true);
    foreach ($slice as $i => $label) {
        $rows[] = [[
            'text'          => '🗂 ' . $label,
            'callback_data' => 'rmp:' . $token . ':' . $i,
        ]];
    }
    if ($pages > 1) {
        $nav = [];
        if ($page > 0)          $nav[] = ['text' => '⬅️ قبلی', 'callback_data' => 'rmn:' . $token . ':' . ($page - 1)];
        $nav[] = ['text' => '📄 ' . ($page + 1) . '/' . $pages, 'callback_data' => 'rmn:' . $token . ':' . $page];
        if ($page < $pages - 1) $nav[] = ['text' => 'بعدی ➡️', 'callback_data' => 'rmn:' . $token . ':' . ($page + 1)];
        $rows[] = $nav;
    }
    $rows[] = [['text' => '✖️ بستن', 'callback_data' => 'rmc:' . $token]];
    return ['inline_keyboard' => $rows];
}

function rmMenuText(int $total): string {
    return "🗑 <b>مدیریت دیتاست‌ها</b> ({$total} مورد)\nروی هر دیتاست بزن تا حذفش کنی (با تایید).";
}

function handleRemoveMenu($chat_id, $from_id): void {
    if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ فقط ادمین.'); return; }
    $datasets = Store::listDatasets();
    if (empty($datasets)) { Telegram::sendMessage($chat_id, '📂 دیتاستی وجود ندارد.'); return; }

    $token = substr(bin2hex(random_bytes(8)), 0, 12);
    $payload = [
        'created_at' => time(),
        'type'       => 'rm_menu',
        'chat_id'    => $chat_id,
        'datasets'   => array_values($datasets),
        'page'       => 0,
        'msg_id'     => 0,
    ];
    savePendingPost($token, $payload);

    $kb = buildRmMenuKeyboard($token, $payload['datasets'], 0);
    $sent = Telegram::sendMessage($chat_id, rmMenuText(count($datasets)), null, 'HTML', $kb);
    if (!empty($sent['result']['message_id'])) {
        $payload['msg_id'] = $sent['result']['message_id'];
        savePendingPost($token, $payload);
    }
}

/** متن و کیبورد پیام «موجودی کیف پول زحل». */
function walletBalanceView(): array {
    $r = Zohal::walletBalance();
    $now = date('Y/m/d H:i');

    if (!empty($r['success'])) {
        $rial  = (int)$r['rial'];
        $toman = intdiv($rial, 10);
        $text  = "💳 <b>موجودی کیف پول زحل</b>\n\n"
               . "<b>" . number_format($rial) . "</b> ریال\n"
               . "(" . number_format($toman) . " تومان)\n\n"
               . "<i>{$now}</i>";
    } else {
        $text = "💳 <b>موجودی کیف پول زحل</b>\n\n"
              . "⚠️ " . htmlspecialchars((string)($r['error'] ?? 'خطای نامشخص')) . "\n";
        if (!empty($r['raw'])) {
            $text .= "\n<pre>" . htmlspecialchars((string)$r['raw']) . "</pre>\n";
        }
        $text .= "\n<i>{$now}</i>";
    }

    $kb = ['inline_keyboard' => [[
        ['text' => '🔄 بروزرسانی', 'callback_data' => 'wallet:refresh'],
    ]]];
    return [$text, $kb];
}

/**
 * گزارش سلامت کش و پیکربندی زحل.
 * روی هاست اشتراکی بدون SSH، این تنها راه دیدن وضعیت کش است.
 */
function handleDiag($chat_id, $from_id): void {
    if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ دسترسی ندارید.'); return; }

    Telegram::sendMessage($chat_id, diagText(Zohal::diagnostics(), Health::report()));
}

/** ساخت متن /diag — جدا از ارسال تا بشود بدون تلگرام تستش کرد. */
function diagText(array $d, array $h): string {
    $yn = fn(bool $b) => $b ? '✅' : '❌';

    // ── مشکلات اول، چون همین‌ها باعث می‌شوند بات بی‌صدا کار نکند ──────────
    $problems = [];

    if (!$d['token_set'])       $problems[] = '❌ <b>ZOHAL_TOKEN تنظیم نشده</b> — هیچ کارتی به شبا تبدیل نمی‌شود.';
    if (!$h['php']['version_ok']) $problems[] = '❌ <b>PHP ' . $h['php']['version'] . '</b> — حداقل ۸.۰ لازم است.';

    foreach ($h['ext']['missing'] as $ext => $why) {
        $problems[] = "❌ افزونهٔ <code>{$ext}</code> نصب/فعال نیست — {$why}";
    }
    foreach ($h['dirs'] as $name => $info) {
        if ($name === 'htaccess') continue;
        if (!$info['exists'])        $problems[] = "❌ پوشهٔ <code>{$name}</code> وجود ندارد.";
        elseif (!$info['writable'])  $problems[] = "❌ پوشهٔ <code>{$name}</code> قابل نوشتن نیست (پرمیشن ۰۷۵۵).";
    }
    if (!$h['dirs']['htaccess']['exists']) {
        $problems[] = '⚠️ <code>data/.htaccess</code> نیست — پوشهٔ داده (شبا، نام، کارت) ممکن است از وب قابل دسترس باشد.';
    }
    if (!$h['funcs']['can_spawn'] && !$h['funcs']['can_detach']) {
        $problems[] = '❌ <code>exec</code> بسته است و پاسخ HTTP هم قابل بستن نیست — صف فقط با <b>کران جاب</b> پردازش می‌شود.';
    }
    if ($h['queue']['pending'] > 0 && $h['queue']['oldest_min'] >= 3) {
        $problems[] = "🚨 <b>{$h['queue']['pending']} کار در صف مانده</b> (قدیمی‌ترین {$h['queue']['oldest_min']} دقیقه) — worker اجرا نمی‌شود.";
    }
    if ($h['queue']['stuck'] > 0) {
        $problems[] = "⚠️ {$h['queue']['stuck']} کار نیمه‌تمام رها شده (وسط پردازش کرش کرده).";
    }
    if (!$h['assets']['font_exists']) {
        $problems[] = '❌ فونت <code>assets/fonts/DejaVuSans.ttf</code> نیست — تصویر گزارش ساخته نمی‌شود.';
    }

    $t = "🔧 <b>وضعیت سیستم</b>\n\n";
    if ($problems) {
        $t .= "<b>مشکلات (" . count($problems) . ")</b>\n" . implode("\n", $problems) . "\n\n";
    } else {
        $t .= "✅ <b>هیچ مشکلی پیدا نشد</b>\n\n";
    }

    $t .= "<b>صف پردازش</b>\n";
    $t .= "• در انتظار: {$h['queue']['pending']}"
        . ($h['queue']['pending'] > 0 ? " (قدیمی‌ترین {$h['queue']['oldest_min']} دقیقه)" : '') . "\n";
    $t .= "• نیمه‌تمام: {$h['queue']['stuck']} | خطا: {$h['queue']['errors']}\n";
    if ($h['funcs']['can_spawn']) {
        $t .= "• پردازش: اجرای خودکار worker ✅\n";
    } elseif ($h['funcs']['can_detach']) {
        $t .= "• پردازش: داخل خود وبهوک ✅ (exec بسته است، کران اختیاری)\n";
    } else {
        $t .= "• پردازش: فقط با کران ❌\n";
    }
    if ($h['queue']['last_error'] !== '') {
        $t .= "• آخرین خطا:\n<pre>" . htmlspecialchars($h['queue']['last_error']) . "</pre>\n";
    }
    $t .= "\n";

    $t .= "<b>زحل</b>\n";
    $t .= $yn($d['token_set']) . " توکن تنظیم شده\n";
    $t .= "• همزمانی: {$d['concurrency']} | انقضای کش: {$d['ttl_days']} روز\n\n";

    $t .= "<b>کش کارت→شبا</b>\n";
    $t .= $yn($d['cache_exists']) . " فایل ساخته شده"
        . ($d['cache_exists'] ? ' (' . number_format($d['cache_bytes']) . ' بایت)' : '') . "\n";
    $t .= "• کل: <b>{$d['cache_total']}</b> | معتبر: <b>{$d['cache_fresh']}</b> | منقضی: {$d['cache_stale']}\n";
    $t .= "• منتقل‌شده از کش زیبال: {$d['cache_legacy']}"
        . ($d['legacy_exists'] ? " (از {$d['legacy_total']})" : '') . "\n\n";

    $t .= "<b>محیط</b>\n";
    $t .= "• PHP {$h['php']['version']} ({$h['php']['sapi']})\n";
    $t .= "• حافظه: {$h['php']['memory']} | زمان اجرا: {$h['php']['max_exec']}s | حجم آپلود: {$h['php']['upload_max']}\n";
    $t .= "• افزونه‌ها: " . ($h['ext']['total'] - count($h['ext']['missing'])) . "/{$h['ext']['total']} ✅\n";

    $t .= "\n<i>" . date('Y/m/d H:i') . "</i>";
    return $t;
}

function handleWalletBalance($chat_id, $from_id): void {
    if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ دسترسی ندارید.'); return; }
    [$text, $kb] = walletBalanceView();
    Telegram::sendMessage($chat_id, $text, null, 'HTML', $kb);
}

function processUpdate(array $update): void {
    $message = $update['message'] ?? $update['edited_message'] ?? null;
    if (!$message) return;

    $chat = $message['chat'];
    $chat_id = $chat['id'];
    $from_id = $message['from']['id'] ?? 0;

    if (!empty($message['text'])) {
        $text = trim($message['text']);
        if ($text === '/start') {
            $kb = ['inline_keyboard' => [[
                ['text' => '💳 موجودی کیف پول زحل', 'callback_data' => 'wallet:refresh'],
            ]]];
            Telegram::sendMessage($chat_id, "سلام! JSON کانال رو بفرست تا فایل‌های خروجی ساخته بشه (نوع واریزی/برگشتی همان اول پرسیده می‌شود).\nگزارش بانک را با کپشن /p بفرست (سامان/شهر — نیازی به لیبل نیست). برای بانک ملی: /p LABEL.\nدستورها: /list , /final LABEL , /rm (منوی حذف) , /rm LABEL , /wallet (موجودی زحل)", null, 'HTML', $kb);
            return;
        }
        if ($text === '/list') { handleList($chat_id, $from_id); return; }
        if ($text === '/wallet' || $text === '/balance') { handleWalletBalance($chat_id, $from_id); return; }
        if ($text === '/diag' || $text === '/status') { handleDiag($chat_id, $from_id); return; }
        if (preg_match('/^\/final\s+(\S+)/', $text, $m)) { handleFinal($chat_id, $from_id, $m[1]); return; }
        if (preg_match('/^\/rm\s+(\S+)/', $text, $m)) { handleRemove($chat_id, $from_id, $m[1]); return; }
        if (preg_match('/^\/rm\s*$/', $text)) { handleRemoveMenu($chat_id, $from_id); return; }

        // در انتظار بازه مبلغ؟ (گام ۰ فیلتر مبلغ)
        if (isAdmin($from_id)) {
            $waitToken = getAmountWait($chat_id);
            if ($waitToken !== null) {
                $payload = loadPendingPost($waitToken);
                if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
                    clearAmountWait($chat_id);
                    Telegram::sendMessage($chat_id, '⚠️ این درخواست منقضی شده. فایل JSON را دوباره بفرست.');
                    return;
                }
                if ($text === 'لغو' || strtolower($text) === 'cancel') {
                    clearAmountWait($chat_id);
                    Telegram::sendMessage($chat_id, '↩️ فیلتر مبلغ لغو شد؛ همه رکوردها در خروجی هستند.');
                    askBankSelectionStep($chat_id, $waitToken, $payload, false);
                    return;
                }
                $range = parseAmountRangeToman($text);
                if ($range === null) {
                    Telegram::sendMessage($chat_id, "⚠️ بازه نامعتبر است. دو عدد به تومان بفرست، مثال:\n<code>100000 5000000</code>\nیا بنویس «لغو» تا بدون فیلتر ادامه بدهیم.");
                    return;
                }
                clearAmountWait($chat_id);
                [$min, $max] = $range;
                $payload = applyAmountFilter($payload, $min, $max);
                savePendingPost($waitToken, $payload);

                if (empty($payload['rows'])) {
                    Telegram::sendMessage($chat_id,
                        "⚠️ هیچ رکوردی در بازه " . number_format($min) . " تا " . number_format($max) . " تومان نیست.\n"
                        . "دوباره بازه بفرست یا فایل JSON را از نو ارسال کن.");
                    setAmountWait($chat_id, $waitToken); // اجازه‌ی تلاش مجدد
                    return;
                }

                Telegram::sendMessage($chat_id,
                    "💰 فیلتر اعمال شد: " . number_format($min) . " تا " . number_format($max) . " تومان\n"
                    . "در بازه: <b>" . count($payload['rows']) . "</b> رکورد | خارج از بازه: <b>" . count($payload['amount_excluded']) . "</b> رکورد");

                askBankSelectionStep($chat_id, $waitToken, $payload, false);
                return;
            }
        }
    }

    if (!empty($message['caption']) && preg_match('/^\/p(?:\s+(\S+))?\s*$/u', trim($message['caption']), $m)) {
        handleMergeCommand($message, (isset($m[1]) && $m[1] !== '') ? $m[1] : null);
        return;
    }

    if (!empty($message['document'])) {
        $doc = $message['document'];
        $file_name = $doc['file_name'] ?? '';
        $mime = $doc['mime_type'] ?? '';
        $ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));

        if ($ext === 'json' || $mime === 'application/json') {
            if (!isAdmin($from_id)) { Telegram::sendMessage($chat_id, '⛔️ فقط ادمین.'); return; }
            handleJsonUpload($doc, $chat, $message['message_id'], currentJobId());
            return;
        }

        Telegram::sendMessage($chat_id, '📎 فایل دریافت شد. اگر گزارش بانک است، کپشن /p بزن (سامان/شهر) یا /p LABEL (بانک ملی).');
        return;
    }
}


// ============================================================
// BANK SELECTION HELPERS (Q1 → Q2 interactive flow)
// ============================================================

/**
 * Build Q1 inline keyboard: one button per bank, togglable ✅/⬜
 */
function buildBankSelectionKeyboard(string $token, array $bankList, array $selectedIdxs): array {
    $rows = [];
    foreach ($bankList as $i => $bank) {
        $selected = in_array($i, $selectedIdxs, true);
        $rows[] = [[
            'text'          => ($selected ? '✅ ' : '⬜ ') . $bank,
            'callback_data' => 'bsel:' . $token . ':' . $i,
        ]];
    }
    // انتخاب/حذف گروهی
    $rows[] = [
        ['text' => '✅ انتخاب همه', 'callback_data' => 'bsel_all:' . $token],
        ['text' => '⬜ حذف همه',    'callback_data' => 'bsel_non:' . $token],
    ];
    $rows[] = [['text' => '✔️ تایید بانک‌ها', 'callback_data' => 'bsel_ok:' . $token]];
    return ['inline_keyboard' => $rows];
}

/**
 * Build Q2 inline keyboard: one button per output type, togglable ✅/⬜
 * OUTPUT_TYPES is defined in config.php — add new formats there.
 */
function buildOutputSelectionKeyboard(string $token, array $selectedTypes): array {
    $rows = [];
    foreach (OUTPUT_TYPES as $key => $cfg) {
        $selected = in_array($key, $selectedTypes, true);
        $rows[] = [[
            'text'          => ($selected ? '✅ ' : '⬜ ') . $cfg['label'],
            'callback_data' => 'osel:' . $token . ':' . $key,
        ]];
    }
    $rows[] = [['text' => '📤 تایید و دریافت فایل‌ها', 'callback_data' => 'osel_ok:' . $token]];
    return ['inline_keyboard' => $rows];
}

/**
 * Generate TXT file (واریز دسته‌ای ملی format) from filtered rows.
 * Returns path on success, null on failure.
 */
function generateTxtOutput(array $rows, string $label, array $dirs): ?string {
    if (empty($rows)) return null;
    $lines = [];
    foreach ($rows as $r) {
        $fields = [(string)$r['amount_rial'], (string)$r['iban'], ''];
        $fields[] = Utils::sanitizeCsvField((string)$r['name']);
        if (defined('TXT_INCLUDE_PRODUCT_CODE') && TXT_INCLUDE_PRODUCT_CODE) {
            $fields[] = (string)$r['code'];
        }
        $lines[] = implode(TXT_SEPARATOR, $fields);
    }
    $path    = $dirs['txt'] . '/' . $label . '.txt';
    $content = implode("\n", $lines);
    if (defined('TXT_USE_UTF8_BOM') && TXT_USE_UTF8_BOM) { $content = "\xEF\xBB\xBF" . $content; }
    file_put_contents($path, $content);
    return $path;
}

/**
 * Generate CSV file for bank (saman|shahr) from filtered rows.
 * Returns [path, record_count].
 */
function generateCsvOutput(array $rows, string $label, array $dirs, string $bank, string $ptype = 'v'): array {
    $suffix = strtoupper($bank);
    $path   = $dirs['csv'] . '/' . $label . '_' . $suffix . '.csv';
    $typeWord = ($ptype === 'b') ? 'برگشتی' : 'واریزی';

    if (!is_dir($dirs['csv'])) { @mkdir($dirs['csv'], 0775, true); }

    if ($bank === 'saman') {
        $header = defined('CSV_BANK_SAMANEH_HEADER') ? CSV_BANK_SAMANEH_HEADER : '';
    } else { // shahr (and any future CSV format)
        $header = defined('CSV_BANK_SHAHR_HEADER') ? CSV_BANK_SHAHR_HEADER : '';
    }

    $fp = @fopen($path, 'w');
    if (!$fp) return [null, 0];

    if (defined('CSV_USE_UTF8_BOM') && CSV_USE_UTF8_BOM) { fwrite($fp, "\xEF\xBB\xBF"); }
    fwrite($fp, $header . "\r\n");

    $count = 0;
    foreach ($rows as $r) {
        fputcsv($fp, [
            $r['iban']        ?? '',
            $r['name']        ?? '',
            $r['amount_rial'] ?? '',
            // توضیحات: «کد بدون # + نوع» — بانک همین را در گزارش echo می‌کند و /p نوع را از آن می‌خواند
            trim(Utils::normalizeProductCode($r['code'] ?? '') . ' ' . $typeWord),
            '',
        ], ',');
        $count++;
    }
    fclose($fp);
    return [$path, $count];
}

/**
 * Final step: generate selected output files, send them, then report
 * unselected bank codes and skipped codes.
 */
function generateAndSendOutputFiles(int $chat_id, array $payload): void {
    $label           = $payload['label'];
    $dirs            = $payload['dirs'];
    $allRows         = $payload['rows'];
    $bankList        = $payload['bank_list'];
    $selectedBankIdxs= $payload['selected_banks'] ?? [];
    $selectedOutputs = $payload['selected_outputs'] ?? [];
    $skippedCodes    = $payload['skipped_codes'] ?? [];
    $pulledCodes     = $payload['pulled_codes'] ?? [];
    $walletFailures  = (int)($payload['wallet_failures'] ?? 0);
    $ptype           = (($payload['ptype'] ?? 'v') === 'b') ? 'b' : 'v';

    // Map selected indices to bank names
    $selectedBankNames = [];
    foreach ($selectedBankIdxs as $i) {
        if (isset($bankList[$i])) $selectedBankNames[] = $bankList[$i];
    }

    // Partition rows: filtered (selected) vs unselected (by bank name)
    $filteredRows      = [];
    $unselectedByBank  = []; // bank_name => [code, ...]
    foreach ($allRows as $r) {
        $bn = $r['bank'] !== '' ? $r['bank'] : 'نامشخص';
        if (in_array($bn, $selectedBankNames, true)) {
            $filteredRows[] = $r;
        } else {
            $unselectedByBank[$bn][] = (string)$r['code'];
        }
    }

    if (empty($selectedOutputs)) {
        Telegram::sendMessage($chat_id, '⚠️ هیچ فرمت خروجی انتخاب نشده بود.');
        return;
    }
    if (empty($filteredRows)) {
        Telegram::sendMessage($chat_id, '⚠️ هیچ رکوردی با بانک‌های انتخابی یافت نشد.');
        return;
    }

    // جمع مبلغ ردیف‌های خروجی (همه فرمت‌ها از همین ردیف‌ها ساخته می‌شوند)
    $sumRial = 0;
    foreach ($filteredRows as $r) { $sumRial += (int)($r['amount_rial'] ?? 0); }
    $sumLine = "💵 جمع مبلغ: " . number_format($sumRial) . " ریال (" . number_format((int)($sumRial / 10)) . " تومان)";

    // Generate each selected output
    foreach ($selectedOutputs as $outType) {
        $cfg = OUTPUT_TYPES[$outType] ?? null;
        if (!$cfg) continue;
        $format = $cfg['format'];

        if ($format === 'txt') {
            $path = generateTxtOutput($filteredRows, $label, $dirs);
            if ($path) {
                Telegram::sendDocument($chat_id, $path,
                    "📄 TXT واریز دسته‌ای ملی: <b>{$label}.txt</b>\nرکوردها: " . count($filteredRows) . "\n" . $sumLine);
            }
        } elseif ($format === 'csv_saman') {
            [$path, $cnt] = generateCsvOutput($filteredRows, $label, $dirs, 'saman', $ptype);
            if ($path) {
                Telegram::sendDocument($chat_id, $path,
                    "📊 CSV بانک سامان: <b>{$label}_SAMAN.csv</b>\nرکوردها: {$cnt}\n" . $sumLine);
            }
        } elseif ($format === 'csv_shahr') {
            [$path, $cnt] = generateCsvOutput($filteredRows, $label, $dirs, 'shahr', $ptype);
            if ($path) {
                Telegram::sendDocument($chat_id, $path,
                    "📊 CSV بانک شهر: <b>{$label}_SHAHR.csv</b>\nرکوردها: {$cnt}\n" . $sumLine);
            }
        }
    }

    // گزارش کدهای خارج از بازه‌ی مبلغ (فیلتر گام ۰)
    $amountExcluded = $payload['amount_excluded'] ?? [];
    if (!empty($amountExcluded) && is_array($amountExcluded)) {
        $af = $payload['amount_filter'] ?? null;
        $rangeTxt = (is_array($af) && count($af) === 2)
            ? (number_format((int)$af[0]) . ' تا ' . number_format((int)$af[1]) . ' تومان')
            : '';
        $lines = [];
        foreach ($amountExcluded as $ex) {
            $c = Utils::normalizeProductCode((string)($ex['code'] ?? ''));
            $t = (int)($ex['toman'] ?? 0);
            $b = trim((string)($ex['bank'] ?? ''));
            $lines[] = ($c !== '' ? '#' . $c : '#?')
                     . ' — ' . number_format($t) . ' تومان'
                     . ($b !== '' ? " ({$b})" : '');
        }
        if (!empty($lines)) {
            sendLongMessage($chat_id, "💰 <b>کدهای خارج از بازه‌ی مبلغ" . ($rangeTxt !== '' ? " ({$rangeTxt})" : '') . ":</b>", $lines);
        }
    }

    // Report unselected banks (codes grouped by bank)
    if (!empty($unselectedByBank)) {
        $lines = [];
        foreach ($unselectedByBank as $bank => $codes) {
            $codes = array_values(array_filter($codes));
            if (empty($codes)) continue;
            $tagged = implode(' ، ', array_map(fn($c) => '#' . Utils::normalizeProductCode($c), $codes));
            $lines[] = "🏦 {$bank}:\n{$tagged}";
        }
        if (!empty($lines)) {
            sendLongMessage($chat_id, "⚠️ <b>کدهای بانک‌های انتخاب‌نشده:</b>", $lines);
        }
    }

    // کیف پول زحل خالی بوده — این خطا ربطی به کارت مشتری ندارد و اگر وسط دسته
    // رخ دهد بقیهٔ کارت‌ها هم رد می‌شوند. جدا و پررنگ هشدار داده می‌شود.
    if ($walletFailures > 0) {
        foreach (ADMIN_IDS as $admin) {
            Telegram::sendMessage($admin,
                "🚨 <b>موجودی کیف پول زحل کافی نبود</b>\n"
                . "{$walletFailures} رکورد فقط به همین دلیل از فایل واریز خارج شدند — کارتشان مشکلی نداشت.\n"
                . "کیف پول را شارژ کن و همان فایل JSON را دوباره بفرست.");
        }
    }

    // Report skipped (احراز / IBAN failure)
    if (!empty($skippedCodes)) {
        $logPath = $dirs['logs'] . '/' . date('Ymd_His') . '_skipped_codes.txt';
        file_put_contents($logPath, implode("\n", $skippedCodes));
        foreach (ADMIN_IDS as $admin) {
            Telegram::sendMessage($admin,
                "کدهای حذف‌شده (در خروجی نیامدند):\n" . implode("\n", $skippedCodes));
        }
    }

    // کدهایی که رکورد سالم داشتند ولی به‌خاطر رکورد خرابِ هم‌کد کنار رفتند.
    // نیاز به رسیدگی دستی دارند، پس جدا اعلام می‌شوند.
    if (!empty($pulledCodes)) {
        foreach (ADMIN_IDS as $admin) {
            Telegram::sendMessage($admin,
                "⚠️ این کدها رکورد سالم هم داشتند، ولی چون رکورد دیگری با همین کد مشکل داشت "
                . "کل کد از فایل واریز خارج شد (نیاز به بررسی دستی):\n"
                . implode(AUTO_POST_CODES_JOINER, $pulledCodes));
        }
    }
}


function processCallback(array $update): void {
    $cb = $update['callback_query'] ?? null;
    if (!$cb) return;

    $from_id = $cb['from']['id'] ?? 0;
    $chat_id = $cb['message']['chat']['id'] ?? null;
    $cbid = $cb['id'] ?? null;
    $data = trim((string)($cb['data'] ?? ''));

    if (!$chat_id || !$cbid) return;
    if (!isAdmin($from_id)) {
        Telegram::answerCallback($cbid, '⛔️ فقط ادمین.');
        return;
    }

    // -------------------------------------------------------
    // موجودی کیف پول زحل (بدون توکن pending — همیشه در دسترس)
    // -------------------------------------------------------
    if ($data === 'wallet:refresh') {
        // استعلام ممکن است چند ثانیه طول بکشد؛ اول callback را جواب بده تا
        // شناسه‌اش منقضی نشود.
        Telegram::answerCallback($cbid, '⏳ در حال استعلام...');
        [$text, $kb] = walletBalanceView();
        try {
            Telegram::editMessageText($chat_id, $cb['message']['message_id'] ?? 0, $text, 'HTML', $kb);
        } catch (\Throwable $e) {
            // اگر ویرایش نشد (مثلاً متن تغییری نکرده) پیام تازه بفرست
            Telegram::sendMessage($chat_id, $text, null, 'HTML', $kb);
        }
        return;
    }

    // -------------------------------------------------------
    // Q0: بدون فیلتر مبلغ → مستقیم برو گام ۱
    // -------------------------------------------------------
    if (preg_match('/^afil_no:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        clearAmountWait($chat_id);
        Telegram::answerCallback($cbid, 'بدون فیلتر ✅');
        askBankSelectionStep($chat_id, $token, $payload, true);
        return;
    }

    // -------------------------------------------------------
    // Q0: تعیین بازه مبلغ → منتظر متن ادمین
    // -------------------------------------------------------
    if (preg_match('/^afil_set:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        setAmountWait($chat_id, $token);
        Telegram::answerCallback($cbid, '');
        try {
            Telegram::editMessageText(
                $chat_id, $payload['msg_id'],
                "🔢 بازه مبلغ را <b>به تومان</b> در یک پیام بفرست.\n"
                . "مثال‌ها:\n<code>100000 5000000</code>\n<code>100,000 - 5,000,000</code>\n<code>۵۰۰۰۰۰ تا ۲۰۰۰۰۰۰</code>\n\n"
                . "رکوردهای خارج از این بازه در خروجی نمی‌آیند و کدشان جدا گزارش می‌شود.",
                'HTML'
            );
        } catch (\Throwable $e) {
            Telegram::sendMessage($chat_id, "🔢 بازه مبلغ را به تومان بفرست. مثال: <code>100000 5000000</code>");
        }
        return;
    }

    // -------------------------------------------------------
    // Q1: انتخاب همه بانک‌ها
    // -------------------------------------------------------
    if (preg_match('/^bsel_all:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $all = !empty($payload['bank_list']) ? range(0, count($payload['bank_list']) - 1) : [];
        $payload['selected_banks'] = $all;
        savePendingPost($token, $payload);
        $kb = buildBankSelectionKeyboard($token, $payload['bank_list'], $all);
        try { Telegram::editMessageReplyMarkup($chat_id, $payload['msg_id'], $kb); } catch (\Throwable $e) {}
        Telegram::answerCallback($cbid, 'همه انتخاب شد ✅');
        return;
    }

    // -------------------------------------------------------
    // Q1: حذف انتخاب همه بانک‌ها
    // -------------------------------------------------------
    if (preg_match('/^bsel_non:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $payload['selected_banks'] = [];
        savePendingPost($token, $payload);
        $kb = buildBankSelectionKeyboard($token, $payload['bank_list'], []);
        try { Telegram::editMessageReplyMarkup($chat_id, $payload['msg_id'], $kb); } catch (\Throwable $e) {}
        Telegram::answerCallback($cbid, 'همه برداشته شد');
        return;
    }

    // -------------------------------------------------------
    // Q1: bank toggle
    // -------------------------------------------------------
    if (preg_match('/^bsel:([a-f0-9]{8,20}):(\d+)$/', $data, $m)) {
        $token = $m[1]; $idx = (int)$m[2];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $sel = $payload['selected_banks'] ?? [];
        if (in_array($idx, $sel, true)) {
            $sel = array_values(array_diff($sel, [$idx]));
        } else {
            $sel[] = $idx;
            sort($sel);
        }
        $payload['selected_banks'] = $sel;
        savePendingPost($token, $payload);

        $kb = buildBankSelectionKeyboard($token, $payload['bank_list'], $sel);
        try { Telegram::editMessageReplyMarkup($chat_id, $payload['msg_id'], $kb); } catch (\Throwable $e) {}
        Telegram::answerCallback($cbid, '');
        return;
    }

    // -------------------------------------------------------
    // Q1 confirm → show Q2
    // -------------------------------------------------------
    if (preg_match('/^bsel_ok:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        if (empty($payload['selected_banks'])) {
            Telegram::answerCallback($cbid, '⚠️ حداقل یک بانک انتخاب کن');
            return;
        }

        $payload['step'] = 'q2';
        $payload['selected_outputs'] = [];
        savePendingPost($token, $payload);

        $selNames = implode('، ', array_map(
            fn($i) => $payload['bank_list'][$i] ?? '?',
            $payload['selected_banks']
        ));
        $q2text = "✅ بانک‌های انتخابی: <b>{$selNames}</b>

📤 <b>گام ۲:</b> چه خروجی‌هایی می‌خواهی؟
<i>(می‌توانی چند مورد را با هم انتخاب کنی)</i>";
        $kb = buildOutputSelectionKeyboard($token, []);

        try {
            Telegram::editMessageText($chat_id, $payload['msg_id'], $q2text, 'HTML', $kb);
        } catch (\Throwable $e) {
            $sent2 = Telegram::sendMessage($chat_id, $q2text, null, 'HTML', $kb);
            if (!empty($sent2['result']['message_id'])) {
                $payload['msg_id'] = $sent2['result']['message_id'];
                savePendingPost($token, $payload);
            }
        }
        Telegram::answerCallback($cbid, '✅ بانک‌ها ثبت شد');
        return;
    }

    // -------------------------------------------------------
    // Q2: output type toggle
    // -------------------------------------------------------
    if (preg_match('/^osel:([a-f0-9]{8,20}):([\w]+)$/', $data, $m)) {
        $token = $m[1]; $outType = $m[2];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        if (!isset(OUTPUT_TYPES[$outType])) {
            Telegram::answerCallback($cbid, 'نوع نامعتبر');
            return;
        }
        $sel = $payload['selected_outputs'] ?? [];
        if (in_array($outType, $sel, true)) {
            $sel = array_values(array_diff($sel, [$outType]));
        } else {
            $sel[] = $outType;
        }
        $payload['selected_outputs'] = $sel;
        savePendingPost($token, $payload);

        $kb = buildOutputSelectionKeyboard($token, $sel);
        try { Telegram::editMessageReplyMarkup($chat_id, $payload['msg_id'], $kb); } catch (\Throwable $e) {}
        Telegram::answerCallback($cbid, '');
        return;
    }

    // -------------------------------------------------------
    // Q2 confirm → generate + send output files
    // -------------------------------------------------------
    if (preg_match('/^osel_ok:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        if (empty($payload['selected_outputs'])) {
            Telegram::answerCallback($cbid, '⚠️ حداقل یک نوع خروجی انتخاب کن');
            return;
        }

        deletePendingPost($token);
        Telegram::answerCallback($cbid, '⏳ در حال ساخت فایل‌ها...');

        try {
            // Update message to show processing status
            Telegram::editMessageText(
                $chat_id, $payload['msg_id'],
                "⏳ در حال ساخت فایل‌های خروجی...",
                'HTML'
            );
        } catch (\Throwable $e) {}

        try {
            generateAndSendOutputFiles((int)$chat_id, $payload);
        } catch (\Throwable $e) {
            foreach (ADMIN_IDS as $admin) {
                Telegram::sendMessage($admin, '⚠️ خطا در ساخت فایل خروجی: <pre>' . htmlspecialchars((string)$e) . '</pre>');
            }
        }
        return;
    }

    // -------------------------------------------------------
    // انتخاب نوع دسته (واریزی/برگشتی) در فلوی JSON → بعدش گام فیلتر مبلغ
    // -------------------------------------------------------
    if (preg_match('/^jty:([a-f0-9]{8,20}):(v|b)$/', $data, $m)) {
        $token = $m[1]; $t = $m[2];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'bank_select') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $payload['ptype'] = $t;
        savePendingPost($token, $payload);
        // ذخیره در متادیتای دیتاست تا /p بعداً نوع را بداند (به‌ویژه بانک ملی)
        if (!empty($payload['label'])) {
            saveDatasetMeta((string)$payload['label'], ['ptype' => $t, 'updated_at' => time()]);
        }
        Telegram::answerCallback($cbid, ($t === 'b' ? 'برگشتی ✅' : 'واریزی ✅'));
        askAmountFilterStep($chat_id, $token, $payload);
        return;
    }

    // -------------------------------------------------------
    // تایید ارسال گزارش نهایی به کانال (نوع از قبل مشخص است)
    // -------------------------------------------------------
    if (preg_match('/^ppost:([a-f0-9]{8,20}):(y|n)$/', $data, $m)) {
        $token = $m[1]; $yes = ($m[2] === 'y');
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'chan_confirm') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        deletePendingPost($token);
        if (!$yes) {
            Telegram::answerCallback($cbid, 'ارسال نشد');
            Telegram::sendMessage($chat_id, '↩️ به کانال ارسال نشد.');
            return;
        }

        $ptype    = in_array(($payload['ptype'] ?? 'v'), ['v','b','a'], true) ? $payload['ptype'] : 'v';
        $counts   = is_array($payload['counts'] ?? null) ? $payload['counts'] : [];
        $codes    = is_array($payload['codes'] ?? null) ? $payload['codes'] : [];
        $rowCodes = is_array($payload['row_codes'] ?? null) ? $payload['row_codes'] : [];
        $pageMeta = is_array($payload['page_meta'] ?? null) ? $payload['page_meta'] : [];
        $channel  = $payload['channel_id'] ?? (defined('AUTO_POST_CHANNEL_ID') ? AUTO_POST_CHANNEL_ID : null);

        $sentOk = false; $sentErr = '';
        if ($channel) {
            $pngs = is_array($payload['pngs'] ?? null) ? $payload['pngs'] : [];
            $xls  = $payload['xls'] ?? '';
            try {
                if (!empty($pngs)) {
                    sendPhotosPaged($channel, $pngs, (string)($payload['label'] ?? 'گزارش'), $ptype, $pageMeta, $rowCodes, $counts);
                    $sentOk = true;
                } elseif ($xls && is_file($xls)) {
                    Telegram::sendDocument($channel, $xls, "📎 فایل گزارش");
                    $sentOk = true;
                }
                if (defined('AUTO_POST_CHANNEL_SEND_XLS') && AUTO_POST_CHANNEL_SEND_XLS && $xls && is_file($xls)) {
                    Telegram::sendDocument($channel, $xls, '📎 فایل اکسل');
                }
            } catch (Throwable $e) {
                $sentOk = false;
                $sentErr = $e->getMessage();
            }
        }

        Telegram::answerCallback($cbid, $sentOk ? 'ارسال شد ✅' : 'ارسال ناموفق بود');
        if ($sentOk) {
            Telegram::sendMessage($chat_id, '✅ به کانال ارسال شد.');
        } else {
            $hint = '⚠️ ارسال به کانال انجام نشد. (بات ادمین هست؟ / آیدی کانال درست است؟)';
            if ($sentErr !== '') $hint .= "\n\n<pre>" . htmlspecialchars($sentErr) . "</pre>";
            Telegram::sendMessage($chat_id, $hint);
        }
        return;
    }

    // -------------------------------------------------------
    // منوی مدیریت دیتاست‌ها: صفحه‌بندی / انتخاب / تایید حذف / بستن
    // -------------------------------------------------------
    if (preg_match('/^rmn:([a-f0-9]{8,20}):(\d+)$/', $data, $m)) {
        $token = $m[1]; $page = (int)$m[2];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'rm_menu') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $payload['page'] = $page;
        savePendingPost($token, $payload);
        $kb = buildRmMenuKeyboard($token, $payload['datasets'] ?? [], $page);
        try { Telegram::editMessageReplyMarkup($chat_id, $payload['msg_id'], $kb); } catch (\Throwable $e) {}
        Telegram::answerCallback($cbid, '');
        return;
    }

    if (preg_match('/^rmp:([a-f0-9]{8,20}):(\d+)$/', $data, $m)) {
        $token = $m[1]; $idx = (int)$m[2];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'rm_menu') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $label = $payload['datasets'][$idx] ?? null;
        if ($label === null) {
            Telegram::answerCallback($cbid, 'مورد نامعتبر');
            return;
        }
        $kb = ['inline_keyboard' => [
            [['text' => '🗑 بله، حذف کن', 'callback_data' => 'rmy:' . $token . ':' . $idx]],
            [['text' => '↩️ برگشت به لیست', 'callback_data' => 'rmb:' . $token]],
        ]];
        try {
            Telegram::editMessageText($chat_id, $payload['msg_id'],
                "⚠️ دیتاست «<b>" . htmlspecialchars((string)$label) . "</b>» با همه فایل‌هایش حذف شود؟", 'HTML', $kb);
        } catch (\Throwable $e) {}
        Telegram::answerCallback($cbid, '');
        return;
    }

    if (preg_match('/^rmy:([a-f0-9]{8,20}):(\d+)$/', $data, $m)) {
        $token = $m[1]; $idx = (int)$m[2];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'rm_menu') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $label = $payload['datasets'][$idx] ?? null;
        if ($label === null || !isValidLabel($label)) {
            Telegram::answerCallback($cbid, 'مورد نامعتبر');
            return;
        }
        $base = DATA_DIR . '/' . $label;
        if (is_dir($base)) rrmdir($base);

        // حذف از لیست و بازگشت به منو
        unset($payload['datasets'][$idx]);
        $payload['datasets'] = array_values($payload['datasets']);
        savePendingPost($token, $payload);

        Telegram::answerCallback($cbid, '🗑 حذف شد');
        if (empty($payload['datasets'])) {
            deletePendingPost($token);
            try { Telegram::editMessageText($chat_id, $payload['msg_id'], "🗑 «" . htmlspecialchars((string)$label) . "» حذف شد.\n📂 دیتاست دیگری باقی نمانده.", 'HTML'); } catch (\Throwable $e) {}
            return;
        }
        $page = min((int)($payload['page'] ?? 0), (int)ceil(count($payload['datasets']) / RM_MENU_PER_PAGE) - 1);
        $payload['page'] = max(0, $page);
        savePendingPost($token, $payload);
        $kb = buildRmMenuKeyboard($token, $payload['datasets'], $payload['page']);
        try {
            Telegram::editMessageText($chat_id, $payload['msg_id'],
                "🗑 «" . htmlspecialchars((string)$label) . "» حذف شد.\n\n" . rmMenuText(count($payload['datasets'])), 'HTML', $kb);
        } catch (\Throwable $e) {}
        return;
    }

    if (preg_match('/^rmb:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        if (!$payload || ($payload['type'] ?? '') !== 'rm_menu') {
            Telegram::answerCallback($cbid, 'منقضی شده');
            return;
        }
        $kb = buildRmMenuKeyboard($token, $payload['datasets'] ?? [], (int)($payload['page'] ?? 0));
        try { Telegram::editMessageText($chat_id, $payload['msg_id'], rmMenuText(count($payload['datasets'] ?? [])), 'HTML', $kb); } catch (\Throwable $e) {}
        Telegram::answerCallback($cbid, '');
        return;
    }

    if (preg_match('/^rmc:([a-f0-9]{8,20})$/', $data, $m)) {
        $token = $m[1];
        $payload = loadPendingPost($token);
        deletePendingPost($token);
        Telegram::answerCallback($cbid, 'بسته شد');
        if ($payload && !empty($payload['msg_id'])) {
            try { Telegram::editMessageText($chat_id, $payload['msg_id'], "🗂 منوی مدیریت دیتاست‌ها بسته شد.", 'HTML'); } catch (\Throwable $e) {}
        }
        return;
    }

    // -------------------------------------------------------
    // Legacy: channel post type (واریزی / برگشتی / هر دو)
    // -------------------------------------------------------
    if (!preg_match('/^ptype:([a-f0-9]{8,20}):(v|b|a)$/', $data, $m)) {
        Telegram::answerCallback($cbid, 'دکمه نامعتبر');
        return;
    }

    $token = $m[1];
    $type = $m[2];
    $payload = loadPendingPost($token);
    if (!$payload) {
        Telegram::answerCallback($cbid, 'این درخواست منقضی شده');
        return;
    }

    $codes = $payload['codes'] ?? [];
    if (!is_array($codes)) $codes = [];
    $rowCodes = $payload['row_codes'] ?? [];
    if (!is_array($rowCodes)) $rowCodes = [];
    $pageMeta = $payload['page_meta'] ?? [];
    if (!is_array($pageMeta)) $pageMeta = [];

    $channel = $payload['channel_id'] ?? (defined('AUTO_POST_CHANNEL_ID') ? AUTO_POST_CHANNEL_ID : null);

$sentOk = false;
$sentErr = '';
if ($channel) {
    $pngs = $payload['pngs'] ?? [];
    if (!is_array($pngs)) $pngs = [];
    $png = $payload['png'] ?? '';
    $xls = $payload['xls'] ?? '';
    $hasPhoto = (!empty($pngs) && is_array($pngs));
    if (!$hasPhoto && $png && is_file($png)) { $pngs = [$png]; $hasPhoto = true; }

    try {
        if ($hasPhoto) {
            sendPhotosPaged($channel, $pngs, (string)($payload['label'] ?? 'گزارش'), $type, $pageMeta, $rowCodes);
            $sentOk = true;
        } elseif ($xls && is_file($xls)) {
            Telegram::sendDocument($channel, $xls, "📎 فایل گزارش");
            $sentOk = true;
        }

        if (defined('AUTO_POST_CHANNEL_SEND_XLS') && AUTO_POST_CHANNEL_SEND_XLS && $xls && is_file($xls)) {
            Telegram::sendDocument($channel, $xls, '📎 فایل اکسل');
        }
    } catch (Throwable $e) {
        $sentOk = false;
        $sentErr = $e->getMessage();
    }
}

    deletePendingPost($token);
    Telegram::answerCallback($cbid, $sentOk ? 'ارسال شد ✅' : 'ارسال ناموفق بود');
    if ($sentOk) {
        Telegram::sendMessage($chat_id, '✅ به کانال ارسال شد.');
    } else {
        $hint = '⚠️ ارسال به کانال انجام نشد. (بات ادمین هست؟ / آیدی کانال درست است؟)';
        if ($sentErr !== '') {
            $hint .= "\n\n<pre>" . htmlspecialchars($sentErr) . "</pre>";
        }
        Telegram::sendMessage($chat_id, $hint);
    }
}

function queuePush(string $raw): ?array {
    if (!is_dir(QUEUE_DIR)) @mkdir(QUEUE_DIR, 0775, true);
    $jobId = date('Ymd_His') . '_' . bin2hex(random_bytes(4));
    $name = $jobId . '.json';
    $path = rtrim(QUEUE_DIR, '/') . '/' . $name;
    if (@file_put_contents($path, $raw) === false) return null;
    return ['path' => $path, 'job_id' => $jobId];
}

/**
 * پایان دادن به پاسخ HTTP و ادامهٔ کار در پس‌زمینه.
 *
 * LiteSpeed تابع `fastcgi_finish_request` ندارد و `litespeed_finish_request`
 * دارد؛ کد قبلی فقط اولی را می‌شناخت و روی هاست LiteSpeed به fallback ناکارآمد
 * ob_flush می‌افتاد. بدون جدا شدن واقعی، هر کار سنگینی تلگرام را منتظر می‌گذارد
 * و باعث retry و پردازش تکراری می‌شود.
 *
 * @return bool آیا واقعاً از کلاینت جدا شدیم
 */
function finishRequest(): bool {
    if (function_exists('litespeed_finish_request')) { litespeed_finish_request(); return true; }
    if (function_exists('fastcgi_finish_request'))   { fastcgi_finish_request();   return true; }
    @ob_end_flush(); @ob_flush(); @flush();
    return false;
}

/**
 * پردازش کارهای صف. همان منطقی که worker.php داشت، اینجا آمده تا وبهوک هم
 * بتواند صدایش بزند — روی هاستی که exec بسته است و کران کار نمی‌کند، این تنها
 * راهی است که صف اصلاً پردازش شود.
 *
 * قفل انحصاری تضمین می‌کند فقط یک پردازشگر همزمان کار کند (وبهوک یا کران)،
 * و claim اتمیک با rename جلوی پردازش دوباره را می‌گیرد.
 *
 * @return int تعداد کارهای پردازش‌شده
 */
function drainQueue(?int $max = null): int {
    $max = $max ?? QUEUE_MAX_JOBS_PER_RUN;
    if (!is_dir(QUEUE_DIR)) @mkdir(QUEUE_DIR, 0775, true);

    $lockFile = rtrim(QUEUE_DIR, '/') . '/.lock';
    $lockFp = @fopen($lockFile, 'c+');
    if (!$lockFp) return 0;
    if (!@flock($lockFp, LOCK_EX | LOCK_NB)) { fclose($lockFp); return 0; }

    $processed = 0;
    $files = glob(rtrim(QUEUE_DIR, '/') . '/*.json') ?: [];
    sort($files, SORT_STRING);

    foreach ($files as $file) {
        if ($processed >= $max) break;

        $claimed = $file . '.processing';
        if (!@rename($file, $claimed)) continue;

        $jobId = preg_replace('/\.json\.processing$/', '', basename($claimed));
        $GLOBALS['APP_JOB_ID'] = $jobId;

        $raw = @file_get_contents($claimed);
        $update = $raw ? json_decode($raw, true) : null;

        try {
            if (is_array($update)) processUpdate($update);
        } catch (Throwable $e) {
            $errDir = rtrim(QUEUE_DIR, '/') . '/_errors';
            if (!is_dir($errDir)) @mkdir($errDir, 0775, true);
            @file_put_contents($errDir . '/' . basename($claimed) . '.error.txt', (string)$e);
        }

        unset($GLOBALS['APP_JOB_ID']);
        @unlink($claimed);
        $processed++;
    }

    @flock($lockFp, LOCK_UN);
    @fclose($lockFp);
    return $processed;
}

function maybeSpawnWorker(): void {
    // Best-effort background worker; cron is still recommended.
    $php = PHP_BINARY ?: 'php';
    $cmd = escapeshellcmd($php) . ' ' . escapeshellarg(__DIR__ . '/worker.php') . ' --once > /dev/null 2>&1 &';
    if (function_exists('exec')) { @exec($cmd); }
    elseif (function_exists('shell_exec')) { @shell_exec($cmd); }
}

function respondOk(): void {
    http_response_code(200);
    header('Content-Type: text/plain; charset=utf-8');
    echo "OK";
}

// پاکسازی state های منقضی (_pending) — روی هر hit وبهوک، سبک و بی‌خطر
function cleanupPendingDir(): void {
    $ttl = defined('PENDING_TTL_SECONDS') ? (int)PENDING_TTL_SECONDS : 172800;
    $now = time();
    foreach ((glob(rtrim(pendingDir(), '/') . '/*.json') ?: []) as $f) {
        $mt = @filemtime($f);
        if ($mt !== false && ($now - $mt) > $ttl) @unlink($f);
    }
}

function webhookFront(): void {
    // Make sure we keep running even if Telegram disconnects right after getting 200 OK
    ignore_user_abort(true);

    // Optional webhook secret (set via setWebhook &secret_token=...)
    if (defined('WEBHOOK_SECRET') && WEBHOOK_SECRET !== '') {
        $got = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
        if (!hash_equals(WEBHOOK_SECRET, $got)) {
            http_response_code(403);
            echo 'Forbidden';
            return;
        }
    }

    cleanupPendingDir();

    $raw = file_get_contents('php://input');
    if (!$raw) { respondOk(); return; }

    $update = json_decode($raw, true);
    if (!$update) { respondOk(); return; }

    // Callback queries are light and should be processed immediately.
    if (!empty($update['callback_query'])) {
        try {
            processCallback($update);
        } catch (Throwable $e) {
            foreach (ADMIN_IDS as $admin) {
                Telegram::sendMessage($admin, '⚠️ خطا در پردازش callback: <pre>' . htmlspecialchars((string)$e) . '</pre>');
            }
        }
        respondOk();
        return;
    }

    $message = $update['message'] ?? $update['edited_message'] ?? null;
    if (!$message) { respondOk(); return; }

    // Heavy updates (any document or merge command) -> queue
    $isHeavy = !empty($message['document']);
    if (!$isHeavy && !empty($message['caption']) && preg_match('/^\/p(\s|$)/', trim($message['caption']))) {
        $isHeavy = true;
    }

    if (!$isHeavy) {
        // Light commands: process before responding OK so we never lose replies
        try {
            processUpdate($update);
        } catch (Throwable $e) {
            foreach (ADMIN_IDS as $admin) {
                Telegram::sendMessage($admin, '⚠️ خطا در پردازش دستور سبک: <pre>' . htmlspecialchars((string)$e) . '</pre>');
            }
        }
        respondOk();
        return;
    }

    // Heavy work: respond OK first to prevent Telegram timeouts, then queue and notify
    respondOk();
    $detached = finishRequest();

    $q = queuePush($raw);
    $chat_id = $message['chat']['id'] ?? null;
    $jobId = $q['job_id'] ?? null;

    if (!$q || !$jobId) {
        if ($chat_id) Telegram::sendMessage($chat_id, '⚠️ خطا: ذخیره درخواست در صف انجام نشد.');
        foreach (ADMIN_IDS as $admin) {
            Telegram::sendMessage($admin, '⚠️ صف‌گذاری ناموفق بود (queuePush failed).');
        }
        return;
    }

    // Notify the user (immediate receipt confirmation)
    $pending = count(glob(rtrim(QUEUE_DIR, '/') . '/*.json') ?: []);
    if ($chat_id) {
        Telegram::sendMessage($chat_id, "✅ فایل/درخواست دریافت شد و در صف قرار گرفت.\nکد پیگیری: <code>{$jobId}</code>\nتعداد در صف: {$pending}");
    }

    maybeSpawnWorker();

    // اگر exec بسته باشد maybeSpawnWorker بی‌صدا هیچ کاری نمی‌کند و بدون کران،
    // صف هرگز خالی نمی‌شود. در آن حالت همین‌جا خودمان پردازش می‌کنیم.
    //
    // فقط وقتی که واقعاً از کلاینت جدا شده‌ایم: در غیر این صورت تلگرام منتظر
    // می‌ماند، تایم‌اوت می‌خورد و همان آپدیت را دوباره می‌فرستد ← کار تکراری.
    if ($detached && !Health::canSpawnWorker()) {
        drainQueue();
    }
}

if (!defined('APP_BOOTSTRAP_ONLY')) {
    webhookFront();
}
