<?php
declare(strict_types=1);

function rayanEnsureDir(string $dir): void
{
    if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
        throw new RuntimeException('ایجاد پوشه ممکن نشد: ' . $dir);
    }
}

function rayanRuntimeDir(array $config): string
{
    $dir = $config['paths']['runtime_dir'] ?? (__DIR__ . '/runtime_rayan');
    rayanEnsureDir($dir);
    return $dir;
}

function rayanLogsDir(array $config): string
{
    $dir = $config['paths']['logs_dir'] ?? (__DIR__ . '/logs_rayan');
    rayanEnsureDir($dir);
    return $dir;
}

function rayanSubscribersFile(array $config): string
{
    return rayanRuntimeDir($config) . '/bale_subscribers.json';
}

function rayanWebhookLog(array $config, string $title, array $data = []): void
{
    $logFile = rayanLogsDir($config) . '/webhook_log.txt';
    $text = '[' . date('Y-m-d H:i:s') . '] ' . $title;
    if (!empty($data)) {
        $text .= PHP_EOL . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    }
    $text .= PHP_EOL . str_repeat('-', 100) . PHP_EOL;
    file_put_contents($logFile, $text, FILE_APPEND | LOCK_EX);
}
function rayanParseHeaderValue(string $headers, string $name): string
{
    if (preg_match_all('/^' . preg_quote($name, '/') . ':\s*(.+)$/im', $headers, $m) && !empty($m[1])) {
        $last = end($m[1]);
        return is_string($last) ? trim($last) : '';
    }

    return '';
}

function rayanSafeFileName(string $name): string
{
    $name = trim($name);
    $name = str_replace(['\\', '/', "\0"], '_', $name);
    $name = preg_replace('/[^\pL\pN\-\._\(\)\[\]\s]+/u', '_', $name) ?? $name;
    $name = preg_replace('/\s+/u', ' ', $name) ?? $name;
    $name = trim($name, " .\t\n\r\0\x0B");

    return $name !== '' ? $name : 'file';
}

function rayanGuessExtensionFromContentType(string $contentType, string $fallbackUrl = ''): string
{
    $contentType = mb_strtolower(trim(explode(';', $contentType)[0] ?? ''), 'UTF-8');

    $map = [
        'application/pdf' => 'pdf',
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
        'image/webp' => 'webp',
        'application/zip' => 'zip',
        'application/msword' => 'doc',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
        'application/vnd.ms-excel' => 'xls',
        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
        'text/plain' => 'txt',
        'text/html' => 'html',
    ];

    if (isset($map[$contentType])) {
        return $map[$contentType];
    }

    $path = parse_url($fallbackUrl, PHP_URL_PATH);
    if (is_string($path)) {
        $ext = strtolower((string)pathinfo($path, PATHINFO_EXTENSION));
        if ($ext !== '') {
            return $ext;
        }
    }

    return 'bin';
}

function rayanResolveDownloadFileName(string $headers, string $effectiveUrl, string $defaultBaseName): string
{
    $contentDisposition = rayanParseHeaderValue($headers, 'Content-Disposition');
    $contentType        = rayanParseHeaderValue($headers, 'Content-Type');

    $fileName = '';

    if ($contentDisposition !== '') {
        if (preg_match("/filename\*=UTF-8''([^;]+)/i", $contentDisposition, $m)) {
            $fileName = rawurldecode(trim((string)$m[1], "\"' "));
        } elseif (preg_match('/filename="?([^"]+)"?/i', $contentDisposition, $m)) {
            $fileName = trim((string)$m[1]);
        }
    }

    $fileName = rayanSafeFileName($fileName);

    if ($fileName === 'file') {
        $ext = rayanGuessExtensionFromContentType($contentType, $effectiveUrl);
        $fileName = rayanSafeFileName($defaultBaseName) . '.' . $ext;
        return $fileName;
    }

    $ext = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
    if ($ext === '') {
        $guessedExt = rayanGuessExtensionFromContentType($contentType, $effectiveUrl);
        $fileName .= '.' . $guessedExt;
    }

    return $fileName;
}

function rayanDownloadAuthenticatedFile(
    string $url,
    string $cookieFile,
    string $saveDir,
    string $defaultBaseName,
    ?string $referer = null
): array {
    rayanEnsureDir($saveDir);

    $ch = curl_init();
    $headers = [
        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
        'Accept: */*',
        'Accept-Language: fa-IR,fa;q=0.9,en-US;q=0.8,en;q=0.7',
        'Connection: keep-alive',
    ];

    if ($referer !== null && trim($referer) !== '') {
        $headers[] = 'Referer: ' . trim($referer);
    }

    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 10,
        CURLOPT_CONNECTTIMEOUT => 30,
        CURLOPT_TIMEOUT        => 180,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_COOKIEJAR      => $cookieFile,
        CURLOPT_COOKIEFILE     => $cookieFile,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_HEADER         => true,
        CURLOPT_ENCODING       => '',
    ]);

    $raw = curl_exec($ch);
    if ($raw === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('خطا در دانلود فایل: ' . $err);
    }

    $status      = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize  = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $effective   = (string)(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url);
    $respHeaders = substr($raw, 0, $headerSize);
    $body        = substr($raw, $headerSize);

    curl_close($ch);

    if ($status >= 400) {
        throw new RuntimeException('دانلود فایل با خطای HTTP انجام شد: ' . $status);
    }

    if ($body === '') {
        throw new RuntimeException('بدنه فایل دانلود شده خالی است.');
    }

    $fileName = rayanResolveDownloadFileName($respHeaders, $effective, $defaultBaseName);
    $path = rtrim($saveDir, '/\\') . '/' . $fileName;

    file_put_contents($path, $body);

    return [
        'path'         => $path,
        'filename'     => $fileName,
        'headers'      => $respHeaders,
        'url'          => $effective,
        'status'       => $status,
        'content_type' => rayanParseHeaderValue($respHeaders, 'Content-Type'),
        'size'         => filesize($path) ?: 0,
    ];
}

function rayanExtractResultDownloadLinks(string $html, string $baseUrl): array
{
    $xpath = rayanCreateXPath($html);

    $contractUrl = '';
    $receiptUrl  = '';
    $orderId     = '';

    $contractNode = $xpath->query('//*[@id="contract-id"][@href]')->item(0);
    if ($contractNode instanceof DOMElement) {
        $contractUrl = rayanAbsoluteUrl($baseUrl, $contractNode->getAttribute('href'));
    }

    $receiptNode = $xpath->query('//*[@id="receipt-id"][@href]')->item(0);
    if ($receiptNode instanceof DOMElement) {
        $receiptUrl = rayanAbsoluteUrl($baseUrl, $receiptNode->getAttribute('href'));
    }

    if ($contractUrl === '') {
        $fallback = $xpath->query('//a[@href][contains(normalize-space(.), "دانلود فایل قرارداد")]')->item(0);
        if ($fallback instanceof DOMElement) {
            $contractUrl = rayanAbsoluteUrl($baseUrl, $fallback->getAttribute('href'));
        }
    }

    if ($receiptUrl === '') {
        $fallback = $xpath->query('//a[@href][contains(normalize-space(.), "دانلود فایل رسید")]')->item(0);
        if ($fallback instanceof DOMElement) {
            $receiptUrl = rayanAbsoluteUrl($baseUrl, $fallback->getAttribute('href'));
        }
    }

    $orderNode = $xpath->query('//*[@id="order_id"]')->item(0);
    if ($orderNode instanceof DOMElement) {
        $orderId = trim((string)$orderNode->getAttribute('value'));
    }

    return [
        'order_id'      => $orderId,
        'contract_url'  => $contractUrl,
        'receipt_url'   => $receiptUrl,
    ];
}

function rayanBaleSendDocument(array $config, string $chatId, string $filePath, string $caption = ''): array
{
    if (!is_file($filePath)) {
        throw new RuntimeException('فایل برای ارسال پیدا نشد: ' . $filePath);
    }

    $token = trim((string)($config['bale']['bot_token'] ?? ''));
    if ($token === '' || str_contains($token, 'PUT_YOUR_')) {
        throw new RuntimeException('توکن بله در config_rayan.php تنظیم نشده است.');
    }

    $url = "https://tapi.bale.ai/bot{$token}/sendDocument";
    $timeout = (int)($config['bale']['request_timeout'] ?? 90);

    $mime = 'application/octet-stream';
    if (function_exists('mime_content_type')) {
        $detected = @mime_content_type($filePath);
        if (is_string($detected) && trim($detected) !== '') {
            $mime = $detected;
        }
    }

    $postFields = [
        'chat_id'  => $chatId,
        'document' => new CURLFile($filePath, $mime, basename($filePath)),
    ];

    if (trim($caption) !== '') {
        $postFields['caption'] = $caption;
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $postFields,
        CURLOPT_TIMEOUT        => $timeout,
        CURLOPT_CONNECTTIMEOUT => 20,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    $response = curl_exec($ch);
    $error    = curl_error($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    rayanSystemLog($config, "BALE sendDocument | HTTP={$httpCode} | ERR={$error} | FILE=" . basename($filePath) . " | RESP=" . mb_substr((string)$response, 0, 1000));

    if ($error) {
        throw new RuntimeException('خطای cURL در ارسال فایل به بله: ' . $error);
    }

    $json = json_decode((string)$response, true);
    if ($httpCode < 200 || $httpCode >= 300 || !is_array($json) || empty($json['ok'])) {
        throw new RuntimeException('خطا در sendDocument بله: ' . mb_substr((string)$response, 0, 500));
    }

    return $json;
}

function rayanNotifyAllDocument(array $config, string $filePath, string $caption = ''): void
{
    $subs = rayanLoadSubscribers($config);
    foreach ($subs as $chatId) {
        try {
            rayanBaleSendDocument($config, (string)$chatId, $filePath, $caption);
        } catch (Throwable $e) {
            rayanSystemLog($config, 'SEND DOC ERROR chat_id=' . $chatId . ' | ' . $e->getMessage());
        }
    }
}
function rayanSystemLog(array $config, string $text): void
{
    $logFile = rayanLogsDir($config) . '/system_log.txt';
    file_put_contents(
        $logFile,
        '[' . date('Y-m-d H:i:s') . '] ' . $text . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

function rayanRespondJson(int $statusCode, array $payload): void
{
    http_response_code($statusCode);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    exit;
}

function rayanNormalizeSpaces(string $text): string
{
    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = str_replace(["\r", "\n", "\t", "\xC2\xA0"], ' ', $text);
    $text = preg_replace('/\s+/u', ' ', trim($text));
    return $text ?? trim($text);
}

function rayanNormalizeCompareText(string $text): string
{
    $text = rayanNormalizeSpaces($text);
    $text = str_replace(['ي', 'ك'], ['ی', 'ک'], $text);
    $text = mb_strtolower($text, 'UTF-8');
    return trim($text);
}

function rayanFaToEn(string $text): string
{
    $fa = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٬','،'];
    $en = ['0','1','2','3','4','5','6','7','8','9',',',','];
    return str_replace($fa, $en, $text);
}

function rayanDigitsOnly(string $text): string
{
    $text = rayanFaToEn($text);
    return preg_replace('/\D+/u', '', $text) ?? '';
}

function rayanAbsoluteUrl(string $base, string $url): string
{
    if ($url === '') {
        return $base;
    }

    if (preg_match('~^https?://~i', $url)) {
        return $url;
    }

    if (strpos($url, '//') === 0) {
        $scheme = parse_url($base, PHP_URL_SCHEME) ?: 'https';
        return $scheme . ':' . $url;
    }

    $baseParts = parse_url($base);
    $scheme = $baseParts['scheme'] ?? 'https';
    $host   = $baseParts['host'] ?? '';
    $port   = isset($baseParts['port']) ? ':' . $baseParts['port'] : '';

    if (strpos($url, '/') === 0) {
        return $scheme . '://' . $host . $port . $url;
    }

    $path = $baseParts['path'] ?? '/';
    $dir  = rtrim(str_replace('\\', '/', dirname($path)), '/');
    if ($dir === '.') {
        $dir = '';
    }

    return $scheme . '://' . $host . $port . $dir . '/' . $url;
}

function rayanHttpRequest(string $url, ?array $postFields, string $cookieFile, array $extraHeaders = []): array
{
    $ch = curl_init();

    $headers = array_merge([
        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
        'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language: fa-IR,fa;q=0.9,en-US;q=0.8,en;q=0.7',
        'Connection: keep-alive',
    ], $extraHeaders);

    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 10,
        CURLOPT_CONNECTTIMEOUT => 30,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_COOKIEJAR      => $cookieFile,
        CURLOPT_COOKIEFILE     => $cookieFile,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_HEADER         => true,
        CURLOPT_ENCODING       => '',
    ]);

    if ($postFields !== null) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postFields));
        $headers[] = 'Content-Type: application/x-www-form-urlencoded';
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    }

    $raw = curl_exec($ch);
    if ($raw === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('curl error: ' . $err);
    }

    $status      = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize  = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $effective   = (string)(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url);
    $respHeaders = substr($raw, 0, $headerSize);
    $body        = substr($raw, $headerSize);

    curl_close($ch);

    return [
        'status'  => $status,
        'headers' => $respHeaders,
        'body'    => $body,
        'url'     => $effective,
    ];
}

function rayanHttpAjaxFormRequest(string $url, array $postFields, string $cookieFile, array $extraHeaders = []): array
{
    $ch = curl_init();

    $headers = array_merge([
        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
        'Accept: application/json, text/javascript, */*; q=0.01',
        'Accept-Language: fa-IR,fa;q=0.9,en-US;q=0.8,en;q=0.7',
        'X-Requested-With: XMLHttpRequest',
        'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
        'Connection: keep-alive',
    ], $extraHeaders);

    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 10,
        CURLOPT_CONNECTTIMEOUT => 30,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_COOKIEJAR      => $cookieFile,
        CURLOPT_COOKIEFILE     => $cookieFile,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_HEADER         => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($postFields),
        CURLOPT_ENCODING       => '',
    ]);

    $raw = curl_exec($ch);
    if ($raw === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('curl error: ' . $err);
    }

    $status      = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize  = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $effective   = (string)(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url);
    $respHeaders = substr($raw, 0, $headerSize);
    $body        = substr($raw, $headerSize);

    curl_close($ch);

    return [
        'status'  => $status,
        'headers' => $respHeaders,
        'body'    => $body,
        'url'     => $effective,
    ];
}

function rayanCreateXPath(string $html): DOMXPath
{
    libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $loaded = $dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);
    if (!$loaded) {
        throw new RuntimeException('Could not parse HTML');
    }
    libxml_clear_errors();
    return new DOMXPath($dom);
}

function rayanGetInnerText(DOMNode $node): string
{
    return trim((string)preg_replace('/\s+/u', ' ', (string)($node->textContent ?? '')));
}

function rayanFindFormByQuery(string $html, string $query, string $baseUrl): ?array
{
    $xpath = rayanCreateXPath($html);
    $form = $xpath->query($query)->item(0);

    if (!$form instanceof DOMElement) {
        return null;
    }

    $action = $form->getAttribute('action') ?: '';
    $method = strtoupper($form->getAttribute('method') ?: 'GET');

    $fields = [];
    $idToName = [];

    foreach ($xpath->query('.//input|.//textarea|.//select', $form) as $el) {
        if (!$el instanceof DOMElement) {
            continue;
        }

        $tag  = strtolower($el->tagName);
        $type = strtolower($el->getAttribute('type') ?: 'text');
        $name = $el->getAttribute('name');
        $id   = $el->getAttribute('id');

        if ($id !== '' && $name !== '') {
            $idToName[$id] = $name;
        }

        if ($name === '') {
            continue;
        }

        if ($tag === 'input') {
            if (in_array($type, ['submit', 'button', 'image', 'file'], true)) {
                continue;
            }

            if (in_array($type, ['checkbox', 'radio'], true)) {
                if ($el->hasAttribute('checked')) {
                    $fields[$name] = $el->getAttribute('value') !== '' ? $el->getAttribute('value') : 'on';
                }
                continue;
            }

            $fields[$name] = $el->getAttribute('value');
            continue;
        }

        if ($tag === 'textarea') {
            $fields[$name] = $el->textContent ?? '';
            continue;
        }

        if ($tag === 'select') {
            $value = '';
            foreach ($xpath->query('.//option', $el) as $opt) {
                if (!$opt instanceof DOMElement) {
                    continue;
                }
                if ($opt->hasAttribute('selected')) {
                    $value = $opt->getAttribute('value');
                    break;
                }
                if ($value === '') {
                    $value = $opt->getAttribute('value');
                }
            }
            $fields[$name] = $value;
        }
    }

    return [
        'action'     => rayanAbsoluteUrl($baseUrl, $action),
        'method'     => $method,
        'fields'     => $fields,
        'id_to_name' => $idToName,
    ];
}

function rayanSetFieldById(array &$form, string $id, string $value): void
{
    if (!isset($form['id_to_name'][$id])) {
        throw new RuntimeException("Field with id {$id} not found");
    }

    $name = $form['id_to_name'][$id];
    $form['fields'][$name] = $value;
}

function rayanFindHrefByText(string $html, string $text, string $baseUrl): ?string
{
    $xpath = rayanCreateXPath($html);
    $nodes = $xpath->query('//a[@href]');

    foreach ($nodes as $node) {
        if (!$node instanceof DOMElement) {
            continue;
        }

        $t = rayanGetInnerText($node);
        if (mb_stripos($t, $text) !== false) {
            return rayanAbsoluteUrl($baseUrl, $node->getAttribute('href'));
        }
    }

    return null;
}

function rayanExtractCreditInfo(string $html): array
{
    $xpath = rayanCreateXPath($html);

    $maxEtebar = null;
    $maxKharid = null;

    foreach ($xpath->query('//li[contains(@class,"list-group-item")]') as $li) {
        $txt = rayanGetInnerText($li);
        if (mb_stripos($txt, 'سقف اعتبار') !== false) {
            $maxEtebar = trim(str_replace(['سقف اعتبار', ':'], '', $txt));
        }
        if (mb_stripos($txt, 'سقف مبلغ خرید') !== false) {
            $maxKharid = trim(str_replace(['سقف مبلغ خرید', ':'], '', $txt));
        }
    }

    $maxCreditNode = $xpath->query('//*[@id="max_credit_id"]')->item(0);
    if ($maxCreditNode instanceof DOMNode) {
        $txt = rayanGetInnerText($maxCreditNode);
        if ($txt !== '') {
            $maxKharid = $txt;
        }
    }

    return [$maxEtebar, $maxKharid];
}
function rayanPickImportantSiteError(string $text): string
{
    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = rayanNormalizeSpaces(strip_tags($text));

    $priorityPatterns = [
        '/مبلغ\s+خرید\s+اعتباری\s+معتبر\s+نیست/u',
        '/اعتبار\s+.*?\s+کافی\s+نیست/u',
        '/سقف\s+.*?\s+خرید/u',
        '/مبلغ\s+.*?\s+معتبر\s+نیست/u',
        '/کد\s+تایید\s+.*?\s+نامعتبر/u',
        '/شماره\s+موبایل\s+.*?\s+معتبر\s+نیست/u',
        '/کد\s+ملی\s+.*?\s+معتبر\s+نیست/u',
    ];

    foreach ($priorityPatterns as $pattern) {
        if (preg_match($pattern, $text, $m)) {
            return trim((string)$m[0]);
        }
    }

    return '';
}

function rayanSanitizeSiteMessageForNotify(string $text, int $maxLen = 160): string
{
    $important = rayanPickImportantSiteError($text);
    if ($important !== '') {
        return $important;
    }

    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = rayanNormalizeSpaces($text);
    $text = str_ireplace(['رایان کیمیا', 'رایان', 'مینی‌پی', 'مینی پی', 'مینیپی', 'MiniPay', 'minipay'], 'سامانه', $text);

    // حذف لینک‌ها
    $text = preg_replace('~https?://\S+~iu', '', $text) ?? $text;
    $text = preg_replace('~www\.\S+~iu', '', $text) ?? $text;
    $text = preg_replace('~[a-z0-9.-]+\.[a-z]{2,}(?:/\S*)?~iu', '', $text) ?? $text;

    // حذف عبارت‌های مزاحم رایج در صفحه سرویس‌ها
    $noise = [
        'لطفا! در مراحل ثبت خرید به هیچ وجه از دکمه بازگشت و یا رفرش استفاده نکنید.',
        'لطفا در مراحل ثبت خرید به هیچ وجه از دکمه بازگشت و یا رفرش استفاده نکنید.',
        'خروج',
        'بعد از ثبت فرم خرید، صفحه کد تایید باز نشد. فایل 05_after_purchase.html را بررسی کن.',
        'خطای صفحه:',
    ];
    $text = str_replace($noise, ' ', $text);

    // حذف بخش‌های اضافی رایج
    $text = preg_replace('/\bURL\s*[:：].*/iu', '', $text) ?? $text;
    $text = preg_replace('/\bRAW\s*[:：].*/iu', '', $text) ?? $text;
    $text = preg_replace('/\bHTTP\s*\d+.*/iu', '', $text) ?? $text;

    $text = rayanNormalizeSpaces($text);

    // فقط قسمت اول پیام مفید
    $parts = preg_split('/[\r\n\|]+|\. |! |\? |؛|، اما |، ولی | ولی /u', $text);
    $first = '';
    foreach ($parts as $part) {
        $part = trim((string)$part);
        if ($part !== '' && $part !== 'خروج') {
            $first = $part;
            break;
        }
    }

    if ($first === '') {
        $first = 'خطای نامشخص از سمت سایت';
    }

    if (mb_strlen($first, 'UTF-8') > $maxLen) {
        $first = mb_substr($first, 0, $maxLen, 'UTF-8') . '...';
    }

    return $first;
}
function rayanPageContains(string $html, string $needle): bool
{
    return mb_stripos($html, $needle) !== false;
}
function rayanRayanOtpResultLooksSuccessful(string $html, int $httpStatus = 200): bool
{
    if ($httpStatus >= 400) {
        return false;
    }

    $text = rayanNormalizeSpaces(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
    if ($text === '') {
        return false;
    }

    // بعضی صفحه‌های موفق هنوز عبارت «کد تایید» یا متن‌های فرم قبلی را در قالب صفحه نگه می‌دارند.
    // بنابراین اول نشانه‌های قطعی موفقیت/قرارداد را بررسی می‌کنیم و بعد سراغ خطاها می‌رویم.
    $strongSuccessPatterns = [
        'id="contract-id"',
        "id='contract-id'",
        'contract-id',
        'id="receipt-id"',
        "id='receipt-id'",
        'receipt-id',
        'دانلود فایل قرارداد',
        'با تشکر از خرید شما - تراکنش MiniPay با موفقیت انجام شد',
        'تراکنش MiniPay با موفقیت انجام شد',
        'پرداخت با موفقیت انجام شد',
        'تراکنش با موفقیت انجام شد',
        'خرید شما با موفقیت انجام شد',
        'خرید با موفقیت انجام شد',
        'ثبت خرید با موفقیت انجام شد',
        'درخواست شما با موفقیت ثبت شد',
        'ثبت نهایی موفق',
        'عملیات با موفقیت انجام شد',
    ];
    foreach ($strongSuccessPatterns as $pattern) {
        if (mb_stripos($html, $pattern, 0, 'UTF-8') !== false || mb_stripos($text, $pattern, 0, 'UTF-8') !== false) {
            return true;
        }
    }

    $negativePatterns = [
        'ناموفق',
        'نا موفق',
        'خطا',
        'نامعتبر',
        'کد تایید اشتباه',
        'کد تایید نامعتبر',
        'اعتبار کافی نیست',
        'معتبر نیست',
        'لطفا مجددا تلاش',
        'Please enter your purchase password',
        'کد تایید',
    ];
    foreach ($negativePatterns as $pattern) {
        if (mb_stripos($text, $pattern, 0, 'UTF-8') !== false) {
            return false;
        }
    }

    $softSuccessPatterns = [
        'با موفقیت ثبت شد',
        'قرارداد',
    ];

    foreach ($softSuccessPatterns as $pattern) {
        if (mb_stripos($html, $pattern, 0, 'UTF-8') !== false || mb_stripos($text, $pattern, 0, 'UTF-8') !== false) {
            return true;
        }
    }

    return false;
}
function rayanTextContainsNormalized(string $haystack, string $needle): bool
{
    $haystack = rayanNormalizeClubText($haystack);
    $needle   = rayanNormalizeClubText($needle);

    if ($haystack === '' || $needle === '') {
        return false;
    }

    return $haystack === $needle
        || mb_stripos($haystack, $needle, 0, 'UTF-8') !== false
        || mb_stripos($needle, $haystack, 0, 'UTF-8') !== false;
}
function rayanNormalizeClubText(string $text): string
{
    $text = rayanNormalizeCompareText($text);
    $text = str_replace(['۱', '1'], 'یک', $text);
    $text = str_replace(['۲', '2'], 'دو', $text);
    return $text;
}

function rayanNormalizeFieldNameText(string $text): string
{
    $text = rayanNormalizeCompareText($text);
    $text = str_replace(['ي', 'ك', 'ة'], ['ی', 'ک', 'ه'], $text);
    $text = str_replace(['أ', 'إ', 'آ'], 'ا', $text);
    $text = preg_replace('/[\s\x{200c}\-_:\/\\()\[\]{}]+/u', '', $text) ?? $text;
    return trim($text);
}

function rayanFieldNameMatches(string $actual, string $expected): bool
{
    $a = rayanNormalizeFieldNameText($actual);
    $e = rayanNormalizeFieldNameText($expected);
    if ($a === '' || $e === '') {
        return false;
    }
    return $a === $e
        || mb_stripos($a, $e, 0, 'UTF-8') !== false
        || mb_stripos($e, $a, 0, 'UTF-8') !== false;
}

function rayanParamMatchesName(array $param, string $fieldName): bool
{
    foreach (['fieldName', 'title', 'label', 'name'] as $key) {
        if (isset($param[$key]) && rayanFieldNameMatches((string)$param[$key], $fieldName)) {
            return true;
        }
    }
    return false;
}

function rayanFindParamByNames(array $params, array $fieldNames = [], array $fallbackIds = []): ?array
{
    foreach ($fieldNames as $fieldName) {
        foreach ($params as $param) {
            if (!is_array($param)) {
                continue;
            }
            if (rayanParamMatchesName($param, (string)$fieldName)) {
                return $param;
            }
        }
    }

    foreach ($fallbackIds as $fieldId) {
        $found = rayanFindParam($params, (int)$fieldId, null);
        if ($found) {
            return $found;
        }
    }

    return null;
}

function rayanGetFieldNameList(array $config, string $group, array $fallback = []): array
{
    $names = $config['field_names'][$group] ?? [];
    if (!is_array($names)) {
        $names = [];
    }
    return array_values(array_unique(array_filter(array_merge($names, $fallback), fn($v) => trim((string)$v) !== '')));
}

function rayanIsPositiveFormFlag(string $value): bool
{
    $n = rayanNormalizeCompareText($value);
    if ($n === '') {
        return false;
    }

    $negative = ['خیر', 'نه', 'false', '0', 'لغو', 'رد', 'تایید نشده', 'انجام نشده', 'ندارد'];
    foreach ($negative as $item) {
        if ($n === rayanNormalizeCompareText($item)) {
            return false;
        }
    }

    $positive = ['تایید شد', 'تاییدشود', 'تایید', 'بلی', 'بله', 'yes', 'true', '1', 'انجام شد', 'دارد', 'ثبت شد', 'بررسی شد', 'برسی شد'];
    foreach ($positive as $item) {
        if (rayanTextContainsNormalized($n, $item)) {
            return true;
        }
    }

    return $n !== '';
}

function rayanFormFlagIsChecked(array $form, array $params, array $fieldNames = [], array $fallbackIds = []): bool
{
    $value = rayanGetFieldValue($form, $params, $fallbackIds, $fieldNames);
    return rayanIsPositiveFormFlag($value);
}

function rayanIsCoreClubPlan(array $config, string $clubLevel): bool
{
    $route = rayanResolveClubRoute($config, $clubLevel);
    if (!$route) {
        return false;
    }
    $provider = trim((string)($route['provider'] ?? ''));
    return in_array($provider, ['rayan', 'minipay_direct'], true);
}

function rayanResolveRequestProvider(array $config, string $clubLevel): string
{
    $route = rayanResolveClubRoute($config, $clubLevel);
    if ($route) {
        $provider = trim((string)($route['provider'] ?? ''));
        if ($provider !== '') {
            return $provider;
        }
    }
    return 'hesaban_only';
}
function rayanResolveClubRoute(array $config, string $clubLevel): ?array
{
    // خیلی مهم: تشخیص طرح‌های کلوپی باید دقیقاً برابر باشد، نه شامل.
    // مثال: «بتا-آسان سطح 2» نباید فقط به خاطر داشتن عبارت «سطح 2» دومرحله‌ای تشخیص داده شود.
    $routes = $config['club_routes'] ?? [
        ['club' => 'کلوپ سطح 1',   'provider' => 'rayan'],
        ['club' => 'کلوپ سطح ۱',   'provider' => 'rayan'],
        ['club' => 'کلوپ سطح یک',  'provider' => 'rayan'],

        ['club' => 'کلوپ سطح 2',   'provider' => 'minipay_direct', 'minipay_profile' => 'level2'],
        ['club' => 'کلوپ سطح ۲',   'provider' => 'minipay_direct', 'minipay_profile' => 'level2'],
        ['club' => 'کلوپ سطح دو',  'provider' => 'minipay_direct', 'minipay_profile' => 'level2'],

        ['club' => 'کلوپ قسطانو',  'provider' => 'minipay_direct', 'minipay_profile' => 'ghestano'],
    ];

    $actual = rayanNormalizeCompareText($clubLevel);
    foreach ($routes as $route) {
        $club = trim((string)($route['club'] ?? ''));
        if ($club === '') {
            continue;
        }

        if ($actual === rayanNormalizeCompareText($club)) {
            return $route;
        }
    }

    return null;
}
function rayanResolveProviderFromClub(array $config, string $clubLevel): ?string
{
    $route = rayanResolveClubRoute($config, $clubLevel);
    if (!$route) {
        return null;
    }

    $provider = trim((string)($route['provider'] ?? ''));
    return $provider !== '' ? $provider : null;
}

function rayanMinipayDirectConfig(array $config, ?string $profile = null): array
{
    $cfg = [];

    if ($profile !== null && $profile !== '') {
        $profiles = $config['minipay_profiles'] ?? [];
        if (isset($profiles[$profile]) && is_array($profiles[$profile])) {
            $cfg = $profiles[$profile];
        }
    }

    if (empty($cfg)) {
        $cfg = $config['minipay_direct'] ?? [];
    }

    return [
        'start_url'                => (string)($cfg['start_url'] ?? 'https://pay.minipay.me/7237'),
        'api_base'                 => (string)($cfg['api_base'] ?? 'https://pay.minipay.me/app/offline-cpg/gateway'),
        'branch_id'                => (string)($cfg['branch_id'] ?? ''),
        'input_field_name'         => (string)($cfg['input_field_name'] ?? 'input'),
        'otp_field_name'           => (string)($cfg['otp_field_name'] ?? 'otp'),
        'otp_field_id'             => (string)($cfg['otp_field_id'] ?? 'otp'),
        'installments_count'       => max(1, (int)($cfg['installments_count'] ?? 12)),
        'inquiry_max_attempts'     => max(1, (int)($cfg['inquiry_max_attempts'] ?? 10)),
        'inquiry_sleep_ms'         => max(0, (int)($cfg['inquiry_sleep_ms'] ?? 1500)),
        'supplier_transaction_id'  => (string)($cfg['supplier_transaction_id'] ?? ''),
    ];
}
function rayanMinipayBuildStartUrlWithToken(string $startUrl, string $offlineToken): string
{
    $sep = str_contains($startUrl, '?') ? '&' : '?';
    return $startUrl . $sep . 'offline_cpg_token=' . rawurlencode($offlineToken);
}

function rayanMinipayDirectApiHeaders(string $startUrl, string $offlineToken): array
{
    return [
        'Authorization: Bearer ' . $offlineToken,
        'Origin: ' . rayanBaseOriginFromUrl($startUrl),
        'Referer: ' . rayanMinipayBuildStartUrlWithToken($startUrl, $offlineToken),
    ];
}

function rayanMinipayRegisterPurchase(
    array $config,
    array $otpContext,
    array $request,
    string $cookieFile
): array {
    $profile = trim((string)($otpContext['minipay_profile'] ?? ''));
    $mp = rayanMinipayDirectConfig($config, $profile !== '' ? $profile : null);

    $apiBase      = rtrim((string)($otpContext['api_base'] ?? $mp['api_base']), '/');
    $offlineToken = trim((string)($otpContext['offline_cpg_token'] ?? ''));
    $startUrl     = (string)($otpContext['start_url'] ?? $mp['start_url']);
    $amount       = rayanDigitsOnly((string)($request['installment_amount'] ?? ''));

    if ($offlineToken === '') {
        throw new RuntimeException('offline_cpg_token برای register-purchase خالی است.');
    }

    if ($amount === '') {
        throw new RuntimeException('مبلغ برای register-purchase خالی است.');
    }

    $installmentsCount = (int)($otpContext['installments_count'] ?? $mp['installments_count']);

    $payload = [
        'offline_cpg_token' => $offlineToken,
        'amount'            => $amount,
        'credit_amount'     => (int)$amount,
        'installments_count'=> $installmentsCount,
    ];

    $supplierTransactionId = trim((string)($mp['supplier_transaction_id'] ?? ''));
    if ($supplierTransactionId !== '') {
        $payload['supplier_transaction_id'] = $supplierTransactionId;
    }

    $res = rayanHttpJsonRequest(
        $apiBase . '/register-purchase',
        $payload,
        $cookieFile,
        rayanMinipayDirectApiHeaders($startUrl, $offlineToken),
        'POST'
    );

    if ((int)$res['status'] >= 400) {
        throw new RuntimeException('register-purchase HTTP error: ' . (int)$res['status']);
    }

    $json = json_decode((string)$res['body'], true);
    if (!is_array($json)) {
        throw new RuntimeException('پاسخ register-purchase معتبر نیست.');
    }

    $ok = !empty($json['meta']['status']);
    $canPurchase = !empty($json['data']['can_purchase']);

    if (!$ok || !$canPurchase) {
        $msg = trim((string)($json['meta']['message'] ?? 'ثبت خرید اعتباری ناموفق بود.'));
        throw new RuntimeException($msg !== '' ? $msg : 'register-purchase ناموفق بود.');
    }

    return [
        'http'    => $res,
        'json'    => $json,
        'payload' => $payload,
    ];
}

function rayanMinipayInquiryOffline(
    array $config,
    array $otpContext,
    string $cookieFile
): array {
    $profile = trim((string)($otpContext['minipay_profile'] ?? ''));
    $mp = rayanMinipayDirectConfig($config, $profile !== '' ? $profile : null);

    $apiBase      = rtrim((string)($otpContext['api_base'] ?? $mp['api_base']), '/');
    $offlineToken = trim((string)($otpContext['offline_cpg_token'] ?? ''));
    $startUrl     = (string)($otpContext['start_url'] ?? $mp['start_url']);
    $maxAttempts  = (int)($mp['inquiry_max_attempts'] ?? 10);
    $sleepMs      = (int)($mp['inquiry_sleep_ms'] ?? 1500);

    if ($offlineToken === '') {
        throw new RuntimeException('offline_cpg_token برای inquiry-offline خالی است.');
    }

    $attempts = [];
    $lastJson = null;

    for ($i = 1; $i <= $maxAttempts; $i++) {
        $res = rayanHttpJsonRequest(
            $apiBase . '/inquiry-offline',
            [
                'offline_cpg_token' => $offlineToken,
            ],
            $cookieFile,
            rayanMinipayDirectApiHeaders($startUrl, $offlineToken),
            'POST'
        );

        $json = json_decode((string)$res['body'], true);
        $lastJson = is_array($json) ? $json : null;

        $attempts[] = [
            'attempt' => $i,
            'status'  => (int)$res['status'],
            'body'    => mb_substr((string)$res['body'], 0, 3000),
        ];

        if ((int)$res['status'] < 400 && is_array($json) && !empty($json['meta']['status'])) {
            $data = (array)($json['data'] ?? []);
            $invoiceId     = trim((string)($data['invoice_id'] ?? ''));
            $transactionId = trim((string)($data['transaction_id'] ?? ''));
            $status        = trim((string)($data['status'] ?? ''));

            if (($invoiceId !== '' || $transactionId !== '') && ($status === '' || $status === 'completed')) {
                return [
                    'http'     => $res,
                    'json'     => $json,
                    'attempts' => $attempts,
                ];
            }
        }

        if ($i < $maxAttempts && $sleepMs > 0) {
            usleep($sleepMs * 1000);
        }
    }

    $msg = is_array($lastJson)
        ? trim((string)($lastJson['meta']['message'] ?? 'استعلام نهایی خرید اعتباری موفق نبود.'))
        : 'پاسخ inquiry-offline معتبر نبود.';

    throw new RuntimeException($msg !== '' ? $msg : 'inquiry-offline ناموفق بود.');
}
function rayanMinipayExtractBranchIdFromUrl(string $url): string
{
    $path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
    if (preg_match('~/(\d+)(?:/)?$~', $path, $m)) {
        return (string)$m[1];
    }
    return '';
}

function rayanHttpJsonRequest(
    string $url,
    ?array $jsonPayload,
    string $cookieFile,
    array $extraHeaders = [],
    string $method = 'POST'
): array {
    $ch = curl_init();

    $headers = array_merge([
        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
        'Accept: application/json, text/plain, */*',
        'Accept-Language: fa-IR,fa;q=0.9,en-US;q=0.8,en;q=0.7',
        'Connection: keep-alive',
        'X-Requested-With: XMLHttpRequest',
    ], $extraHeaders);

    $body = null;
    if ($jsonPayload !== null) {
        $body = json_encode($jsonPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $headers[] = 'Content-Type: application/json; charset=UTF-8';
    }

    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 10,
        CURLOPT_CONNECTTIMEOUT => 30,
        CURLOPT_TIMEOUT        => 120,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_COOKIEJAR      => $cookieFile,
        CURLOPT_COOKIEFILE     => $cookieFile,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_HEADER         => true,
        CURLOPT_ENCODING       => '',
        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
    ]);

    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    }

    $raw = curl_exec($ch);
    if ($raw === false) {
        $err = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('curl json error: ' . $err);
    }

    $status      = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize  = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $effective   = (string)(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url);
    $respHeaders = substr($raw, 0, $headerSize);
    $bodyResp    = substr($raw, $headerSize);

    curl_close($ch);

    return [
        'status'  => $status,
        'headers' => $respHeaders,
        'body'    => $bodyResp,
        'url'     => $effective,
    ];
}

function rayanMinipayResponseMessage(array $json): string
{
    return trim((string)($json['meta']['message'] ?? ''));
}

function rayanMinipayResponseOk(array $json): bool
{
    return !empty($json['meta']['status']);
}

function rayanMinipayCallWithAttempts(
    string $url,
    array $attempts,
    string $cookieFile,
    array $extraHeaders = []
): array {
    $allAttempts = [];
    $lastRes = null;
    $lastJson = null;
    $matchedAttempt = null;

    foreach ($attempts as $attempt) {
        $mode    = (string)($attempt['mode'] ?? 'json');
        $payload = (array)($attempt['payload'] ?? []);
        $label   = (string)($attempt['label'] ?? '');

        $offlineToken = '';
        foreach (['offline_cpg_token', 'offlineCpgToken', 'token'] as $tokenKey) {
            if (isset($payload[$tokenKey]) && trim((string)$payload[$tokenKey]) !== '') {
                $offlineToken = trim((string)$payload[$tokenKey]);
                break;
            }
        }

        $headerSets = [
            [],
        ];

        if ($offlineToken !== '') {
            $headerSets = [
                [],
                ['offline_cpg_token: ' . $offlineToken],
                ['offline-cpg-token: ' . $offlineToken],
                ['Offline-Cpg-Token: ' . $offlineToken],
                ['x-offline-cpg-token: ' . $offlineToken],
                ['X-Offline-Cpg-Token: ' . $offlineToken],
                ['Authorization: Bearer ' . $offlineToken],
            ];
        }

        foreach ($headerSets as $tokenHeaders) {
            $headers = array_merge($extraHeaders, $tokenHeaders);

            if ($mode === 'form') {
                $res = rayanHttpAjaxFormRequest($url, $payload, $cookieFile, $headers);
            } else {
                $res = rayanHttpJsonRequest($url, $payload, $cookieFile, $headers, 'POST');
            }

            $json = json_decode((string)$res['body'], true);

            $allAttempts[] = [
                'mode'          => $mode,
                'label'         => $label,
                'payload'       => $payload,
                'token_headers' => $tokenHeaders,
                'status'        => (int)$res['status'],
                'body'          => (string)$res['body'],
            ];

            $lastRes  = $res;
            $lastJson = is_array($json) ? $json : null;

            if (is_array($json) && rayanMinipayResponseOk($json)) {
                $matchedAttempt = [
                    'mode'          => $mode,
                    'label'         => $label,
                    'token_headers' => $tokenHeaders,
                ];

                return [
                    'response'        => $res,
                    'json'            => $json,
                    'attempts'        => $allAttempts,
                    'matched_attempt' => $matchedAttempt,
                ];
            }
        }
    }

    return [
        'response'        => $lastRes,
        'json'            => $lastJson,
        'attempts'        => $allAttempts,
        'matched_attempt' => $matchedAttempt,
    ];
}


function rayanBaseOriginFromUrl(string $url): string
{
    $parts  = parse_url($url);
    $scheme = $parts['scheme'] ?? 'https';
    $host   = $parts['host'] ?? '';
    $port   = isset($parts['port']) ? ':' . $parts['port'] : '';

    if ($host === '') {
        throw new RuntimeException('هاست URL قابل تشخیص نیست: ' . $url);
    }

    return $scheme . '://' . $host . $port;
}

function rayanMinipayDirectResolveIdentifier(array $request, array $config): string
{
    $mp = rayanMinipayDirectConfig($config);

    $fixed = trim((string)($mp['fixed_identifier'] ?? ''));
    if ($fixed !== '') {
        return $fixed;
    }

    $source = trim((string)($mp['identifier_source'] ?? 'mobile'));

    switch ($source) {
        case 'national_id':
            return trim((string)($request['national_id'] ?? ''));

        case 'mobile':
        default:
            return trim((string)($request['mobile'] ?? ''));
    }
}

function rayanUniquePayloadList(array $rows): array
{
    $seen = [];
    $out  = [];

    foreach ($rows as $row) {
        if (!is_array($row)) {
            continue;
        }

        ksort($row);
        $key = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

        if (!isset($seen[$key])) {
            $seen[$key] = true;
            $out[] = $row;
        }
    }

    return $out;
}

function rayanMinipayBuildValidateBranchPayloads(string $branchId): array
{
    return rayanUniquePayloadList([
        ['branch_id' => $branchId],
        ['branchId'  => $branchId],
        ['branch'    => $branchId],
        ['id'        => $branchId],
        ['code'      => $branchId],
    ]);
}

function rayanMinipayBuildFindUserPayloads(string $token, string $identifier, array $mp): array
{
    $tokenKeys = ['offline_cpg_token', 'offlineCpgToken', 'token'];
    $fields    = (array)($mp['identifier_field_candidates'] ?? ['input']);

    $rows = [];
    foreach ($tokenKeys as $tokenKey) {
        foreach ($fields as $field) {
            $field = trim((string)$field);
            if ($field === '') {
                continue;
            }

            $rows[] = [
                $tokenKey => $token,
                $field    => $identifier,
            ];
        }
    }

    return rayanUniquePayloadList($rows);
}

function rayanMinipayBuildValidateUserPayloads(string $token, string $otpCode, array $mp, string $identifier = ''): array
{
    $tokenKeys   = ['offline_cpg_token', 'offlineCpgToken', 'token'];
    $otpFields   = (array)($mp['otp_field_candidates'] ?? ['otp']);
    $idFields    = (array)($mp['identifier_field_candidates'] ?? ['input']);

    $rows = [];

    foreach ($tokenKeys as $tokenKey) {
        foreach ($otpFields as $otpField) {
            $otpField = trim((string)$otpField);
            if ($otpField === '') {
                continue;
            }

            $rows[] = [
                $tokenKey => $token,
                $otpField => $otpCode,
            ];

            if ($identifier !== '') {
                foreach ($idFields as $idField) {
                    $idField = trim((string)$idField);
                    if ($idField === '') {
                        continue;
                    }

                    $rows[] = [
                        $tokenKey => $token,
                        $otpField => $otpCode,
                        $idField  => $identifier,
                    ];
                }
            }
        }
    }

    return rayanUniquePayloadList($rows);
}

function rayanMinipayGatewayCall(
    string $url,
    array $payloadCandidates,
    string $cookieFile,
    array $extraHeaders = []
): array {
    $attempts = [];
    $lastMessage = 'پاسخ موفقی از سرویس خرید اعتباری دریافت نشد.';

    foreach ($payloadCandidates as $payload) {
        foreach (['json', 'form'] as $mode) {
            if ($mode === 'json') {
                $res = rayanHttpJsonRequest($url, $payload, $cookieFile, $extraHeaders, 'POST');
            } else {
                $res = rayanHttpAjaxFormRequest($url, $payload, $cookieFile, $extraHeaders);
            }

            $json = json_decode((string)$res['body'], true);

            $attempts[] = [
                'mode'    => $mode,
                'payload' => $payload,
                'status'  => $res['status'],
                'body'    => mb_substr((string)$res['body'], 0, 1500),
            ];

            if (
                $res['status'] >= 200 &&
                $res['status'] < 300 &&
                is_array($json) &&
                !empty($json['meta']['status'])
            ) {
                return [
                    'http'     => $res,
                    'json'     => $json,
                    'payload'  => $payload,
                    'mode'     => $mode,
                    'attempts' => $attempts,
                ];
            }

            if (is_array($json) && isset($json['meta']['message'])) {
                $lastMessage = (string)$json['meta']['message'];
            } elseif ($res['status'] >= 400) {
                $lastMessage = 'HTTP ' . $res['status'];
            }
        }
    }

    throw new RuntimeException($lastMessage);
}
function rayanSubmitForm(array $form, string $cookieFile, array $extraHeaders = []): array
{
    $method = strtoupper((string)($form['method'] ?? 'POST'));
    $action = (string)($form['action'] ?? '');
    $fields = (array)($form['fields'] ?? []);

    if ($method === 'GET') {
        $query = http_build_query($fields);
        $url   = $action;

        if ($query !== '') {
            $url .= (str_contains($url, '?') ? '&' : '?') . $query;
        }

        return rayanHttpRequest($url, null, $cookieFile, $extraHeaders);
    }

    return rayanHttpRequest($action, $fields, $cookieFile, $extraHeaders);
}
function rayanFindFormWithSubmitByQuery(string $html, string $query, string $baseUrl): ?array
{
    $xpath = rayanCreateXPath($html);
    $form = $xpath->query($query)->item(0);

    if (!$form instanceof DOMElement) {
        return null;
    }

    $action = $form->getAttribute('action') ?: '';
    $methodAttr = trim((string)$form->getAttribute('method'));
    $method = strtoupper($methodAttr !== '' ? $methodAttr : 'POST');

    $fields = [];
    $idToName = [];
    $submitAdded = false;
    $submitLabel = '';

    foreach ($xpath->query('.//input|.//textarea|.//select|.//button', $form) as $el) {
        if (!$el instanceof DOMElement) {
            continue;
        }

        $tag  = strtolower($el->tagName);
        $type = strtolower($el->getAttribute('type') ?: ($tag === 'button' ? 'submit' : 'text'));
        $name = trim((string)$el->getAttribute('name'));
        $id   = trim((string)$el->getAttribute('id'));

        if ($id !== '' && $name !== '') {
            $idToName[$id] = $name;
        }

        if ($tag === 'input') {
            if (in_array($type, ['file', 'image'], true)) {
                continue;
            }

            if (in_array($type, ['checkbox', 'radio'], true)) {
                if ($el->hasAttribute('checked') && $name !== '') {
                    $fields[$name] = $el->getAttribute('value') !== '' ? $el->getAttribute('value') : 'on';
                }
                continue;
            }

            if (in_array($type, ['submit', 'button'], true)) {
                if (!$submitAdded) {
                    $submitAdded = true;
                    $submitLabel = trim((string)$el->getAttribute('value'));
                    if ($name !== '') {
                        $fields[$name] = $el->getAttribute('value') !== '' ? $el->getAttribute('value') : '1';
                    }
                }
                continue;
            }

            if ($name !== '') {
                $fields[$name] = $el->getAttribute('value');
            }
            continue;
        }

        if ($tag === 'textarea') {
            if ($name !== '') {
                $fields[$name] = $el->textContent ?? '';
            }
            continue;
        }

        if ($tag === 'select') {
            if ($name === '') {
                continue;
            }

            $value = '';
            foreach ($xpath->query('.//option', $el) as $opt) {
                if (!$opt instanceof DOMElement) {
                    continue;
                }
                if ($opt->hasAttribute('selected')) {
                    $value = $opt->getAttribute('value');
                    break;
                }
                if ($value === '') {
                    $value = $opt->getAttribute('value');
                }
            }
            $fields[$name] = $value;
            continue;
        }

        if ($tag === 'button') {
            if (!in_array($type, ['', 'submit', 'button'], true)) {
                continue;
            }

            if (!$submitAdded) {
                $submitAdded = true;
                $submitLabel = rayanGetInnerText($el);
                if ($name !== '') {
                    $value = $el->getAttribute('value');
                    $fields[$name] = $value !== '' ? $value : ($submitLabel !== '' ? $submitLabel : '1');
                }
            }
        }
    }

    return [
        'action'       => rayanAbsoluteUrl($baseUrl, $action),
        'method'       => $method,
        'fields'       => $fields,
        'id_to_name'   => $idToName,
        'submit_label' => $submitLabel,
    ];
}

function rayanIsMinipayAmountPage(string $html): bool
{
    return
        rayanPageContains($html, 'مبلغ کل تراکنش') ||
        rayanPageContains($html, 'مبلغ تراکنش را وارد کنید') ||
        rayanPageContains($html, 'placeholder="مبلغ کل تراکنش را وارد کنید"') ||
        rayanPageContains($html, 'name="amount"');
}

function rayanIsMinipayContinueOnlyPage(string $html): bool
{
    if (rayanIsMinipayAmountPage($html)) {
        return false;
    }

    if (rayanLooksLikeMinipayHomePage($html)) {
        return false;
    }

    if (
        rayanPageContains($html, 'تایید و پرداخت') ||
        rayanPageContains($html, 'پرداخت نهایی') ||
        rayanPageContains($html, 'تکمیل پرداخت') ||
        rayanPageContains($html, 'شماره پیگیری') ||
        rayanPageContains($html, 'کد پیگیری') ||
        rayanPageContains($html, 'بازگشت کد پیگیری') ||
        rayanPageContains($html, 'رسید پرداخت') ||
        rayanPageContains($html, 'دانلود فایل قرارداد') ||
        rayanPageContains($html, 'دانلود فایل رسید')
    ) {
        return false;
    }

    return rayanPageContains($html, 'تایید و ادامه');
}
function rayanLooksLikeMinipayHomePage(string $html): bool
{
    return
        rayanPageContains($html, 'مینی پی | درگاه پرداخت اعتباری') &&
        (
            rayanPageContains($html, 'وب اپلیکیشن') ||
            rayanPageContains($html, 'نسخه وب') ||
            rayanPageContains($html, 'دریافت از') ||
            rayanPageContains($html, 'کافه بازار')
        ) &&
        !rayanPageContains($html, 'شماره پیگیری') &&
        !rayanPageContains($html, 'دانلود فایل قرارداد') &&
        !rayanPageContains($html, 'دانلود فایل رسید') &&
        !rayanPageContains($html, 'تراکنش با موفقیت') &&
        !rayanPageContains($html, 'پرداخت با موفقیت');
}

function rayanLooksLikeMinipayFinalSuccessPage(string $html): bool
{
    if (rayanIsMinipayAmountPage($html)) {
        return false;
    }

    if (rayanIsMinipayContinueOnlyPage($html)) {
        return false;
    }

    if (rayanLooksLikeMinipayHomePage($html)) {
        return false;
    }

    $strongMarkers = [
        'دانلود فایل قرارداد',
        'دانلود فایل رسید',
        'تراکنش MiniPay با موفقیت انجام شد',
        'پرداخت با موفقیت انجام شد',
        'پرداخت شما با موفقیت انجام شد',
        'خرید شما با موفقیت انجام شد',
        'درخواست شما با موفقیت ثبت شد',
        'ثبت نهایی با موفقیت انجام شد',
        'شماره پیگیری',
        'شماره سفارش',
        'کد پیگیری',
        'بازگشت کد پیگیری',
        'رسید پرداخت',
    ];

    foreach ($strongMarkers as $marker) {
        if (rayanPageContains($html, $marker)) {
            return true;
        }
    }

    $pairedMarkers = [
        ['تراکنش', 'موفق'],
        ['پرداخت', 'موفق'],
        ['خرید', 'موفق'],
        ['ثبت', 'موفق'],
        ['دانلود', 'قرارداد'],
        ['دانلود', 'رسید'],
    ];

    foreach ($pairedMarkers as [$a, $b]) {
        if (rayanPageContains($html, $a) && rayanPageContains($html, $b)) {
            return true;
        }
    }

    return false;
}

function rayanDetectMinipayFinalState(string $html, string $finalUrl = '', string $startUrl = ''): array
{
    $isAmountPage    = rayanIsMinipayAmountPage($html);
    $isPayPage       = rayanIsMinipayPayConfirmPage($html);
    $isContinuePage  = rayanIsMinipayContinueOnlyPage($html);
    $isHomePage      = rayanLooksLikeMinipayHomePage($html);
    $isSuccessPage   = rayanLooksLikeMinipayFinalSuccessPage($html);
    $visibleErrors   = rayanExtractVisibleErrors($html);
    $trackingCode    = rayanExtractMinipayTrackingCode($html);

    $state = 'unknown';
    $reasons = [];

    if ($isAmountPage) {
        $state = 'amount_page';
        $reasons[] = 'amount_page_still_open';
    } elseif ($isPayPage) {
        $state = 'pay_page';
        $reasons[] = 'pay_page_still_open';
    } elseif ($isContinuePage) {
        $state = 'continue_page';
        $reasons[] = 'continue_page_still_open';
    } elseif ($isSuccessPage || $trackingCode !== '') {
        $state = 'success';
    } elseif ($isHomePage) {
        $state = 'home_page';
        $reasons[] = 'returned_to_home_page';
    } else {
        $state = 'unknown';
        $reasons[] = 'no_valid_success_marker_found';
    }

    $finalPath = rtrim((string)(parse_url($finalUrl, PHP_URL_PATH) ?? ''), '/');
    $startPath = rtrim((string)(parse_url($startUrl, PHP_URL_PATH) ?? ''), '/');

    if ($finalPath !== '' && $startPath !== '' && $finalPath === $startPath && $isHomePage) {
        $reasons[] = 'redirected_back_to_start_url';
    }

    if ($visibleErrors !== '') {
        $reasons[] = 'visible_error: ' . $visibleErrors;
    }

    return [
        'state'          => $state,
        'looks_success'  => ($state === 'success'),
        'failure_reason' => implode(' | ', $reasons),
        'flags'          => [
            'is_amount_page'   => $isAmountPage,
            'is_continue_page' => $isContinuePage,
            'is_pay_page'      => $isPayPage,
            'is_home_page'     => $isHomePage,
            'is_success_page'  => $isSuccessPage,
        ],
        'visible_errors' => $visibleErrors,
        'tracking_code'  => $trackingCode,
    ];
}
function rayanFindMinipayAmountForm(string $html, string $baseUrl): ?array
{
    $queries = [
        '//form[.//input[@name="amount"]]',
        '//form[.//input[contains(@placeholder, "مبلغ کل تراکنش")]]',
        '//form[.//label[contains(normalize-space(.), "مبلغ کل تراکنش")]]',
    ];

    foreach ($queries as $query) {
        $form = rayanFindFormWithSubmitByQuery($html, $query, $baseUrl);
        if ($form) {
            return $form;
        }
    }

    return null;
}
function rayanFindOnlyFormFallback(string $html, string $baseUrl): ?array
{
    try {
        $xpath = rayanCreateXPath($html);
        $forms = $xpath->query('//form');
        if (!$forms || $forms->length !== 1) {
            return null;
        }

        return rayanFindFormWithSubmitByQuery($html, '//form', $baseUrl);
    } catch (Throwable $e) {
        return null;
    }
}
function rayanFindMinipayContinueForm(string $html, string $baseUrl): ?array
{
    $queries = [
        '//form[.//button[contains(normalize-space(.), "تایید و ادامه")]]',
        '//form[.//input[(@type="submit" or @type="button") and contains(@value, "تایید و ادامه")]]',
        '//form[.//*[contains(normalize-space(.), "تایید و ادامه") and not(contains(normalize-space(.), "تایید و پرداخت"))]]',
    ];

    foreach ($queries as $query) {
        $form = rayanFindFormWithSubmitByQuery($html, $query, $baseUrl);
        if ($form) {
            return $form;
        }
    }

    return null;
}

function rayanExtractMinipayNextUrls(array $validateUserJson, array $otpContext): array
{
    $rawUrls = [
        $validateUserJson['data']['next_url'] ?? '',
        $validateUserJson['data']['redirect_url'] ?? '',
        $validateUserJson['data']['url'] ?? '',
        $validateUserJson['data']['callback_url'] ?? '',
        $validateUserJson['data']['payment_url'] ?? '',
        $otpContext['next_url'] ?? '',
        $otpContext['start_url'] ?? '',
    ];

    $urls = [];
    $base = (string)($otpContext['start_url'] ?? '');

    foreach ($rawUrls as $url) {
        $url = trim((string)$url);
        if ($url === '') {
            continue;
        }

        if (!preg_match('~^https?://~i', $url) && $base !== '') {
            $url = rayanAbsoluteUrl($base, $url);
        }

        $urls[] = $url;
    }

    return array_values(array_unique($urls));
}

function rayanContinueDirectMinipayAfterOtp(
    array $config,
    int $requestId,
    array $request,
    array $otpContext,
    array $validateUserJson,
    string $cookieFile
): array {
    $amount = rayanDigitsOnly((string)($request['installment_amount'] ?? ''));
    if ($amount === '') {
        throw new RuntimeException('مبلغ نهایی درخواست برای قسطانو/سطح 2 خالی است.');
    }

    $candidateUrls = rayanExtractMinipayNextUrls($validateUserJson, $otpContext);
    if (empty($candidateUrls)) {
        throw new RuntimeException('آدرس مرحله بعد پیدا نشد.');
    }

    $currentRes = null;

    foreach ($candidateUrls as $idx => $url) {
        rayanRequestLog($config, $requestId, 'MINIPAY OTP STEP 2 - OPEN NEXT PAGE: ' . $url);

        $tmpRes = rayanHttpRequest(
            $url,
            null,
            $cookieFile,
            [
                'Referer: ' . (string)($otpContext['start_url'] ?? $url),
            ]
        );

        rayanSaveRequestHtml($config, $requestId, '07_minipay_next_page_' . ($idx + 1), (string)$tmpRes['body']);

        if ((int)$tmpRes['status'] >= 400) {
            continue;
        }

        $tmpHtml = (string)$tmpRes['body'];

        if (
            rayanIsMinipayAmountPage($tmpHtml) ||
            rayanIsMinipayContinueOnlyPage($tmpHtml) ||
            rayanIsMinipayPayConfirmPage($tmpHtml) ||
            rayanLooksLikeMinipayFinalSuccessPage($tmpHtml) ||
            rayanLooksLikeMinipayHomePage($tmpHtml) ||
            rayanExtractMinipayTrackingCode($tmpHtml) !== ''
        ) {
            $currentRes = $tmpRes;
            break;
        }

        if ($currentRes === null) {
            $currentRes = $tmpRes;
        }
    }

    if (!$currentRes || (int)$currentRes['status'] >= 400) {
        throw new RuntimeException('صفحه بعد از تایید OTP باز نشد.');
    }

    $currentHtml = (string)$currentRes['body'];
    $stepCounter = 0;

    while ($stepCounter < 6) {
        $stepCounter++;

        rayanSaveRequestText(
            $config,
            $requestId,
            'minipay_step_' . $stepCounter . '_snippet',
            mb_substr(rayanNormalizeSpaces(strip_tags($currentHtml)), 0, 5000)
        );

        if (rayanLooksLikeMinipayFinalSuccessPage($currentHtml) || rayanExtractMinipayTrackingCode($currentHtml) !== '') {
            break;
        }

        if (rayanIsMinipayAmountPage($currentHtml)) {
            rayanRequestLog($config, $requestId, 'MINIPAY OTP STEP 3 - SUBMIT AMOUNT FORM');

            $amountForm = rayanFindMinipayAmountForm($currentHtml, (string)$currentRes['url']);
            if (!$amountForm) {
                throw new RuntimeException('فرم مبلغ تراکنش پیدا نشد.');
            }

            $amountForm['fields']['amount'] = $amount;

            if (isset($amountForm['fields']['transaction_amount'])) {
                $amountForm['fields']['transaction_amount'] = $amount;
            }
            if (isset($amountForm['fields']['total_amount'])) {
                $amountForm['fields']['total_amount'] = $amount;
            }
            if (isset($amountForm['fields']['price'])) {
                $amountForm['fields']['price'] = $amount;
            }

            $amountRes = rayanSubmitForm(
                $amountForm,
                $cookieFile,
                [
                    'Referer: ' . (string)$currentRes['url'],
                ]
            );

            rayanSaveRequestHtml($config, $requestId, '08_minipay_after_amount_submit', (string)$amountRes['body']);

            if ((int)$amountRes['status'] >= 400) {
                throw new RuntimeException('ارسال فرم مبلغ خطا داد: HTTP ' . (int)$amountRes['status']);
            }

            if (rayanIsMinipayAmountPage((string)$amountRes['body'])) {
                $visibleErrors = rayanExtractVisibleErrors((string)$amountRes['body']);
                $message = 'بعد از ثبت مبلغ، هنوز صفحه مبلغ باز است.';
                if ($visibleErrors !== '') {
                    $message .= ' خطای صفحه: ' . $visibleErrors;
                }
                throw new RuntimeException($message);
            }

            $currentRes  = $amountRes;
            $currentHtml = (string)$currentRes['body'];
            continue;
        }

        if (rayanIsMinipayPayConfirmPage($currentHtml)) {
            rayanRequestLog($config, $requestId, 'MINIPAY OTP STEP 4 - SUBMIT PAY CONFIRM');
        
            $payForm = rayanFindMinipayPayConfirmForm($currentHtml, (string)$currentRes['url']);
            if (!$payForm) {
                throw new RuntimeException('فرم تایید و پرداخت پیدا نشد.');
            }
        
            $payRes = rayanSubmitForm(
                $payForm,
                $cookieFile,
                [
                    'Referer: ' . (string)$currentRes['url'],
                ]
            );
        
            rayanSaveRequestHtml($config, $requestId, '09_minipay_after_pay_submit', (string)$payRes['body']);
            rayanSaveRequestText(
                $config,
                $requestId,
                '09_minipay_after_pay_submit_snippet',
                mb_substr(rayanNormalizeSpaces(strip_tags((string)$payRes['body'])), 0, 5000)
            );
        
            if ((int)$payRes['status'] >= 400) {
                throw new RuntimeException('ارسال فرم تایید و پرداخت خطا داد: HTTP ' . (int)$payRes['status']);
            }
        
            if (rayanIsMinipayPayConfirmPage((string)$payRes['body'])) {
                $visibleErrors = rayanExtractVisibleErrors((string)$payRes['body']);
                $message = 'بعد از زدن تایید و پرداخت، هنوز همان صفحه پرداخت باز است.';
                if ($visibleErrors !== '') {
                    $message .= ' خطای صفحه: ' . $visibleErrors;
                }
                throw new RuntimeException($message);
            }
        
            $currentRes  = $payRes;
            $currentHtml = (string)$currentRes['body'];
            continue;
        }
        
        if (rayanIsMinipayContinueOnlyPage($currentHtml)) {
            rayanRequestLog($config, $requestId, 'MINIPAY OTP STEP 5 - SUBMIT FINAL CONTINUE');
        
            $continueForm = rayanFindMinipayContinueForm($currentHtml, (string)$currentRes['url']);
            if (!$continueForm) {
                throw new RuntimeException('فرم تایید و ادامه پیدا نشد.');
            }
        
            $continueRes = rayanSubmitForm(
                $continueForm,
                $cookieFile,
                [
                    'Referer: ' . (string)$currentRes['url'],
                ]
            );
        
            rayanSaveRequestHtml($config, $requestId, '10_minipay_after_continue_submit', (string)$continueRes['body']);
        
            if ((int)$continueRes['status'] >= 400) {
                throw new RuntimeException('ارسال فرم تایید و ادامه خطا داد: HTTP ' . (int)$continueRes['status']);
            }
        
            if (rayanIsMinipayContinueOnlyPage((string)$continueRes['body'])) {
                $visibleErrors = rayanExtractVisibleErrors((string)$continueRes['body']);
                $message = 'بعد از تایید و ادامه، هنوز همان صفحه باقی مانده است.';
                if ($visibleErrors !== '') {
                    $message .= ' خطای صفحه: ' . $visibleErrors;
                }
                throw new RuntimeException($message);
            }
        
            $currentRes  = $continueRes;
            $currentHtml = (string)$currentRes['body'];
            continue;
        }

        break;
    }

    $finalState   = rayanDetectMinipayFinalState(
        $currentHtml,
        (string)$currentRes['url'],
        (string)($otpContext['start_url'] ?? '')
    );
    $trackingCode = (string)($finalState['tracking_code'] ?? '');

    $looksSuccess = (bool)$finalState['looks_success'];
    if (!$looksSuccess && $trackingCode !== '' && rayanPageContains($currentHtml, 'موفق')) {
        $looksSuccess = true;
    }

    return [
        'amount'         => $amount,
        'final_url'      => (string)$currentRes['url'],
        'final_status'   => (int)$currentRes['status'],
        'final_html'     => $currentHtml,
        'looks_success'  => $looksSuccess,
        'final_state'    => (string)$finalState['state'],
        'failure_reason' => (string)$finalState['failure_reason'],
        'page_flags'     => (array)$finalState['flags'],
        'visible_errors' => (string)$finalState['visible_errors'],
        'tracking_code'  => $trackingCode,
    ];
}
function rayanIsMinipayDirectOtpPage(string $html, array $config): bool
{
    $mp = rayanMinipayDirectConfig($config);

    return
        rayanPageContains($html, 'کد تایید') ||
        rayanPageContains($html, 'otp') ||
        rayanPageContains($html, $mp['otp_field_id']) ||
        rayanPageContains($html, 'تایید و ادامه');
}

function rayanExtractDirectMinipayOtpContext(string $html, string $baseUrl, array $config): array
{
    $mp = rayanMinipayDirectConfig($config);

    $query = sprintf(
        '//form[.//*[@id="%s"] or .//*[@name="%s"] or .//label[contains(normalize-space(.), "کد تایید")]]',
        $mp['otp_field_id'],
        $mp['otp_field_name']
    );

    $form = rayanFindFormByQuery($html, $query, $baseUrl);
    if (!$form) {
        throw new RuntimeException('فرم کد تایید پیدا نشد.');
    }

    return [
        'type'            => 'minipay_direct',
        'otp_page_url'    => $baseUrl,
        'otp_form_action' => (string)$form['action'],
        'otp_form_method' => (string)$form['method'],
        'otp_form_fields' => (array)$form['fields'],
        'otp_field_id'    => $mp['otp_field_id'],
        'otp_field_name'  => $mp['otp_field_name'],
    ];
}
function rayanRequestDebugDir(array $config, int $requestId): string
{
    $dir = rayanRuntimeDir($config) . '/request_' . $requestId;
    rayanEnsureDir($dir);
    return $dir;
}

function rayanClearRequestDebugDir(array $config, int $requestId): void
{
    $dir = rayanRequestDebugDir($config, $requestId);
    $files = @scandir($dir);
    if (!is_array($files)) {
        return;
    }

    foreach ($files as $file) {
        if ($file === '.' || $file === '..') {
            continue;
        }

        $path = $dir . '/' . $file;
        if (is_file($path)) {
            @unlink($path);
        }
    }
}

function rayanSaveRequestHtml(array $config, int $requestId, string $name, string $html): void
{
    file_put_contents(rayanRequestDebugDir($config, $requestId) . '/' . $name . '.html', $html);
}

function rayanSaveRequestText(array $config, int $requestId, string $name, string $text): void
{
    file_put_contents(rayanRequestDebugDir($config, $requestId) . '/' . $name . '.txt', $text);
}

function rayanSaveRequestJson(array $config, int $requestId, string $name, $data): void
{
    file_put_contents(
        rayanRequestDebugDir($config, $requestId) . '/' . $name . '.json',
        json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
    );
}

function rayanRequestLog(array $config, int $requestId, string $text): void
{
    $logFile = rayanRequestDebugDir($config, $requestId) . '/run.log';
    file_put_contents(
        $logFile,
        '[' . date('Y-m-d H:i:s') . '] ' . $text . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

function rayanBaleApi(array $config, string $method, array $params = []): array
{
    $token = trim((string)($config['bale']['bot_token'] ?? ''));
    if ($token === '' || str_contains($token, 'PUT_YOUR_')) {
        throw new RuntimeException('توکن بله در config_rayan.php تنظیم نشده است.');
    }

    $url = "https://tapi.bale.ai/bot{$token}/{$method}";
    $timeout = (int)($config['bale']['request_timeout'] ?? 90);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($params),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded; charset=UTF-8'],
        CURLOPT_TIMEOUT        => $timeout,
        CURLOPT_CONNECTTIMEOUT => 20,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    $response = curl_exec($ch);
    $error    = curl_error($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    rayanSystemLog($config, "BALE {$method} | HTTP={$httpCode} | ERR={$error} | RESP=" . mb_substr((string)$response, 0, 1000));

    if ($error) {
        throw new RuntimeException('خطای cURL بله: ' . $error);
    }

    $json = json_decode((string)$response, true);
    if ($httpCode < 200 || $httpCode >= 300 || !is_array($json) || empty($json['ok'])) {
        throw new RuntimeException('خطا در API بله: ' . mb_substr((string)$response, 0, 500));
    }

    return $json;
}

function rayanBaleSendMessage(array $config, string $chatId, string $text, ?array $replyMarkup = null): array
{
    $params = [
        'chat_id' => $chatId,
        'text'    => $text,
    ];

    if ($replyMarkup !== null) {
        $params['reply_markup'] = json_encode($replyMarkup, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

    return rayanBaleApi($config, 'sendMessage', $params);
}

function rayanLoadSubscribers(array $config): array
{
    $file = rayanSubscribersFile($config);
    if (!is_file($file)) {
        return [];
    }

    $json = json_decode((string)file_get_contents($file), true);
    return is_array($json) ? $json : [];
}

function rayanSaveSubscribers(array $config, array $subscribers): void
{
    file_put_contents(
        rayanSubscribersFile($config),
        json_encode(array_values(array_unique($subscribers)), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
    );
}

function rayanAddSubscriber(array $config, string $chatId): void
{
    $chatId = trim($chatId);
    if ($chatId === '') {
        return;
    }

    $subs = rayanLoadSubscribers($config);
    if (!in_array($chatId, $subs, true)) {
        $subs[] = $chatId;
        rayanSaveSubscribers($config, $subs);
    }
}

function rayanNotifyAll(array $config, string $text): void
{
    $subs = rayanLoadSubscribers($config);
    foreach ($subs as $chatId) {
        try {
            rayanBaleSendMessage($config, (string)$chatId, $text);
        } catch (Throwable $e) {
            rayanSystemLog($config, 'NOTIFY ERROR chat_id=' . $chatId . ' | ' . $e->getMessage());
        }
    }
}

function rayanFindParam(array $params, ?int $fieldId = null, ?string $fieldName = null): ?array
{
    if ($fieldName !== null && trim($fieldName) !== '') {
        foreach ($params as $param) {
            if (!is_array($param)) {
                continue;
            }
            if (rayanParamMatchesName($param, $fieldName)) {
                return $param;
            }
        }
    }

    if ($fieldId !== null) {
        foreach ($params as $param) {
            if (!is_array($param)) {
                continue;
            }

            if (isset($param['fieldId']) && (int)$param['fieldId'] === $fieldId) {
                return $param;
            }
        }
    }

    return null;
}

function rayanGetParamDisplayValue(?array $param): string
{
    if (!$param) {
        return '';
    }

    // فرم‌افزار برای فیلدهای انتخابی معمولاً value را به شکل کد داخلی می‌فرستد
    // و متن واقعی انتخاب‌شده را داخل title می‌گذارد. برای پنل ما متن واقعی مهم‌تر است.
    $title = trim((string)($param['title'] ?? ''));
    $value = trim((string)($param['value'] ?? ''));

    if ($title !== '') {
        return $title;
    }
    return $value;
}

function rayanGetParamRawValue(?array $param): string
{
    if (!$param) {
        return '';
    }

    $value = trim((string)($param['value'] ?? ''));
    if ($value !== '') {
        return $value;
    }

    $title = trim((string)($param['title'] ?? ''));
    return $title;
}

function rayanGetFieldValue(array $form, array $params, array $fieldIds = [], array $fieldNames = []): string
{
    foreach ($fieldNames as $fieldName) {
        $param = rayanFindParam($params, null, (string)$fieldName);
        $value = rayanGetParamDisplayValue($param);
        if ($value !== '') {
            return $value;
        }
    }

    foreach ($fieldIds as $fieldId) {
        $key = 'field_' . $fieldId;
        if (array_key_exists($key, $form)) {
            $value = trim((string)$form[$key]);
            if ($value !== '') {
                return $value;
            }
        }
    }

    foreach ($fieldIds as $fieldId) {
        $param = rayanFindParam($params, (int)$fieldId, null);
        $value = rayanGetParamDisplayValue($param);
        if ($value !== '') {
            return $value;
        }
    }

    return '';
}

function rayanNormalizeScalarValue($value)
{
    if ($value === null) {
        return null;
    }

    if (is_bool($value)) {
        return $value ? '1' : '0';
    }

    if (is_scalar($value)) {
        return (string)$value;
    }

    return json_encode($value, JSON_UNESCAPED_UNICODE);
}

function rayanDb(array $config): PDO
{
    $db = $config['db'] ?? [];

    return new PDO(
        "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4",
        (string)$db['user'],
        (string)$db['pass'],
        [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]
    );
}
function rayanEnsureMinipayResultColumns(PDO $pdo): void
{
    $existing = rayanGetExistingColumns($pdo, 'rayan_purchase_requests');

    $queries = [
        'minipay_invoice_id' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN minipay_invoice_id BIGINT NULL
        ",
        'minipay_transaction_id' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN minipay_transaction_id BIGINT NULL
        ",
        'minipay_tracking_code' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN minipay_tracking_code VARCHAR(100) NULL
        ",
        'minipay_status' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN minipay_status VARCHAR(50) NULL
        ",
        'minipay_result_json' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN minipay_result_json LONGTEXT NULL
        ",
    ];

    foreach ($queries as $column => $sql) {
        if (!isset($existing[$column])) {
            $pdo->exec($sql);
        }
    }
}

function rayanEnsureRequestOwnershipColumns(PDO $pdo): void
{
    $existing = rayanGetExistingColumns($pdo, 'rayan_purchase_requests');

    $queries = [
        'source' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN source VARCHAR(64) NOT NULL DEFAULT 'webhook'
        ",
        'created_by' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN created_by VARCHAR(100) NULL
        ",
        'created_by_display' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN created_by_display VARCHAR(255) NULL
        ",
        'submitted_by' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN submitted_by VARCHAR(100) NULL
        ",
        'submitted_by_display' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN submitted_by_display VARCHAR(255) NULL
        ",
        'submitted_at' => "
            ALTER TABLE rayan_purchase_requests
            ADD COLUMN submitted_at DATETIME NULL
        ",
    ];

    $changed = false;
    foreach ($queries as $column => $sql) {
        if (!isset($existing[$column])) {
            $pdo->exec($sql);
            $changed = true;
        }
    }

    $indexes = rayanGetExistingIndexes($pdo, 'rayan_purchase_requests');
    if (!isset($indexes['idx_source_created_by'])) {
        $pdo->exec("CREATE INDEX idx_source_created_by ON rayan_purchase_requests (source, created_by)");
    }
    if (!isset($indexes['idx_submitted_by'])) {
        $pdo->exec("CREATE INDEX idx_submitted_by ON rayan_purchase_requests (submitted_by)");
    }

    // بک‌فیل برای داده‌های قبلی: درخواست‌های ساخته‌شده از فرم داخلی با creator_id همان کاربر ثبت‌کننده قابل تفکیک می‌شوند.
    if ($changed) {
        try {
            $pdo->exec("
                UPDATE rayan_purchase_requests r
                LEFT JOIN forms_submissions s ON s.id = r.submission_id
                SET
                    r.source = CASE
                        WHEN r.source = 'manual_site_form' THEN 'manual_site_form'
                        WHEN s.url_referrer = 'manual-site-form' THEN 'manual_site_form'
                        WHEN s.raw_json LIKE '%manual_site_form%' THEN 'manual_site_form'
                        ELSE COALESCE(NULLIF(r.source, ''), 'webhook')
                    END,
                    r.created_by = CASE
                        WHEN (r.source = 'manual_site_form' OR s.url_referrer = 'manual-site-form' OR s.raw_json LIKE '%manual_site_form%')
                            THEN COALESCE(NULLIF(r.created_by, ''), NULLIF(s.creator_id, ''))
                        ELSE r.created_by
                    END,
                    r.created_by_display = CASE
                        WHEN (r.source = 'manual_site_form' OR s.url_referrer = 'manual-site-form' OR s.raw_json LIKE '%manual_site_form%')
                            THEN COALESCE(NULLIF(r.created_by_display, ''), NULLIF(s.creator_id, ''))
                        ELSE r.created_by_display
                    END
            ");
        } catch (Throwable $e) {
            // اگر دیتابیس روی نسخه/مجوز خاصی محدودیت داشت، اجرای پنل نباید متوقف شود؛ درخواست‌های جدید از همین لحظه owner می‌گیرند.
        }
    }
}

function rayanGetExistingIndexes(PDO $pdo, string $tableName): array
{
    $stmt = $pdo->prepare("
        SELECT INDEX_NAME
        FROM INFORMATION_SCHEMA.STATISTICS
        WHERE TABLE_SCHEMA = DATABASE()
          AND TABLE_NAME = :table_name
    ");
    $stmt->execute([':table_name' => $tableName]);

    $indexes = [];
    foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
        $indexes[(string)$row['INDEX_NAME']] = true;
    }

    return $indexes;
}

function rayanBuildProfitAmountLine(array $audit): string
{
    $resultLabel = trim((string)($audit['result_label'] ?? ''));
    $profitValue = (float)($audit['profit_value'] ?? 0);
    $profitAbs   = abs($profitValue);

    if ($resultLabel === 'سود') {
        return 'مقدار سود: ' . rayanFormatMoney((string)$profitAbs);
    }

    if ($resultLabel === 'ضرر') {
        return 'مقدار ضرر: ' . rayanFormatMoney((string)$profitAbs);
    }

    return 'مقدار سود/ضرر: 0';
}
function rayanEnsureSchema(PDO $pdo): void
{
    $pdo->exec("
        CREATE TABLE IF NOT EXISTS forms_submissions (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
            form_entry_id BIGINT UNSIGNED NOT NULL,
            formatted_id VARCHAR(100) NULL,
            form_name VARCHAR(255) NULL,
            creator_id VARCHAR(100) NULL,
            create_date VARCHAR(100) NULL,
            url_referrer LONGTEXT NULL,
            browser VARCHAR(255) NULL,
            ip VARCHAR(64) NULL,
            coordinate VARCHAR(255) NULL,
            payment_status INT NULL,
            seller_name VARCHAR(255) NULL,
            final_status VARCHAR(255) NULL,
            club_level VARCHAR(255) NULL,
            processed_at DATETIME NULL,
            raw_json LONGTEXT NULL,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uq_form_entry_id (form_entry_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");

    $pdo->exec("
        CREATE TABLE IF NOT EXISTS forms_submission_params (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
            submission_id BIGINT UNSIGNED NOT NULL,
            param_local_id BIGINT NULL,
            field_id BIGINT NULL,
            field_name VARCHAR(255) NULL,
            title LONGTEXT NULL,
            value_text LONGTEXT NULL,
            number_value DECIMAL(20,3) NULL,
            group_name VARCHAR(255) NULL,
            action_value VARCHAR(255) NULL,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            KEY idx_submission_id (submission_id),
            KEY idx_field_id (field_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");

    $pdo->exec("
        CREATE TABLE IF NOT EXISTS rayan_purchase_requests (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
            form_entry_id BIGINT UNSIGNED NOT NULL,
            submission_id BIGINT UNSIGNED NULL,
            seller_name VARCHAR(255) NULL,
            final_status VARCHAR(255) NULL,
            club_level VARCHAR(255) NULL,
            national_id VARCHAR(32) NULL,
            mobile VARCHAR(32) NULL,
            product_name VARCHAR(255) NULL,
            installment_amount VARCHAR(64) NULL,
            postal_code VARCHAR(32) NULL,
            source VARCHAR(64) NOT NULL DEFAULT 'webhook',
            created_by VARCHAR(100) NULL,
            created_by_display VARCHAR(255) NULL,
            submitted_by VARCHAR(100) NULL,
            submitted_by_display VARCHAR(255) NULL,
            submitted_at DATETIME NULL,
            address LONGTEXT NULL,
            status VARCHAR(50) NOT NULL DEFAULT 'new',
            last_step VARCHAR(255) NULL,
            debug_dir LONGTEXT NULL,
            cookie_file LONGTEXT NULL,
            otp_context_json LONGTEXT NULL,
            error_message LONGTEXT NULL,
            otp_requested_at DATETIME NULL,
            completed_at DATETIME NULL,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uq_form_entry_id (form_entry_id),
            KEY idx_status (status),
            KEY idx_source_created_by (source, created_by),
            KEY idx_submitted_by (submitted_by)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");
    rayanEnsureMinipayResultColumns($pdo);
    rayanEnsureRequestOwnershipColumns($pdo);
}

function rayanGetExistingColumns(PDO $pdo, string $tableName): array
{
    $stmt = $pdo->prepare("
        SELECT COLUMN_NAME
        FROM INFORMATION_SCHEMA.COLUMNS
        WHERE TABLE_SCHEMA = DATABASE()
          AND TABLE_NAME = :table_name
    ");
    $stmt->execute([':table_name' => $tableName]);

    $columns = [];
    foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
        $columns[$row['COLUMN_NAME']] = true;
    }

    return $columns;
}

function rayanEnsureDynamicFieldColumns(PDO $pdo, array $form): void
{
    $existingColumns = rayanGetExistingColumns($pdo, 'forms_submissions');

    foreach ($form as $key => $value) {
        if (!preg_match('/^field_\d+$/', (string)$key)) {
            continue;
        }

        if (isset($existingColumns[$key])) {
            continue;
        }

        $pdo->exec("ALTER TABLE forms_submissions ADD COLUMN `{$key}` LONGTEXT NULL");
        $existingColumns[$key] = true;
    }
}

function rayanUpsertFormSubmission(
    PDO $pdo,
    array $config,
    array $data,
    array $form,
    array $params,
    string $sellerName,
    string $finalStatus,
    string $clubLevel
): array {
    rayanEnsureDynamicFieldColumns($pdo, $form);

    $formEntryId = isset($form['id']) ? (int)$form['id'] : 0;
    if ($formEntryId <= 0) {
        throw new RuntimeException('form.id معتبر نیست.');
    }

    $saveData = [
        'form_entry_id'  => $formEntryId,
        'formatted_id'   => rayanNormalizeScalarValue($form['formattedId'] ?? null),
        'form_name'      => $config['form_name'] ?? 'فرم',
        'creator_id'     => rayanNormalizeScalarValue($form['creatorId'] ?? null),
        'create_date'    => rayanNormalizeScalarValue($form['createDate'] ?? null),
        'url_referrer'   => rayanNormalizeScalarValue($form['urlReferrer'] ?? null),
        'browser'        => rayanNormalizeScalarValue($form['browser'] ?? null),
        'ip'             => rayanNormalizeScalarValue($form['ip'] ?? ($_SERVER['REMOTE_ADDR'] ?? null)),
        'coordinate'     => rayanNormalizeScalarValue($form['coordinate'] ?? null),
        'payment_status' => isset($form['paymentStatus']) ? (int)$form['paymentStatus'] : null,
        'seller_name'    => $sellerName,
        'final_status'   => $finalStatus,
        'club_level'     => $clubLevel,
        'processed_at'   => date('Y-m-d H:i:s'),
        'raw_json'       => json_encode($data, JSON_UNESCAPED_UNICODE),
    ];

    foreach ($form as $key => $value) {
        if (!preg_match('/^field_\d+$/', (string)$key)) {
            continue;
        }

        $saveData[$key] = rayanNormalizeScalarValue($value);
    }

    $columns       = array_keys($saveData);
    $quotedColumns = array_map(fn($c) => "`{$c}`", $columns);
    $placeholders  = array_map(fn($c) => ":{$c}", $columns);

    $updates = [];
    foreach ($columns as $column) {
        if ($column === 'form_entry_id') {
            continue;
        }
        $updates[] = "`{$column}` = VALUES(`{$column}`)";
    }

    $sql = "
        INSERT INTO forms_submissions (" . implode(', ', $quotedColumns) . ")
        VALUES (" . implode(', ', $placeholders) . ")
        ON DUPLICATE KEY UPDATE " . implode(', ', $updates);

    $stmt = $pdo->prepare($sql);
    foreach ($saveData as $key => $value) {
        $stmt->bindValue(':' . $key, $value);
    }
    $stmt->execute();

    $submissionStmt = $pdo->prepare("
        SELECT id
        FROM forms_submissions
        WHERE form_entry_id = :form_entry_id
        LIMIT 1
    ");
    $submissionStmt->execute([':form_entry_id' => $formEntryId]);
    $submissionRow = $submissionStmt->fetch();

    if (!$submissionRow) {
        throw new RuntimeException('شناسه submission پیدا نشد.');
    }

    $submissionId = (int)$submissionRow['id'];

    $deleteParamsStmt = $pdo->prepare("
        DELETE FROM forms_submission_params
        WHERE submission_id = :submission_id
    ");
    $deleteParamsStmt->execute([':submission_id' => $submissionId]);

    if (!empty($params)) {
        $paramStmt = $pdo->prepare("
            INSERT INTO forms_submission_params (
                submission_id,
                param_local_id,
                field_id,
                field_name,
                title,
                value_text,
                number_value,
                group_name,
                action_value
            ) VALUES (
                :submission_id,
                :param_local_id,
                :field_id,
                :field_name,
                :title,
                :value_text,
                :number_value,
                :group_name,
                :action_value
            )
        ");

        foreach ($params as $param) {
            if (!is_array($param)) {
                continue;
            }

            $paramStmt->execute([
                ':submission_id' => $submissionId,
                ':param_local_id' => isset($param['id']) ? (int)$param['id'] : null,
                ':field_id'       => isset($param['fieldId']) ? (int)$param['fieldId'] : null,
                ':field_name'     => $param['fieldName'] ?? null,
                ':title'          => $param['title'] ?? null,
                ':value_text'     => $param['value'] ?? null,
                ':number_value'   => (isset($param['number']) && $param['number'] !== '') ? (float)$param['number'] : null,
                ':group_name'     => $param['group'] ?? null,
                ':action_value'   => isset($param['action']) ? (string)$param['action'] : null,
            ]);
        }
    }

    return [
        'submission_id' => $submissionId,
    ];
}

function rayanMatchesFilters(array $config, string $sellerName, string $finalStatus, string $clubLevel): bool
{
    $filters = $config['filters'] ?? [];

    $requiredFinal = rayanNormalizeCompareText((string)($filters['required_final_status'] ?? ''));
    if ($requiredFinal !== '' && rayanNormalizeCompareText($finalStatus) !== $requiredFinal) {
        return false;
    }

    $enableSeller = (bool)($filters['enable_seller_filter'] ?? false);
    $acceptedSellers = $filters['accepted_seller_names'] ?? [];
    if ($enableSeller && is_array($acceptedSellers) && !empty($acceptedSellers)) {
        $sellerOk = false;
        foreach ($acceptedSellers as $item) {
            if (rayanNormalizeCompareText((string)$item) === rayanNormalizeCompareText($sellerName)) {
                $sellerOk = true;
                break;
            }
        }
        if (!$sellerOk) {
            return false;
        }
    }

    $enableClub = (bool)($filters['enable_club_filter'] ?? false);
    if ($enableClub) {
        $provider = rayanResolveProviderFromClub($config, $clubLevel);
        if ($provider === null) {
            return false;
        }
    }

    return true;
}

function rayanRequestHasAnyRegistration(PDO $pdo, array $request): bool
{
    $status = (string)($request['status'] ?? '');

    // فقط وضعیت‌های اولیه یعنی هنوز هیچ ثبت/ارسالی انجام نشده است.
    if (!in_array($status, ['new', 'invalid'], true)) {
        return true;
    }

    foreach ([
        'submitted_by',
        'submitted_by_display',
        'submitted_at',
        'debug_dir',
        'cookie_file',
        'otp_context_json',
        'otp_requested_at',
        'completed_at',
        'minipay_tracking_code',
        'minipay_status',
        'minipay_result_json',
    ] as $column) {
        if (trim((string)($request[$column] ?? '')) !== '') {
            return true;
        }
    }

    foreach (['minipay_invoice_id', 'minipay_transaction_id'] as $column) {
        if ((int)($request[$column] ?? 0) > 0) {
            return true;
        }
    }

    $requestId = (int)($request['id'] ?? 0);
    $formEntryId = (int)($request['form_entry_id'] ?? 0);

    if ($requestId > 0 || $formEntryId > 0) {
        try {
            $stmt = $pdo->prepare("
                SELECT 1
                FROM rayan_hesaban_invoice_docs
                WHERE request_id = :request_id
                   OR form_entry_id = :form_entry_id
                LIMIT 1
            ");
            $stmt->execute([
                ':request_id' => $requestId,
                ':form_entry_id' => $formEntryId,
            ]);
            if ($stmt->fetchColumn()) {
                return true;
            }
        } catch (Throwable $e) {
            // اگر جدول حسابان هنوز ساخته نشده باشد، همین چک‌های وضعیت و فیلدهای بالا کافی است.
        }
    }

    return false;
}

function rayanCreateOrUpdatePurchaseRequest(PDO $pdo, array $requestData): array
{
    $formEntryId = (int)$requestData['form_entry_id'];

    $check = $pdo->prepare("SELECT * FROM rayan_purchase_requests WHERE form_entry_id = :form_entry_id LIMIT 1");
    $check->execute([':form_entry_id' => $formEntryId]);
    $existing = $check->fetch(PDO::FETCH_ASSOC);

    if ($existing) {
        $hasAnyRegistration = rayanRequestHasAnyRegistration($pdo, $existing);

        if (!$hasAnyRegistration) {
            // اگر فرم‌افزار همان form.id را دوباره فرستاد و درخواست قبلی هنوز هیچ ثبت/ارسالی نداشت،
            // درخواست قبلی واقعاً حذف می‌شود و نسخه جدید با request id تازه ساخته می‌شود.
            $delete = $pdo->prepare("DELETE FROM rayan_purchase_requests WHERE id = :id LIMIT 1");
            $delete->execute([':id' => (int)$existing['id']]);
            $existing = null;
        } else {
            // اگر حتی یک‌بار ثبت مرحله اول، OTP، ثبت حسابان یا تلاش ثبت انجام شده باشد،
            // وضعیت عملیاتی قبلی حفظ می‌شود و فقط اطلاعات خواندنی فرم به‌روزرسانی می‌شود.
            $stmt = $pdo->prepare("
                UPDATE rayan_purchase_requests SET
                    submission_id = :submission_id,
                    seller_name = :seller_name,
                    buyer_name = :buyer_name,
                    final_status = :final_status,
                    club_level = :club_level,
                    national_id = :national_id,
                    mobile = :mobile,
                    product_name = :product_name,
                    installment_amount = :installment_amount,
                    postal_code = :postal_code,
                    address = :address
                WHERE id = :id
            ");
            $stmt->execute([
                ':submission_id'       => $requestData['submission_id'],
                ':seller_name'         => $requestData['seller_name'],
                ':buyer_name'          => $requestData['buyer_name'] ?? '',
                ':final_status'        => $requestData['final_status'],
                ':club_level'          => $requestData['club_level'],
                ':national_id'         => $requestData['national_id'],
                ':mobile'              => $requestData['mobile'],
                ':product_name'        => $requestData['product_name'],
                ':installment_amount'  => $requestData['installment_amount'],
                ':postal_code'         => $requestData['postal_code'],
                ':address'             => $requestData['address'],
                ':id'                  => (int)$existing['id'],
            ]);

            $row = rayanGetPurchaseRequestById($pdo, (int)$existing['id']);
            $row['is_new'] = false;
            $row['preserved_state'] = true;
            $row['duplicate_form_entry_preserved'] = true;
            return $row;
        }
    }

    $stmt = $pdo->prepare("
        INSERT INTO rayan_purchase_requests (
            form_entry_id, submission_id, seller_name, buyer_name, final_status, club_level,
            national_id, mobile, product_name, installment_amount, postal_code, address, status, last_step
        ) VALUES (
            :form_entry_id, :submission_id, :seller_name, :buyer_name, :final_status, :club_level,
            :national_id, :mobile, :product_name, :installment_amount, :postal_code, :address, 'new', 'ذخیره اولیه'
        )
    ");
    $stmt->execute([
        ':form_entry_id'        => $requestData['form_entry_id'],
        ':submission_id'        => $requestData['submission_id'],
        ':seller_name'          => $requestData['seller_name'],
        ':buyer_name'           => $requestData['buyer_name'] ?? '',
        ':final_status'         => $requestData['final_status'],
        ':club_level'           => $requestData['club_level'],
        ':national_id'          => $requestData['national_id'],
        ':mobile'               => $requestData['mobile'],
        ':product_name'         => $requestData['product_name'],
        ':installment_amount'   => $requestData['installment_amount'],
        ':postal_code'          => $requestData['postal_code'],
        ':address'              => $requestData['address'],
    ]);

    $id = (int)$pdo->lastInsertId();
    $row = rayanGetPurchaseRequestById($pdo, $id);
    $row['is_new'] = true;
    $row['preserved_state'] = false;
    return $row;
}

function rayanGetPurchaseRequestById(PDO $pdo, int $requestId): array
{
    $stmt = $pdo->prepare("SELECT * FROM rayan_purchase_requests WHERE id = :id LIMIT 1");
    $stmt->execute([':id' => $requestId]);
    $row = $stmt->fetch();
    if (!$row) {
        throw new RuntimeException('درخواست پیدا نشد.');
    }
    return $row;
}

function rayanFindLatestAwaitingOtpRequest(PDO $pdo): ?array
{
    $stmt = $pdo->query("
        SELECT *
        FROM rayan_purchase_requests
        WHERE status = 'awaiting_otp'
        ORDER BY
            CASE WHEN otp_requested_at IS NULL THEN 1 ELSE 0 END,
            otp_requested_at DESC,
            id DESC
        LIMIT 1
    ");
    $row = $stmt->fetch();
    return $row ?: null;
}

function rayanUpdatePurchaseRequest(PDO $pdo, int $requestId, array $fields): void
{
    if (empty($fields)) {
        return;
    }

    $sets = [];
    $params = [':id' => $requestId];

    foreach ($fields as $column => $value) {
        $sets[] = "`{$column}` = :{$column}";
        $params[':' . $column] = $value;
    }

    $sql = "UPDATE rayan_purchase_requests SET " . implode(', ', $sets) . " WHERE id = :id";
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
}

function rayanBuildRequestDataFromForm(array $form, array $params, array $config = []): array
{
    $names = fn(string $key, array $fallback): array => rayanGetFieldNameList($config, $key, $fallback);

    $nationalId = rayanDigitsOnly(rayanGetFieldValue($form, $params, [], $names('national_id', ['کد ملی', 'شماره ملی'])));
    $mobile     = rayanDigitsOnly(rayanGetFieldValue($form, $params, [], $names('mobile', ['موبایل / تلفن', 'شماره تلفن', 'تلفن همراه', 'موبایل'])));
    $postal     = rayanDigitsOnly(rayanGetFieldValue($form, $params, [], $names('postal_code', ['کد پستی'])));
    $amount     = rayanDigitsOnly(rayanGetFieldValue($form, $params, [], $names('installment_amount', ['جمع کل باسود (بدون پیش پرداخت )', 'مبلغ خرید اعتباری', 'مبلغ با سود'])));

    if (strlen($mobile) === 10 && str_starts_with($mobile, '9')) {
        $mobile = '0' . $mobile;
    }

    $productParam  = rayanFindParamByNames($params, $names('product_code', ['کد کالا', 'کد کالا حسابان', 'کد محصول', 'نام کالا']), []);
    $purchaseParam = rayanFindParamByNames($params, $names('purchase_amount', ['مبلغ خرید حسابان وب', 'مبلغ خرید حسابان‌وب', 'قیمت خرید', 'مبلغ خرید']), []);
    $salesParam    = rayanFindParamByNames($params, $names('sales_amount', ['فاکتور حسابان وب( با کارمزد اولیه )', 'فاکتور حسابان وب (با کارمزد اقساط اولیه )', 'مبلغ فاکتور فروش حسابان وب', 'مبلغ فاکتور فروش حسابان‌وب', 'مبلغ فروش', 'قیمت فروش', 'انتقال به جامع', 'انتقال به جامع کلی', 'جمع کل باسود (بدون پیش پرداخت )']), []);

    return [
        'buyer_name'         => rayanNormalizeSpaces(rayanGetFieldValue($form, $params, [], $names('buyer_name', ['نام خریدار', 'نام مشتری', 'نام و نام خانوادگی']))),
        'national_id'        => $nationalId,
        'mobile'             => $mobile,
        'product_name'       => rayanNormalizeSpaces(rayanGetFieldValue($form, $params, [], $names('product_name', ['نام کالا', 'کالا', 'شرح کالا']))),
        'product_code'       => rayanParamCodeString($productParam, ''),
        'installment_amount' => $amount,
        'sales_amount'       => rayanParamNumberString($salesParam, ''),
        'purchase_amount'    => rayanParamNumberString($purchaseParam, ''),
        'postal_code'        => $postal,
        'address'            => rayanNormalizeSpaces(rayanGetFieldValue($form, $params, [], $names('address', ['ادرس', 'آدرس', 'نشانی']))),
    ];
}

function rayanEvaluateFormIntakeRules(array $config, array $form, array $params, string $clubLevel): array
{
    $finalNames = rayanGetFieldNameList($config, 'approved_flag', ['تایید شد', 'تایید نهایی', 'وضعیت تایید', 'تایید شود']);
    $combinedNames = rayanGetFieldNameList($config, 'combined_accounting_flag', [
        'فاکتور و بررسی نهایی',
        'فاکتور و برسی نهایی',
        'فاکتور وبررسی نهایی',
        'فاکتور وبرسی نهایی',
    ]);

    $approved   = rayanFormFlagIsChecked($form, $params, $finalNames, []);
    $combinedOk = rayanFormFlagIsChecked($form, $params, $combinedNames, []);
    $isCoreClub = rayanIsCoreClubPlan($config, $clubLevel);

    return [
        'accepted' => true,
        'provider' => $isCoreClub ? rayanResolveRequestProvider($config, $clubLevel) : 'hesaban_only',
        'reason' => $isCoreClub
            ? 'فرم کلوپی بدون نیاز به فیلتر فرم‌افزار وارد پنل شد و منتظر ثبت مرحله اول است.'
            : 'فرم غیرکلوپی بدون نیاز به تیک تایید/ثبت فاکتور فرم‌افزار وارد پنل شد و منتظر اقدام مدیر است.',
        'approved' => $approved,
        'combined_accounting_ok' => $combinedOk,
        'is_core_club' => $isCoreClub,
    ];
}

function rayanFormatMoney(string $amount): string
{
    $amount = trim(rayanFaToEn($amount));
    if ($amount === '') {
        return $amount;
    }

    $normalized = str_replace(['٬', '،', ',', ' '], '', $amount);

    if (is_numeric($normalized)) {
        $number = (float)$normalized;

        // اگر اعشار واقعی ندارد، بدون اعشار نمایش بده
        if (abs($number - round($number)) < 0.00001) {
            return number_format((int)round($number));
        }

        return number_format($number, 2, '.', ',');
    }

    $digits = preg_replace('/\D+/', '', $normalized);
    if ($digits === '') {
        return $amount;
    }

    return number_format((int)$digits);
}

function rayanFormatRequestInfo(array $request): string
{
    return
        "شماره درخواست: " . (string)($request['id'] ?? '-') . "\n" .
        "کد ملی: " . (string)($request['national_id'] ?? '-') . "\n" .
        "شماره تلفن: " . (string)($request['mobile'] ?? '-') . "\n" .
        "نام کالا: " . (string)($request['product_name'] ?? '-') . "\n" .
        "مبلغ با سود: " . rayanFormatMoney((string)($request['installment_amount'] ?? '0')) . "\n" .
        "کد پستی: " . (string)($request['postal_code'] ?? '-') . "\n" .
        "آدرس: " . (string)($request['address'] ?? '-');
}

function rayanNotifyRequestStartSummary(array $config, array $request): void
{
    $text =
        "شروع پردازش درخواست جدید\n\n" .
        rayanFormatRequestInfo($request);

    rayanNotifyAll($config, $text);
}

function rayanNotifyProgress(array $config, int $requestId, int $percent): void
{
    $percent = max(0, min(100, $percent));
    rayanNotifyAll($config, "پیشرفت درخواست {$requestId}: {$percent}%");
}

function rayanNotifyOtpNeeded(array $config, array $request): void
{
    $text =
        "کد تایید را بفرست\n\n" .
        rayanFormatRequestInfo($request) .
        "\n\nفقط عدد کد تایید را در ربات بفرست.\nمثال:\n123456";

    rayanNotifyAll($config, $text);
}

function rayanNotifyFailedSimple(array $config, array $request, string $error): void
{
    $cleanError = rayanSanitizeSiteMessageForNotify($error);

    $text =
        "درخواست " . (string)($request['id'] ?? '-') . " ناموفق بود.\n" .
        "خطا: " . $cleanError;

    rayanNotifyAll($config, $text);
}

function rayanExtractVisibleErrors(string $html): string
{
    try {
        $xpath = rayanCreateXPath($html);
        $texts = [];

        $queries = [
            '//*[contains(@class,"alert")]',
            '//*[contains(@class,"error")]',
            '//*[contains(@class,"invalid")]',
            '//*[contains(@class,"help-block")]',
            '//*[contains(@class,"text-danger")]',
            '//*[contains(@class,"errorlist")]',
            '//ul[contains(@class,"errorlist")]/li',
        ];

        foreach ($queries as $query) {
            foreach ($xpath->query($query) as $node) {
                $txt = rayanNormalizeSpaces($node->textContent ?? '');
                if ($txt !== '' && !in_array($txt, $texts, true)) {
                    $texts[] = $txt;
                }
                if (count($texts) >= 5) {
                    break 2;
                }
            }
        }

        return implode(' | ', $texts);
    } catch (Throwable $e) {
        return '';
    }
}

function rayanExtractOtpContext(string $html, string $baseUrl, array $config): array
{
    $form = rayanFindFormByQuery(
        $html,
        '//form[@id="frmNewCustomer"]',
        $baseUrl
    );

    if (!$form) {
        throw new RuntimeException('فرم OTP پیدا نشد.');
    }

    $fields = $form['fields'];

    $csrf = (string)($fields['csrfmiddlewaretoken'] ?? '');
    $customerMobile = (string)($fields['customer_mobile'] ?? '');
    $amount = (string)($fields['amount'] ?? '');
    $orderId = (string)($fields['order_id'] ?? '');
    $customerId = (string)($fields['customer_id'] ?? '');
    $payToken = (string)($fields['pay_token'] ?? '');

    if ($csrf === '' || $customerMobile === '' || $amount === '' || $orderId === '') {
        throw new RuntimeException('مقادیر مخفی صفحه OTP کامل نیست.');
    }

    $base = rtrim((string)$config['rayan']['base_url'], '/');

    return [
        'csrfmiddlewaretoken' => $csrf,
        'customer_mobile'     => $customerMobile,
        'amount'              => $amount,
        'order_id'            => $orderId,
        'customer_id'         => $customerId,
        'pay_token'           => $payToken,
        'otp_page_url'        => $baseUrl,
        'payment_request_url' => $base . '/customer/payment_request_minipay/',
        'auth_url'            => $base . '/customer/authorization_payment_minipay/',
        'result_url'          => $base . '/customer/purchase_result_minipay/',
    ];
}

function rayanStartMinipayOtpRequest(array $otpContext, string $cookieFile): array
{
    $res = rayanHttpAjaxFormRequest(
        (string)$otpContext['payment_request_url'],
        [
            'csrfmiddlewaretoken' => (string)$otpContext['csrfmiddlewaretoken'],
            'amount'              => (string)$otpContext['amount'],
            'customer_mobile'     => (string)$otpContext['customer_mobile'],
        ],
        $cookieFile,
        [
            'Referer: ' . (string)$otpContext['otp_page_url'],
        ]
    );

    if ($res['status'] >= 400) {
        throw new RuntimeException('payment_request_minipay HTTP error: ' . $res['status']);
    }

    $json = json_decode((string)$res['body'], true);
    if (!is_array($json)) {
        throw new RuntimeException('پاسخ payment_request_minipay معتبر نیست.');
    }

    if (empty($json['payment_token'])) {
        throw new RuntimeException('payment_token از پاسخ ارسال رمز خرید پیدا نشد. RAW: ' . mb_substr((string)$res['body'], 0, 1000));
    }

    return [
        'payment_token'           => (string)$json['payment_token'],
        'payment_message'         => (string)($json['payment_message'] ?? ''),
        'payment_request_message' => (string)($json['payment_request_message'] ?? ''),
        'authentication_message'  => (string)($json['authentication_message'] ?? ''),
        'callback_url'            => (string)($json['callback_url'] ?? ''),
        'payment_url'             => (string)($json['payment_url'] ?? ''),
        'raw'                     => $json,
        'http'                    => $res,
    ];
}

function rayanStartRayanProcessForRequest(PDO $pdo, array $config, int $requestId): void
{
    $request = rayanGetPurchaseRequestById($pdo, $requestId);
    $rayan = $config['rayan'];

    rayanClearRequestDebugDir($config, $requestId);

    $debugDir   = rayanRequestDebugDir($config, $requestId);
    $cookieFile = $debugDir . '/cookie.txt';

    if (is_file($cookieFile)) {
        @unlink($cookieFile);
    }

    rayanUpdatePurchaseRequest($pdo, $requestId, [
        'status'        => 'processing',
        'last_step'     => 'شروع پردازش',
        'debug_dir'     => $debugDir,
        'cookie_file'   => $cookieFile,
        'error_message' => null,
        'otp_context_json' => null,
        'otp_requested_at' => null,
        'completed_at'     => null,
    ]);

    $request = rayanGetPurchaseRequestById($pdo, $requestId);

    try {
        rayanNotifyRequestStartSummary($config, $request);
        rayanNotifyProgress($config, $requestId, 10);

        rayanRequestLog($config, $requestId, 'STEP 1 - GET START PAGE');
        $res = rayanHttpRequest((string)$rayan['start_url'], null, $cookieFile);
        rayanSaveRequestHtml($config, $requestId, '01_start', $res['body']);

        if ($res['status'] >= 400) {
            throw new RuntimeException('Start page HTTP error: ' . $res['status']);
        }

        rayanRequestLog($config, $requestId, 'STEP 2 - FIND LOGIN FORM');
        $loginForm = rayanFindFormByQuery(
            $res['body'],
            '//form[.//*[@id="id_username"] and .//*[@id="id_password"]]',
            $res['url']
        );
        if (!$loginForm) {
            throw new RuntimeException('Login form not found');
        }

        rayanSetFieldById($loginForm, 'id_username', (string)$rayan['username']);
        rayanSetFieldById($loginForm, 'id_password', (string)$rayan['password']);

        rayanRequestLog($config, $requestId, 'STEP 3 - POST LOGIN');
        $res = rayanHttpRequest($loginForm['action'], $loginForm['fields'], $cookieFile, [
            'Referer: ' . (string)$rayan['start_url'],
        ]);
        rayanSaveRequestHtml($config, $requestId, '02_after_login', $res['body']);

        if ($res['status'] >= 400) {
            throw new RuntimeException('Login HTTP error: ' . $res['status']);
        }

        rayanNotifyProgress($config, $requestId, 25);

        $clubUrl = rayanFindHrefByText($res['body'], 'طرح باشگاه مشتریان', $res['url']);
        if ($clubUrl) {
            rayanRequestLog($config, $requestId, 'STEP 4 - OPEN CLUB PAGE');
            $res = rayanHttpRequest($clubUrl, null, $cookieFile, [
                'Referer: ' . $res['url'],
            ]);
            rayanSaveRequestHtml($config, $requestId, '03_after_club', $res['body']);
        } else {
            rayanSaveRequestHtml($config, $requestId, '03_after_club', $res['body']);
        }

        rayanRequestLog($config, $requestId, 'STEP 5 - FIND QUERY FORM');
        $queryForm = rayanFindFormByQuery(
            $res['body'],
            '//form[.//*[@id="id_n_id"] and .//*[@id="id_mobile"]]',
            $res['url']
        );
        if (!$queryForm) {
            throw new RuntimeException('Query form not found');
        }

        rayanSetFieldById($queryForm, 'id_n_id', (string)$request['national_id']);
        rayanSetFieldById($queryForm, 'id_mobile', (string)$request['mobile']);

        rayanRequestLog($config, $requestId, 'STEP 6 - POST QUERY FORM');
        $res = rayanHttpRequest($queryForm['action'], $queryForm['fields'], $cookieFile, [
            'Referer: ' . $res['url'],
        ]);
        rayanSaveRequestHtml($config, $requestId, '04_after_query', $res['body']);
        rayanSaveRequestText(
            $config,
            $requestId,
            '04_after_query_snippet',
            mb_substr(rayanNormalizeSpaces(strip_tags($res['body'])), 0, 5000)
        );

        if ($res['status'] >= 400) {
            throw new RuntimeException('Query HTTP error: ' . $res['status']);
        }

        [$maxEtebar, $maxKharid] = rayanExtractCreditInfo($res['body']);
        rayanRequestLog(
            $config,
            $requestId,
            'CREDIT LIMIT=' . ($maxEtebar ?: 'NOT FOUND') . ' | BUY LIMIT=' . ($maxKharid ?: 'NOT FOUND')
        );

        rayanNotifyProgress($config, $requestId, 45);

        rayanRequestLog($config, $requestId, 'STEP 7 - FIND PURCHASE FORM');
        $purchaseForm = rayanFindFormByQuery(
            $res['body'],
            '//form[@id="frmNewPurchase"]',
            $res['url']
        );
        if (!$purchaseForm) {
            throw new RuntimeException('Purchase form not found');
        }

        if (isset($purchaseForm['fields']['category'])) {
            $purchaseForm['fields']['category'] = (string)$rayan['category_value'];
        }
        if (isset($purchaseForm['fields']['product_name'])) {
            // برای ثبت مرحله اول سطح ۱، نام کالا باید همیشه «گوشی» ارسال شود؛
            // نام/کد کالای واقعی فقط برای گزارش و فاکتورهای حسابان‌وب نگه داشته می‌شود.
            $purchaseForm['fields']['product_name'] = 'گوشی';
        }
        if (isset($purchaseForm['fields']['installment_amount'])) {
            $purchaseForm['fields']['installment_amount'] = (string)$request['installment_amount'];
        }
        if (isset($purchaseForm['fields']['postal_code'])) {
            $purchaseForm['fields']['postal_code'] = (string)$request['postal_code'];
        }
        if (isset($purchaseForm['fields']['address'])) {
            $purchaseForm['fields']['address'] = (string)$request['address'];
        }
        if (isset($purchaseForm['fields']['customer_mobile']) && trim((string)$purchaseForm['fields']['customer_mobile']) === '') {
            $purchaseForm['fields']['customer_mobile'] = (string)$request['mobile'];
        }

        rayanNotifyProgress($config, $requestId, 65);

        rayanRequestLog($config, $requestId, 'STEP 8 - POST PURCHASE FORM');
        $res = rayanHttpRequest($purchaseForm['action'], $purchaseForm['fields'], $cookieFile, [
            'Referer: ' . $res['url'],
        ]);
        rayanSaveRequestHtml($config, $requestId, '05_after_purchase', $res['body']);
        rayanSaveRequestText(
            $config,
            $requestId,
            '05_after_purchase_snippet',
            mb_substr(rayanNormalizeSpaces(strip_tags($res['body'])), 0, 5000)
        );

        if ($res['status'] >= 400) {
            throw new RuntimeException('Purchase HTTP error: ' . $res['status']);
        }

        rayanNotifyProgress($config, $requestId, 80);

        if (
            !rayanPageContains($res['body'], 'frmNewCustomer') &&
            !rayanPageContains($res['body'], 'confirm-code') &&
            !rayanPageContains($res['body'], 'کد تایید') &&
            !rayanPageContains($res['body'], 'Please enter your purchase password')
        ) {
            $visibleErrors = rayanExtractVisibleErrors($res['body']);
            $importantError = rayanPickImportantSiteError($visibleErrors !== '' ? $visibleErrors : $res['body']);

            if ($importantError !== '') {
                throw new RuntimeException($importantError);
            }

            if ($visibleErrors !== '') {
                throw new RuntimeException(rayanSanitizeSiteMessageForNotify($visibleErrors));
            }

            throw new RuntimeException('صفحه کد تایید باز نشد. فایل 05_after_purchase.html ذخیره شد.');
        }

        rayanRequestLog($config, $requestId, 'STEP 9 - EXTRACT OTP CONTEXT');
        $otpContext = rayanExtractOtpContext($res['body'], $res['url'], $config);
        rayanSaveRequestJson($config, $requestId, '06_otp_context_before_request', $otpContext);

        rayanRequestLog($config, $requestId, 'STEP 10 - CALL payment_request_minipay');
        $otpStart = rayanStartMinipayOtpRequest($otpContext, $cookieFile);

        rayanRequestLog($config, $requestId, 'OTP AJAX STATUS=' . (string)($otpStart['http']['status'] ?? ''));
        rayanSaveRequestText($config, $requestId, '07_otp_request_raw_body', (string)($otpStart['http']['body'] ?? ''));

        $otpContext['payment_token'] = $otpStart['payment_token'];
        $otpContext['payment_message'] = $otpStart['payment_message'];
        $otpContext['payment_request_message'] = $otpStart['payment_request_message'];
        $otpContext['authentication_message'] = $otpStart['authentication_message'];
        $otpContext['callback_url'] = $otpStart['callback_url'];
        $otpContext['payment_url'] = $otpStart['payment_url'];

        rayanSaveRequestJson($config, $requestId, '07_otp_request_response', $otpStart['raw']);
        rayanSaveRequestJson($config, $requestId, '08_otp_context_final', $otpContext);

        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'           => 'awaiting_otp',
            'last_step'        => 'منتظر کد تایید',
            'otp_context_json' => json_encode($otpContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
            'otp_requested_at' => date('Y-m-d H:i:s'),
            'error_message'    => null,
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);

        rayanNotifyProgress($config, $requestId, 90);
        rayanNotifyOtpNeeded($config, $request);
    } catch (Throwable $e) {
        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'        => 'failed',
            'last_step'     => 'خطا',
            'error_message' => $e->getMessage(),
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);
        rayanRequestLog($config, $requestId, 'ERROR: ' . $e->getMessage());
        rayanNotifyFailedSimple($config, $request, $e->getMessage());

        throw $e;
    }
}
function rayanStartDirectMinipayProcessForRequest(PDO $pdo, array $config, int $requestId, ?string $profile = null): void
{
    $request = rayanGetPurchaseRequestById($pdo, $requestId);
    $mp      = rayanMinipayDirectConfig($config, $profile);
    rayanClearRequestDebugDir($config, $requestId);

    $debugDir   = rayanRequestDebugDir($config, $requestId);
    $cookieFile = $debugDir . '/cookie.txt';

    if (is_file($cookieFile)) {
        @unlink($cookieFile);
    }

    rayanUpdatePurchaseRequest($pdo, $requestId, [
        'status'           => 'processing',
        'last_step'        => 'شروع پردازش قسطانو',
        'debug_dir'        => $debugDir,
        'cookie_file'      => $cookieFile,
        'error_message'    => null,
        'otp_context_json' => null,
        'otp_requested_at' => null,
        'completed_at'     => null,
        'minipay_invoice_id' => null,
        'minipay_transaction_id' => null,
        'minipay_tracking_code' => null,
        'minipay_status' => null,
        'minipay_result_json' => null,
    ]);

    $request = rayanGetPurchaseRequestById($pdo, $requestId);

    try {
        $mobile = rayanDigitsOnly((string)$request['mobile']);
        if (strlen($mobile) === 10 && str_starts_with($mobile, '9')) {
            $mobile = '0' . $mobile;
        }

        if ($mobile === '' || strlen($mobile) < 11) {
            throw new RuntimeException('شماره موبایل درخواست معتبر نیست.');
        }

        $branchId = trim((string)$mp['branch_id']);
        if ($branchId === '') {
            $branchId = rayanMinipayExtractBranchIdFromUrl((string)$mp['start_url']);
        }
        if ($branchId === '') {
            throw new RuntimeException('شناسه شعبه از تنظیمات پیدا نشد.');
        }

        $apiBase = rtrim((string)$mp['api_base'], '/');

        rayanNotifyRequestStartSummary($config, $request);
        rayanNotifyProgress($config, $requestId, 10);

        rayanRequestLog($config, $requestId, 'MINIPAY STEP 1 - OPEN START PAGE');
        $startRes = rayanHttpRequest((string)$mp['start_url'], null, $cookieFile);
        rayanSaveRequestHtml($config, $requestId, '01_minipay_start', (string)$startRes['body']);

        if ((int)$startRes['status'] >= 400) {
            throw new RuntimeException('صفحه شروع سرویس خرید اعتباری خطا داد: ' . (int)$startRes['status']);
        }

        rayanNotifyProgress($config, $requestId, 25);

        rayanRequestLog($config, $requestId, 'MINIPAY STEP 2 - CALL validate-branch');
        $validateBranch = rayanMinipayCallWithAttempts(
            $apiBase . '/validate-branch',
            [
                [
                    'mode'    => 'json',
                    'label'   => 'json-string-branch_id',
                    'payload' => ['branch_id' => $branchId],
                ],
                [
                    'mode'    => 'json',
                    'label'   => 'json-int-branch_id',
                    'payload' => ['branch_id' => (int)$branchId],
                ],
                [
                    'mode'    => 'form',
                    'label'   => 'form-string-branch_id',
                    'payload' => ['branch_id' => $branchId],
                ],
            ],
            $cookieFile,
            [
                'Referer: ' . (string)$mp['start_url'],
                'Origin: ' . rayanBaseOriginFromUrl((string)$mp['start_url']),
            ]
        );

        rayanSaveRequestJson($config, $requestId, '02_minipay_validate_branch_attempts', $validateBranch['attempts']);

        $validateBranchJson = is_array($validateBranch['json']) ? $validateBranch['json'] : null;
        if (!is_array($validateBranchJson) || !rayanMinipayResponseOk($validateBranchJson)) {
            $msg = is_array($validateBranchJson)
                ? rayanMinipayResponseMessage($validateBranchJson)
                : 'پاسخ validate-branch معتبر نبود.';
            throw new RuntimeException($msg !== '' ? $msg : 'validate-branch ناموفق بود.');
        }

        rayanSaveRequestJson($config, $requestId, '02_minipay_validate_branch_response', $validateBranchJson);

        $offlineToken = trim((string)($validateBranchJson['data']['offline_cpg_token'] ?? ''));
        if ($offlineToken === '') {
            throw new RuntimeException('offline_cpg_token از validate-branch برنگشت.');
        }

        $startUrlWithToken = rayanMinipayBuildStartUrlWithToken((string)$mp['start_url'], $offlineToken);

        rayanNotifyProgress($config, $requestId, 45);

        rayanRequestLog($config, $requestId, 'MINIPAY STEP 3 - CALL find-user');
        $findUser = rayanMinipayCallWithAttempts(
            $apiBase . '/find-user',
            [
                [
                    'mode'    => 'json',
                    'label'   => 'json-input-mobile',
                    'payload' => [
                        'input'             => $mobile,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
                [
                    'mode'    => 'json',
                    'label'   => 'json-wallet_id-mobile',
                    'payload' => [
                        'wallet_id'         => $mobile,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
                [
                    'mode'    => 'json',
                    'label'   => 'json-mobile-mobile',
                    'payload' => [
                        'mobile'            => $mobile,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
                [
                    'mode'    => 'form',
                    'label'   => 'form-input-mobile',
                    'payload' => [
                        'input'             => $mobile,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
            ],
            $cookieFile,
            [
                'Referer: ' . $startUrlWithToken,
                'Origin: ' . rayanBaseOriginFromUrl((string)$mp['start_url']),
            ]
        );

        rayanSaveRequestJson($config, $requestId, '03_minipay_find_user_attempts', $findUser['attempts']);

        $findUserJson = is_array($findUser['json']) ? $findUser['json'] : null;
        if (!is_array($findUserJson) || !rayanMinipayResponseOk($findUserJson)) {
            $msg = is_array($findUserJson)
                ? rayanMinipayResponseMessage($findUserJson)
                : 'پاسخ find-user معتبر نبود.';
            throw new RuntimeException($msg !== '' ? $msg : 'find-user ناموفق بود.');
        }

        rayanSaveRequestJson($config, $requestId, '03_minipay_find_user_response', $findUserJson);

        $offlineToken = trim((string)($findUserJson['data']['offline_cpg_token'] ?? $offlineToken));
        $startUrlWithToken = rayanMinipayBuildStartUrlWithToken((string)$mp['start_url'], $offlineToken);
        $otpSent      = !empty($findUserJson['data']['otp_sent']);

        if (!$otpSent) {
            $msg = rayanMinipayResponseMessage($findUserJson);
            throw new RuntimeException($msg !== '' ? $msg : 'OTP ارسال نشد.');
        }

        $otpContext = [
            'type'               => 'minipay_direct_api',
            'api_base'           => $apiBase,
            'start_url'          => (string)$mp['start_url'],
            'start_url_with_token'=> $startUrlWithToken,
            'branch_id'          => $branchId,
            'mobile'             => $mobile,
            'offline_cpg_token'  => $offlineToken,
            'input_field_name'   => (string)$mp['input_field_name'],
            'otp_field_name'     => (string)$mp['otp_field_name'],
            'installments_count' => (int)$mp['installments_count'],
            'matched_find_user'  => $findUser['matched_attempt'] ?? null,
            'find_user_message'  => rayanMinipayResponseMessage($findUserJson),
            'minipay_profile'    => $profile ?: 'default',
        ];

        rayanSaveRequestJson($config, $requestId, '04_minipay_otp_context', $otpContext);

        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'           => 'awaiting_otp',
            'last_step'        => 'منتظر کد تایید قسطانو',
            'otp_context_json' => json_encode($otpContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
            'otp_requested_at' => date('Y-m-d H:i:s'),
            'error_message'    => null,
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);

        rayanNotifyProgress($config, $requestId, 90);
        rayanNotifyOtpNeeded($config, $request);

    } catch (Throwable $e) {
        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'        => 'failed',
            'last_step'     => 'خطا در مسیر قسطانو',
            'error_message' => $e->getMessage(),
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);
        rayanRequestLog($config, $requestId, 'MINIPAY ERROR: ' . $e->getMessage());
        rayanNotifyFailedSimple($config, $request, $e->getMessage());

        throw $e;
    }
}
function rayanStartPurchaseProcessForRequest(PDO $pdo, array $config, int $requestId): void
{
    $request = rayanGetPurchaseRequestById($pdo, $requestId);
    $route   = rayanResolveClubRoute($config, (string)($request['club_level'] ?? ''));

    if (!$route) {
        throw new RuntimeException('برای این نوع طرح فروش مسیر پردازش تعریف نشده است: ' . (string)$request['club_level']);
    }

    $provider = trim((string)($route['provider'] ?? ''));

    if ($provider === 'rayan') {
        rayanStartRayanProcessForRequest($pdo, $config, $requestId);
        return;
    }

    if ($provider === 'minipay_direct') {
        $profile = trim((string)($route['minipay_profile'] ?? ''));
        rayanStartDirectMinipayProcessForRequest(
            $pdo,
            $config,
            $requestId,
            $profile !== '' ? $profile : null
        );
        return;
    }

    throw new RuntimeException('برای این نوع طرح فروش مسیر پردازش تعریف نشده است: ' . (string)$request['club_level']);
}
function rayanEnsureHesabanInvoiceSchema(PDO $pdo): void
{
    $pdo->exec("
        CREATE TABLE IF NOT EXISTS rayan_hesaban_invoice_docs (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
            form_entry_id BIGINT UNSIGNED NOT NULL,
            submission_id BIGINT UNSIGNED NULL,
            request_id BIGINT UNSIGNED NULL,
            invoice_type VARCHAR(20) NOT NULL,
            source_mode VARCHAR(100) NULL,
            source_store_title VARCHAR(255) NULL,
            invoice_uuid CHAR(36) NOT NULL,
            api_status VARCHAR(50) NOT NULL DEFAULT 'new',
            party_title VARCHAR(255) NULL,
            party_name VARCHAR(255) NULL,
            party_surname VARCHAR(255) NULL,
            nation_code VARCHAR(32) NULL,
            phone_number VARCHAR(32) NULL,
            postal_code VARCHAR(32) NULL,
            address LONGTEXT NULL,
            product_title VARCHAR(255) NULL,
            product_code VARCHAR(64) NULL,
            amount BIGINT NULL,
            request_payload LONGTEXT NULL,
            response_body LONGTEXT NULL,
            error_message LONGTEXT NULL,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uq_form_invoice_type (form_entry_id, invoice_type),
            KEY idx_api_status (api_status),
            KEY idx_invoice_type (invoice_type)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");
}

function rayanStableUuid(string $seed): string
{
    $hex = md5($seed);
    return
        substr($hex, 0, 8) . '-' .
        substr($hex, 8, 4) . '-' .
        substr($hex, 12, 4) . '-' .
        substr($hex, 16, 4) . '-' .
        substr($hex, 20, 12);
}

function rayanSplitCustomerName(string $fullName): array
{
    $fullName = rayanNormalizeSpaces($fullName);
    if ($fullName === '') {
        return ['', ''];
    }

    $parts = preg_split('/\s+/u', $fullName) ?: [];
    $parts = array_values(array_filter($parts, static fn($v) => trim((string)$v) !== ''));

    if (count($parts) === 0) {
        return ['', ''];
    }

    if (count($parts) === 1) {
        return [$parts[0], ''];
    }

    $surname = (string)array_pop($parts);
    $name    = trim(implode(' ', $parts));

    return [$name, $surname];
}

function rayanParamNumberString(?array $param, string $fallback = ''): string
{
    if (is_array($param) && array_key_exists('number', $param) && $param['number'] !== null && $param['number'] !== '') {
        if (is_numeric($param['number'])) {
            return number_format((float)$param['number'], 0, '', '');
        }
    }

    $fallback = trim($fallback);
    if ($fallback !== '') {
        $digits = rayanDigitsOnly($fallback);
        if ($digits !== '') {
            return $digits;
        }
    }

    if (is_array($param)) {
        $value = trim((string)($param['value'] ?? ''));
        if ($value !== '') {
            $digits = rayanDigitsOnly($value);
            if ($digits !== '') {
                return $digits;
            }
        }
    }

    return '';
}

function rayanParamCodeString(?array $param, string $fallback = ''): string
{
    if (is_array($param) && array_key_exists('number', $param) && $param['number'] !== null && $param['number'] !== '') {
        if (is_numeric($param['number'])) {
            return number_format((float)$param['number'], 0, '', '');
        }
    }

    $fallback = trim($fallback);
    if ($fallback !== '') {
        $fallback = rayanFaToEn($fallback);
        $fallback = preg_replace('/\.0+$/', '', $fallback) ?? $fallback;
        return trim($fallback);
    }

    if (is_array($param)) {
        $value = trim((string)($param['value'] ?? ''));
        if ($value !== '') {
            $value = rayanFaToEn($value);
            $value = preg_replace('/\.0+$/', '', $value) ?? $value;
            return trim($value);
        }
    }

    return '';
}

function rayanHasSuccessfulHesabanInvoices(PDO $pdo, int $formEntryId): bool
{
    if ($formEntryId <= 0) {
        return false;
    }

    $stmt = $pdo->prepare("
        SELECT invoice_type, api_status
        FROM rayan_hesaban_invoice_docs
        WHERE form_entry_id = :form_entry_id
          AND invoice_type IN ('sales', 'purchase')
    ");
    $stmt->execute([':form_entry_id' => $formEntryId]);

    $ok = ['sales' => false, 'purchase' => false];
    foreach (($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) as $row) {
        $type = (string)($row['invoice_type'] ?? '');
        $status = (string)($row['api_status'] ?? '');
        if (isset($ok[$type]) && $status === 'success') {
            $ok[$type] = true;
        }
    }

    return $ok['sales'] && $ok['purchase'];
}

function rayanGetHesabanInvoiceRow(PDO $pdo, int $formEntryId, string $invoiceType): ?array
{
    $stmt = $pdo->prepare("
        SELECT *
        FROM rayan_hesaban_invoice_docs
        WHERE form_entry_id = :form_entry_id
          AND invoice_type = :invoice_type
        LIMIT 1
    ");
    $stmt->execute([
        ':form_entry_id' => $formEntryId,
        ':invoice_type'  => $invoiceType,
    ]);

    $row = $stmt->fetch();
    return $row ?: null;
}

function rayanUpsertHesabanInvoiceRow(PDO $pdo, array $row): void
{
    $sql = "
        INSERT INTO rayan_hesaban_invoice_docs (
            form_entry_id,
            submission_id,
            request_id,
            invoice_type,
            source_mode,
            source_store_title,
            invoice_uuid,
            api_status,
            party_title,
            party_name,
            party_surname,
            nation_code,
            phone_number,
            postal_code,
            address,
            product_title,
            product_code,
            amount,
            request_payload,
            response_body,
            error_message
        ) VALUES (
            :form_entry_id,
            :submission_id,
            :request_id,
            :invoice_type,
            :source_mode,
            :source_store_title,
            :invoice_uuid,
            :api_status,
            :party_title,
            :party_name,
            :party_surname,
            :nation_code,
            :phone_number,
            :postal_code,
            :address,
            :product_title,
            :product_code,
            :amount,
            :request_payload,
            :response_body,
            :error_message
        )
        ON DUPLICATE KEY UPDATE
            submission_id       = VALUES(submission_id),
            request_id          = VALUES(request_id),
            source_mode         = VALUES(source_mode),
            source_store_title  = VALUES(source_store_title),
            invoice_uuid        = VALUES(invoice_uuid),
            api_status          = VALUES(api_status),
            party_title         = VALUES(party_title),
            party_name          = VALUES(party_name),
            party_surname       = VALUES(party_surname),
            nation_code         = VALUES(nation_code),
            phone_number        = VALUES(phone_number),
            postal_code         = VALUES(postal_code),
            address             = VALUES(address),
            product_title       = VALUES(product_title),
            product_code        = VALUES(product_code),
            amount              = VALUES(amount),
            request_payload     = VALUES(request_payload),
            response_body       = VALUES(response_body),
            error_message       = VALUES(error_message)
    ";

    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':form_entry_id'     => $row['form_entry_id'],
        ':submission_id'     => $row['submission_id'] ?? null,
        ':request_id'        => $row['request_id'] ?? null,
        ':invoice_type'      => $row['invoice_type'],
        ':source_mode'       => $row['source_mode'] ?? null,
        ':source_store_title'=> $row['source_store_title'] ?? null,
        ':invoice_uuid'      => $row['invoice_uuid'],
        ':api_status'        => $row['api_status'],
        ':party_title'       => $row['party_title'] ?? null,
        ':party_name'        => $row['party_name'] ?? null,
        ':party_surname'     => $row['party_surname'] ?? null,
        ':nation_code'       => $row['nation_code'] ?? null,
        ':phone_number'      => $row['phone_number'] ?? null,
        ':postal_code'       => $row['postal_code'] ?? null,
        ':address'           => $row['address'] ?? null,
        ':product_title'     => $row['product_title'] ?? null,
        ':product_code'      => $row['product_code'] ?? null,
        ':amount'            => $row['amount'] ?? null,
        ':request_payload'   => $row['request_payload'] ?? null,
        ':response_body'     => $row['response_body'] ?? null,
        ':error_message'     => $row['error_message'] ?? null,
    ]);
}

function rayanHesabanConfig(array $config): array
{
    $hw = $config['hesabanweb'] ?? [];

    return [
        'enabled'                   => (bool)($hw['enabled'] ?? false),
        'token'                     => trim((string)($hw['token'] ?? '')),
        'base_url'                  => rtrim((string)($hw['base_url'] ?? 'https://app.hesabanweb.com'), '/'),
        'timeout'                   => (int)($hw['timeout'] ?? 60),

        'sales_storage_id'          => (int)($hw['sales_storage_id'] ?? $hw['storage_id'] ?? 1),
        'purchase_storage_id'       => (int)($hw['purchase_storage_id'] ?? $hw['storage_id'] ?? 1),

        'sales_taxable'             => (bool)($hw['sales_taxable'] ?? $hw['taxable'] ?? true),
        'purchase_taxable'          => (bool)($hw['purchase_taxable'] ?? $hw['taxable'] ?? true),

        'sales_count'               => (int)($hw['sales_count'] ?? $hw['article_count'] ?? 1),
        'purchase_count'            => (int)($hw['purchase_count'] ?? $hw['article_count'] ?? 1),

        'sales_discount'            => (int)($hw['sales_discount'] ?? $hw['discount'] ?? 0),
        'purchase_discount'         => (int)($hw['purchase_discount'] ?? $hw['discount'] ?? 0),

        'payment_description'       => trim((string)($hw['payment_description'] ?? 'ایجاد خودکار فاکتور فروش از فرم تایید شده')),
        'sales_description_prefix'  => trim((string)($hw['sales_description_prefix'] ?? 'فاکتور فروش ایجاد شده از فرم‌افزار')),
        'purchase_description_prefix'=> trim((string)($hw['purchase_description_prefix'] ?? 'فاکتور خرید ایجاد شده از فرم‌افزار')),
        'transport_cost'            => (int)($hw['transport_cost'] ?? 0),
    ];
}

function rayanGetHesabanSourceInfo(array $form, array $params): array
{
    $sourceMode = rayanNormalizeSpaces(
        rayanGetFieldValue($form, $params, [1152], ['فروش از کالا ها'])
    );

    $sellerStoreTitle = rayanNormalizeSpaces(
        rayanGetFieldValue($form, $params, [1399], ['خرید از فروشگاه'])
    );

    $fromCoworker = rayanTextContainsNormalized($sourceMode, 'از همکار خریدم');
    $fromOwnStock = rayanTextContainsNormalized($sourceMode, 'انبار خودم');

    return [
        'source_mode'        => $sourceMode,
        'seller_store_title' => $sellerStoreTitle,
        'from_coworker'      => $fromCoworker,
        'from_own_stock'     => $fromOwnStock,
    ];
}

function rayanBuildHesabanCommonData(array $form, array $params, int $formEntryId): array
{
    $fullName = rayanNormalizeSpaces(rayanGetFieldValue($form, $params, [1137], ['نام مشتری']));
    [$name, $surname] = rayanSplitCustomerName($fullName);

    $nationalId = rayanDigitsOnly(rayanGetFieldValue($form, $params, [1138], ['کد ملی']));
    $mobile     = rayanDigitsOnly(rayanGetFieldValue($form, $params, [1139], ['موبایل / تلفن']));
    $postalCode = rayanDigitsOnly(rayanGetFieldValue($form, $params, [1324], ['کد پستی']));
    $address    = rayanNormalizeSpaces(rayanGetFieldValue($form, $params, [1323], ['ادرس', 'آدرس']));
    $clubLevel  = rayanNormalizeSpaces(rayanGetFieldValue($form, $params, [1170], ['نوع طرح فروش']));

    if (strlen($mobile) === 10 && str_starts_with($mobile, '9')) {
        $mobile = '0' . $mobile;
    }

    // کد کالا برای حسابان: از field 1400
    $productParam = rayanFindParam($params, 1400, 'نام کالا');

    // مبلغ فاکتور خرید: از field 1154
    $purchaseAmountParam = rayanFindParam($params, 1154, 'قیمت خرید');

    // مبلغ فاکتور فروش / انتقال مقصد: از field 1395
    $salesAmountParam = rayanFindParam($params, 1395, 'انتقال به جامع کلی');

    $productTitle = '';
    if (is_array($productParam)) {
        $productTitle = trim((string)($productParam['title'] ?? ''));
    }
    if ($productTitle === '') {
        $productTitle = rayanNormalizeSpaces(rayanGetFieldValue($form, $params, [1400], ['نام کالا']));
    }

    $productCode    = rayanParamCodeString($productParam, (string)($form['field_1400'] ?? ''));
    $purchaseAmount = rayanParamNumberString($purchaseAmountParam, (string)($form['field_1154'] ?? ''));
    $salesAmount    = rayanParamNumberString($salesAmountParam, (string)($form['field_1395'] ?? ''));

    if ($fullName === '') {
        throw new RuntimeException('برای حسابان‌وب، نام مشتری خالی است.');
    }

    if ($nationalId === '') {
        throw new RuntimeException('برای حسابان‌وب، کد ملی خالی است.');
    }

    if ($productCode === '') {
        throw new RuntimeException('برای حسابان‌وب، کد کالا از field 1400 پیدا نشد.');
    }

    // اینجا دیگر purchaseAmount را اجباری نکن
    if ($salesAmount === '' || (int)$salesAmount <= 0) {
        $salesAmount = '1';
    }

    return [
        'full_name'       => $fullName,
        'name'            => $name !== '' ? $name : $fullName,
        'surname'         => $surname,
        'national_id'     => $nationalId,
        'mobile'          => $mobile,
        'postal_code'     => $postalCode,
        'address'         => $address,
        'product_title'   => $productTitle,
        'product_code'    => $productCode,
        'purchase_amount' => (int)$purchaseAmount,
        'sales_amount'    => (int)$salesAmount,
        'club_level'      => $clubLevel,
        'club_label'      => rayanGetClubLabel($clubLevel),
        'form_number'     => (string)($form['formattedId'] ?? $form['id'] ?? $formEntryId),
    ];
}
function rayanGetClubKey(string $clubLevel): string
{
    if (rayanTextContainsNormalized($clubLevel, 'قسطانو')) {
        return 'ghestano';
    }

    if (
        rayanTextContainsNormalized($clubLevel, 'سطح دو') ||
        rayanTextContainsNormalized($clubLevel, 'سطح 2') ||
        rayanTextContainsNormalized($clubLevel, 'سطح ۲')
    ) {
        return 'level2';
    }

    return 'level1';
}

function rayanGetClubLabel(string $clubLevel): string
{
    $key = rayanGetClubKey($clubLevel);

    if ($key === 'ghestano') {
        return 'قسطانو';
    }

    if ($key === 'level2') {
        return 'سطح 2';
    }

    return 'سطح 1';
}
function rayanResolveSalesReceiptTarget(array $config, string $clubLevel): array
{
    $key = rayanGetClubKey($clubLevel);

    $defaults = [
            'level1' => [
                'account_id' => 185123467172, // آیدی واقعی حساب سطح 1 را بگذار
                'name'       => 'مرکز کلوب جامع سطح 1 (مشتری)',
            ],
            'level2' => [
                'account_id' => 185123474797, // این را از خروجی خودت داشتیم
                'name'       => 'مرکز کلوب سطح2 (تامین کننده)',
            ],
            'ghestano' => [
                'account_id' => 185123466971, // آیدی واقعی حساب قسطانو را بگذار
                'name'       => 'اقای گلد قسطانو جامع (مشتری)',
            ],
    ];

    $cfg = (array)($config['hesabanweb']['sales_receipt_targets'][$key] ?? []);
    $target = array_merge($defaults[$key] ?? [], $cfg);

    return [
        'account_id' => (int)($target['account_id'] ?? 0),
        'name'       => trim((string)($target['name'] ?? '')),
    ];
}
function rayanBuildHesabanSalesInvoiceData(array $config, array $form, array $params, int $formEntryId): array
{
    $hw   = rayanHesabanConfig($config);
    $base = rayanBuildHesabanCommonData($form, $params, $formEntryId);
    $src  = rayanGetHesabanSourceInfo($form, $params);

    $invoiceUuid   = rayanStableUuid('hesabanweb-sales-' . $formEntryId);
    $receiptTarget = rayanResolveSalesReceiptTarget($config, (string)$base['club_level']);

    if ((int)$receiptTarget['account_id'] <= 0) {
        throw new RuntimeException(
            'account_id مقصد دریافت برای طرح ' . $base['club_label'] . ' در config تنظیم نشده است.'
        );
    }

    $receiptAmount = (int)$base['sales_amount'];
    if ($receiptAmount <= 0) {
        throw new RuntimeException('مبلغ فاکتور فروش معتبر نیست.');
    }

    $payload = [
        'id'       => $invoiceUuid,
        'dateTime' => gmdate('Y-m-d\TH:i:s.v\Z'),

        'customer' => [
            'isRealPerson'    => true,
            'title'           => $base['full_name'],
            'name'            => $base['name'],
            'surname'         => $base['surname'],
            'phoneNumber'     => $base['mobile'],
            'workPhoneNumber' => '',
            'email'           => '',
            'address'         => $base['address'],
            'postalCode'      => $base['postal_code'],
            'nationCode'      => $base['national_id'],
            'economicCode'    => '',
        ],

        'articles' => [
            [
                'storageId'   => $hw['sales_storage_id'],
                'productCode' => $base['product_code'],
                'count'       => $hw['sales_count'],
                'amount'      => $receiptAmount,
                'discount'    => $hw['sales_discount'],
                'taxable'     => $hw['sales_taxable'],
                'description' => $base['product_title'] !== ''
                    ? $base['product_title']
                    : ('کالا ' . $base['product_code']),
            ]
        ],

        // این payment طبق مستند ساده AddInvoice
        'payment' => [
            'internetPaymentId' => (string)$base['form_number'],
            'description'       => $hw['payment_description'] . ' | ' . $base['club_label'],
        ],

        // این بخش را هم نگه می‌داریم چون از نتورک واقعی معلوم شد مقصد اینجاست
        'cashReceipts' => [
            [
                'key'          => rayanStableUuid('sales-receipt-' . $formEntryId),
                'dateTime'     => gmdate('Y-m-d\TH:i:s'),
                'amount'       => $receiptAmount,
                'description'  => $hw['payment_description'] . ' | ' . $base['club_label'],
                'explain'      => $hw['payment_description'] . ' | ' . $base['club_label'],
                'billNumber'   => (string)$base['form_number'],
                'trackingCode' => (string)$base['form_number'],
                'receiverName' => $receiptTarget['name'] !== '' ? $receiptTarget['name'] : null,
                'isWithPos'    => false,
                'toAccountId'  => (int)$receiptTarget['account_id'],
                'toAccount'    => [
                    'id'   => (int)$receiptTarget['account_id'],
                    'name' => $receiptTarget['name'],
                ],
            ]
        ],

        'checkReceipts' => [],
        'additions'     => [],
        'deductions'    => [],
        'isTemporary'   => false,
        'description'   => trim(
            $hw['sales_description_prefix'] .
            ' | فرم: ' . $base['form_number'] .
            ' | مشتری: ' . $base['full_name'] .
            ' | کد کالا: ' . $base['product_code'] .
            ' | طرح: ' . $base['club_label'] .
            ($src['source_mode'] !== '' ? ' | مبدا: ' . $src['source_mode'] : '')
        ),
    ];

    return [
        'invoice_type'        => 'sales',
        'invoice_uuid'        => $invoiceUuid,
        'source_mode'         => $src['source_mode'],
        'source_store_title'  => $src['seller_store_title'],
        'party_title'         => $base['full_name'],
        'party_name'          => $base['name'],
        'party_surname'       => $base['surname'],
        'nation_code'         => $base['national_id'],
        'phone_number'        => $base['mobile'],
        'postal_code'         => $base['postal_code'],
        'address'             => $base['address'],
        'product_title'       => $base['product_title'],
        'product_code'        => $base['product_code'],
        'amount'              => $receiptAmount,
        'club_level'          => $base['club_level'],
        'club_label'          => $base['club_label'],
        'receipt_target_id'   => $receiptTarget['account_id'],
        'receipt_target_name' => $receiptTarget['name'],
        'payload'             => $payload,
    ];
}
function rayanBuildHesabanPurchaseInvoiceData(array $config, array $form, array $params, int $formEntryId): array
{
    $hw   = rayanHesabanConfig($config);
    $base = rayanBuildHesabanCommonData($form, $params, $formEntryId, $config);
    $src  = rayanGetHesabanSourceInfo($form, $params);

    $isCoreClub = rayanIsCoreClubPlan($config, (string)$base['club_level']);

    // قاعده جدید دستی/حسابداری: فاکتور خرید همیشه به نام همان خریدار/مشتری ثبت می‌شود،
    // نه به نام فروشگاه یا همکار تأمین‌کننده. نام فروشگاه فقط در توضیحات نگه داشته می‌شود.
    $sellerTitle = $base['full_name'];
    $sellerIsReal = true;
    $sellerNationCode = $base['national_id'];
    $sellerPhone = $base['mobile'];
    $sellerPostal = $base['postal_code'];
    $sellerAddress = $base['address'];

    if ((int)($base['purchase_amount'] ?? 0) <= 0) {
        throw new RuntimeException('برای حسابان‌وب، مبلغ خرید حسابان وب معتبر نیست. نام فیلد باید مثل «مبلغ خرید حسابان وب» یا «قیمت خرید» باشد.');
    }

    $invoiceUuid = rayanStableUuid('hesabanweb-purchase-' . $formEntryId);

    $payload = [
        'id'       => $invoiceUuid,
        'dateTime' => gmdate('Y-m-d\TH:i:s.v\Z'),
        'dueDate'  => gmdate('Y-m-d\TH:i:s.v\Z'),
        'seller' => [
            'isRealPerson'    => $sellerIsReal,
            'title'           => $sellerTitle,
            'name'            => $sellerTitle,
            'surname'         => '',
            'phoneNumber'     => $sellerPhone,
            'workPhoneNumber' => '',
            'email'           => '',
            'address'         => $sellerAddress,
            'postalCode'      => $sellerPostal,
            'nationCode'      => $sellerNationCode,
            'economicCode'    => '',
        ],
        'articles' => [[
            'storageId'   => $hw['purchase_storage_id'],
            'productCode' => $base['product_code'],
            'count'       => $hw['purchase_count'],
            'amount'      => $base['purchase_amount'],
            'discount'    => $hw['purchase_discount'],
            'taxable'     => $hw['purchase_taxable'],
            'description' => ($isCoreClub ? 'فاکتور خرید کلوپی | ' : 'فاکتور خرید طرح فروش | ') . ($base['product_title'] !== '' ? $base['product_title'] : ('کالا ' . $base['product_code'])),
        ]],
        'transportCost' => $hw['transport_cost'],
        'description'   => trim(
            $hw['purchase_description_prefix'] .
            ' | فرم: ' . $base['form_number'] .
            ' | فروشنده/خریدار مبنا: ' . $sellerTitle .
            ' | کد کالا: ' . $base['product_code'] .
            ' | طرح: ' . $base['club_label'] .
            ($src['seller_store_title'] !== '' ? ' | تأمین‌کننده: ' . $src['seller_store_title'] : '')
        ),
    ];

    return [
        'invoice_type'       => 'purchase',
        'invoice_uuid'       => $invoiceUuid,
        'source_mode'        => $src['source_mode'],
        'source_store_title' => $src['seller_store_title'],
        'customer_title'     => $base['full_name'],
        'party_title'        => $sellerTitle,
        'party_name'         => $sellerTitle,
        'party_surname'      => '',
        'nation_code'        => $sellerNationCode,
        'phone_number'       => $sellerPhone,
        'postal_code'        => $sellerPostal,
        'address'            => $sellerAddress,
        'product_title'      => $base['product_title'],
        'product_code'       => $base['product_code'],
        'amount'             => $base['purchase_amount'],
        'payload'            => $payload,
    ];
}

function rayanHesabanSendInvoice(array $config, string $path, array $payload): array
{
    $hw = rayanHesabanConfig($config);

    if ($hw['token'] === '') {
        throw new RuntimeException('توکن حسابان‌وب در config_rayan.php تنظیم نشده است.');
    }

    $url = $hw['base_url'] . $path;

    $headers = [
        'Accept: text/plain',
        'Content-Type: application/json; charset=UTF-8',
        'Authorization: Bearer ' . $hw['token'],
    ];

    $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if ($body === false) {
        throw new RuntimeException('json_encode برای payload حسابان‌وب ناموفق بود.');
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_TIMEOUT        => $hw['timeout'],
        CURLOPT_CONNECTTIMEOUT => 20,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    $response = curl_exec($ch);
    $error    = curl_error($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($error) {
        throw new RuntimeException('خطای cURL حسابان‌وب: ' . $error);
    }

    $response = (string)$response;

    if ($httpCode === 401) {
        throw new RuntimeException('احراز هویت حسابان‌وب ناموفق بود. توکن منقضی شده یا نامعتبر است. RESP=' . mb_substr($response, 0, 1000));
    }

    if ($httpCode === 403) {
        throw new RuntimeException('دسترسی حسابان‌وب غیرمجاز است. RESP=' . mb_substr($response, 0, 1000));
    }

    if ($httpCode < 200 || $httpCode >= 300) {
        throw new RuntimeException('خطای API حسابان‌وب. HTTP=' . $httpCode . ' | RESP=' . mb_substr($response, 0, 1000));
    }

    return [
        'http_code' => $httpCode,
        'body'      => $response,
    ];
}

function rayanHesabanAddInvoice(array $config, array $payload): array
{
    return rayanHesabanSendInvoice($config, '/SalesInvoice/AddInvoice', $payload);
}

function rayanHesabanAddPurchaseInvoice(array $config, array $payload): array
{
    return rayanHesabanSendInvoice($config, '/PurchaseInvoice/AddInvoice', $payload);
}
function rayanHesabanApiRequest(array $config, string $method, string $path, ?array $payload = null): array
{
    $hw = rayanHesabanConfig($config);

    if ($hw['token'] === '') {
        throw new RuntimeException('توکن حسابان‌وب در config_rayan.php تنظیم نشده است.');
    }

    $url = $hw['base_url'] . $path;

    $headers = [
        'Accept: text/plain',
        'Authorization: Bearer ' . $hw['token'],
    ];

    $body = null;
    if ($payload !== null) {
        $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($body === false) {
            throw new RuntimeException('json_encode برای درخواست حسابان‌وب ناموفق بود.');
        }
        $headers[] = 'Content-Type: application/json; charset=UTF-8';
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_TIMEOUT        => $hw['timeout'],
        CURLOPT_CONNECTTIMEOUT => 20,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);

    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    }

    $response = curl_exec($ch);
    $error    = curl_error($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($error) {
        throw new RuntimeException('خطای cURL حسابان‌وب: ' . $error);
    }

    return [
        'http_code' => $httpCode,
        'body'      => (string)$response,
        'url'       => $url,
    ];
}

function rayanHesabanToFloat($value): float
{
    if ($value === null) {
        return 0.0;
    }

    $text = rayanFaToEn(trim((string)$value));
    $text = str_replace(['٬', '،', ',', ' '], '', $text);

    if ($text === '' || !is_numeric($text)) {
        return 0.0;
    }

    return (float)$text;
}

function rayanFormatSignedMoney(float $amount): string
{
    $sign = $amount > 0 ? '+' : ($amount < 0 ? '-' : '');
    $abs  = abs($amount);

    if (abs($abs - round($abs)) < 0.00001) {
        return $sign . number_format((int)round($abs));
    }

    return $sign . number_format($abs, 2, '.', ',');
}

function rayanHesabanGetProductByCode(array $config, string $productCode): array
{
    $productCode = trim(rayanFaToEn($productCode));
    if ($productCode === '') {
        throw new RuntimeException('کد کالا برای استعلام حسابان خالی است.');
    }

    $paths = [
        '/Product/GetProductByCode?code=' . rawurlencode($productCode),
        '/Product/GetProductByCode?Code=' . rawurlencode($productCode),
        '/Product/GetProductByCode?productCode=' . rawurlencode($productCode),
        '/Product/GetProductByCode?ProductCode=' . rawurlencode($productCode),
        '/Product/GetProductByCode/' . rawurlencode($productCode),
    ];

    $attempts = [];
    $lastBody = '';

    foreach ($paths as $path) {
        $res = rayanHesabanApiRequest($config, 'GET', $path, null);
        $lastBody = (string)$res['body'];

        $attempts[] = [
            'path'      => $path,
            'http_code' => (int)$res['http_code'],
            'body'      => mb_substr((string)$res['body'], 0, 800),
        ];

        if ((int)$res['http_code'] === 401) {
            throw new RuntimeException('احراز هویت حسابان‌وب ناموفق بود. توکن منقضی شده یا نامعتبر است.');
        }

        if ((int)$res['http_code'] === 403) {
            throw new RuntimeException('دسترسی حسابان‌وب غیرمجاز است.');
        }

        if ((int)$res['http_code'] < 200 || (int)$res['http_code'] >= 300) {
            continue;
        }

        $json = json_decode((string)$res['body'], true);
        if (!is_array($json)) {
            continue;
        }

        if (
            array_key_exists('id', $json) ||
            array_key_exists('code', $json) ||
            array_key_exists('name', $json)
        ) {
            return [
                'product'  => $json,
                'path'     => $path,
                'attempts' => $attempts,
            ];
        }
    }

    throw new RuntimeException(
        'استعلام کالا از حسابان‌وب ناموفق بود. کد کالا: ' . $productCode . ' | آخرین پاسخ: ' . mb_substr($lastBody, 0, 1000)
    );
}

function rayanHesabanPickBuyPrice(array $product): array
{
    foreach (['enumerationUnit1BuyPrice', 'enumerationUnit2BuyPrice', 'enumerationUnit3BuyPrice'] as $field) {
        if (array_key_exists($field, $product)) {
            $value = rayanHesabanToFloat($product[$field]);
            if ($value > 0) {
                return [
                    'field' => $field,
                    'value' => $value,
                ];
            }
        }
    }

    return [
        'field' => 'enumerationUnit1BuyPrice',
        'value' => 0.0,
    ];
}

function rayanBuildInvoicePriceAudit(array $product, array $doc): array
{
    $picked = rayanHesabanPickBuyPrice($product);

    $invoiceType       = trim((string)($doc['invoice_type'] ?? 'sales'));
    $invoiceTypeTitle  = $invoiceType === 'purchase' ? 'خرید' : 'فروش';
    $invoiceCount      = max(1, (int)($doc['payload']['articles'][0]['count'] ?? 1));
    $invoiceUnitAmount = rayanHesabanToFloat($doc['amount'] ?? 0);
    $invoiceTotal      = $invoiceUnitAmount * $invoiceCount;
    $buyPrice          = (float)$picked['value'];

    $rawDiff = $invoiceUnitAmount - $buyPrice;

    if ($invoiceType === 'purchase') {
        $profitValue = $buyPrice - $invoiceUnitAmount;
    } else {
        $profitValue = $invoiceUnitAmount - $buyPrice;
    }

    $profitPercent = $buyPrice > 0 ? (($profitValue / $buyPrice) * 100) : null;

    $resultLabel = 'بدون سود و زیان';
    if ($profitValue > 0.00001) {
        $resultLabel = 'سود';
    } elseif ($profitValue < -0.00001) {
        $resultLabel = 'زیان';
    }

    $diffLabel = 'بدون اختلاف';
    if ($rawDiff > 0.00001) {
        $diffLabel = 'اختلاف مثبت';
    } elseif ($rawDiff < -0.00001) {
        $diffLabel = 'اختلاف منفی';
    }

    return [
        'invoice_type'         => $invoiceType,
        'invoice_type_title'   => $invoiceTypeTitle,
        'product_code'         => trim((string)($doc['product_code'] ?? ($product['code'] ?? ''))),
        'product_name'         => trim((string)($product['name'] ?? ($doc['product_title'] ?? ''))),
        'buy_price_field'      => (string)$picked['field'],
        'hesaban_buy_price'    => $buyPrice,
        'invoice_count'        => $invoiceCount,
        'invoice_unit_amount'  => $invoiceUnitAmount,
        'invoice_total_amount' => $invoiceTotal,
        'raw_difference'       => $rawDiff,
        'difference_value'     => abs($rawDiff),
        'difference_label'     => $diffLabel,
        'profit_value'         => $profitValue,
        'profit_abs_value'     => abs($profitValue),
        'profit_percent'       => $profitPercent,
        'result_label'         => $resultLabel,
        'buy_price_available'  => $buyPrice > 0,
    ];
}

function rayanNotifyInvoicePriceAudit(array $config, array $audit): void
{
    $profitValue = (float)($audit['profit_value'] ?? 0);
    $profitAbs   = abs($profitValue);

    $profitLine = 'مقدار سود/ضرر: 0';
    if (($audit['result_label'] ?? '') === 'سود') {
        $profitLine = 'مقدار سود: ' . rayanFormatMoney((string)$profitAbs);
    } elseif (($audit['result_label'] ?? '') === 'ضرر') {
        $profitLine = 'مقدار ضرر: ' . rayanFormatMoney((string)$profitAbs);
    }

    $text =
        "بررسی قیمت قبل از ثبت فاکتور {$audit['invoice_type_title']}\n\n" .
        "کالا: " . ($audit['product_name'] !== '' ? $audit['product_name'] : '-') . "\n" .
        "کد کالا: " . ($audit['product_code'] !== '' ? $audit['product_code'] : '-') . "\n" .
        "فیلد مرجع قیمت خرید در حسابان: " . (string)$audit['buy_price_field'] . "\n" .
        "قیمت خرید حسابان: " . rayanFormatMoney((string)$audit['hesaban_buy_price']) . "\n" .
        "تعداد: " . (string)$audit['invoice_count'] . "\n" .
        "مبلغ ثبت شده در فاکتور: " . rayanFormatMoney((string)($audit['registered_invoice_amount'] ?? 0)) . "\n" .
        "مبلغ مبنای مقایسه: " . rayanFormatMoney((string)($audit['invoice_unit_amount'] ?? 0)) . "\n" .
        "اختلاف مستقیم مبلغ مبنای مقایسه با قیمت خرید حسابان: " . rayanFormatSignedMoney((float)$audit['raw_difference']) . "\n" .
        "نتیجه: " . (string)$audit['result_label'] . "\n" .
        $profitLine;

    if ($audit['profit_percent'] !== null) {
        $percent = (float)$audit['profit_percent'];
        $percentText = ($percent > 0 ? '+' : '') . number_format($percent, 2) . '%';
        $text .= "\nدرصد سود/ضرر: " . $percentText;
    } else {
        $text .= "\nدرصد سود/ضرر: نامشخص";
    }

    if (empty($audit['buy_price_available'])) {
        $text .= "\nهشدار: قیمت خرید مرجع در حسابان صفر یا خالی بود.";
    }

    rayanNotifyAll($config, $text);
}
function rayanTryAuditInvoicePriceBeforeCreate(array $config, array $doc): array
{
    $productCode = trim((string)($doc['product_code'] ?? ''));

    try {
        $lookup  = rayanHesabanGetProductByCode($config, $productCode);
        $product = (array)$lookup['product'];

        $audit = rayanBuildInvoicePriceAudit($product, $doc);
        $audit['lookup_path'] = (string)($lookup['path'] ?? '');

        rayanSystemLog(
            $config,
            'HESABAN PRICE AUDIT | ' . json_encode($audit, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        );

        return [
            'status' => 'success',
            'audit'  => $audit,
        ];
    } catch (Throwable $e) {
        rayanSystemLog(
            $config,
            'HESABAN PRICE AUDIT FAILED | product_code=' . $productCode . ' | ERR=' . $e->getMessage()
        );

        return [
            'status'       => 'failed',
            'product_code' => $productCode,
            'error'        => $e->getMessage(),
        ];
    }
}
function rayanExtractHesabanInvoiceApiId(string $responseBody): string
{
    $json = json_decode($responseBody, true);
    if (is_array($json) && array_key_exists('id', $json)) {
        return trim((string)$json['id']);
    }

    return '';
}

function rayanBuildPriceAuditSummaryText(array $priceAudit): string
{
    if (
        ($priceAudit['status'] ?? '') !== 'success' ||
        !isset($priceAudit['audit']) ||
        !is_array($priceAudit['audit'])
    ) {
        return '';
    }

    $audit = $priceAudit['audit'];

    $hesabanPrice = (float)($audit['hesaban_buy_price'] ?? 0);
    $invoicePrice = (float)($audit['invoice_unit_amount'] ?? 0);

    $percentText = '-';
    if ($hesabanPrice > 0) {
        $percent = (($invoicePrice - $hesabanPrice) / $hesabanPrice) * 100;
        $percentText = number_format($percent, 2) . '%';
    }

    return
        "قیمت خرید استعلامی حسابان: " . rayanFormatMoney((string)$hesabanPrice) . "\n" .
        "قیمت درج شده در فاکتور: " . rayanFormatMoney((string)$invoicePrice) . "\n" .
        "درصد : " . $percentText;
}

function rayanNotifyHesabanInvoiceResult(
    array $config,
    string $mode,
    array $doc,
    ?array $api = null,
    ?string $errorMessage = null,
    array $priceAudit = []
): void {
    $invoiceType = (string)($doc['invoice_type'] ?? 'sales');
    $productTitle = trim((string)($doc['product_title'] ?? ''));
    $partyTitle   = trim((string)($doc['party_title'] ?? ''));

    if ($mode === 'success') {
        if ($invoiceType === 'purchase') {
            $text = "فاکتور خرید برای " . ($partyTitle !== '' ? $partyTitle : '-') . " ثبت شد.";
            rayanNotifyAll($config, $text);
            return;
        }

        $auditText = rayanBuildPriceAuditSummaryText($priceAudit);

        $text =
            "فاکتور فروش ثبت شد.\n" .
            "کالا: " . ($productTitle !== '' ? $productTitle : '-') ;

        if ($auditText !== '') {
            $text .= "\n" . $auditText;
        }

        rayanNotifyAll($config, trim($text));
        return;
    }

    $cleanError = rayanSanitizeSiteMessageForNotify((string)$errorMessage);

    $text =
        ($invoiceType === 'purchase' ? "ثبت فاکتور خرید ناموفق بود." : "ثبت فاکتور فروش ناموفق بود.") . "\n" .
        "خطا: " . $cleanError;

    rayanNotifyAll($config, trim($text));
}

function rayanProcessSingleHesabanInvoice(
    PDO $pdo,
    array $config,
    int $formEntryId,
    int $submissionId,
    ?int $requestId,
    array $doc
): array {
    $invoiceType = (string)$doc['invoice_type'];

    $existing = rayanGetHesabanInvoiceRow($pdo, $formEntryId, $invoiceType);
    if ($existing && (string)($existing['api_status'] ?? '') === 'success') {
        return [
            'status'        => 'skipped_duplicate_success',
            'invoice_type'  => $invoiceType,
            'form_entry_id' => $formEntryId,
            'invoice_uuid'  => (string)($existing['invoice_uuid'] ?? ''),
        ];
    }

    // قبل از ثبت فاکتور: استعلام قیمت از حسابان + اعلام در بله
    $priceAudit = rayanTryAuditInvoicePriceBeforeCreate($config, $doc);

    $row = [
        'form_entry_id'      => $formEntryId,
        'submission_id'      => $submissionId,
        'request_id'         => $requestId,
        'invoice_type'       => $invoiceType,
        'source_mode'        => $doc['source_mode'] ?? null,
        'source_store_title' => $doc['source_store_title'] ?? null,
        'invoice_uuid'       => $doc['invoice_uuid'],
        'api_status'         => 'sending',
        'party_title'        => $doc['party_title'] ?? null,
        'party_name'         => $doc['party_name'] ?? null,
        'party_surname'      => $doc['party_surname'] ?? null,
        'nation_code'        => $doc['nation_code'] ?? null,
        'phone_number'       => $doc['phone_number'] ?? null,
        'postal_code'        => $doc['postal_code'] ?? null,
        'address'            => $doc['address'] ?? null,
        'product_title'      => $doc['product_title'] ?? null,
        'product_code'       => $doc['product_code'] ?? null,
        'amount'             => $doc['amount'] ?? null,
        'request_payload'    => json_encode($doc['payload'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
        'response_body'      => null,
        'error_message'      => null,
    ];

    rayanUpsertHesabanInvoiceRow($pdo, $row);

    try {
        if ($invoiceType === 'purchase') {
            $api = rayanHesabanAddPurchaseInvoice($config, $doc['payload']);
        } else {
            $api = rayanHesabanAddInvoice($config, $doc['payload']);
        }

        $row['api_status']    = 'success';
        $row['response_body'] = (string)$api['body'];
        $row['error_message'] = null;

        rayanUpsertHesabanInvoiceRow($pdo, $row);

        rayanSystemLog(
            $config,
            'HESABANWEB ' . strtoupper($invoiceType) . ' SUCCESS | form_entry_id=' . $formEntryId . ' | HTTP=' . (int)$api['http_code']
        );

        // پیام بله بعد از ثبت موفق فاکتور
        rayanNotifyHesabanInvoiceResult(
            $config,
            'success',
            $doc,
            $api,
            null,
            $priceAudit
        );

        return [
            'status'        => 'success',
            'invoice_type'  => $invoiceType,
            'form_entry_id' => $formEntryId,
            'invoice_uuid'  => $doc['invoice_uuid'],
            'product_code'  => $doc['product_code'],
            'amount'        => $doc['amount'],
            'response_body' => (string)$api['body'],
            'price_audit'   => $priceAudit,
        ];
    } catch (Throwable $e) {
        $row['api_status']    = 'failed';
        $row['response_body'] = null;
        $row['error_message'] = $e->getMessage();

        rayanUpsertHesabanInvoiceRow($pdo, $row);

        rayanSystemLog(
            $config,
            'HESABANWEB ' . strtoupper($invoiceType) . ' FAILED | form_entry_id=' . $formEntryId . ' | ERR=' . $e->getMessage()
        );

        // پیام بله بعد از خطای ثبت فاکتور
        rayanNotifyHesabanInvoiceResult(
            $config,
            'failed',
            $doc,
            null,
            $e->getMessage(),
            $priceAudit
        );

        throw $e;
    }
}
function rayanTryCreateHesabanInvoiceForApprovedForm(
    PDO $pdo,
    array $config,
    int $formEntryId,
    int $submissionId,
    ?int $requestId,
    string $finalStatus,
    array $form,
    array $params
): array {
    $hw = rayanHesabanConfig($config);

    if (!$hw['enabled']) {
        return [
            'status' => 'skipped_disabled',
            'form_entry_id' => $formEntryId,
        ];
    }

    if (rayanNormalizeCompareText($finalStatus) !== rayanNormalizeCompareText('تایید شد')) {
        return [
            'status' => 'skipped_not_approved',
            'form_entry_id' => $formEntryId,
        ];
    }

    rayanEnsureHesabanInvoiceSchema($pdo);

    $src = rayanGetHesabanSourceInfo($form, $params);
    $results = [];

    // قاعده جدید: برای همه طرح‌ها، چه کلوپی چه غیرکلوپی، ابتدا فاکتور خرید ساخته می‌شود.
    // اگر خرید خطا بدهد، فروش اجرا نمی‌شود تا ترتیب حسابداری به‌هم نخورد.
    $purchaseDoc = rayanBuildHesabanPurchaseInvoiceData($config, $form, $params, $formEntryId);
    $results['purchase'] = rayanProcessSingleHesabanInvoice(
        $pdo,
        $config,
        $formEntryId,
        $submissionId,
        $requestId,
        $purchaseDoc
    );

    $salesDoc = rayanBuildHesabanSalesInvoiceData($config, $form, $params, $formEntryId);
    $results['sales'] = rayanProcessSingleHesabanInvoice(
        $pdo,
        $config,
        $formEntryId,
        $submissionId,
        $requestId,
        $salesDoc
    );

    return [
        'status'            => 'success',
        'form_entry_id'     => $formEntryId,
        'submission_id'     => $submissionId,
        'request_id'        => $requestId,
        'source_mode'       => $src['source_mode'],
        'source_store_title'=> $src['seller_store_title'],
        'did_purchase'      => true,
        'results'           => $results,
    ];
}

function rayanTryCreateHesabanInvoiceForApprovedFormSafely(
    PDO $pdo,
    array $config,
    int $formEntryId,
    int $submissionId,
    ?int $requestId,
    string $finalStatus,
    array $form,
    array $params
): array {
    try {
        return rayanTryCreateHesabanInvoiceForApprovedForm(
            $pdo,
            $config,
            $formEntryId,
            $submissionId,
            $requestId,
            $finalStatus,
            $form,
            $params
        );
    } catch (Throwable $e) {
        rayanSystemLog(
            $config,
            'HESABAN SAFE WRAPPER FAILED | form_entry_id=' . $formEntryId . ' | ERR=' . $e->getMessage()
        );

        return [
            'status'        => 'failed',
            'form_entry_id' => $formEntryId,
            'error'         => $e->getMessage(),
        ];
    }
}
function rayanSubmitRayanOtpForRequest(PDO $pdo, array $config, int $requestId, string $otpCode): void
{
    $request = rayanGetPurchaseRequestById($pdo, $requestId);

    if ((string)$request['status'] !== 'awaiting_otp') {
        throw new RuntimeException('این درخواست منتظر OTP نیست.');
    }

    $cookieFile = trim((string)($request['cookie_file'] ?? ''));
    if ($cookieFile === '' || !is_file($cookieFile)) {
        throw new RuntimeException('کوکی درخواست پیدا نشد.');
    }

    $otpContext = json_decode((string)($request['otp_context_json'] ?? ''), true);
    if (!is_array($otpContext)) {
        throw new RuntimeException('اطلاعات OTP درخواست خراب یا خالی است.');
    }

    rayanUpdatePurchaseRequest($pdo, $requestId, [
        'status'    => 'processing',
        'last_step' => 'ثبت کد تایید',
    ]);

    try {
        rayanNotifyProgress($config, $requestId, 95);

        rayanRequestLog($config, $requestId, 'STEP 11 - SUBMIT OTP CODE');
        rayanSaveRequestText($config, $requestId, '09_received_otp', $otpCode);

        $authRes = rayanHttpAjaxFormRequest(
            (string)$otpContext['auth_url'],
            [
                'csrfmiddlewaretoken' => (string)$otpContext['csrfmiddlewaretoken'],
                'code'                => $otpCode,
                'mobile_number'       => (string)$otpContext['customer_mobile'],
                'payment_token'       => (string)$otpContext['payment_token'],
                'amount'              => (string)$otpContext['amount'],
                'order_id'            => (string)$otpContext['order_id'],
            ],
            $cookieFile,
            [
                'Referer: ' . (string)$otpContext['otp_page_url'],
            ]
        );

        rayanSaveRequestText($config, $requestId, '10_auth_response_raw', $authRes['body']);

        if ($authRes['status'] < 200 || $authRes['status'] >= 300) {
            throw new RuntimeException('authorization_payment_minipay HTTP error: ' . $authRes['status']);
        }

        rayanRequestLog($config, $requestId, 'STEP 12 - OPEN RESULT PAGE');

        $resultRes = rayanHttpRequest(
            (string)$otpContext['result_url'],
            null,
            $cookieFile,
            [
                'Referer: ' . (string)$otpContext['otp_page_url'],
            ]
        );
        rayanSaveRequestHtml($config, $requestId, '11_result_page', $resultRes['body']);

        $resultHtml = (string)$resultRes['body'];

        $isSuccess = rayanRayanOtpResultLooksSuccessful($resultHtml, (int)$resultRes['status']);

        if (!$isSuccess) {
            $visibleErrors = rayanExtractVisibleErrors($resultHtml);
            $message = 'بعد از ثبت کد، صفحه موفق نرفت.';
            if ($visibleErrors !== '') {
                $message .= ' خطای صفحه: ' . $visibleErrors;
            }
            throw new RuntimeException($message);
        }

        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'        => 'completed',
            'last_step'     => 'ثبت نهایی موفق',
            'completed_at'  => date('Y-m-d H:i:s'),
            'error_message' => null,
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);

        rayanNotifyProgress($config, $requestId, 100);
        rayanNotifyAll($config, "درخواست {$requestId} با موفقیت ثبت نهایی شد.");

        try {
            $links = rayanExtractResultDownloadLinks($resultHtml, $resultRes['url']);
            rayanSaveRequestJson($config, $requestId, '12_result_links', $links);

            if (!empty($links['contract_url'])) {
                $downloadInfo = rayanDownloadAuthenticatedFile(
                    (string)$links['contract_url'],
                    $cookieFile,
                    rayanRequestDebugDir($config, $requestId),
                    'contract_request_' . $requestId,
                    $resultRes['url']
                );

                rayanSaveRequestJson($config, $requestId, '13_contract_download_info', $downloadInfo);

                $caption =
                    "فایل قرارداد درخواست {$requestId}\n\n" .
                    rayanFormatRequestInfo($request);

                rayanNotifyAllDocument($config, (string)$downloadInfo['path'], $caption);
            } else {
                rayanNotifyAll(
                    $config,
                    "درخواست {$requestId} موفق بود ولی لینک دانلود فایل قرارداد پیدا نشد."
                );
            }
        } catch (Throwable $e) {
            rayanRequestLog($config, $requestId, 'CONTRACT DOWNLOAD ERROR: ' . $e->getMessage());

            rayanNotifyAll(
                $config,
                "درخواست {$requestId} موفق بود ولی ارسال فایل قرارداد ناموفق شد.\n"
                . "خطا: " . rayanSanitizeSiteMessageForNotify($e->getMessage())
            );
        }

    } catch (Throwable $e) {
        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'        => 'failed',
            'last_step'     => 'ثبت OTP ناموفق',
            'error_message' => $e->getMessage(),
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);
        rayanRequestLog($config, $requestId, 'OTP ERROR: ' . $e->getMessage());
        rayanNotifyFailedSimple($config, $request, $e->getMessage());

        throw $e;
    }
}
function rayanSubmitDirectMinipayOtpForRequest(PDO $pdo, array $config, int $requestId, string $otpCode): void
{
    $request = rayanGetPurchaseRequestById($pdo, $requestId);

    if ((string)$request['status'] !== 'awaiting_otp') {
        throw new RuntimeException('این درخواست منتظر OTP نیست.');
    }

    $cookieFile = trim((string)($request['cookie_file'] ?? ''));
    if ($cookieFile === '' || !is_file($cookieFile)) {
        throw new RuntimeException('کوکی درخواست پیدا نشد.');
    }

    $otpContext = json_decode((string)($request['otp_context_json'] ?? ''), true);
    if (!is_array($otpContext)) {
        throw new RuntimeException('اطلاعات OTP قسطانو خراب یا خالی است.');
    }

    if (($otpContext['type'] ?? '') !== 'minipay_direct_api') {
        throw new RuntimeException('اطلاعات کد تایید معتبر نیست.');
    }

    $otpCode = rayanDigitsOnly($otpCode);
    if ($otpCode === '') {
        throw new RuntimeException('کد تایید خالی است.');
    }

    $apiBase      = rtrim((string)($otpContext['api_base'] ?? ''), '/');
    $offlineToken = trim((string)($otpContext['offline_cpg_token'] ?? ''));
    $startUrl     = trim((string)($otpContext['start_url'] ?? ''));
    $mobile       = trim((string)($otpContext['mobile'] ?? ''));

    if ($apiBase === '' || $offlineToken === '' || $startUrl === '') {
        throw new RuntimeException('اطلاعات API یا offline_cpg_token ناقص است.');
    }

    rayanUpdatePurchaseRequest($pdo, $requestId, [
        'status'    => 'processing',
        'last_step' => 'ثبت کد تایید قسطانو',
    ]);

    try {
        rayanNotifyProgress($config, $requestId, 95);
        rayanRequestLog($config, $requestId, 'MINIPAY OTP STEP 1 - CALL validate-user');
        rayanSaveRequestText($config, $requestId, '05_minipay_received_otp', $otpCode);

        $validateUser = rayanMinipayCallWithAttempts(
            $apiBase . '/validate-user',
            [
                [
                    'mode'    => 'json',
                    'label'   => 'json-otp',
                    'payload' => [
                        'otp'               => $otpCode,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
                [
                    'mode'    => 'json',
                    'label'   => 'json-code',
                    'payload' => [
                        'code'              => $otpCode,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
                [
                    'mode'    => 'json',
                    'label'   => 'json-otp-input-mobile',
                    'payload' => [
                        'otp'               => $otpCode,
                        'input'             => $mobile,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
                [
                    'mode'    => 'form',
                    'label'   => 'form-otp',
                    'payload' => [
                        'otp'               => $otpCode,
                        'offline_cpg_token' => $offlineToken,
                    ],
                ],
            ],
            $cookieFile,
            [
                'Referer: ' . (string)($otpContext['start_url_with_token'] ?? rayanMinipayBuildStartUrlWithToken($startUrl, $offlineToken)),
                'Origin: ' . rayanBaseOriginFromUrl($startUrl),
            ]
        );

        rayanSaveRequestJson($config, $requestId, '06_minipay_validate_user_attempts', $validateUser['attempts']);

        $validateUserJson = is_array($validateUser['json']) ? $validateUser['json'] : null;
        if (!is_array($validateUserJson) || !rayanMinipayResponseOk($validateUserJson)) {
            $msg = is_array($validateUserJson)
                ? rayanMinipayResponseMessage($validateUserJson)
                : 'پاسخ validate-user معتبر نبود.';
            throw new RuntimeException($msg !== '' ? $msg : 'validate-user ناموفق بود.');
        }

        rayanSaveRequestJson($config, $requestId, '06_minipay_validate_user_response', $validateUserJson);

        rayanNotifyProgress($config, $requestId, 97);
        rayanRequestLog($config, $requestId, 'MINIPAY OTP STEP 2 - CALL register-purchase');

        $registerPurchase = rayanMinipayRegisterPurchase($config, $otpContext, $request, $cookieFile);
        rayanSaveRequestJson($config, $requestId, '07_minipay_register_purchase_payload', $registerPurchase['payload']);
        rayanSaveRequestJson($config, $requestId, '07_minipay_register_purchase_response', $registerPurchase['json']);

        rayanNotifyProgress($config, $requestId, 98);
        rayanRequestLog($config, $requestId, 'MINIPAY OTP STEP 3 - CALL inquiry-offline');

        $inquiry = rayanMinipayInquiryOffline($config, $otpContext, $cookieFile);
        rayanSaveRequestJson($config, $requestId, '08_minipay_inquiry_offline_attempts', $inquiry['attempts']);
        rayanSaveRequestJson($config, $requestId, '08_minipay_inquiry_offline_response', $inquiry['json']);

        $resultData      = (array)($inquiry['json']['data'] ?? []);
        $invoiceId       = trim((string)($resultData['invoice_id'] ?? ''));
        $transactionId   = trim((string)($resultData['transaction_id'] ?? ''));
        $minipayStatus   = trim((string)($resultData['status'] ?? ''));
        $trackingCode    = $transactionId !== '' ? $transactionId : $invoiceId;
        $installments    = is_array($resultData['installments'] ?? null) ? $resultData['installments'] : [];
        $installmentsCnt = isset($resultData['installments_count']) ? (int)$resultData['installments_count'] : count($installments);

        if ($invoiceId === '' && $transactionId === '') {
            throw new RuntimeException('بعد از inquiry-offline نه invoice_id برگشت نه transaction_id.');
        }

        $firstInstallment = is_array($installments[0] ?? null) ? $installments[0] : [];
        $firstDueDate     = trim((string)($firstInstallment['due_date'] ?? ''));
        $firstAmount      = trim((string)($firstInstallment['installment_amount'] ?? ''));

        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'                 => 'completed',
            'last_step'              => 'مرحله اول با موفقیت انجام شد',
            'completed_at'           => date('Y-m-d H:i:s'),
            'error_message'          => null,
            'minipay_invoice_id'     => $invoiceId !== '' ? (int)$invoiceId : null,
            'minipay_transaction_id' => $transactionId !== '' ? (int)$transactionId : null,
            'minipay_tracking_code'  => $trackingCode !== '' ? $trackingCode : null,
            'minipay_status'         => $minipayStatus !== '' ? $minipayStatus : null,
            'minipay_result_json'    => json_encode($inquiry['json'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);

        rayanNotifyProgress($config, $requestId, 100);

        $text =
            "درخواست {$requestId} با موفقیت ثبت شد.\n\n" .
            "نوع طرح: " . ((string)($request['club_level'] ?? '-') !== '' ? (string)$request['club_level'] : '-') . "\n" .
            rayanFormatRequestInfo($request) . "\n" .
            "شماره اینویس: " . ($invoiceId !== '' ? $invoiceId : '-') . "\n" .
            "کد پیگیری: " . ($trackingCode !== '' ? $trackingCode : '-') . "\n" .
            "وضعیت: " . ($minipayStatus !== '' ? $minipayStatus : '-') . "\n" .
            "تعداد اقساط: " . ($installmentsCnt > 0 ? $installmentsCnt : '-');
        if ($firstDueDate !== '') {
            $text .= "\nسررسید قسط اول: " . $firstDueDate;
        }

        if ($firstAmount !== '') {
            $text .= "\nمبلغ قسط اول: " . rayanFormatMoney($firstAmount);
        }

        rayanNotifyAll($config, trim($text));

    } catch (Throwable $e) {
        rayanUpdatePurchaseRequest($pdo, $requestId, [
            'status'        => 'failed',
            'last_step'     => 'ثبت OTP قسطانو ناموفق',
            'error_message' => $e->getMessage(),
        ]);

        $request = rayanGetPurchaseRequestById($pdo, $requestId);
        rayanRequestLog($config, $requestId, 'MINIPAY OTP ERROR: ' . $e->getMessage());
        rayanNotifyFailedSimple($config, $request, $e->getMessage());

        throw $e;
    }
}
function rayanSubmitOtpForRequest(PDO $pdo, array $config, int $requestId, string $otpCode): void
{
    $request  = rayanGetPurchaseRequestById($pdo, $requestId);
    $provider = rayanResolveProviderFromClub($config, (string)($request['club_level'] ?? ''));

    if ($provider === 'rayan') {
        rayanSubmitRayanOtpForRequest($pdo, $config, $requestId, $otpCode);
        return;
    }

    if ($provider === 'minipay_direct') {
        rayanSubmitDirectMinipayOtpForRequest($pdo, $config, $requestId, $otpCode);
        return;
    }

    throw new RuntimeException('برای این درخواست مسیر OTP تعریف نشده است.');
}
function rayanExtractMinipayNuxtInfo(string $html): array
{
    $info = [
        'base_url'        => '',
        'web_url'         => '',
        'build_assets_dir'=> '',
        'build_id'        => '',
        'script_srcs'     => [],
    ];

    if (preg_match('/base_url:"([^"]+)"/u', $html, $m)) {
        $info['base_url'] = $m[1];
    }

    if (preg_match('/web_url:"([^"]+)"/u', $html, $m)) {
        $info['web_url'] = $m[1];
    }

    if (preg_match('/buildAssetsDir:"([^"]+)"/u', $html, $m)) {
        $info['build_assets_dir'] = $m[1];
    }

    if (preg_match('/buildId:"([^"]+)"/u', $html, $m)) {
        $info['build_id'] = $m[1];
    }

    if (preg_match_all('/<script[^>]+src="([^"]+)"/ui', $html, $m)) {
        $info['script_srcs'] = array_values(array_unique($m[1]));
    }

    return $info;
}

function rayanLooksLikeNuxtClientRenderedPage(string $html): bool
{
    return
        str_contains($html, '__NUXT_DATA__') ||
        str_contains($html, 'window.__NUXT__') ||
        str_contains($html, 'id="__nuxt"');
}
function rayanIsMinipayPayConfirmPage(string $html): bool
{
    if (rayanIsMinipayAmountPage($html)) {
        return false;
    }

    if (rayanLooksLikeMinipayHomePage($html)) {
        return false;
    }

    if (rayanLooksLikeMinipayFinalSuccessPage($html)) {
        return false;
    }

    return
        rayanPageContains($html, 'تایید و پرداخت') ||
        rayanPageContains($html, 'پرداخت نهایی') ||
        rayanPageContains($html, 'تکمیل پرداخت');
}
function rayanFindMinipayPayConfirmForm(string $html, string $baseUrl): ?array
{
    $queries = [
        '//form[.//button[contains(normalize-space(.), "تایید و پرداخت")]]',
        '//form[.//input[(@type="submit" or @type="button") and contains(@value, "تایید و پرداخت")]]',
        '//form[.//*[contains(normalize-space(.), "تایید و پرداخت")]]',
        '//form[.//button[contains(normalize-space(.), "پرداخت")]]',
        '//form[.//input[(@type="submit" or @type="button") and contains(@value, "پرداخت")]]',
    ];

    foreach ($queries as $query) {
        $form = rayanFindFormWithSubmitByQuery($html, $query, $baseUrl);
        if ($form) {
            return $form;
        }
    }

    // اگر فقط یک فرم در صفحه بود، همون رو fallback بگیر
    return rayanFindOnlyFormFallback($html, $baseUrl);
}

function rayanExtractMinipayTrackingCode(string $html): string
{
    $plain = rayanNormalizeSpaces(strip_tags($html));
    $patterns = [
        '/بازگشت\s*کد\s*پیگیری\s*[:：]?\s*([0-9۰-۹]{4,})/u',
        '/کد\s*پیگیری\s*[:：]?\s*([0-9۰-۹]{4,})/u',
        '/شماره\s*پیگیری\s*[:：]?\s*([0-9۰-۹]{4,})/u',
        '/پیگیری\s*[:：]?\s*([0-9۰-۹]{4,})/u',
    ];

    foreach ($patterns as $pattern) {
        if (preg_match($pattern, $plain, $m)) {
            return rayanDigitsOnly((string)$m[1]);
        }
    }

    try {
        $xpath = rayanCreateXPath($html);

        $orderNode = $xpath->query('//*[@id="order_id"]')->item(0);
        if ($orderNode instanceof DOMElement) {
            $value = rayanDigitsOnly((string)$orderNode->getAttribute('value'));
            if ($value !== '') {
                return $value;
            }
        }

        foreach ($xpath->query('//*[contains(text(), "پیگیری") or contains(text(), "Tracking") or contains(text(), "tracking")]') as $node) {
            $txt = rayanNormalizeSpaces($node->textContent ?? '');
            if (preg_match('/([0-9۰-۹]{4,})/u', $txt, $m)) {
                return rayanDigitsOnly((string)$m[1]);
            }
        }
    } catch (Throwable $e) {
        // ignore
    }

    return '';
}