<?php
declare(strict_types=1);
define('XUPING_PATCH_VERSION', 'standalone-variants-json-state-2026-08-16');
define('XUPING_COMPLETENESS_FIX', 'resumable-journal-2026-08-19');

error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('log_errors', '1');
@ini_set('max_execution_time', '0');
@set_time_limit(0);

mb_internal_encoding('UTF-8');
date_default_timezone_set('Asia/Tehran');

$config  = require __DIR__ . '/config.php';
$adasCfg = require __DIR__ . '/config_xuping_mixin.php';
require_once __DIR__ . '/state_store.php';

$BASE_URL      = trim((string)($adasCfg['adas']['base_url'] ?? 'https://xupingir.com'));
if ($BASE_URL === '') {
    $BASE_URL = 'https://xupingir.com';
}
$CATEGORY_IDS  = (array)($adasCfg['adas']['category_ids'] ?? ($adasCfg['adas']['category_urls'] ?? []));
$DATA_DIR      = (string)($adasCfg['adas']['data_dir'] ?? (__DIR__ . '/data_xuping'));

$STATE_FILE    = $DATA_DIR . '/state.json';
$LOG_FILE      = $DATA_DIR . '/debug.log';
$TRACE_FILE    = $DATA_DIR . '/cron_trace.log';
$LOCK_FILE     = $DATA_DIR . '/run.lock';

ensure_dir($DATA_DIR);

$lockFp = fopen($LOCK_FILE, 'c+');
if (!$lockFp) {
    exit("LOCK_OPEN_FAIL\n");
}
if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
    exit("ALREADY_RUNNING\n");
}

try {
    $ok = main(
        $config,
        $adasCfg,
        $BASE_URL,
        $CATEGORY_IDS,
        $STATE_FILE,
        $LOG_FILE,
        $TRACE_FILE
    );

    echo $ok ? "OK\n" : "ABORTED_MIXIN_DOWN\n";
} catch (Throwable $e) {
    logit($LOG_FILE, 'FATAL ' . $e->getMessage());
    logit($TRACE_FILE, 'FATAL ' . $e->getMessage());
    echo 'ERROR: ' . $e->getMessage() . "\n";
} finally {
    flock($lockFp, LOCK_UN);
    fclose($lockFp);
}

function main(
    array $config,
    array $adasCfg,
    string $baseUrl,
    array $categoryIds,
    string $stateFile,
    string $logFile,
    string $traceFile
): bool {
    $state = xuping_state_load($stateFile);
    $chatIds = extract_chat_ids((array)$state['subscribers']);

    $baseline = !(bool)$state['baseline_completed'];
    $siteSyncOnBaseline     = (bool)($adasCfg['adas']['site_sync_on_baseline'] ?? true);
    $sendMessagesOnBaseline = (bool)($adasCfg['adas']['send_messages_on_baseline'] ?? false);

    $prevSent = (array)$state['sent'];
    $newSent  = [];

    logit($logFile,   'RUN_START baseline=' . ($baseline ? '1' : '0') . ' categories=' . count($categoryIds));
    logit($traceFile, 'RUN_START baseline=' . ($baseline ? '1' : '0') . ' categories=' . count($categoryIds));
    logit($traceFile, 'PATCH_VERSION=' . XUPING_PATCH_VERSION);

    $mixinMap = mixin_get_categories_map_with_retry($config, $traceFile, 3, 3);    
    $mixinAvailable = !empty($mixinMap);

    logit($traceFile, 'MIXIN_AVAILABLE=' . ($mixinAvailable ? '1' : '0'));

    if (!$mixinAvailable) {
        logit($logFile, 'MIXIN_UNAVAILABLE_AT_START');
        logit($traceFile, 'MIXIN_UNAVAILABLE_AT_START');

        if (!empty($chatIds)) {
            notify_mixin_unavailable(
                $config,
                $adasCfg,
                $chatIds,
                $logFile,
                'دریافت دسته‌های سایت مقصد ناموفق بود'
            );
        }

        // در baseline نباید وضعیت state.json جلو برود.
        if ($baseline) {
            logit($logFile, 'RUN_ABORT_BASELINE_MIXIN_UNAVAILABLE');
            logit($traceFile, 'RUN_ABORT_BASELINE_MIXIN_UNAVAILABLE');
            return false;
        }
    }

    $changesSent = 0;
    $totalSeen   = 0;
    $runSeenUrls = [];
    $baselineCycleComplete = true;
    $runStartedAt = microtime(true);
    $maxRunSeconds = max(10, (int)($adasCfg['adas']['max_run_seconds'] ?? 22));

    xuping_cli_status('START patch=' . XUPING_COMPLETENESS_FIX . ' baseline=' . ($baseline ? '1' : '0'));

    foreach ($categoryIds as $catIdRaw) {
        $catEntry = $catIdRaw;
        $catId = category_key_from_entry($catEntry);
        if ($catId === '') {
            continue;
        }

        $sourceCategoryName = category_name_from_entry($catEntry, $adasCfg);
        $sourceCategoryUrl  = category_url_from_entry($catEntry, $baseUrl, $adasCfg);
        if ($sourceCategoryUrl === '') {
            logit($traceFile, "CATEGORY_SKIPPED_NO_URL id={$catId} source_cat={$sourceCategoryName}");
            continue;
        }

        $mappedCategoryName = map_source_category_name($sourceCategoryName, $adasCfg);
        $mixinCategoryId    = resolve_mixin_category_id($mixinMap, $adasCfg, $catId, $sourceCategoryName, $mappedCategoryName);
        $siteSyncDisabled   = is_site_sync_disabled_for_category($catId, $sourceCategoryName, $adasCfg);

        logit(
            $traceFile,
            "SCAN_CATEGORY id={$catId} source_cat={$sourceCategoryName} url={$sourceCategoryUrl} mapped_cat={$mappedCategoryName} mixin_cat_id=" . (string)($mixinCategoryId ?? 'null') . " sync_disabled=" . ($siteSyncDisabled ? '1' : '0')
        );

        // اگر سایت مقصد در دسترس نیست، دسته‌هایی که باید سینک شوند را کامل اسکپ کن
        // تا نه پیام گمراه‌کننده برود، نه snapshot داخل state.json خراب شود.
        if (!$siteSyncDisabled && !$mixinAvailable) {
            logit(
                $traceFile,
                "CATEGORY_SKIPPED_MIXIN_UNAVAILABLE id={$catId} source_cat={$sourceCategoryName}"
            );
            continue;
        }

        $items = fetch_all_category_products($catEntry, $baseUrl, $logFile, $traceFile, $adasCfg);
        $allCategoryItems = count($items);
        $batchSize = max(1, (int)($adasCfg['adas']['products_per_run'] ?? 80));
        [$items, $batchStart, $batchNext] = xuping_select_category_batch(
            $items,
            $stateFile,
            $catId,
            $batchSize
        );
        logit(
            $traceFile,
            "CATEGORY_BATCH id={$catId} total={$allCategoryItems} start={$batchStart} selected="
            . count($items) . " next={$batchNext} limit={$batchSize}"
        );
        xuping_cli_status(
            "BATCH category={$catId} total={$allCategoryItems} start={$batchStart} selected="
            . count($items) . " next={$batchNext}"
        );
        $variantMode = get_variant_mode_by_cat($catId, $sourceCategoryName, $adasCfg);
        if ($variantMode !== 'none') {
            logit($traceFile, "VARIANT_CHECK_ENABLED category={$sourceCategoryName} mode={$variantMode}");
        }

        $seenThis = 0;
        $batchProcessed = 0;

        foreach ($items as $url => $item) {
            if ($batchProcessed > 0 && (microtime(true) - $runStartedAt) >= $maxRunSeconds) {
                logit($traceFile, "CATEGORY_TIME_BUDGET_STOP id={$catId} processed={$batchProcessed} budget={$maxRunSeconds}");
                xuping_cli_status("TIME_BUDGET_STOP category={$catId} processed={$batchProcessed}");
                break;
            }
            $nextCursor = (int)($item['__scan_next_cursor'] ?? $batchNext);
            unset($item['__scan_next_cursor']);
            // پیش از عملیات شبکه ذخیره می‌شود تا اگر هاست پردازش را کشت، اجرای
            // بعد دوباره از ابتدای دسته شروع نشود. مورد نیمه‌تمام در دور بعدی
            // کامل بازبینی خواهد شد و journal مانع محصول تکراری می‌شود.
            xuping_save_category_cursor($stateFile, $catId, $nextCursor);
            $batchProcessed++;
            $url = trim((string)($item['url'] ?? ''));
            if ($url === '') {
                continue;
            }

            if (isset($runSeenUrls[$url])) {
                logit($traceFile, "SKIP_DUPLICATE_IN_RUN url={$url} from_category={$sourceCategoryName}");
                continue;
            }
            $runSeenUrls[$url] = true;

            $seenThis++;
            $totalSeen++;

            $item['variant_mode']     = $variantMode;
            $detailHtml = fetch_product_detail_html($url, $logFile, $adasCfg);
            if ($detailHtml !== '') {
                $detailTitle = extract_product_title_html($detailHtml);
                if ($detailTitle !== '') {
                    $item['title'] = $detailTitle;
                }
                $ptJsonVariants = extract_xuping_ptjson_variants_html($detailHtml, $item, $variantMode);
                $ptJsonFields    = xuping_variant_fields_from_ptjson_variants($ptJsonVariants);
                $domFields       = extract_product_variant_fields_html($detailHtml, $variantMode);

                // منبع اصلی واریانت‌های ژوپینگ ptJson است؛ data-sel="Color" ممکن است در response واقعی خالی باشد.
                $item['xuping_variant_details'] = $ptJsonVariants;
                $item['variant_fields'] = !empty($ptJsonFields) ? $ptJsonFields : $domFields;
                $item['description_html'] = extract_product_feature_items_html($detailHtml);

                logit($logFile, 'PRODUCT_DETAILS_OK url=' . $url . ' title=' . (string)($item['title'] ?? '') . ' fields=' . json_encode($item['variant_fields'], JSON_UNESCAPED_UNICODE) . ' ptjson_variants=' . count($ptJsonVariants) . ' desc_len=' . mb_strlen(strip_tags((string)$item['description_html'])));

                $variantValueCount = 0;
                foreach ((array)$item['variant_fields'] as $vfVals) {
                    $variantValueCount += count((array)$vfVals);
                }

                if (!empty($ptJsonVariants)) {
                    logit($traceFile, 'PRODUCT_PTJSON_VARIANTS_FOUND url=' . $url . ' count=' . count($ptJsonVariants) . ' variants=' . json_encode($ptJsonVariants, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
                }

                if ($variantValueCount > 0) {
                    logit($traceFile, 'PRODUCT_VARIANTS_FOUND url=' . $url . ' count=' . $variantValueCount . ' fields=' . json_encode($item['variant_fields'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
                } else {
                    logit($traceFile, 'PRODUCT_VARIANTS_EMPTY url=' . $url . ' has_ptjson=' . (mb_stripos($detailHtml, 'id="ptJson"') !== false || mb_stripos($detailHtml, "id='ptJson'") !== false ? '1' : '0') . ' has_prsz=' . (mb_stripos($detailHtml, 'prsz') !== false ? '1' : '0') . ' has_data_sel=' . (mb_stripos($detailHtml, 'data-sel') !== false ? '1' : '0') . ' has_data_code=' . (mb_stripos($detailHtml, 'data-code') !== false ? '1' : '0'));
                }
            } else {
                $item['variant_fields']   = [];
                $item['xuping_variant_details'] = [];
                $item['description_html'] = '';
                logit($logFile, 'PRODUCT_DETAILS_EMPTY url=' . $url);
            }

            $item['source_category_id']   = $catId;
            $item['source_category_name'] = $sourceCategoryName;
            $item['mapped_category_name'] = $mappedCategoryName;
            $item['mixin_category_id']    = $siteSyncDisabled ? null : $mixinCategoryId;
            $item['site_sync_disabled']   = $siteSyncDisabled;
            $item['ts']                   = time();

            $prev = $prevSent[$url] ?? null;

            if ($baseline) {
                $syncOk = true;

                if (!$siteSyncDisabled && $siteSyncOnBaseline && $mixinAvailable) {
                    $syncOk = sync_item_to_mixin_with_retry($stateFile, $config, $adasCfg, $item, $traceFile, 3, 3);
                }

                if (!$syncOk) {
                    if (!empty($chatIds)) {
                        $cap = caption_sync_failed($item, 'در baseline همگام‌سازی با سایت مقصد انجام نشد');
                        foreach ($chatIds as $cid) {
                            send_rubika_message($config, $adasCfg, $cid, $cap, $logFile);
                        }
                    }

                    if ($prev !== null) {
                        $newSent[$url] = $prev;
                    }
                    continue;
                }

                if ($sendMessagesOnBaseline && !empty($chatIds)) {
                    $cap = caption_new_item($item);
                    foreach ($chatIds as $cid) {
                        send_rubika_message($config, $adasCfg, $cid, $cap, $logFile);
                    }
                }

                $newSent[$url] = $item;
                continue;
            }

            if ($prev === null) {
                $syncOk = true;

                if (!$siteSyncDisabled && $mixinAvailable) {
                    $syncOk = sync_item_to_mixin_with_retry($stateFile, $config, $adasCfg, $item, $traceFile, 3, 3);
                }

                if ($syncOk) {
                    if (!empty($chatIds)) {
                        $cap = caption_new_item($item);
                        foreach ($chatIds as $cid) {
                            send_rubika_message($config, $adasCfg, $cid, $cap, $logFile);
                        }
                        $changesSent++;
                    }

                    $newSent[$url] = $item;
                } else {
                    if (!empty($chatIds)) {
                        $cap = caption_sync_failed($item, 'بعد از چند تلاش، ایجاد محصول روی سایت مقصد انجام نشد');
                        foreach ($chatIds as $cid) {
                            send_rubika_message($config, $adasCfg, $cid, $cap, $logFile);
                        }
                    }
                    logit($traceFile, 'NEW_ITEM_BUT_SYNC_FAILED url=' . $url);
                }

                continue;
            }

            $hasChanges = !items_equal($prev, $item);

            if ($hasChanges) {
                $syncOk = true;

                if (!$siteSyncDisabled && $mixinAvailable) {
                    $syncOk = sync_item_to_mixin_with_retry($stateFile, $config, $adasCfg, $item, $traceFile, 3, 3);
                }

                if ($syncOk) {
                    if (!empty($chatIds)) {
                        $cap = build_change_caption($prev, $item);
                        foreach ($chatIds as $cid) {
                            send_rubika_message($config, $adasCfg, $cid, $cap, $logFile);
                        }
                        $changesSent++;
                    }
                    $newSent[$url] = $item;
                } else {
                    if (!empty($chatIds)) {
                        $cap = caption_sync_failed($item, 'بعد از چند تلاش اتصال یا بروزرسانی سایت مقصد انجام نشد');
                        foreach ($chatIds as $cid) {
                            send_rubika_message($config, $adasCfg, $cid, $cap, $logFile);
                        }
                    }
                    $newSent[$url] = $prev;
                    logit($traceFile, 'CHANGE_DETECTED_BUT_SYNC_FAILED url=' . $url);
                }
            } else {
                $backfillOk = true;

                if (!$siteSyncDisabled && $mixinAvailable) {
                    $backfillOk = maybe_backfill_unsynced_item($stateFile, $config, $adasCfg, $item, $traceFile);
                }

                if ($backfillOk) {
                    $newSent[$url] = $item;
                } else {
                    $newSent[$url] = $prev;
                    logit($traceFile, 'NO_CHANGE_BUT_BACKFILL_FAILED_KEEP_PREV url=' . $url);
                }
            }
        }

        if ($batchProcessed < count($items) || $batchNext !== 0) {
            $baselineCycleComplete = false;
        } else {
            xuping_mark_category_cycle_complete($stateFile, $catId, $allCategoryItems);
        }

        logit($traceFile, "CATEGORY_DONE id={$catId} source_cat={$sourceCategoryName} seen={$seenThis}");
        xuping_cli_status("CATEGORY_DONE category={$catId} processed={$batchProcessed} synced={$seenThis}");
    }

    // موارد قبلی که این ران عمداً بررسی/سینک نشده‌اند حفظ شوند
    foreach ($prevSent as $url => $prevItem) {
        if (!isset($newSent[$url])) {
            $newSent[$url] = $prevItem;
        }
    }

    xuping_state_update($stateFile, static function (array &$latest) use ($newSent, $baseline, $baselineCycleComplete): void {
        $latest['sent'] = $newSent;
        if ($baseline && $baselineCycleComplete) {
            $latest['baseline_completed'] = true;
        }
    });

    if ($baseline && $baselineCycleComplete) {
        logit($logFile,   'BASELINE_COMPLETED items=' . count($newSent) . ' totalSeen=' . $totalSeen);
        logit($traceFile, 'BASELINE_COMPLETED items=' . count($newSent) . ' totalSeen=' . $totalSeen);
    } elseif ($baseline) {
        logit($logFile,   'BASELINE_BATCH_PROGRESS items=' . count($newSent) . ' totalSeen=' . $totalSeen);
        logit($traceFile, 'BASELINE_BATCH_PROGRESS items=' . count($newSent) . ' totalSeen=' . $totalSeen);
    } else {
        logit($logFile,   'SCAN_COMPLETED items=' . count($newSent) . ' totalSeen=' . $totalSeen . ' changes_sent=' . $changesSent);
        logit($traceFile, 'SCAN_COMPLETED items=' . count($newSent) . ' totalSeen=' . $totalSeen . ' changes_sent=' . $changesSent);
    }

    return true;
}

function notify_mixin_unavailable(
    array $config,
    array $adasCfg,
    array $chatIds,
    string $logFile,
    string $reason = ''
): void {
    if (empty($chatIds)) {
        return;
    }

    $lines = [];
    $lines[] = '⚠️ سایت مقصد پاسخ نداد';
    $lines[] = 'برای جلوگیری از ارسال تغییرات گمراه‌کننده، دسته‌های قابل سینک در این ران اعمال نشدند.';
    $lines[] = 'در ران بعدی دوباره تلاش می‌شود.';

    if ($reason !== '') {
        $lines[] = 'جزئیات: ' . $reason;
    }

    $text = implode("\n", $lines);

    foreach ($chatIds as $cid) {
        send_rubika_message($config, $adasCfg, (string)$cid, $text, $logFile);
        usleep(200000);
    }
}

function choose_best_description_html(array $item, ?array $existing = null): string
{
    $scraped = trim((string)($item['description_html'] ?? ''));
    if ($scraped !== '') {
        return $scraped;
    }

    $existingDesc = '';
    if (is_array($existing)) {
        $existingDesc = trim((string)($existing['description'] ?? ''));
    }
    if ($existingDesc !== '') {
        return $existingDesc;
    }

    $title = normalize_space((string)($item['title'] ?? 'محصول'));
    return '<p>' . htmlspecialchars($title, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '</p>';
}

function standalone_sync_key(string $sourceUrl, string $variantKey): string
{
    return hash('sha256', trim($sourceUrl) . "\n" . trim($variantKey));
}

function xuping_scan_progress_file(string $stateFile): string
{
    return dirname($stateFile) . '/scan_progress.json';
}

function xuping_load_scan_progress(string $stateFile): array
{
    $path = xuping_scan_progress_file($stateFile);
    if (!is_file($path)) return [];
    $decoded = json_decode((string)file_get_contents($path), true);
    return is_array($decoded) ? $decoded : [];
}

function xuping_save_category_cursor(string $stateFile, string $categoryId, int $cursor): void
{
    static $progress = null;
    if (!is_array($progress)) {
        $progress = xuping_load_scan_progress($stateFile);
    }
    $progress[$categoryId] = max(0, $cursor);
    $progress['updated_at'] = date(DATE_ATOM);

    $path = xuping_scan_progress_file($stateFile);
    $tmp = $path . '.tmp.' . getmypid();
    $json = json_encode($progress, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if (!is_string($json) || file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
        @unlink($tmp);
        throw new RuntimeException('SCAN_PROGRESS_WRITE_FAIL');
    }
}

function xuping_mark_category_cycle_complete(string $stateFile, string $categoryId, int $total): void
{
    $progress = xuping_load_scan_progress($stateFile);
    if (!isset($progress['completed_cycles']) || !is_array($progress['completed_cycles'])) {
        $progress['completed_cycles'] = [];
    }
    $old = (array)($progress['completed_cycles'][$categoryId] ?? []);
    $progress['completed_cycles'][$categoryId] = [
        'count' => (int)($old['count'] ?? 0) + 1,
        'total_seen' => max(0, $total),
        'completed_at' => date(DATE_ATOM),
    ];
    $progress['updated_at'] = date(DATE_ATOM);

    $path = xuping_scan_progress_file($stateFile);
    $tmp = $path . '.tmp.' . getmypid();
    $json = json_encode($progress, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    if (!is_string($json) || file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
        @unlink($tmp);
        throw new RuntimeException('SCAN_PROGRESS_COMPLETE_WRITE_FAIL');
    }
}

function xuping_select_category_batch(
    array $items,
    string $stateFile,
    string $categoryId,
    int $limit
): array {
    $total = count($items);
    if ($total === 0) return [[], 0, 0];

    $progress = xuping_load_scan_progress($stateFile);
    $storedCursor = (int)($progress[$categoryId] ?? 0);
    // یک محصول هم‌پوشانی عمدی است: اگر اجرای قبلی وسط درخواست شبکه کشته شده
    // باشد، همان محصول دوباره بررسی می‌شود و sync-map جلوی ساخت تکراری را می‌گیرد.
    $start = $storedCursor > 0 ? $storedCursor - 1 : 0;
    if ($start < 0 || $start >= $total) $start = 0;
    $take = min(max(1, $limit), $total - $start);
    $keys = array_keys($items);
    $selected = [];

    for ($i = 0; $i < $take; $i++) {
        $index = $start + $i;
        $key = $keys[$index];
        $row = $items[$key];
        $row['__scan_next_cursor'] = ($index + 1) % $total;
        $selected[$key] = $row;
    }

    $next = ($start + $take >= $total) ? 0 : ($start + $take);
    return [$selected, $start, $next];
}

function xuping_cli_status(string $message): void
{
    if (PHP_SAPI === 'cli') {
        echo $message . PHP_EOL;
    }
}

function xuping_sync_map_journal_file(string $stateFile): string
{
    return dirname($stateFile) . '/sync_map.jsonl';
}

function &xuping_sync_map_cache(string $stateFile): array
{
    static $caches = [];
    if (!array_key_exists($stateFile, $caches)) {
        $state = xuping_state_load($stateFile);
        $cache = (array)($state['sync_map'] ?? []);
        $journal = xuping_sync_map_journal_file($stateFile);
        if (is_file($journal)) {
            $fp = fopen($journal, 'rb');
            if ($fp !== false) {
                while (($line = fgets($fp)) !== false) {
                    $entry = json_decode(trim($line), true);
                    $key = (string)($entry['key'] ?? '');
                    $record = $entry['record'] ?? null;
                    if ($key !== '' && is_array($record)) {
                        $cache[$key] = $record;
                    }
                }
                fclose($fp);
            }
        }
        $caches[$stateFile] = $cache;
    }
    return $caches[$stateFile];
}

function xuping_append_sync_map_record(string $stateFile, string $key, array $record): void
{
    $line = json_encode(
        ['key' => $key, 'record' => $record],
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    );
    if (!is_string($line) || file_put_contents(
        xuping_sync_map_journal_file($stateFile),
        $line . PHP_EOL,
        FILE_APPEND | LOCK_EX
    ) === false) {
        throw new RuntimeException('SYNC_MAP_JOURNAL_WRITE_FAIL');
    }
}

function get_synced_mixin_record(string $stateFile, string $sourceUrl, string $variantKey): ?array
{
    $map = &xuping_sync_map_cache($stateFile);
    $key = standalone_sync_key($sourceUrl, $variantKey);
    $record = $map[$key] ?? null;
    return is_array($record) ? $record : null;
}

function get_synced_mixin_product_id(string $stateFile, string $sourceUrl, string $variantKey): ?int
{
    $record = get_synced_mixin_record($stateFile, $sourceUrl, $variantKey);
    $id = (int)($record['mixin_product_id'] ?? 0);
    return $id > 0 ? $id : null;
}

function get_synced_mixin_records_for_source(string $stateFile, string $sourceUrl): array
{
    $map = &xuping_sync_map_cache($stateFile);
    $records = [];

    foreach ($map as $key => $record) {
        if (!is_array($record) || (string)($record['source_url'] ?? '') !== $sourceUrl) {
            continue;
        }
        $records[(string)$key] = $record;
    }

    return $records;
}

function upsert_sync_map(
    string $stateFile,
    string $sourceUrl,
    string $variantKey,
    int $mixinProductId,
    string $payloadHash
): void {
    $key = standalone_sync_key($sourceUrl, $variantKey);
    $now = date(DATE_ATOM);

    $map = &xuping_sync_map_cache($stateFile);
    $old = is_array($map[$key] ?? null) ? $map[$key] : [];
    $record = [
        'source_url' => $sourceUrl,
        'variant_key' => $variantKey,
        'mixin_product_id' => $mixinProductId,
        'payload_hash' => $payloadHash,
        'created_at' => (string)($old['created_at'] ?? $now),
        'updated_at' => $now,
        'active' => true,
    ];
    $map[$key] = $record;
    xuping_append_sync_map_record($stateFile, $key, $record);
}

function mark_sync_map_inactive(string $stateFile, string $mapKey): void
{
    $map = &xuping_sync_map_cache($stateFile);
    if (!is_array($map[$mapKey] ?? null)) return;
    $map[$mapKey]['active'] = false;
    $map[$mapKey]['updated_at'] = date(DATE_ATOM);
    xuping_append_sync_map_record($stateFile, $mapKey, $map[$mapKey]);
}

function ensure_dir(string $path): void
{
    if (!is_dir($path)) {
        @mkdir($path, 0755, true);
    }
}

function logit(string $path, string $msg): void
{
    file_put_contents($path, '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL, FILE_APPEND);
}

function extract_chat_ids(array $subs): array
{
    if (array_keys($subs) !== range(0, count($subs) - 1)) {
        return array_map('strval', array_keys($subs));
    }
    return array_values(array_filter(array_map('strval', $subs)));
}

function extract_product_title_html(string $html): string
{
    libxml_use_internal_errors(true);

    $dom = new DOMDocument();
    if (!$dom->loadHTML('<?xml encoding="UTF-8">' . $html)) {
        return '';
    }

    $xp = new DOMXPath($dom);
    $node = $xp->query("//h1[contains(concat(' ', normalize-space(@class), ' '), ' product-name ')] | //h1[@itemrolep='name'] | //h1")->item(0);
    return $node ? normalize_space($node->textContent) : '';
}

function fetch_product_detail_html(string $url, string $logFile, array $adasCfg = []): string
{
    $attempts = (int)($adasCfg['adas']['product_details_retry'] ?? 3);

    for ($i = 1; $i <= $attempts; $i++) {
        $html = http_get($url, $logFile, $adasCfg);
        if ($html !== '') {
            return $html;
        }

        logit($logFile, 'PRODUCT_DETAIL_FETCH_FAIL attempt=' . $i . ' url=' . $url);
        if ($i < $attempts) {
            usleep(700000);
        }
    }

    return '';
}

function xuping_dom_node_text_with_breaks(DOMDocument $dom, DOMNode $node): string
{
    $html = '';
    foreach ($node->childNodes as $child) {
        $html .= $dom->saveHTML($child);
    }

    $html = preg_replace('~<\s*br\s*/?\s*>~iu', "\n", (string)$html);
    $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = preg_replace("~[ \t\x{00A0}]+~u", ' ', (string)$text);
    $text = preg_replace("~\s*\n\s*~u", "\n", (string)$text);
    $text = preg_replace("~\n{3,}~u", "\n\n", (string)$text);
    return trim($text);
}

function xuping_description_is_marketing_text(string $text): bool
{
    $key = normalize_text_key($text);
    if ($key === '') {
        return true;
    }

    foreach (['ژوپینگ', 'xuping', 'فروش جدیدترین', 'مرجعی کامل برای خرید آنلاین', '04134449090'] as $bad) {
        if (mb_stripos($key, normalize_text_key($bad)) !== false) {
            return true;
        }
    }

    return false;
}

function extract_product_feature_items_html(string $html): string
{
    libxml_use_internal_errors(true);

    $dom = new DOMDocument();
    if (!$dom->loadHTML('<?xml encoding="UTF-8">' . $html)) {
        return '';
    }

    $xp = new DOMXPath($dom);

    // ساختار درست ژوپینگ:
    // ul.attribs  ->  div.item > div.cnn-box/ws-pre-line  ->  div.item موجودی
    // فقط همین توضیح محصول باید برداشته شود؛ متن‌های متا/سئوی پایین صفحه نباید وارد مقصد شوند.
    $queries = [
        "//div[contains(concat(' ', normalize-space(@class), ' '), ' product-info ')]//ul[contains(concat(' ', normalize-space(@class), ' '), ' attribs ')]/following-sibling::div[contains(concat(' ', normalize-space(@class), ' '), ' item ')][.//div[contains(concat(' ', normalize-space(@class), ' '), ' cnn-box ') or contains(concat(' ', normalize-space(@class), ' '), ' ws-pre-line ')]][1]//div[contains(concat(' ', normalize-space(@class), ' '), ' cnn-box ') or contains(concat(' ', normalize-space(@class), ' '), ' ws-pre-line ')][1]",
        "//div[contains(concat(' ', normalize-space(@class), ' '), ' product-info ')]//div[contains(concat(' ', normalize-space(@class), ' '), ' cnn-box ') or contains(concat(' ', normalize-space(@class), ' '), ' ws-pre-line ')][1]",
    ];

    foreach ($queries as $q) {
        $node = $xp->query($q)->item(0);
        if (!($node instanceof DOMElement)) {
            continue;
        }

        $text = xuping_dom_node_text_with_breaks($dom, $node);
        if ($text === '' || xuping_description_is_marketing_text($text)) {
            continue;
        }

        $safe = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $safe = nl2br($safe, false);
        return '<p>' . $safe . '</p>';
    }

    return '';
}

function fetch_product_feature_items_html(string $url, string $logFile, array $adasCfg = []): string
{
    $attempts = (int)($adasCfg['adas']['product_details_retry'] ?? 3);

    for ($i = 1; $i <= $attempts; $i++) {
        $html = http_get($url, $logFile, $adasCfg);
        if ($html === '') {
            logit($logFile, 'PRODUCT_FEATURES_FETCH_FAIL attempt=' . $i . ' url=' . $url);
            if ($i < $attempts) {
                usleep(700000);
            }
            continue;
        }

        $featureHtml = extract_product_feature_items_html($html);
        if ($featureHtml !== '') {
            logit($logFile, 'PRODUCT_FEATURES_HTML_OK attempt=' . $i . ' url=' . $url . ' len=' . mb_strlen($featureHtml));
            return $featureHtml;
        }

        logit($logFile, 'PRODUCT_FEATURES_EMPTY attempt=' . $i . ' url=' . $url);
        if ($i < $attempts) {
            usleep(700000);
        }
    }

    return '';
}

function http_get(string $url, string $logFile, array $adasCfg = []): string
{
    $timeout = (int)($adasCfg['adas']['request_timeout'] ?? 25);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
        CURLOPT_HTTPHEADER => [
            'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome Safari',
            'Accept-Language: fa-IR,fa;q=0.9,en-US;q=0.8,en;q=0.7',
        ],
    ]);

    $res  = curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = (string)curl_error($ch);
    curl_close($ch);

    if ($res === false || $code >= 400) {
        logit($logFile, "HTTP_FAIL code={$code} err={$err} url={$url}");
        return '';
    }

    return (string)$res;
}

function rb_post(string $url, array $payload, string $logFile): array
{
    $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $jsonPayload,
        CURLOPT_TIMEOUT => 20,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Accept: application/json',
        ],
    ]);

    $res  = curl_exec($ch);
    $err  = (string)curl_error($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($res === false) {
        $res = '';
    }

    logit($logFile, "RB HTTP={$code} ERR={$err} URL={$url} PAYLOAD={$jsonPayload} RES={$res}");

    $j = json_decode($res, true);
    return is_array($j) ? $j : ['status' => 'ERROR', 'raw' => $res];
}

function bot_api_root_from_config(array $config, array $adasCfg): string
{
    $botCfg = (array)($adasCfg['bot'] ?? []);
    $provider = normalize_text_key((string)($botCfg['provider'] ?? 'auto'));

    $token = trim((string)($botCfg['token'] ?? $adasCfg['rubika']['token'] ?? $config['rubika']['token'] ?? ''));
    $base  = trim((string)($botCfg['api_base'] ?? $adasCfg['rubika']['api_base'] ?? $config['rubika']['api_base'] ?? 'https://botapi.rubika.ir/v3/'));

    if ($token === '') {
        return '';
    }

    if (str_contains($base, '{token}')) {
        return rtrim(str_replace('{token}', $token, $base), '/') . '/';
    }

    $base = rtrim($base, '/');

    // Bale/Telegram-like: https://tapi.bale.ai/bot<TOKEN>/method
    if ($provider === 'bale' || str_contains($base, 'tapi.bale.ai')) {
        if (preg_match('~/bot' . preg_quote($token, '~') . '$~u', $base)) {
            return $base . '/';
        }
        if (preg_match('~/bot$~u', $base)) {
            return $base . $token . '/';
        }
        return $base . '/bot' . $token . '/';
    }

    // Rubika-like: https://botapi.rubika.ir/v3/<TOKEN>/method
    if (preg_match('~/' . preg_quote($token, '~') . '$~u', $base)) {
        return $base . '/';
    }

    return $base . '/' . $token . '/';
}

function send_rubika_message(array $config, array $adasCfg, string $chatId, string $text, string $logFile): bool
{
    $api = bot_api_root_from_config($config, $adasCfg);
    if ($api === '') {
        logit($logFile, 'BOT_TOKEN_EMPTY_IN_CONFIG');
        return false;
    }

    $resp = rb_post($api . 'sendMessage', ['chat_id' => $chatId, 'text' => $text], $logFile);
    return (($resp['status'] ?? '') === 'OK') || (($resp['ok'] ?? false) === true);
}

function normalize_space(string $s): string
{
    $s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $s = preg_replace('~\s+~u', ' ', $s);
    return trim((string)$s);
}

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

function normalize_text_key(string $s): string
{
    $s = normalize_digits(normalize_space($s));
    $s = str_replace(['ي','ك'], ['ی','ک'], $s);
    return mb_strtolower($s);
}

function extract_numberish(string $s): string
{
    $s = normalize_digits(normalize_space($s));
    if (preg_match('/([0-9][0-9\.,]*)/u', $s, $m)) {
        return $m[1];
    }
    return '';
}

function extract_price_text(string $s): string
{
    $num = extract_numberish($s);
    return $num !== '' ? normalize_space($num . ' تومان') : '';
}

function price_to_int(string $priceText): int
{
    $num = extract_numberish($priceText);
    if ($num === '') {
        return 0;
    }

    $num = str_replace([',', '.'], '', $num);
    return is_numeric($num) ? (int)$num : 0;
}

function normalize_list(array $items): array
{
    $out = [];
    foreach ($items as $item) {
        $item = normalize_space((string)$item);
        if ($item !== '') {
            $out[] = $item;
        }
    }

    $out = array_values(array_unique($out));
    sort($out, SORT_NATURAL);
    return $out;
}

function normalize_attr_map(array $map): array
{
    $out = [];
    foreach ($map as $k => $vals) {
        $key = normalize_space((string)$k);
        if ($key === '') {
            continue;
        }

        $vals = normalize_list((array)$vals);
        if (!empty($vals)) {
            $out[$key] = $vals;
        }
    }

    ksort($out, SORT_NATURAL);
    return $out;
}

function attr_map_equal(array $a, array $b): bool
{
    return json_encode(normalize_attr_map($a), JSON_UNESCAPED_UNICODE)
        === json_encode(normalize_attr_map($b), JSON_UNESCAPED_UNICODE);
}

function abs_url(string $base, string $href): string
{
    $href = trim($href);
    if ($href === '') {
        return '';
    }
    if (preg_match('~^https?://~i', $href)) {
        return $href;
    }
    if (str_starts_with($href, '//')) {
        return 'https:' . $href;
    }
    if (str_starts_with($href, '/')) {
        return rtrim($base, '/') . $href;
    }
    return rtrim($base, '/') . '/' . $href;
}

function category_key_from_entry($entry): string
{
    if (is_array($entry)) {
        foreach (['id','key','code','name','url'] as $k) {
            if (isset($entry[$k]) && trim((string)$entry[$k]) !== '') {
                $v = trim((string)$entry[$k]);
                if ($k === 'url') {
                    return substr(hash('sha256', $v), 0, 12);
                }
                return $v;
            }
        }
        return '';
    }

    $v = trim((string)$entry);
    if ($v === '') {
        return '';
    }
    if (preg_match('~^https?://~i', $v)) {
        if (preg_match('~/category/(\d+)~u', $v, $m)) {
            return (string)$m[1];
        }
        return substr(hash('sha256', $v), 0, 12);
    }
    return $v;
}

function category_url_from_entry($entry, $baseUrl, array $adasCfg): string
{
    $baseUrl = trim((string)($baseUrl ?: ($adasCfg['adas']['base_url'] ?? 'https://xupingir.com')));
    if ($baseUrl === '') {
        $baseUrl = 'https://xupingir.com';
    }
    if (is_array($entry)) {
        $url = trim((string)($entry['url'] ?? ''));
        if ($url !== '') {
            return abs_url($baseUrl, $url);
        }
        $key = category_key_from_entry($entry);
    } else {
        $raw = trim((string)$entry);
        if (preg_match('~^https?://~i', $raw) || str_starts_with($raw, '/')) {
            return abs_url($baseUrl, $raw);
        }
        $key = $raw;
    }

    $map = (array)($adasCfg['adas']['category_url_map'] ?? []);
    if ($key !== '' && isset($map[$key])) {
        return abs_url($baseUrl, (string)$map[$key]);
    }

    if ($key !== '' && preg_match('~^\d+$~', $key)) {
        return rtrim($baseUrl, '/') . '/fa/category/' . $key;
    }

    return '';
}

function category_name_from_entry($entry, array $adasCfg): string
{
    if (is_array($entry)) {
        $name = normalize_space((string)($entry['name'] ?? $entry['title'] ?? ''));
        if ($name !== '') {
            return $name;
        }
    }

    $key = category_key_from_entry($entry);
    $map = (array)($adasCfg['adas']['category_name_map'] ?? []);
    if ($key !== '' && isset($map[$key])) {
        return normalize_space((string)$map[$key]);
    }

    $url = category_url_from_entry($entry, (string)($adasCfg['adas']['base_url'] ?? 'https://xupingir.com'), $adasCfg);
    if ($url !== '') {
        $path = urldecode((string)(parse_url($url, PHP_URL_PATH) ?? ''));
        if (preg_match('~/category/\d+[-_](.+)$~u', $path, $m)) {
            return normalize_space(str_replace(['-', '_'], ' ', $m[1]));
        }
    }

    return $key !== '' ? normalize_space($key) : 'دسته';
}

function category_name_from_id($catId, array $adasCfg): string
{
    return category_name_from_entry($catId, $adasCfg);
}

function map_source_category_name(string $name, array $adasCfg): string
{
    $map = (array)($adasCfg['adas']['category_alias_map'] ?? []);
    $name = normalize_space($name);
    return normalize_space((string)($map[$name] ?? $name));
}

function is_site_sync_disabled_for_category($catId, string $sourceCategoryName, array $adasCfg): bool
{
    $ids = array_map('strval', (array)($adasCfg['adas']['site_sync_excluded_category_ids'] ?? []));
    if (in_array((string)$catId, $ids, true)) {
        return true;
    }

    $names = array_map('normalize_text_key', (array)($adasCfg['adas']['site_sync_excluded_category_names'] ?? []));
    return in_array(normalize_text_key($sourceCategoryName), $names, true);
}


function find_category_id_by_name(array $mixinMap, string $name): ?int
{
    $name = normalize_space($name);
    if ($name === '') {
        return null;
    }

    if (isset($mixinMap[$name]) && is_numeric($mixinMap[$name])) {
        return (int)$mixinMap[$name];
    }

    $target = normalize_text_key($name);

    foreach ($mixinMap as $catName => $catId) {
        if (normalize_text_key((string)$catName) === $target && is_numeric($catId)) {
            return (int)$catId;
        }
    }

    foreach ($mixinMap as $catName => $catId) {
        $k = normalize_text_key((string)$catName);
        if ($k !== '' && $target !== '' && (mb_strpos($k, $target) !== false || mb_strpos($target, $k) !== false) && is_numeric($catId)) {
            return (int)$catId;
        }
    }

    return null;
}

function resolve_mixin_category_id(array $mixinMap, array $adasCfg, $catId, string $sourceCategoryName, string $mappedCategoryName): ?int
{
    $manual = (array)($adasCfg['adas']['mixin_category_map'] ?? []);

    $manualKeys = array_values(array_unique([
        (string)$catId,
        normalize_space((string)$sourceCategoryName),
        normalize_space((string)$mappedCategoryName),
    ]));

    foreach ($manualKeys as $manualKey) {
        if ($manualKey === '' || !array_key_exists($manualKey, $manual)) {
            continue;
        }

        $v = $manual[$manualKey];
        if (is_numeric($v)) {
            return (int)$v;
        }
        if (is_string($v) && trim($v) !== '') {
            $found = find_category_id_by_name($mixinMap, $v);
            if ($found !== null) {
                return $found;
            }
        }
    }

    $id = find_category_id_by_name($mixinMap, $mappedCategoryName);
    if ($id !== null) {
        return $id;
    }

    $id = find_category_id_by_name($mixinMap, $sourceCategoryName);
    if ($id !== null) {
        return $id;
    }

    $fallback = $adasCfg['adas']['fallback_mixin_category_id'] ?? null;
    return is_numeric($fallback) ? (int)$fallback : null;
}

function category_page_url($categoryEntry, int $page, string $baseUrl, array $adasCfg): string
{
    $url = category_url_from_entry($categoryEntry, $baseUrl, $adasCfg);
    if ($url === '') {
        return '';
    }
    if ($page <= 1) {
        return $url;
    }

    $param = (string)($adasCfg['adas']['pagination_query_param'] ?? 'page');
    if ($param === '') {
        $param = 'page';
    }

    $sep = str_contains($url, '?') ? '&' : '?';
    return $url . $sep . rawurlencode($param) . '=' . $page;
}

function fetch_all_category_products($catEntry, string $baseUrl, string $logFile, string $traceFile, array $adasCfg): array
{
    // Xuping category pages render an empty placeholder in HTML and load products by Ajax.
    // Try /fa/Product/ProductList first, then fall back to old HTML parsing if Ajax is unavailable.
    $useAjax = (bool)($adasCfg['adas']['use_ajax_product_list'] ?? true);
    if ($useAjax) {
        $ajaxItems = fetch_all_category_products_ajax($catEntry, $baseUrl, $logFile, $traceFile, $adasCfg);
        if (!empty($ajaxItems)) {
            return $ajaxItems;
        }

        $catId = category_key_from_entry($catEntry);
        logit($traceFile, "CATEGORY_AJAX_EMPTY_FALLBACK_HTML cat={$catId}");
    }

    $all = [];
    $maxPages = (int)($adasCfg['adas']['max_category_pages'] ?? 60);
    $catId = category_key_from_entry($catEntry);

    for ($page = 1; $page <= $maxPages; $page++) {
        $pageUrl = category_page_url($catEntry, $page, $baseUrl, $adasCfg);
        if ($pageUrl === '') {
            logit($traceFile, "CATEGORY_PAGE_URL_EMPTY cat={$catId} page={$page}");
            break;
        }

        $html = http_get($pageUrl, $logFile, $adasCfg);

        if ($html === '') {
            logit($traceFile, "CATEGORY_PAGE_FETCH_FAIL cat={$catId} page={$page} page_url={$pageUrl}");
            break;
        }

        $items = parse_xuping_products($html, $baseUrl);
        $count = count($items);
        logit($traceFile, "CATEGORY_PAGE_DONE cat={$catId} page={$page} items={$count} page_url={$pageUrl}");

        if ($count === 0) {
            break;
        }

        $before = count($all);
        foreach ($items as $url => $item) {
            $all[$url] = $item;
        }
        $newAdded = count($all) - $before;

        if ($page >= 2 && $newAdded === 0) {
            logit($traceFile, "CATEGORY_STOP_BY_NO_NEW_ITEMS cat={$catId} page={$page}");
            break;
        }
    }

    return $all;
}


function fetch_all_category_products_ajax($catEntry, string $baseUrl, string $logFile, string $traceFile, array $adasCfg): array
{
    $all = [];
    $catId = category_key_from_entry($catEntry);
    if ($catId === '' || !preg_match('~^\d+$~', $catId)) {
        logit($traceFile, "CATEGORY_AJAX_SKIP_BAD_CAT_ID cat={$catId}");
        return [];
    }

    $referer = category_url_from_entry($catEntry, $baseUrl, $adasCfg);
    if ($referer === '') {
        logit($traceFile, "CATEGORY_AJAX_SKIP_NO_REFERER cat={$catId}");
        return [];
    }

    $endpoint = trim((string)($adasCfg['adas']['ajax_product_list_endpoint'] ?? ''));
    if ($endpoint === '') {
        $endpoint = rtrim($baseUrl, '/') . '/fa/Product/ProductList';
    } else {
        $endpoint = abs_url($baseUrl, $endpoint);
    }

    $hardMaxPages = (int)($adasCfg['adas']['ajax_hard_max_pages'] ?? 500);
    if ($hardMaxPages < 1) {
        $hardMaxPages = 500;
    }

    $maxPages = (int)($adasCfg['adas']['max_category_pages'] ?? 500);
    if ($maxPages < 1) {
        $maxPages = 500;
    }
    $maxPages = min($maxPages, $hardMaxPages);

    $cookieFile = tempnam(sys_get_temp_dir(), 'xuping_cookie_');
    if ($cookieFile === false) {
        $cookieFile = __DIR__ . '/data_xuping/xuping_cookie_' . getmypid() . '_' . mt_rand(1000, 9999) . '.txt';
    }

    try {
        // Xuping ProductList is session-sensitive. The browser sends x-visitor-id and lang cookies
        // even before the Ajax request. Without them, the endpoint may return TotalCount but Items=[]
        // or the global TotalCount (all site products) instead of the selected category.
        xuping_seed_browser_cookies($cookieFile, $referer);

        // Open category once to let the site set _lvgss/lang/session/verification cookies.
        $categoryHtmlForCookies = xuping_http_get_cookie($referer, $cookieFile, $logFile, $adasCfg);
        xuping_seed_token_from_html_if_available($cookieFile, $referer, $categoryHtmlForCookies);
        xuping_log_cookie_state($cookieFile, $traceFile, $catId);

        $expectedCategoryTotal = xuping_expected_category_total_from_html($categoryHtmlForCookies, $catEntry, $catId);
        if ($expectedCategoryTotal !== null && $expectedCategoryTotal > 0) {
            $expectedMaxPages = (int)ceil($expectedCategoryTotal / 15) + 2;
            // When we have the real category total, do NOT cap by old max_category_pages=60;
            // only hardMaxPages remains as a safety cap. Female watches currently need ~91 pages.
            $maxPages = min($hardMaxPages, max(1, $expectedMaxPages));
            logit($traceFile, "XUPING_CATEGORY_EXPECTED_TOTAL cat={$catId} expected={$expectedCategoryTotal} max_pages={$maxPages}");
        } else {
            // If the page does not expose a category count, never trust ProductList TotalCount blindly;
            // on Xuping it can be the whole-site count (6403) while the current category is much smaller.
            $maxPages = min($maxPages, (int)($adasCfg['adas']['ajax_unknown_total_max_pages'] ?? 80));
            logit($traceFile, "XUPING_CATEGORY_EXPECTED_TOTAL_MISSING cat={$catId} capped_max_pages={$maxPages}");
        }

        $totalCount = null;
        $lastPayloadKey = null;
        $noNewPages = 0;

        for ($page = 1; $page <= $maxPages; $page++) {
            $json = xuping_product_list_post($endpoint, $referer, $catId, $page, $cookieFile, $logFile, $traceFile, $adasCfg, $lastPayloadKey);
            if (!is_array($json)) {
                logit($traceFile, "CATEGORY_AJAX_PAGE_FAIL cat={$catId} page={$page} endpoint={$endpoint}");
                break;
            }

            $items = parse_xuping_ajax_products($json, $baseUrl);
            $count = count($items);
            $pageTotal = xuping_json_int($json, ['TotalCount', 'totalCount', 'total_count']);
            $serverCount = xuping_json_int($json, ['Count', 'count']);
            if ($pageTotal !== null) {
                $totalCount = $pageTotal;
            }

            $itemsBeforeFilter = $count;
            // Exact browser payload is Cats=<category>&Size=15, with page number in Referer.
            // When this payload is used, the server already returns the correct category;
            // title filtering would wrongly drop valid items such as watches without the word مردانه/زنانه.
            $usedExactCatsPayload = is_string($lastPayloadKey) && str_starts_with($lastPayloadKey, 'Cats_');
            if (!$usedExactCatsPayload) {
                $items = xuping_filter_ajax_items_for_category($items, $catEntry, $catId);
            }
            $count = count($items);
            $filteredOut = $itemsBeforeFilter - $count;

            logit(
                $traceFile,
                "CATEGORY_AJAX_PAGE_DONE cat={$catId} page={$page} items={$count} raw_items={$itemsBeforeFilter} filtered_out={$filteredOut} count=" . (string)($serverCount ?? 'null') . " total=" . (string)($totalCount ?? 'null') . " expected=" . (string)($expectedCategoryTotal ?? 'null') . " payload=" . (string)($lastPayloadKey ?? '-') . " endpoint={$endpoint}"
            );

            if ($itemsBeforeFilter === 0) {
                break;
            }

            $before = count($all);
            foreach ($items as $url => $item) {
                if ($expectedCategoryTotal !== null && count($all) >= $expectedCategoryTotal) {
                    break;
                }
                $all[$url] = $item;
            }
            $newAdded = count($all) - $before;

            if ($expectedCategoryTotal !== null && $expectedCategoryTotal > 0 && count($all) >= $expectedCategoryTotal) {
                logit($traceFile, "CATEGORY_AJAX_STOP_BY_EXPECTED_TOTAL cat={$catId} seen=" . count($all) . " expected={$expectedCategoryTotal}");
                break;
            }

            // Never extend by ProductList TotalCount when it looks like the whole-site count.
            // The category page itself exposes the reliable per-category count.
            if ($expectedCategoryTotal === null && $totalCount !== null && $totalCount > 0 && $totalCount <= 500) {
                if (count($all) >= $totalCount) {
                    logit($traceFile, "CATEGORY_AJAX_STOP_BY_SAFE_TOTAL cat={$catId} seen=" . count($all) . " total={$totalCount}");
                    break;
                }
            }

            if ($page >= 2 && $newAdded === 0) {
                $noNewPages++;
                logit($traceFile, "CATEGORY_NO_NEW_ITEMS cat={$catId} page={$page} no_new_pages={$noNewPages}");
                if ($noNewPages >= 3) {
                    logit($traceFile, "CATEGORY_STOP_BY_REPEATED_NO_NEW_ITEMS cat={$catId} page={$page}");
                    break;
                }
            } else {
                $noNewPages = 0;
            }
        }

        if ($expectedCategoryTotal !== null && count($all) > $expectedCategoryTotal) {
            $all = array_slice($all, 0, $expectedCategoryTotal, true);
        }
    } finally {
        if (is_file($cookieFile)) {
            @unlink($cookieFile);
        }
    }

    return $all;
}


function xuping_expected_category_total_from_html(string $html, $catEntry, string $catId): ?int
{
    if ($html === '') {
        return null;
    }

    $labels = xuping_category_count_labels($catEntry, $catId);
    $text = normalize_digits(normalize_space(strip_tags($html)));
    $text = str_replace(['ي','ك'], ['ی','ک'], $text);

    foreach ($labels as $label) {
        $label = normalize_space(str_replace(['ي','ك'], ['ی','ک'], $label));
        if ($label === '') {
            continue;
        }
        $q = preg_quote($label, '~');
        if (preg_match('~' . $q . '\s*\(([0-9][0-9,]*)\)~u', $text, $m)) {
            return (int)str_replace(',', '', $m[1]);
        }
    }

    // Fallback: the current category is shown near the product title as "محصولات <label>".
    foreach ($labels as $label) {
        $label = normalize_space(str_replace(['ي','ك'], ['ی','ک'], $label));
        if ($label === '') {
            continue;
        }
        if (mb_strpos($text, 'محصولات ' . $label) !== false) {
            // If only one category count is present close to this label, use it.
            $q = preg_quote($label, '~');
            if (preg_match('~' . $q . '\s*\(([0-9][0-9,]*)\)~u', $text, $m)) {
                return (int)str_replace(',', '', $m[1]);
            }
        }
    }

    return null;
}

function xuping_category_count_labels($catEntry, string $catId): array
{
    $name = is_array($catEntry) ? normalize_space((string)($catEntry['name'] ?? $catEntry['title'] ?? '')) : '';
    $labels = [];

    if ($catId === '511735') {
        $labels[] = 'ساعت مچی مردانه';
    } elseif ($catId === '511736') {
        $labels[] = 'ساعت مچی زنانه';
    } elseif ($catId === '511737') {
        $labels[] = 'ست مردانه و زنانه';
    }

    if ($name !== '') {
        $labels[] = $name;
        $labels[] = str_replace('های ', 'مچی ', $name);
        $labels[] = str_replace('ساعت های ', 'ساعت مچی ', $name);
    }

    return array_values(array_unique(array_filter($labels, static fn($v) => normalize_space((string)$v) !== '')));
}

function xuping_filter_ajax_items_for_category(array $items, $catEntry, string $catId): array
{
    $out = [];
    foreach ($items as $url => $item) {
        $title = normalize_text_key((string)($item['title'] ?? ''));
        if (xuping_product_title_matches_category($title, $catEntry, $catId)) {
            $out[$url] = $item;
        }
    }
    return $out;
}

function xuping_product_title_matches_category(string $titleKey, $catEntry, string $catId): bool
{
    if ($titleKey === '') {
        return true;
    }

    if ($catId === '511735') {
        return mb_strpos($titleKey, 'مردانه') !== false && mb_strpos($titleKey, 'زنانه') === false;
    }

    if ($catId === '511736') {
        return mb_strpos($titleKey, 'زنانه') !== false && mb_strpos($titleKey, 'مردانه') === false;
    }

    if ($catId === '511737') {
        return mb_strpos($titleKey, 'ست') !== false || (mb_strpos($titleKey, 'مردانه') !== false && mb_strpos($titleKey, 'زنانه') !== false);
    }

    return true;
}

function xuping_json_int(array $json, array $keys): ?int
{
    foreach ($keys as $key) {
        if (array_key_exists($key, $json) && is_numeric($json[$key])) {
            return (int)$json[$key];
        }
    }
    return null;
}


function xuping_uuid_v4(): string
{
    try {
        $data = random_bytes(16);
    } catch (Throwable $e) {
        $data = md5(uniqid((string)mt_rand(), true), true);
    }
    $data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
    $data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}

function xuping_cookie_domain(string $url): string
{
    $host = parse_url($url, PHP_URL_HOST);
    $host = is_string($host) && $host !== '' ? strtolower($host) : 'xupingir.com';
    return $host;
}

function xuping_cookie_line(string $domain, string $name, string $value, int $expiry = 0): string
{
    // Netscape cookie format: domain, includeSubdomains, path, secure, expiry, name, value
    return $domain . "\tFALSE\t/\tTRUE\t" . $expiry . "\t" . $name . "\t" . $value . "\n";
}

function xuping_seed_browser_cookies(string $cookieFile, string $referer): void
{
    $domain = xuping_cookie_domain($referer);
    $existing = is_file($cookieFile) ? (string)@file_get_contents($cookieFile) : '';
    $lines = '';

    if (!preg_match('/\tlang\t/m', $existing)) {
        $lines .= xuping_cookie_line($domain, 'lang', 'fa', time() + 31536000);
    }
    if (!preg_match('/\tx-visitor-id\t/m', $existing)) {
        $lines .= xuping_cookie_line($domain, 'x-visitor-id', xuping_uuid_v4(), time() + 31536000);
    }

    if ($lines !== '') {
        @file_put_contents($cookieFile, $existing . $lines);
    }
}

function xuping_seed_token_from_html_if_available(string $cookieFile, string $referer, string $html): void
{
    if ($html === '') {
        return;
    }

    $token = '';
    if (preg_match('~name=["\\\']__RequestVerificationToken["\\\'][^>]*value=["\\\']([^"\\\']+)["\\\']~i', $html, $m)) {
        $token = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
    } elseif (preg_match('~__RequestVerificationToken["\\\']?\s*[:=]\s*["\\\']([^"\\\']+)["\\\']~i', $html, $m)) {
        $token = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }

    if ($token === '') {
        return;
    }

    $existing = is_file($cookieFile) ? (string)@file_get_contents($cookieFile) : '';
    if (preg_match('/\t__RequestVerificationToken\t/m', $existing)) {
        return;
    }

    $domain = xuping_cookie_domain($referer);
    @file_put_contents($cookieFile, $existing . xuping_cookie_line($domain, '__RequestVerificationToken', $token, 0));
}

function xuping_log_cookie_state(string $cookieFile, string $traceFile, string $catId): void
{
    $txt = is_file($cookieFile) ? (string)@file_get_contents($cookieFile) : '';
    $hasLang = preg_match('/\tlang\t/m', $txt) ? 1 : 0;
    $hasVisitor = preg_match('/\tx-visitor-id\t/m', $txt) ? 1 : 0;
    $hasToken = preg_match('/\t__RequestVerificationToken\t/m', $txt) ? 1 : 0;
    $hasLvgs = preg_match('/\t_lvgss\t/m', $txt) ? 1 : 0;
    logit($traceFile, "XUPING_COOKIE_STATE cat={$catId} lang={$hasLang} visitor={$hasVisitor} token={$hasToken} lvgss={$hasLvgs}");
}

function xuping_http_get_cookie(string $url, string $cookieFile, string $logFile, array $adasCfg): string
{
    $timeout = (int)($adasCfg['adas']['request_timeout'] ?? 25);
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
        CURLOPT_ENCODING => '',
        CURLOPT_COOKIEJAR => $cookieFile,
        CURLOPT_COOKIEFILE => $cookieFile,
        CURLOPT_HTTPHEADER => [
            'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.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',
        ],
    ]);

    $res = curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err = (string)curl_error($ch);
    curl_close($ch);

    if ($res === false || $code >= 400) {
        logit($logFile, "XUPING_COOKIE_GET_FAIL code={$code} err={$err} url={$url}");
        return '';
    }

    return (string)$res;
}

function xuping_referer_with_page_number(string $referer, int $page): string
{
    $page = max(1, $page);
    $parts = parse_url($referer);
    if (!is_array($parts)) {
        return $referer;
    }

    $query = [];
    if (isset($parts['query']) && is_string($parts['query']) && $parts['query'] !== '') {
        parse_str($parts['query'], $query);
    }
    $query['PageNumber'] = $page;

    $scheme = isset($parts['scheme']) ? $parts['scheme'] . '://' : '';
    $host = $parts['host'] ?? '';
    $port = isset($parts['port']) ? ':' . $parts['port'] : '';
    $user = $parts['user'] ?? '';
    $pass = isset($parts['pass']) ? ':' . $parts['pass'] : '';
    $pass = ($user !== '' || $pass !== '') ? $pass . '@' : '';
    $path = $parts['path'] ?? '';
    $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';

    return $scheme . $user . $pass . $host . $port . $path . '?' . http_build_query($query) . $fragment;
}

function xuping_product_list_post(
    string $endpoint,
    string $referer,
    string $catId,
    int $page,
    string $cookieFile,
    string $logFile,
    string $traceFile,
    array $adasCfg,
    ?string &$lastPayloadKey = null
): ?array {
    $payloads = xuping_product_list_payload_candidates($catId, $page, $lastPayloadKey);
    $firstEmptyShape = null;
    $firstEmptyPayloadKey = null;

    foreach ($payloads as $payloadKey => $payload) {
        $body = http_build_query($payload);
        $timeout = (int)($adasCfg['adas']['request_timeout'] ?? 25);
        $requestReferer = xuping_referer_with_page_number($referer, $page);

        $ch = curl_init($endpoint);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT => $timeout,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
            CURLOPT_ENCODING => '',
            CURLOPT_COOKIEJAR => $cookieFile,
            CURLOPT_COOKIEFILE => $cookieFile,
            CURLOPT_REFERER => $requestReferer,
            CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
            CURLOPT_HTTPHEADER => [
                'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
                'Accept: */*',
                'Accept-Language: en-US,en;q=0.9,fa;q=0.8,de;q=0.7',
                'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
                'Origin: ' . xuping_origin_from_url($referer),
                'Referer: ' . $requestReferer,
                'X-Requested-With: XMLHttpRequest',
                'sec-ch-ua: "Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
                'sec-ch-ua-mobile: ?0',
                'sec-ch-ua-platform: "Windows"',
                'Sec-Fetch-Site: same-origin',
                'Sec-Fetch-Mode: cors',
                'Sec-Fetch-Dest: empty',
            ],
        ]);

        $res = curl_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err = (string)curl_error($ch);
        curl_close($ch);

        if ($res === false || $code >= 400) {
            logit($logFile, "XUPING_AJAX_HTTP_FAIL code={$code} err={$err} payload={$payloadKey} body={$body} url={$endpoint}");
            continue;
        }

        $raw = trim((string)$res);
        $json = json_decode($raw, true);
        if (!is_array($json)) {
            logit($logFile, 'XUPING_AJAX_JSON_FAIL payload=' . $payloadKey . ' body=' . $body . ' raw=' . mb_substr($raw, 0, 300));
            continue;
        }

        if (xuping_json_has_product_shape($json)) {
            $itemCount = count(xuping_ajax_items_array($json));
            $serverCount = xuping_json_int($json, ['Count', 'count']);
            $totalCount = xuping_json_int($json, ['TotalCount', 'totalCount', 'total_count']);

            if ($itemCount > 0) {
                $lastPayloadKey = $payloadKey;
                logit($traceFile, 'XUPING_AJAX_PAYLOAD_OK cat=' . $catId . ' page=' . $page . ' payload=' . $payloadKey . ' items=' . $itemCount . ' body=' . $body . ' body_len=' . strlen($body) . ' referer=' . $requestReferer);
                return $json;
            }

            if ($firstEmptyShape === null) {
                $firstEmptyShape = $json;
                $firstEmptyPayloadKey = $payloadKey;
            }

            logit(
                $traceFile,
                'XUPING_AJAX_EMPTY_SHAPE_TRY_NEXT cat=' . $catId .
                ' page=' . $page .
                ' payload=' . $payloadKey .
                ' count=' . (string)($serverCount ?? 'null') .
                ' total=' . (string)($totalCount ?? 'null') .
                ' body=' . $body
            );
            continue;
        }

        logit($logFile, 'XUPING_AJAX_SHAPE_FAIL payload=' . $payloadKey . ' body=' . $body . ' keys=' . implode(',', array_keys($json)));
    }

    if ($firstEmptyShape !== null) {
        $lastPayloadKey = $firstEmptyPayloadKey;
        return $firstEmptyShape;
    }

    return null;
}

function xuping_product_list_payload_candidates(string $catId, int $page, ?string $preferredKey = null): array
{
    // DevTools shows content-length=19, but Headers alone do not expose the exact Form Data.
    // For this Xuping page the category can be bound by Referer/session, so page-size payloads
    // must be tried before category-id payloads. CatId payloads are kept as fallback.
    $all = [
        // Exact browser Form Data from DevTools:
        // Cats=511735&Size=15 (content-length=19). Pagination is NOT in POST body;
        // the browser changes the category URL Referer with ?PageNumber=N.
        // Real browser Form Data:
        // page 1: Cats=<id>&Size=15
        // page 2+: Cats=<id>&Page=<n>&Size=15
        'Cats_Size_15' => ($page > 1 ? ['Cats' => $catId, 'Page' => $page, 'Size' => 15] : ['Cats' => $catId, 'Size' => 15]),
        'cats_size_15' => ($page > 1 ? ['cats' => $catId, 'page' => $page, 'size' => 15] : ['cats' => $catId, 'size' => 15]),
        'Cats_size_15' => ($page > 1 ? ['Cats' => $catId, 'Page' => $page, 'size' => 15] : ['Cats' => $catId, 'size' => 15]),
        'cats_Size_15' => ($page > 1 ? ['cats' => $catId, 'page' => $page, 'Size' => 15] : ['cats' => $catId, 'Size' => 15]),
        'Cats_Page_Size_15' => ['Cats' => $catId, 'Page' => $page, 'Size' => 15],

        // Referer/session based payloads. These are the most likely when the body length stays
        // the same across 511735 / 511736 / 511737 and only the Referer changes.
        'Page_PageSize_15' => ['Page' => $page, 'PageSize' => 15],
        'page_pageSize_15' => ['page' => $page, 'pageSize' => 15],
        'Page_pageSize_15' => ['Page' => $page, 'pageSize' => 15],
        'page_PageSize_15' => ['page' => $page, 'PageSize' => 15],
        'Page_Count_15' => ['Page' => $page, 'Count' => 15],
        'page_count_15' => ['page' => $page, 'count' => 15],
        'Page_Take_15' => ['Page' => $page, 'Take' => 15],
        'page_take_15' => ['page' => $page, 'take' => 15],
        'Page_Limit_15' => ['Page' => $page, 'Limit' => 15],
        'page_limit_15' => ['page' => $page, 'limit' => 15],
        'Page_PerPage_15' => ['Page' => $page, 'PerPage' => 15],
        'page_perPage_15' => ['page' => $page, 'perPage' => 15],
        'Page_Size_15' => ['Page' => $page, 'Size' => 15],
        'page_size_15' => ['page' => $page, 'size' => 15],
        'Page_Order_0' => ['Page' => $page, 'Order' => 0],
        'page_order_0' => ['page' => $page, 'order' => 0],
        'Page_Sort_0' => ['Page' => $page, 'Sort' => 0],
        'page_sort_0' => ['page' => $page, 'sort' => 0],

        // Category-id payloads. The browser header content-length=19 also matches CatId=511736&Page=1.
        'CatId_Page' => ['CatId' => $catId, 'Page' => $page],
        'catId_page' => ['catId' => $catId, 'page' => $page],
        'CatId_page' => ['CatId' => $catId, 'page' => $page],
        'catId_Page' => ['catId' => $catId, 'Page' => $page],
        'Page_CatId' => ['Page' => $page, 'CatId' => $catId],
        'page_catId' => ['page' => $page, 'catId' => $catId],

        // Group variants; kept because the category page URLs are group/category pages.
        'group_page' => ['group' => $catId, 'page' => $page],
        'Group_Page' => ['Group' => $catId, 'Page' => $page],
        'group_Page' => ['group' => $catId, 'Page' => $page],
        'Group_page' => ['Group' => $catId, 'page' => $page],
        'page_group' => ['page' => $page, 'group' => $catId],
        'Page_Group' => ['Page' => $page, 'Group' => $catId],

        // Broader fallbacks.
        'categoryId_Page' => ['categoryId' => $catId, 'Page' => $page],
        'CategoryId_Page' => ['CategoryId' => $catId, 'Page' => $page],
        'category_page' => ['category' => $catId, 'page' => $page],
        'Category_Page' => ['Category' => $catId, 'Page' => $page],
        'cid_page' => ['cid' => $catId, 'page' => $page],
        'id_Page' => ['id' => $catId, 'Page' => $page],
        'Id_Page' => ['Id' => $catId, 'Page' => $page],
        'page_only' => ['page' => $page],
        'Page_only' => ['Page' => $page],
    ];

    if ($preferredKey !== null && isset($all[$preferredKey])) {
        return [$preferredKey => $all[$preferredKey]] + $all;
    }

    return $all;
}

function xuping_json_has_product_shape(array $json): bool
{
    if (isset($json['Items']) && is_array($json['Items'])) {
        return true;
    }
    if (isset($json['items']) && is_array($json['items'])) {
        return true;
    }
    if (isset($json['Result']) && is_array($json['Result']) && isset($json['Result']['Items']) && is_array($json['Result']['Items'])) {
        return true;
    }
    if (isset($json['Data']) && is_array($json['Data']) && isset($json['Data']['Items']) && is_array($json['Data']['Items'])) {
        return true;
    }
    return false;
}

function xuping_ajax_items_array(array $json): array
{
    if (isset($json['Items']) && is_array($json['Items'])) {
        return $json['Items'];
    }
    if (isset($json['items']) && is_array($json['items'])) {
        return $json['items'];
    }
    if (isset($json['Result']) && is_array($json['Result']) && isset($json['Result']['Items']) && is_array($json['Result']['Items'])) {
        return $json['Result']['Items'];
    }
    if (isset($json['Data']) && is_array($json['Data']) && isset($json['Data']['Items']) && is_array($json['Data']['Items'])) {
        return $json['Data']['Items'];
    }
    return [];
}

function parse_xuping_ajax_products(array $json, string $baseUrl): array
{
    $out = [];
    foreach (xuping_ajax_items_array($json) as $row) {
        if (!is_array($row)) {
            continue;
        }

        $url = abs_url($baseUrl, (string)($row['Link'] ?? $row['link'] ?? $row['Url'] ?? $row['url'] ?? ''));
        if ($url === '') {
            continue;
        }

        $title = normalize_space((string)($row['Name'] ?? $row['name'] ?? $row['Title'] ?? $row['title'] ?? ''));
        if ($title === '') {
            $title = 'محصول ژوپینگ';
        }

        $pText = normalize_space((string)($row['PText'] ?? $row['pText'] ?? $row['PriceText'] ?? $row['priceText'] ?? ''));
        $finalPrice = $row['FinalPrice'] ?? $row['finalPrice'] ?? $row['Price'] ?? $row['price'] ?? null;
        $priceNew = '';
        if ($pText !== '') {
            $priceNew = extract_price_text($pText);
        } elseif (is_numeric($finalPrice) && (float)$finalPrice > 0) {
            $priceNew = number_format((float)$finalPrice, 0, '.', ',') . ' تومان';
        }

        $hasAddBox = $row['HasAddBox'] ?? $row['hasAddBox'] ?? null;
        $stock = 'in';
        if ($hasAddBox === false || $hasAddBox === 0 || $hasAddBox === 'false') {
            $stock = 'out';
        }
        $blob = normalize_text_key($title . ' ' . $pText . ' ' . (string)($row['StockText'] ?? $row['stockText'] ?? ''));
        if (mb_strpos($blob, 'ناموجود') !== false || mb_strpos($blob, 'اتمام موجودی') !== false || mb_strpos($blob, 'موجود نیست') !== false) {
            $stock = 'out';
            $priceNew = '';
        }

        $imgUrl = abs_url($baseUrl, (string)($row['Pic'] ?? $row['pic'] ?? $row['Image'] ?? $row['image'] ?? $row['ImageUrl'] ?? $row['imageUrl'] ?? ''));

        $out[$url] = [
            'url' => $url,
            'title' => $title,
            'stock' => $stock,
            'price_new' => $priceNew,
            'price_old' => '',
            'image_url' => $imgUrl,
            'variant_fields' => [],
        ];
    }

    return $out;
}

function xuping_origin_from_url(string $url): string
{
    $scheme = (string)(parse_url($url, PHP_URL_SCHEME) ?: 'https');
    $host = (string)(parse_url($url, PHP_URL_HOST) ?: 'xupingir.com');
    return $scheme . '://' . $host;
}

function parse_xuping_products(string $html, string $baseUrl): array
{
    $out = [];

    libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    if (!$dom->loadHTML('<?xml encoding="UTF-8">' . $html)) {
        return $out;
    }

    $xp = new DOMXPath($dom);
    $boxes = $xp->query("//a[@product-thumb='root' or contains(concat(' ', normalize-space(@class), ' '), ' product-thumb2 ')]");
    if (!$boxes) {
        return $out;
    }

    foreach ($boxes as $box) {
        if (!($box instanceof DOMElement)) {
            continue;
        }

        $href = (string)$box->getAttribute('href');
        $url = abs_url($baseUrl, $href);
        if ($url === '') {
            continue;
        }

        $titleNode = $xp->query(".//*[@product-thumb='name'] | .//*[contains(concat(' ', normalize-space(@class), ' '), ' ttl ')] | .//h1 | .//h2 | .//h3", $box)->item(0);
        $title = $titleNode ? normalize_space($titleNode->textContent) : '';
        if ($title === '') {
            $img = $xp->query(".//img", $box)->item(0);
            if ($img instanceof DOMElement) {
                $title = normalize_space((string)($img->getAttribute('alt') ?: $img->getAttribute('title')));
            }
        }
        if ($title === '') {
            $title = 'محصول ژوپینگ';
        }

        $priceNew = '';
        $priceOld = '';

        $priceNode = $xp->query(".//*[contains(concat(' ', normalize-space(@class), ' '), ' price-box ')]//*[contains(concat(' ', normalize-space(@class), ' '), ' fnl ') or contains(concat(' ', normalize-space(@class), ' '), ' num ')] | .//*[contains(concat(' ', normalize-space(@class), ' '), ' fnl ')]", $box)->item(0);
        if ($priceNode) {
            $priceNew = extract_price_text($priceNode->textContent);
        }

        $oldNode = $xp->query(".//*[contains(concat(' ', normalize-space(@class), ' '), ' old ') or contains(concat(' ', normalize-space(@class), ' '), ' prv ') or contains(concat(' ', normalize-space(@class), ' '), ' crossed ')]", $box)->item(0);
        if ($oldNode) {
            $priceOld = extract_price_text($oldNode->textContent);
        }

        $blob = normalize_digits(normalize_space($box->textContent));
        $stock = 'in';

        if (mb_stripos($blob, 'ناموجود') !== false || mb_stripos($blob, 'اتمام موجودی') !== false || mb_stripos($blob, 'موجود نیست') !== false) {
            $stock = 'out';
            $priceNew = '';
            $priceOld = '';
        }

        $imgUrl = '';
        $img = $xp->query(".//img", $box)->item(0);
        if ($img instanceof DOMElement) {
            $imgUrl = abs_url($baseUrl, (string)($img->getAttribute('data-src') ?: $img->getAttribute('src')));
        }

        $out[$url] = [
            'url' => $url,
            'title' => $title,
            'stock' => $stock,
            'price_new' => $priceNew,
            'price_old' => $priceOld,
            'image_url' => $imgUrl,
            'variant_fields' => [],
        ];
    }

    return $out;
}

function parse_galleryadas_products(string $html, string $baseUrl): array
{
    return parse_xuping_products($html, $baseUrl);
}

function get_variant_mode_by_cat($catId, string $categoryName, array $adasCfg): string
{
    $manual = (array)($adasCfg['adas']['variant_mode_map'] ?? []);
    if (isset($manual[$catId])) {
        return (string)$manual[$catId];
    }
    if (isset($manual[$categoryName])) {
        return (string)$manual[$categoryName];
    }
    return 'all';
}

function variant_label_by_mode(string $mode): string
{
    return $mode === 'color' ? 'رنگ' : 'سایز';
}

function extract_generic_option_value(string $raw): string
{
    $raw = normalize_space($raw);
    $raw = preg_replace('~^انتخاب\s+ویژگی\s*[:\-]?\s*~u', '', $raw);
    return normalize_space((string)$raw);
}

function detect_attr_name_from_value(string $value, string $mode = 'all'): string
{
    $value = normalize_space($value);

    if ($mode === 'size') {
        return 'سایز';
    }
    if ($mode === 'color') {
        return 'رنگ';
    }

    if (mb_stripos($value, 'رنگ') !== false) {
        return 'رنگ';
    }
    if (mb_stripos($value, 'سایز') !== false) {
        return 'سایز';
    }
    if (mb_stripos($value, 'مدل') !== false) {
        return 'مدل';
    }
    if (mb_stripos($value, 'طرح') !== false) {
        return 'طرح';
    }
    if (mb_stripos($value, 'اندازه') !== false) {
        return 'اندازه';
    }
    if (mb_stripos($value, 'حروف') !== false) {
        return 'حروف';
    }

    return 'انتخاب ویژگی';
}

function add_variant_field_value(array &$fields, string $attrName, string $value): void
{
    $attrName = normalize_space($attrName);
    $value = extract_generic_option_value($value);

    if ($attrName === '' || $value === '' || $value === 'انتخاب ویژگی') {
        return;
    }

    if (!isset($fields[$attrName])) {
        $fields[$attrName] = [];
    }
    $fields[$attrName][] = $value;
}

function xuping_attr_name_from_sel(string $sel, string $fallback, string $mode): string
{
    $selKey = normalize_text_key($sel);
    if ($fallback !== '') {
        $fallback = preg_replace('~[:：]\s*\d+\s*$~u', '', $fallback);
        $fallback = preg_replace('~\s+\d+\s*$~u', '', $fallback);
        $fallback = normalize_space((string)$fallback);
        if ($fallback !== '') {
            return $fallback;
        }
    }

    if ($selKey === 'color' || mb_stripos($selKey, 'رنگ') !== false) {
        return 'رنگ';
    }
    if ($selKey === 'size' || mb_stripos($selKey, 'سایز') !== false || mb_stripos($selKey, 'اندازه') !== false) {
        return 'سایز';
    }
    if ($selKey === 'model' || mb_stripos($selKey, 'مدل') !== false) {
        return 'مدل';
    }

    return variant_label_by_mode($mode);
}

function xuping_option_value_from_node(DOMElement $node): string
{
    // برای ژوپینگ، رنگ‌ها معمولاً اسم متنی ندارند و به شکل data-code="#462020" value="120" هستند.
    // مقدار قابل سنجش را همان کد رنگ می‌گیریم، نه متن خالی داخل span.
    if ($node->hasAttribute('data-code')) {
        $code = normalize_space((string)$node->getAttribute('data-code'));
        if ($code !== '') {
            return $code;
        }
    }

    foreach (['data-name','data-title','title','aria-label','data-text','data-value','value'] as $attr) {
        if ($node->hasAttribute($attr)) {
            $v = normalize_space((string)$node->getAttribute($attr));
            if ($v !== '') {
                return $v;
            }
        }
    }

    $txt = normalize_space($node->textContent);
    if ($txt !== '') {
        return $txt;
    }

    return '';
}

function html_attr_value_from_tag(string $tag, string $attr): string
{
    $attrQ = preg_quote($attr, '~');
    if (preg_match('~\b' . $attrQ . '\s*=\s*(["\'])(.*?)\1~isu', $tag, $m)) {
        return normalize_space((string)$m[2]);
    }
    if (preg_match('~\b' . $attrQ . '\s*=\s*([^\s>]+)~isu', $tag, $m)) {
        return normalize_space(trim((string)$m[1], '"\''));
    }
    return '';
}

function find_matching_div_end(string $html, int $openPos): int
{
    if (!preg_match_all('~</?div\b[^>]*>~isu', $html, $matches, PREG_OFFSET_CAPTURE, $openPos)) {
        return -1;
    }

    $depth = 0;
    foreach ($matches[0] as $pair) {
        $tag = (string)$pair[0];
        $pos = (int)$pair[1];
        $isClose = preg_match('~^</div~iu', $tag) === 1;
        $isSelfClosing = preg_match('~/\s*>$~u', $tag) === 1;

        if (!$isClose) {
            $depth++;
            if ($isSelfClosing) {
                $depth--;
            }
        } else {
            $depth--;
            if ($depth <= 0) {
                return $pos + strlen($tag);
            }
        }
    }

    return -1;
}


function xuping_unescape_variant_html(string $html): string
{
    $s = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');

    // بعضی وقت‌ها ژوپینگ HTML ویژگی‌ها را داخل JSON/JS escape می‌کند؛ برای همین DOM/regex عادی مقدارها را نمی‌بیند.
    $repls = [
        '\\u003c' => '<',
        '\\u003C' => '<',
        '\\u003e' => '>',
        '\\u003E' => '>',
        '\\u0022' => '"',
        '\\u0027' => "'",
        '\\/' => '/',
        '\\"' => '"',
        "\\'" => "'",
    ];

    return strtr($s, $repls);
}

function extract_xuping_direct_color_fields(string $html, string $mode = 'all'): array
{
    $fields = [];
    $s = xuping_unescape_variant_html($html);

    // اگر صفحه اصلاً سکشن ویژگی/رنگ نداشت، وارد استخراج سراسری نشویم.
    $hasColorSelector = preg_match('~data-sel\s*=\s*(?:["\']|&quot;)?\s*Color\s*(?:["\']|&quot;)?~iu', $s) === 1
        || mb_stripos($s, 'data-sel="Color"') !== false
        || mb_stripos($s, "data-sel='Color'") !== false
        || mb_stripos($s, 'data-sel=Color') !== false
        || mb_stripos($s, 'data-sel') !== false;

    if (!$hasColorSelector || mb_stripos($s, 'data-code') === false) {
        return [];
    }

    $rawOptions = [];

    // حالت اصلی: <div data-code="#963e3e" value="122" ...>
    if (preg_match_all('~<div\b[^>]*\bdata-code\s*=\s*(?:["\']|&quot;)?\s*(#[0-9a-fA-F]{3,8})\s*(?:["\']|&quot;)?[^>]*>~isu', $s, $tags, PREG_SET_ORDER)) {
        foreach ($tags as $m) {
            $tag = (string)$m[0];
            if (stripos($tag, 'data-sel') !== false) {
                continue;
            }
            $code = normalize_space((string)$m[1]);
            $id = html_attr_value_from_tag($tag, 'value');
            if ($code !== '') {
                $rawOptions[] = ['code' => $code, 'id' => $id];
            }
        }
    }

    // fallback خیلی تهاجمی برای وقتی تگ‌ها داخل string ناقص/escape شده‌اند.
    if (empty($rawOptions)) {
        if (preg_match_all('~data-code\s*=\s*(?:["\']|&quot;)?\s*(#[0-9a-fA-F]{3,8})\s*(?:["\']|&quot;)?(?:(?!data-code).){0,260}?\bvalue\s*=\s*(?:["\']|&quot;)?\s*([0-9A-Za-z_\-]+)~isu', $s, $pairs, PREG_SET_ORDER)) {
            foreach ($pairs as $m) {
                $rawOptions[] = ['code' => normalize_space((string)$m[1]), 'id' => normalize_space((string)$m[2])];
            }
        }
    }

    // حالت value قبل از data-code، برای اطمینان.
    if (empty($rawOptions)) {
        if (preg_match_all('~\bvalue\s*=\s*(?:["\']|&quot;)?\s*([0-9A-Za-z_\-]+)\s*(?:["\']|&quot;)?(?:(?!value).){0,260}?data-code\s*=\s*(?:["\']|&quot;)?\s*(#[0-9a-fA-F]{3,8})~isu', $s, $pairs, PREG_SET_ORDER)) {
            foreach ($pairs as $m) {
                $rawOptions[] = ['code' => normalize_space((string)$m[2]), 'id' => normalize_space((string)$m[1])];
            }
        }
    }

    if (empty($rawOptions)) {
        return [];
    }

    $codeCounts = [];
    foreach ($rawOptions as $opt) {
        $code = (string)($opt['code'] ?? '');
        if ($code === '') {
            continue;
        }
        $key = mb_strtolower($code);
        $codeCounts[$key] = ($codeCounts[$key] ?? 0) + 1;
    }

    foreach ($rawOptions as $opt) {
        $code = normalize_space((string)($opt['code'] ?? ''));
        $id = normalize_space((string)($opt['id'] ?? ''));
        if ($code === '') {
            continue;
        }

        // اگر یک کد رنگ چند بار آمده، شناسه value را هم اضافه می‌کنیم تا مقصد گزینه‌های تکراری را حذف نکند.
        $key = mb_strtolower($code);
        $label = $code;
        if (($codeCounts[$key] ?? 0) > 1 && $id !== '') {
            $label = $code . ' (' . $id . ')';
        }

        add_variant_field_value($fields, 'رنگ', $label);
    }

    return normalize_attr_map($fields);
}

function extract_product_variant_fields_regex(string $html, string $mode = 'all'): array
{
    $fields = [];

    if (!preg_match_all('~<div\b[^>]*\bdata-sel\s*=\s*(["\'])(.*?)\1[^>]*>~isu', $html, $containers, PREG_OFFSET_CAPTURE)) {
        return [];
    }

    foreach ($containers[0] as $idx => $pair) {
        $openTag = (string)$pair[0];
        $openPos = (int)$pair[1];
        $sel = normalize_space((string)($containers[2][$idx][0] ?? ''));

        $endPos = find_matching_div_end($html, $openPos);
        if ($endPos <= $openPos) {
            continue;
        }

        $block = substr($html, $openPos, $endPos - $openPos);

        $before = substr($html, max(0, $openPos - 1200), min(1200, $openPos));
        $fallback = '';
        if (preg_match('~<span\b[^>]*class\s*=\s*(["\'])[^"\']*\btitle\b[^"\']*\1[^>]*>\s*(.*?)\s*</span>\s*<div\b[^>]*\bdata-sel\b~isu', $before . $openTag, $hm)) {
            $fallback = normalize_space(strip_tags((string)$hm[2]));
        }
        if ($fallback === '' && preg_match('~<span\b[^>]*class\s*=\s*(["\'])[^"\']*\btitle\b[^"\']*\1[^>]*>(.*?)$~isu', $before, $hm)) {
            $fallback = normalize_space(strip_tags((string)$hm[2]));
        }

        $fallback = preg_replace('~[:：]\s*\d+\s*$~u', '', (string)$fallback);
        $fallback = preg_replace('~\s+\d+\s*$~u', '', (string)$fallback);
        $attrName = xuping_attr_name_from_sel($sel, normalize_space((string)$fallback), $mode);

        if (!preg_match_all('~<(?P<tag>div|label|span|button|input)\b(?P<attrs>[^>]*(?:\bdata-code\b|\bdata-value\b|\bdata-name\b|\bdata-title\b|\btitle\b|\baria-label\b|\bvalue\b)[^>]*)>~isu', $block, $opts, PREG_SET_ORDER)) {
            continue;
        }

        foreach ($opts as $opt) {
            $tagText = '<' . (string)$opt['tag'] . (string)$opt['attrs'] . '>';
            // خود کانتینر data-sel یا wrapperهای بدون مقدار واقعی را رد کن.
            if (stripos($tagText, 'data-sel') !== false) {
                continue;
            }

            $class = normalize_text_key(html_attr_value_from_tag($tagText, 'class'));
            if (html_attr_value_from_tag($tagText, 'disabled') !== '' || mb_stripos($class, 'disabled') !== false || mb_stripos($class, 'unavailable') !== false) {
                continue;
            }

            $value = '';
            foreach (['data-code','data-name','data-title','title','aria-label','data-text','data-value','value'] as $attr) {
                $value = html_attr_value_from_tag($tagText, $attr);
                if ($value !== '') {
                    break;
                }
            }

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

            add_variant_field_value($fields, $attrName, $value);
        }
    }

    return normalize_attr_map($fields);
}

function extract_product_variant_fields_html(string $html, string $mode = 'all'): array
{
    $fields = [];

    // اول با DOM می‌خوانیم، بعد regex fallback/merge می‌زنیم تا اگر HTML واقعی ژوپینگ کمی ناقص/نامتوازن بود، واریانت‌ها صفر نشوند.
    if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
        libxml_use_internal_errors(true);
        $dom = new DOMDocument();
        if ($dom->loadHTML('<?xml encoding="UTF-8">' . $html)) {
            $xp = new DOMXPath($dom);

            $containers = $xp->query("//div[contains(concat(' ', normalize-space(@class), ' '), ' product-info ')]//div[@data-sel] | //div[contains(concat(' ', normalize-space(@class), ' '), ' prsz ')]//div[@data-sel]");

            if ($containers) {
                foreach ($containers as $container) {
                    if (!($container instanceof DOMElement)) {
                        continue;
                    }

                    $sel = (string)$container->getAttribute('data-sel');
                    $heading = '';
                    $parent = $container->parentNode;
                    if ($parent instanceof DOMElement) {
                        $headingNode = $xp->query(".//*[contains(concat(' ', normalize-space(@class), ' '), ' title ')]", $parent)->item(0);
                        if ($headingNode instanceof DOMNode) {
                            $heading = normalize_space($headingNode->textContent);
                            $heading = preg_replace('~[:：]\s*\d+\s*$~u', '', (string)$heading);
                            $heading = preg_replace('~\s+\d+\s*$~u', '', (string)$heading);
                            $heading = normalize_space((string)$heading);
                        }
                    }

                    $attrName = xuping_attr_name_from_sel($sel, $heading, $mode);

                    $options = $xp->query("./*[self::div or self::label or self::span or self::button or self::input][@value or @data-code or @data-value or @data-name or @data-title or @title or @aria-label]", $container);
                    if (!$options || $options->length === 0) {
                        $options = $xp->query(".//*[self::div or self::label or self::span or self::button or self::input][@value or @data-code or @data-value or @data-name or @data-title or @title or @aria-label]", $container);
                    }
                    if (!$options) {
                        continue;
                    }

                    foreach ($options as $opt) {
                        if (!($opt instanceof DOMElement)) {
                            continue;
                        }

                        $class = normalize_text_key((string)$opt->getAttribute('class'));
                        if ($opt->hasAttribute('disabled') || mb_stripos($class, 'disabled') !== false || mb_stripos($class, 'unavailable') !== false) {
                            continue;
                        }

                        $value = xuping_option_value_from_node($opt);
                        if ($value === '') {
                            continue;
                        }

                        add_variant_field_value($fields, $attrName, $value);
                    }
                }
            }
        }
    }

    $regexFields = extract_product_variant_fields_regex($html, $mode);
    foreach ($regexFields as $attrName => $vals) {
        foreach ((array)$vals as $val) {
            add_variant_field_value($fields, (string)$attrName, (string)$val);
        }
    }

    // fallback نهایی مخصوص ژوپینگ: هر data-code رنگ را مستقیم از کل HTML، حتی اگر escape شده باشد، بخوان.
    $directColorFields = extract_xuping_direct_color_fields($html, $mode);
    foreach ($directColorFields as $attrName => $vals) {
        foreach ((array)$vals as $val) {
            add_variant_field_value($fields, (string)$attrName, (string)$val);
        }
    }

    return normalize_attr_map($fields);
}

function fetch_product_variant_fields(string $url, string $mode, string $logFile, array $adasCfg = []): array
{
    $html = http_get($url, $logFile, $adasCfg);
    if ($html === '') {
        logit($logFile, 'PRODUCT_FETCH_FAIL url=' . $url);
        return [];
    }

    $fields = extract_product_variant_fields_html($html, $mode);
    logit($logFile, 'PRODUCT_VARIANT_FIELDS url=' . $url . ' mode=' . $mode . ' fields=' . json_encode($fields, JSON_UNESCAPED_UNICODE));
    return $fields;
}


function xuping_raw_input_tag_by_id(string $html, string $id): string
{
    $idQ = preg_quote($id, '~');
    if (preg_match('~<input\b(?=[^>]*\bid\s*=\s*(["\'])' . $idQ . '\1)[^>]*>~isu', $html, $m)) {
        return (string)$m[0];
    }
    if (preg_match('~<input\b(?=[^>]*\bid\s*=\s*' . $idQ . '(?:\s|/|>))[^>]*>~isu', $html, $m)) {
        return (string)$m[0];
    }
    return '';
}

function xuping_raw_attr_from_tag(string $tag, string $attr): string
{
    if ($tag === '') {
        return '';
    }

    $attrQ = preg_quote($attr, '~');
    if (preg_match('~\b' . $attrQ . '\s*=\s*(["\'])(.*?)\1~isu', $tag, $m)) {
        return html_entity_decode((string)$m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }
    if (preg_match('~\b' . $attrQ . '\s*=\s*([^\s>]+)~isu', $tag, $m)) {
        return html_entity_decode(trim((string)$m[1], '"\''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }

    return '';
}

function xuping_hidden_json_by_id(string $html, string $id): ?array
{
    $tag = xuping_raw_input_tag_by_id($html, $id);
    if ($tag === '') {
        return null;
    }

    $raw = xuping_raw_attr_from_tag($tag, 'value');
    if (trim($raw) === '') {
        return null;
    }

    $candidates = [];
    $candidates[] = $raw;
    $candidates[] = stripslashes($raw);
    $candidates[] = xuping_unescape_variant_html($raw);
    $candidates[] = stripslashes(xuping_unescape_variant_html($raw));

    foreach (array_values(array_unique($candidates)) as $candidate) {
        $candidate = trim((string)$candidate);
        if ($candidate === '') {
            continue;
        }
        $json = json_decode($candidate, true);
        if (is_array($json)) {
            return $json;
        }
    }

    return null;
}

function xuping_ptjson_base_price_from_html(string $html): int
{
    $tag = xuping_raw_input_tag_by_id($html, 'ptJson');
    if ($tag === '') {
        return 0;
    }

    $price = xuping_numeric_to_int(xuping_raw_attr_from_tag($tag, 'data-price'));
    return $price > 0 ? $price : 0;
}

function xuping_numeric_to_int($value): int
{
    if ($value === null) {
        return 0;
    }

    if (is_int($value)) {
        return $value;
    }
    if (is_float($value)) {
        return (int)round($value);
    }
    if (is_numeric($value)) {
        return (int)round((float)$value);
    }

    $s = normalize_digits((string)$value);
    $s = str_replace([',', '٬', '،', 'تومان', 'ریال', ' '], '', $s);
    $s = preg_replace('/[^0-9\.]/u', '', (string)$s);
    if ($s === '') {
        return 0;
    }

    return (int)round((float)$s);
}

function xuping_color_code_normalize($code): string
{
    $code = trim((string)$code);
    if ($code === '') {
        return '';
    }
    $code = ltrim($code, '#');
    if (!preg_match('~^[0-9a-fA-F]{3,8}$~', $code)) {
        return '';
    }
    return '#' . mb_strtolower($code);
}

function xuping_hex_to_rgb(string $code): ?array
{
    $code = xuping_color_code_normalize($code);
    if ($code === '') {
        return null;
    }

    $hex = ltrim($code, '#');
    if (strlen($hex) === 3) {
        $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
    }
    if (strlen($hex) >= 8) {
        // اگر alpha هم داشت، فقط RGB مهم است.
        $hex = substr($hex, 0, 6);
    }
    if (strlen($hex) !== 6 || !ctype_xdigit($hex)) {
        return null;
    }

    return [
        hexdec(substr($hex, 0, 2)),
        hexdec(substr($hex, 2, 2)),
        hexdec(substr($hex, 4, 2)),
    ];
}

function xuping_rgb_to_hsv(int $r, int $g, int $b): array
{
    $rf = $r / 255;
    $gf = $g / 255;
    $bf = $b / 255;

    $max = max($rf, $gf, $bf);
    $min = min($rf, $gf, $bf);
    $delta = $max - $min;

    if ($delta == 0.0) {
        $h = 0.0;
    } elseif ($max == $rf) {
        $h = 60 * fmod((($gf - $bf) / $delta), 6);
    } elseif ($max == $gf) {
        $h = 60 * ((($bf - $rf) / $delta) + 2);
    } else {
        $h = 60 * ((($rf - $gf) / $delta) + 4);
    }

    if ($h < 0) {
        $h += 360;
    }

    $s = $max == 0.0 ? 0.0 : ($delta / $max);
    $v = $max;

    return [$h, $s, $v];
}

function xuping_friendly_color_name_from_hex(string $code): string
{
    $rgb = xuping_hex_to_rgb($code);
    if ($rgb === null) {
        return '';
    }

    [$r, $g, $b] = $rgb;
    [$h, $s, $v] = xuping_rgb_to_hsv($r, $g, $b);

    // رنگ‌های خنثی
    if ($v <= 0.08) {
        return 'مشکی';
    }
    if ($s <= 0.08 && $v >= 0.92) {
        return 'سفید';
    }
    if ($s <= 0.12) {
        if ($v >= 0.75) {
            return 'نقره‌ای';
        }
        if ($v >= 0.35) {
            return 'خاکستری';
        }
        return 'نوک‌مدادی';
    }

    // قهوه‌ای‌ها، مسی‌ها و طلایی‌ها
    if (($h >= 15 && $h < 45) && $v < 0.62) {
        return $v < 0.38 ? 'قهوه‌ای تیره' : 'قهوه‌ای';
    }
    if (($h >= 20 && $h < 55) && $r > $g && $g > $b) {
        if ($v >= 0.65 && $s >= 0.35) {
            return 'طلایی';
        }
        return 'مسی';
    }

    // قرمز، زرشکی، صورتی
    if ($h >= 345 || $h < 15) {
        if ($v < 0.42) {
            return 'زرشکی تیره';
        }
        if ($r >= 120 && $g <= 95 && $b <= 95) {
            return 'زرشکی';
        }
        if ($s < 0.35 || $v > 0.82) {
            return 'صورتی';
        }
        return 'قرمز';
    }

    if ($h >= 15 && $h < 28) {
        return $v < 0.55 ? 'قهوه‌ای مایل به قرمز' : 'آجری';
    }
    if ($h >= 28 && $h < 45) {
        return 'نارنجی';
    }
    if ($h >= 45 && $h < 70) {
        return $v >= 0.72 ? 'زرد' : 'کرم';
    }
    if ($h >= 70 && $h < 165) {
        if ($v < 0.35) {
            return 'سبز تیره';
        }
        return 'سبز';
    }
    if ($h >= 165 && $h < 195) {
        return 'فیروزه‌ای';
    }
    if ($h >= 195 && $h < 255) {
        return $v < 0.4 ? 'سرمه‌ای' : 'آبی';
    }
    if ($h >= 255 && $h < 290) {
        return 'بنفش';
    }
    if ($h >= 290 && $h < 345) {
        return $v >= 0.72 ? 'صورتی' : 'بنفش مایل به صورتی';
    }

    return '';
}

function xuping_color_name_is_useful(string $name): bool
{
    $name = normalize_space($name);
    if ($name === '') {
        return false;
    }

    $key = normalize_text_key($name);
    if (preg_match('~^#?[0-9a-f]{3,8}$~iu', $key)) {
        return false;
    }
    if (preg_match('~^[0-9]+$~u', $key)) {
        return false;
    }

    return !in_array($key, ['color', 'colour', 'رنگ', 'انتخاب رنگ', 'none', 'null'], true);
}

function xuping_ptjson_color_label(array $color, int $colorId): string
{
    // خواسته جدید: متن واریانت فقط همان عدد/نامی باشد که ژوپینگ جلوی رنگ نشان می‌دهد.
    // در ptJson معمولاً Colors[].Name همان عدد نمایشی است، مثل 3، 4، 5، 6.
    // دیگر از ColorCode مثل #963e3e اسم رنگ نمی‌سازیم و کد رنگ را هم داخل نام واریانت نمی‌گذاریم.
    $name = normalize_space((string)($color['Name'] ?? $color['name'] ?? ''));

    if ($name !== '') {
        return $name;
    }

    // fallback پایدار: اگر Name نبود، فقط خود ColorId را می‌گذاریم تا پایش بعدی هم با همین بسنجد.
    return (string)$colorId;
}

function xuping_variant_attrs_key(array $attrs): string
{
    $parts = [];
    foreach ($attrs as $attrName => $value) {
        $attrName = normalize_space((string)$attrName);
        $value = normalize_space((string)$value);
        if ($attrName !== '' && $value !== '') {
            $parts[] = normalize_text_key($attrName) . '=' . normalize_text_key($value);
        }
    }
    sort($parts, SORT_NATURAL);
    return implode('|', $parts);
}

function xuping_rebuild_variant_attribute_value(array $attrs): string
{
    $parts = [];
    foreach ($attrs as $value) {
        $value = normalize_space((string)$value);
        if ($value !== '') {
            $parts[] = $value;
        }
    }
    return normalize_space(implode(' / ', $parts));
}

function xuping_uniquify_duplicate_variant_labels(array $variants): array
{
    // اگر چند واریانت دقیقاً یک ترکیب ویژگی یکسان پیدا کنند، مقصد آن‌ها را یکی می‌بیند.
    // در این حالت فقط برای همان تکراری‌ها یک شماره پایدار اضافه می‌کنیم، نه برای همه رنگ‌ها.
    $counts = [];
    foreach ($variants as $variant) {
        if (!is_array($variant)) {
            continue;
        }
        $key = xuping_variant_attrs_key((array)($variant['attributes'] ?? []));
        if ($key !== '') {
            $counts[$key] = ($counts[$key] ?? 0) + 1;
        }
    }

    foreach ($variants as $i => $variant) {
        if (!is_array($variant)) {
            continue;
        }

        $attrs = (array)($variant['attributes'] ?? []);
        $key = xuping_variant_attrs_key($attrs);
        if ($key === '' || ($counts[$key] ?? 0) <= 1 || !isset($attrs['رنگ'])) {
            continue;
        }

        $colorId = (int)($variant['source_color_id'] ?? 0);
        $suffix = $colorId > 0 ? (' شماره ' . $colorId) : (' شماره ' . ($i + 1));
        $baseLabel = normalize_space((string)$attrs['رنگ']);
        if ($baseLabel !== '' && mb_stripos($baseLabel, ' شماره ') === false) {
            $attrs['رنگ'] = $baseLabel . $suffix;
            $variants[$i]['attributes'] = $attrs;
            $variants[$i]['attribute_value'] = xuping_rebuild_variant_attribute_value($attrs);
        }
    }

    return $variants;
}

function extract_xuping_ptjson_variants_html(string $html, array $item = [], string $mode = 'all'): array
{
    if ($mode === 'none') {
        return [];
    }

    $pt = xuping_hidden_json_by_id($html, 'ptJson');
    if (!is_array($pt)) {
        return [];
    }

    $colorsById = [];
    foreach ((array)($pt['Colors'] ?? $pt['colors'] ?? []) as $color) {
        if (!is_array($color)) {
            continue;
        }
        $id = (int)($color['Id'] ?? $color['id'] ?? 0);
        if ($id <= 0) {
            continue;
        }
        $colorsById[$id] = $color;
    }

    $basePrice = price_to_int((string)($item['price_new'] ?? ''));
    if ($basePrice <= 0) {
        $basePrice = xuping_ptjson_base_price_from_html($html);
    }

    $variants = [];
    $seen = [];

    foreach ((array)($pt['Counts'] ?? $pt['counts'] ?? []) as $row) {
        if (!is_array($row)) {
            continue;
        }

        $hidden = (bool)($row['Hidden'] ?? $row['hidden'] ?? false);
        if ($hidden) {
            continue;
        }

        $stock = (int)($row['Count'] ?? $row['count'] ?? 0);
        if ($stock <= 0) {
            continue;
        }

        $colorId = (int)($row['ColorId'] ?? $row['colorId'] ?? 0);
        $sizeId  = (int)($row['SizeId'] ?? $row['sizeId'] ?? 0);
        $countId = (int)($row['Id'] ?? $row['id'] ?? 0);

        if ($colorId <= 0 && $sizeId <= 0) {
            continue;
        }

        $attrs = [];
        $labelParts = [];
        $colorCode = '';

        if ($colorId > 0) {
            $color = $colorsById[$colorId] ?? ['Id' => $colorId, 'Name' => (string)$colorId, 'ColorCode' => ''];
            $colorLabel = xuping_ptjson_color_label($color, $colorId);
            $colorCode = xuping_color_code_normalize($color['ColorCode'] ?? $color['colorCode'] ?? '');
            $attrs['رنگ'] = $colorLabel;
            $labelParts[] = $colorLabel;
        }

        if ($sizeId > 0) {
            $sizeName = normalize_space((string)($row['SizeName'] ?? $row['sizeName'] ?? $row['Size'] ?? $row['size'] ?? $sizeId));
            if ($sizeName === '') {
                $sizeName = 'سایز ' . $sizeId;
            }
            $attrs['سایز'] = $sizeName;
            $labelParts[] = $sizeName;
        }

        if (empty($attrs)) {
            continue;
        }

        $sourcePrice = xuping_numeric_to_int($row['Price'] ?? $row['price'] ?? 0);
        $sourceCompare = xuping_numeric_to_int($row['OriginalPrice'] ?? $row['originalPrice'] ?? 0);
        if ($sourcePrice <= 0) {
            $sourcePrice = $basePrice;
        }
        if ($sourceCompare <= 0) {
            $sourceCompare = $sourcePrice;
        }
        if ($sourceCompare > 0 && $sourcePrice > $sourceCompare) {
            $sourceCompare = $sourcePrice;
        }

        $sig = json_encode($attrs, JSON_UNESCAPED_UNICODE) . '|stock=' . $stock . '|count=' . $countId;
        if (isset($seen[$sig])) {
            continue;
        }
        $seen[$sig] = true;

        $variants[] = [
            'source_count_id' => $countId,
            'source_color_id' => $colorId,
            'source_size_id' => $sizeId,
            'attribute_name' => count($attrs) === 1 ? (string)array_key_first($attrs) : 'ترکیب',
            'attribute_value' => normalize_space(implode(' / ', $labelParts)),
            'attributes' => $attrs,
            'color_code' => $colorCode,
            'stock' => $stock,
            'price' => $sourcePrice,
            'compare_at_price' => $sourceCompare,
        ];
    }

    $variants = xuping_uniquify_duplicate_variant_labels($variants);
    return array_values($variants);
}

function xuping_variant_fields_from_ptjson_variants(array $variants): array
{
    $fields = [];
    foreach ($variants as $variant) {
        if (!is_array($variant)) {
            continue;
        }
        foreach ((array)($variant['attributes'] ?? []) as $attrName => $value) {
            add_variant_field_value($fields, (string)$attrName, (string)$value);
        }
    }
    return normalize_attr_map($fields);
}

function normalize_xuping_variant_details(array $variants): array
{
    $out = [];
    foreach ($variants as $variant) {
        if (!is_array($variant)) {
            continue;
        }
        $attrs = normalize_attr_map((array)($variant['attributes'] ?? []));
        if (empty($attrs)) {
            continue;
        }

        $flatAttrs = [];
        foreach ($attrs as $attrName => $vals) {
            $value = normalize_space((string)($vals[0] ?? ''));
            if ($value !== '') {
                $flatAttrs[$attrName] = $value;
            }
        }
        if (empty($flatAttrs)) {
            continue;
        }

        $stock = max(0, (int)($variant['stock'] ?? 0));
        if ($stock <= 0) {
            continue;
        }

        $out[] = [
            'source_count_id' => (int)($variant['source_count_id'] ?? 0),
            'source_color_id' => (int)($variant['source_color_id'] ?? 0),
            'source_size_id' => (int)($variant['source_size_id'] ?? 0),
            'attribute_value' => normalize_space((string)($variant['attribute_value'] ?? implode(' / ', $flatAttrs))),
            'attributes' => $flatAttrs,
            'color_code' => normalize_space((string)($variant['color_code'] ?? '')),
            'stock' => $stock,
            'price' => xuping_numeric_to_int($variant['price'] ?? 0),
            'compare_at_price' => xuping_numeric_to_int($variant['compare_at_price'] ?? 0),
        ];
    }
    return $out;
}

function xuping_variant_details_equal(array $a, array $b): bool
{
    $snap = static function (array $vars): array {
        $rows = [];
        foreach (normalize_xuping_variant_details($vars) as $v) {
            $attrParts = [];
            foreach ((array)$v['attributes'] as $ak => $av) {
                $attrParts[] = normalize_text_key((string)$ak) . '=' . normalize_text_key((string)$av);
            }
            sort($attrParts, SORT_NATURAL);
            $rows[] = implode('|', $attrParts)
                . '|stock=' . (int)$v['stock']
                . '|price=' . (int)$v['price']
                . '|compare=' . (int)$v['compare_at_price'];
        }
        sort($rows, SORT_NATURAL);
        return $rows;
    };

    return json_encode($snap($a), JSON_UNESCAPED_UNICODE) === json_encode($snap($b), JSON_UNESCAPED_UNICODE);
}

function xuping_markup_variant_price(int $sourcePrice, int $fallbackPrice, float $markupPercent, bool $sourceWasScraped): int
{
    if ($sourcePrice > 0) {
        return $sourceWasScraped ? apply_markup_percent($sourcePrice, $markupPercent) : $sourcePrice;
    }
    return $fallbackPrice;
}

function items_equal(array $a, array $b): bool
{
    return
        (string)($a['title'] ?? '') === (string)($b['title'] ?? '') &&
        (string)($a['stock'] ?? '') === (string)($b['stock'] ?? '') &&
        (string)($a['price_new'] ?? '') === (string)($b['price_new'] ?? '') &&
        (string)($a['price_old'] ?? '') === (string)($b['price_old'] ?? '') &&
        normalize_space(strip_tags((string)($a['description_html'] ?? ''))) === normalize_space(strip_tags((string)($b['description_html'] ?? ''))) &&
        attr_map_equal((array)($a['variant_fields'] ?? []), (array)($b['variant_fields'] ?? [])) &&
        xuping_variant_details_equal((array)($a['xuping_variant_details'] ?? []), (array)($b['xuping_variant_details'] ?? []));
}

function join_values(array $vals): string
{
    return implode('، ', normalize_list($vals));
}

function caption_new_item(array $item): string
{
    $lines = [
        '🆕 محصول جدید',
        'دسته: ' . (string)($item['source_category_name'] ?? '-'),
        (string)($item['title'] ?? 'محصول'),
    ];

    if (!empty($item['site_sync_disabled'])) {
        $lines[] = '🚫 این دسته فقط در ربات اعلام می‌شود و روی سایت مقصد اعمال نمی‌شود';
    }

    if ((string)($item['stock'] ?? '') === 'out') {
        $lines[] = '❌ ناموجود';
    } else {
        if ((string)($item['price_old'] ?? '') !== '') {
            $lines[] = '🏷 قیمت قبلی: ' . $item['price_old'];
        }
        if ((string)($item['price_new'] ?? '') !== '') {
            $lines[] = '💰 قیمت: ' . $item['price_new'];
        }
    }

    foreach (normalize_attr_map((array)($item['variant_fields'] ?? [])) as $attr => $vals) {
        $lines[] = '🔹 ' . $attr . ': ' . join_values($vals);
    }

    $lines[] = (string)($item['url'] ?? '');
    return implode("\n", $lines);
}

function build_change_caption(array $prev, array $item): string
{
    $lines = [
        '🔄 تغییر محصول',
        'دسته: ' . (string)($item['source_category_name'] ?? ($prev['source_category_name'] ?? '-')),
        (string)($item['title'] ?? 'محصول'),
    ];

    if (!empty($item['site_sync_disabled'])) {
        $lines[] = '🚫 این دسته فقط در ربات اعلام می‌شود و روی سایت مقصد اعمال نمی‌شود';
    }

    if ((string)($prev['stock'] ?? '') !== (string)($item['stock'] ?? '')) {
        $lines[] = '📦 موجودی قبل: ' . (((string)($prev['stock'] ?? '') === 'out') ? 'ناموجود' : 'موجود');
        $lines[] = '📦 موجودی بعد: ' . (((string)($item['stock'] ?? '') === 'out') ? 'ناموجود' : 'موجود');
    }

    if ((string)($prev['price_old'] ?? '') !== (string)($item['price_old'] ?? '')) {
        $lines[] = '🏷 قیمت قبلی قبل: ' . ((string)($prev['price_old'] ?? '') !== '' ? (string)$prev['price_old'] : '-');
        $lines[] = '🏷 قیمت قبلی بعد: ' . ((string)($item['price_old'] ?? '') !== '' ? (string)$item['price_old'] : '-');
    }

    if ((string)($prev['price_new'] ?? '') !== (string)($item['price_new'] ?? '')) {
        $lines[] = '💰 قیمت قبل: ' . ((string)($prev['price_new'] ?? '') !== '' ? (string)$prev['price_new'] : '-');
        $lines[] = '💰 قیمت بعد: ' . ((string)($item['price_new'] ?? '') !== '' ? (string)$item['price_new'] : '-');
    }

    $oldMap = normalize_attr_map((array)($prev['variant_fields'] ?? []));
    $newMap = normalize_attr_map((array)($item['variant_fields'] ?? []));
    $allKeys = array_values(array_unique(array_merge(array_keys($oldMap), array_keys($newMap))));
    sort($allKeys, SORT_NATURAL);

    foreach ($allKeys as $attr) {
        $oldVals = $oldMap[$attr] ?? [];
        $newVals = $newMap[$attr] ?? [];

        if ($oldVals === $newVals) {
            continue;
        }

        $added = array_values(array_diff($newVals, $oldVals));
        $removed = array_values(array_diff($oldVals, $newVals));

        if ($added) {
            $lines[] = '➕ ' . $attr . ' اضافه شد: ' . implode('، ', $added);
        }
        if ($removed) {
            $lines[] = '➖ ' . $attr . ' حذف شد: ' . implode('، ', $removed);
        }

        $lines[] = '🔹 ' . $attr . ' فعلی: ' . ($newVals ? implode('، ', $newVals) : '-');
    }

    $lines[] = (string)($item['url'] ?? '');
    return implode("\n", $lines);
}

function caption_sync_failed(array $item, string $reason): string
{
    return "⚠️ خطای همگام‌سازی\n"
        . 'دسته: ' . (string)($item['source_category_name'] ?? '-') . "\n"
        . (string)($item['title'] ?? 'محصول') . "\n"
        . $reason . "\n"
        . (string)($item['url'] ?? '');
}

function armitaj_markup_percent(array $adasCfg): float
{
    return (float)($adasCfg['adas']['price_markup_percent'] ?? 20);
}

function apply_markup_percent(int $price, float $percent): int
{
    return $price > 0 ? (int)round($price * (1 + ($percent / 100))) : 0;
}

function get_item_source_prices(array $item): array
{
    $newPrice = price_to_int((string)($item['price_new'] ?? ''));
    $oldPrice = price_to_int((string)($item['price_old'] ?? ''));

    if ($newPrice > 0 && $oldPrice > 0 && $oldPrice < $newPrice) {
        $oldPrice = $newPrice;
    }
    if ($newPrice > 0 && $oldPrice === 0) {
        $oldPrice = $newPrice;
    }

    return ['price_new' => $newPrice, 'price_old' => $oldPrice];
}

function v4_variant_signature(array $attrs): string
{
    $parts = [];

    foreach ($attrs as $attr) {
        if (!is_array($attr)) {
            continue;
        }

        $name = normalize_space((string)($attr['attribute_name'] ?? $attr['name'] ?? ''));
        $value = normalize_space((string)($attr['value'] ?? ''));

        if ($name !== '' && $value !== '') {
            $parts[] = normalize_text_key($name) . '=' . normalize_text_key($value);
        }
    }

    sort($parts, SORT_NATURAL);
    return implode('|', $parts);
}

function cartesian_attr_combos(array $variantFields): array
{
    $result = [[]];

    foreach ($variantFields as $attrName => $vals) {
        $next = [];
        foreach ($result as $base) {
            foreach ((array)$vals as $val) {
                $row = $base;
                $row[$attrName] = $val;
                $next[] = $row;
            }
        }
        $result = $next;
    }

    return $result;
}

function standalone_variant_key(array $variant): string
{
    $parts = [];
    foreach (['source_color_id' => 'color', 'source_size_id' => 'size', 'source_count_id' => 'count'] as $field => $prefix) {
        $id = (int)($variant[$field] ?? 0);
        if ($id > 0) {
            $parts[] = $prefix . ':' . $id;
        }
    }

    if (!empty($parts)) {
        return implode('|', $parts);
    }

    $attrs = [];
    foreach ((array)($variant['attributes'] ?? []) as $name => $value) {
        $name = normalize_text_key((string)$name);
        $value = normalize_text_key((string)$value);
        if ($name !== '' && $value !== '') {
            $attrs[$name] = $value;
        }
    }
    ksort($attrs, SORT_NATURAL);

    return 'attrs:' . substr(hash('sha256', json_encode($attrs, JSON_UNESCAPED_UNICODE)), 0, 24);
}

function expand_item_to_standalone_products(array $item): array
{
    $units = [];
    $ptJsonVariants = normalize_xuping_variant_details((array)($item['xuping_variant_details'] ?? []));

    foreach ($ptJsonVariants as $variant) {
        $key = standalone_variant_key($variant);
        if (isset($units[$key])) {
            continue;
        }

        $unit = $item;
        $unit['standalone_variant_key'] = $key;
        $unit['standalone_variant_attributes'] = (array)($variant['attributes'] ?? []);
        $unit['standalone_variant_stock_known'] = true;
        $unit['standalone_variant_stock'] = max(0, (int)($variant['stock'] ?? 0));
        $unit['standalone_variant_price'] = max(0, (int)($variant['price'] ?? 0));
        $unit['standalone_variant_compare_at_price'] = max(0, (int)($variant['compare_at_price'] ?? 0));
        $units[$key] = $unit;
    }

    if (empty($units)) {
        $variantFields = normalize_attr_map((array)($item['variant_fields'] ?? []));
        foreach (cartesian_attr_combos($variantFields) as $combo) {
            if (empty($combo)) {
                continue;
            }

            $variant = ['attributes' => $combo];
            $key = standalone_variant_key($variant);
            if (isset($units[$key])) {
                continue;
            }

            $unit = $item;
            $unit['standalone_variant_key'] = $key;
            $unit['standalone_variant_attributes'] = $combo;
            $unit['standalone_variant_stock_known'] = false;
            $unit['standalone_variant_stock'] = 0;
            $unit['standalone_variant_price'] = 0;
            $unit['standalone_variant_compare_at_price'] = 0;
            $units[$key] = $unit;
        }
    }

    if (empty($units)) {
        $unit = $item;
        $unit['standalone_variant_key'] = 'base';
        $unit['standalone_variant_attributes'] = [];
        $unit['standalone_variant_stock_known'] = false;
        $unit['standalone_variant_stock'] = 0;
        $unit['standalone_variant_price'] = 0;
        $unit['standalone_variant_compare_at_price'] = 0;
        $units['base'] = $unit;
    }

    return array_values($units);
}

function standalone_variant_title(array $item): string
{
    $title = normalize_space((string)($item['title'] ?? 'محصول'));
    $parts = [];
    foreach ((array)($item['standalone_variant_attributes'] ?? []) as $name => $value) {
        $name = normalize_space((string)$name);
        $value = normalize_space((string)$value);
        if ($value !== '') {
            $parts[] = $name !== '' ? ($name . ': ' . $value) : $value;
        }
    }

    return empty($parts) ? $title : ($title . ' - ' . implode(' / ', $parts));
}

function build_v4_standalone_product_payload(array $adasCfg, array $item, ?array $existing = null): array
{
    $url = (string)($item['url'] ?? '');
    $variantKey = (string)($item['standalone_variant_key'] ?? 'base');
    $title = standalone_variant_title($item);
    $identifier = 'XUPING_' . substr(hash('sha256', $url . "\n" . $variantKey), 0, 24);
    $mixinCatId = (int)($item['mixin_category_id'] ?? 0);
    $markupPercent = armitaj_markup_percent($adasCfg);

    $sourcePrices = get_item_source_prices($item);
    $variantPrice = (int)($item['standalone_variant_price'] ?? 0);
    $variantCompare = (int)($item['standalone_variant_compare_at_price'] ?? 0);
    $rawPrice = $variantPrice > 0 ? $variantPrice : (int)$sourcePrices['price_new'];
    $rawCompare = $variantCompare > 0 ? $variantCompare : (int)$sourcePrices['price_old'];

    if ($rawPrice <= 0 && is_array($existing)) {
        $rawPrice = (int)($existing['price'] ?? 0);
    } else {
        $rawPrice = apply_markup_percent($rawPrice, $markupPercent);
    }

    if ($rawCompare <= 0) {
        $rawCompare = $rawPrice;
    } elseif ($variantCompare > 0 || (int)$sourcePrices['price_old'] > 0) {
        $rawCompare = apply_markup_percent($rawCompare, $markupPercent);
    }
    if ($rawCompare > 0 && $rawPrice > $rawCompare) {
        $rawCompare = $rawPrice;
    }

    $stockKnown = (bool)($item['standalone_variant_stock_known'] ?? false);
    $stock = max(0, (int)($item['standalone_variant_stock'] ?? 0));
    $available = $stockKnown
        ? ($stock > 0 && $rawPrice > 0)
        : ((string)($item['stock'] ?? '') === 'in' && $rawPrice > 0);

    $payload = [
        'name' => $title,
        'english_name' => $identifier,
        'description' => choose_best_description_html($item, $existing),
        'analysis' => '',
        'main_category_id' => $mixinCatId,
        'is_digital' => false,
        'price' => $rawPrice,
        'compare_at_price' => $rawCompare,
        'special_offer' => false,
        'special_offer_end' => null,
        'stock' => $stockKnown ? $stock : 0,
        'stock_type' => $available ? 'unlimited' : 'out_of_stock',
        'available' => $available,
        'seo_title' => $title,
        'seo_description' => '',
        'product_identifier' => (string)($existing['product_identifier'] ?? $identifier),
        'processing_time' => 0,
        'secondary_attributes' => [],
        'main_attributes' => [],
        'variants' => [],
    ];

    if ($existing === null) {
        $payload['brand_id'] = null;
    }

    return $payload;
}

function mixin_base_url(array $config): string
{
    return rtrim((string)$config['mixin']['base'], '/');
}

function mixin_api_key(array $config): string
{
    return (string)$config['mixin']['api_key'];
}

function mixin_http_json(array $config, string $method, string $endpoint, ?array $payload, string $traceFile): array
{
    $url = mixin_base_url($config) . $endpoint;

    $headers = [
        'Accept: application/json',
        'Authorization: Api-Key ' . mixin_api_key($config),
    ];

    $jsonPayload = $payload !== null
        ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        : null;

    if ($payload !== null) {
        $headers[] = 'Content-Type: application/json';
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 40,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_CUSTOMREQUEST => strtoupper($method),
        CURLOPT_HTTPHEADER => $headers,
    ]);

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

    $res  = curl_exec($ch);
    $err  = curl_error($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $resText = is_string($res) ? $res : '';

    logit(
        $traceFile,
        'MIXIN_HTTP_JSON method=' . strtoupper($method)
        . ' url=' . $url
        . ' code=' . $code
        . ' err=' . $err
        . ' req=' . ($jsonPayload ?? 'null')
        . ' resp=' . $resText
    );

    return [
        'http_code' => $code,
        'json' => json_decode($resText, true),
        'raw' => $resText,
        'error' => $err,
    ];
}

function mixin_is_ok(array $resp): bool
{
    return (($resp['http_code'] ?? 0) >= 200 && ($resp['http_code'] ?? 0) < 300);
}
function mixin_get_categories_map_with_retry(array $config, string $traceFile, int $attempts = 3, int $sleepSeconds = 3): array
{
    $lastError = '';

    for ($i = 1; $i <= $attempts; $i++) {
        logit($traceFile, "MIXIN_CATEGORIES_ATTEMPT {$i}/{$attempts}");

        $resp = mixin_http_json($config, 'GET', '/api/management/v1/categories/', null, $traceFile);

        if (mixin_is_ok($resp)) {
            $map = [];
            foreach ((array)($resp['json']['result'] ?? $resp['json']['results'] ?? []) as $row) {
                $name = normalize_space((string)($row['name'] ?? ''));
                $id   = (int)($row['id'] ?? 0);

                if ($name !== '' && $id > 0) {
                    $map[$name] = $id;
                }
            }

            logit($traceFile, 'MIXIN_CATEGORIES_MAP_FINAL=' . json_encode($map, JSON_UNESCAPED_UNICODE));
            return $map;
        }

        $lastError = (string)($resp['error'] ?? ('HTTP ' . (string)($resp['http_code'] ?? 0)));
        logit($traceFile, "MIXIN_CATEGORIES_ATTEMPT_FAIL attempt={$i} err={$lastError}");

        if ($i < $attempts) {
            sleep($sleepSeconds);
        }
    }

    logit($traceFile, 'MIXIN_CATEGORIES_MAP_FINAL=[]');
    return [];
}
function mixin_get_categories_map(array $config, string $traceFile): array
{
    $resp = mixin_http_json($config, 'GET', '/api/management/v1/categories/', null, $traceFile);

    $map = [];
    foreach ((array)($resp['json']['result'] ?? $resp['json']['results'] ?? []) as $row) {
        $name = normalize_space((string)($row['name'] ?? ''));
        $id   = (int)($row['id'] ?? 0);

        if ($name !== '' && $id > 0) {
            $map[$name] = $id;
        }
    }

    logit($traceFile, 'MIXIN_CATEGORIES_MAP_FINAL=' . json_encode($map, JSON_UNESCAPED_UNICODE));
    return $map;
}

function mixin_create_product(array $config, array $payload, string $traceFile): ?int
{
    $resp = mixin_http_json($config, 'POST', '/api/v4/products/', $payload, $traceFile);
    if (!mixin_is_ok($resp)) {
        return null;
    }

    $id = (int)($resp['json']['data']['id'] ?? 0);
    return $id > 0 ? $id : null;
}

function mixin_get_product(array $config, int $id, string $traceFile): ?array
{
    $resp = mixin_http_json($config, 'GET', '/api/v4/products/' . $id . '/', null, $traceFile);
    return mixin_is_ok($resp) && is_array($resp['json']['data'] ?? null) ? $resp['json']['data'] : null;
}

function mixin_update_product(array $config, int $id, array $payload, string $traceFile): ?array
{
    $resp = mixin_http_json($config, 'PATCH', '/api/v4/products/' . $id . '/', $payload, $traceFile);
    return mixin_is_ok($resp) && is_array($resp['json']['data'] ?? null) ? $resp['json']['data'] : null;
}

function expected_payload_snapshot(array $payload): array
{
    $variantSigs = [];

    foreach ((array)($payload['variants'] ?? []) as $variant) {
        if (!is_array($variant)) {
            continue;
        }
        if (!empty($variant['isDeleted'])) {
            continue;
        }

        $variantSigs[] = v4_variant_signature((array)($variant['attributes'] ?? []));
    }
    sort($variantSigs, SORT_NATURAL);

    return [
        'name' => normalize_space((string)($payload['name'] ?? '')),
        'main_category_id' => (int)($payload['main_category_id'] ?? 0),
        'price' => (int)($payload['price'] ?? 0),
        'compare_at_price' => (int)($payload['compare_at_price'] ?? 0),
        'available' => (bool)($payload['available'] ?? false),
        'stock_type' => normalize_space((string)($payload['stock_type'] ?? '')),
        'description_text' => normalize_space(strip_tags((string)($payload['description'] ?? ''))),
        'variant_count' => count($variantSigs),
        'variant_sigs' => $variantSigs,
    ];
}

function remote_product_snapshot(array $remote): array
{
    $variantSigs = [];

    foreach ((array)($remote['variants'] ?? []) as $variant) {
        if (!is_array($variant)) {
            continue;
        }
        $variantSigs[] = v4_variant_signature((array)($variant['attributes'] ?? []));
    }
    sort($variantSigs, SORT_NATURAL);

    $mainCategoryId = 0;
    if (isset($remote['main_category_id'])) {
        $mainCategoryId = (int)$remote['main_category_id'];
    } elseif (!empty($remote['main_category']['id'])) {
        $mainCategoryId = (int)$remote['main_category']['id'];
    }

    $stockType = '';
    if (is_array($remote['stock_type'] ?? null)) {
        $stockType = normalize_space((string)($remote['stock_type']['value'] ?? ''));
    } else {
        $stockType = normalize_space((string)($remote['stock_type'] ?? ''));
    }

    return [
        'name' => normalize_space((string)($remote['name'] ?? '')),
        'main_category_id' => $mainCategoryId,
        'price' => (int)round((float)($remote['price'] ?? 0)),
        'compare_at_price' => (int)round((float)($remote['compare_at_price'] ?? 0)),
        'available' => (bool)($remote['available'] ?? false),
        'stock_type' => $stockType,
        'description_text' => normalize_space(strip_tags((string)($remote['description'] ?? ''))),
        'variant_count' => count($variantSigs),
        'variant_sigs' => $variantSigs,
    ];
}

function verify_remote_product_matches_payload(array $config, int $productId, array $payload, string $traceFile): bool
{
    $remote = mixin_get_product($config, $productId, $traceFile);
    if (!$remote) {
        logit($traceFile, 'VERIFY_REMOTE_FAIL reason=no_remote_product id=' . $productId);
        return false;
    }

    $expected = expected_payload_snapshot($payload);
    $actual   = remote_product_snapshot($remote);

    foreach (['name','main_category_id','price','compare_at_price','available','stock_type','description_text','variant_count'] as $field) {
        if ($expected[$field] !== $actual[$field]) {
            logit(
                $traceFile,
                'VERIFY_REMOTE_MISMATCH id=' . $productId
                . ' field=' . $field
                . ' expected=' . json_encode($expected[$field], JSON_UNESCAPED_UNICODE)
                . ' actual=' . json_encode($actual[$field], JSON_UNESCAPED_UNICODE)
            );
            return false;
        }
    }

    if (json_encode($expected['variant_sigs'], JSON_UNESCAPED_UNICODE) !== json_encode($actual['variant_sigs'], JSON_UNESCAPED_UNICODE)) {
        logit(
            $traceFile,
            'VERIFY_REMOTE_MISMATCH id=' . $productId
            . ' field=variant_sigs expected=' . json_encode($expected['variant_sigs'], JSON_UNESCAPED_UNICODE)
            . ' actual=' . json_encode($actual['variant_sigs'], JSON_UNESCAPED_UNICODE)
        );
        return false;
    }

    logit($traceFile, 'VERIFY_REMOTE_OK id=' . $productId);
    return true;
}

function sync_item_to_mixin_with_retry(
    string $stateFile,
    array $config,
    array $adasCfg,
    array $item,
    string $traceFile,
    int $maxAttempts = 3,
    int $sleepSeconds = 3
): bool {
    $url = (string)($item['url'] ?? '');

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        logit($traceFile, 'SYNC_RETRY_ATTEMPT attempt=' . $attempt . ' url=' . $url);

        $ok = sync_item_to_mixin($stateFile, $config, $adasCfg, $item, $traceFile);
        if ($ok) {
            return true;
        }

        if ($attempt < $maxAttempts) {
            sleep($sleepSeconds);
        }
    }

    return false;
}

function maybe_backfill_unsynced_item(string $stateFile, array $config, array $adasCfg, array $item, string $traceFile): bool
{
    $url = (string)($item['url'] ?? '');
    if ($url === '') {
        return false;
    }

    $mixinCatId = (int)($item['mixin_category_id'] ?? 0);
    if ($mixinCatId <= 0) {
        return false;
    }

    return sync_item_to_mixin_with_retry($stateFile, $config, $adasCfg, $item, $traceFile, 3, 3);
}

function standalone_payload_hash(array $payload): string
{
    return hash(
        'sha256',
        json_encode(expected_payload_snapshot($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    );
}

function sync_standalone_unit_to_mixin(
    string $stateFile,
    array $config,
    array $adasCfg,
    array $unit,
    string $traceFile
): bool {
    $url = (string)($unit['url'] ?? '');
    $variantKey = (string)($unit['standalone_variant_key'] ?? 'base');
    $title = standalone_variant_title($unit);
    $record = get_synced_mixin_record($stateFile, $url, $variantKey);
    $mappedId = (int)($record['mixin_product_id'] ?? 0);

    $existing = $mappedId > 0 ? mixin_get_product($config, $mappedId, $traceFile) : null;
    $payload = build_v4_standalone_product_payload($adasCfg, $unit, $existing);
    $payloadHash = standalone_payload_hash($payload);

    if (
        $mappedId > 0
        && $existing !== null
        && (bool)($record['active'] ?? true)
        && hash_equals((string)($record['payload_hash'] ?? ''), $payloadHash)
    ) {
        logit($traceFile, 'STANDALONE_SYNC_UNCHANGED id=' . $mappedId . ' variant=' . $variantKey . ' url=' . $url);
        return true;
    }

    if ($mappedId <= 0 || $existing === null) {
        $newId = mixin_create_product($config, $payload, $traceFile);
        if ($newId === null) {
            logit($traceFile, 'STANDALONE_CREATE_FAIL variant=' . $variantKey . ' title=' . $title . ' url=' . $url);
            return false;
        }

        // شناسه بلافاصله ذخیره می‌شود تا حتی اگر verify یا اجرای PHP قطع شد،
        // تلاش بعدی محصول تکراری نسازد و همان محصول تازه را اصلاح کند.
        upsert_sync_map($stateFile, $url, $variantKey, $newId, '');

        if (!verify_remote_product_matches_payload($config, $newId, $payload, $traceFile)) {
            logit($traceFile, 'STANDALONE_CREATE_VERIFY_FAIL id=' . $newId . ' variant=' . $variantKey . ' url=' . $url);
            return false;
        }

        upsert_sync_map($stateFile, $url, $variantKey, $newId, $payloadHash);
        logit($traceFile, 'STANDALONE_CREATE_OK id=' . $newId . ' variant=' . $variantKey . ' title=' . $title . ' url=' . $url);
        return true;
    }

    $updated = mixin_update_product($config, $mappedId, $payload, $traceFile);
    if ($updated === null) {
        logit($traceFile, 'STANDALONE_UPDATE_FAIL id=' . $mappedId . ' variant=' . $variantKey . ' url=' . $url);
        return false;
    }

    if (!verify_remote_product_matches_payload($config, $mappedId, $payload, $traceFile)) {
        logit($traceFile, 'STANDALONE_UPDATE_VERIFY_FAIL id=' . $mappedId . ' variant=' . $variantKey . ' url=' . $url);
        return false;
    }

    upsert_sync_map($stateFile, $url, $variantKey, $mappedId, $payloadHash);
    logit($traceFile, 'STANDALONE_UPDATE_OK id=' . $mappedId . ' variant=' . $variantKey . ' title=' . $title . ' url=' . $url);
    return true;
}

function deactivate_removed_standalone_units(
    string $stateFile,
    array $config,
    string $sourceUrl,
    array $activeVariantKeys,
    string $traceFile
): bool {
    $active = array_fill_keys($activeVariantKeys, true);
    $ok = true;

    foreach (get_synced_mixin_records_for_source($stateFile, $sourceUrl) as $mapKey => $record) {
        $variantKey = (string)($record['variant_key'] ?? 'base');
        if (isset($active[$variantKey]) || !(bool)($record['active'] ?? true)) {
            continue;
        }

        $productId = (int)($record['mixin_product_id'] ?? 0);
        if ($productId <= 0) {
            mark_sync_map_inactive($stateFile, $mapKey);
            continue;
        }

        $updated = mixin_update_product($config, $productId, [
            'available' => false,
            'stock' => 0,
            'stock_type' => 'out_of_stock',
        ], $traceFile);

        if ($updated === null) {
            $ok = false;
            logit($traceFile, 'STANDALONE_DEACTIVATE_FAIL id=' . $productId . ' variant=' . $variantKey . ' url=' . $sourceUrl);
            continue;
        }

        mark_sync_map_inactive($stateFile, $mapKey);
        logit($traceFile, 'STANDALONE_DEACTIVATE_OK id=' . $productId . ' variant=' . $variantKey . ' url=' . $sourceUrl);
    }

    return $ok;
}

function sync_item_to_mixin(string $stateFile, array $config, array $adasCfg, array $item, string $traceFile): bool
{
    $url        = (string)($item['url'] ?? '');
    $title      = normalize_space((string)($item['title'] ?? 'محصول'));
    $mixinCatId = (int)($item['mixin_category_id'] ?? 0);

    if ($url === '' || $mixinCatId <= 0) {
        logit($traceFile, 'SYNC_SKIP_NO_CATEGORY title=' . $title . ' url=' . $url);
        return false;
    }

    $units = expand_item_to_standalone_products($item);
    $activeVariantKeys = [];
    $allOk = true;

    logit($traceFile, 'STANDALONE_SYNC_START title=' . $title . ' url=' . $url . ' products=' . count($units));

    foreach ($units as $unit) {
        $activeVariantKeys[] = (string)($unit['standalone_variant_key'] ?? 'base');
        if (!sync_standalone_unit_to_mixin($stateFile, $config, $adasCfg, $unit, $traceFile)) {
            $allOk = false;
        }
    }

    if (!deactivate_removed_standalone_units($stateFile, $config, $url, $activeVariantKeys, $traceFile)) {
        $allOk = false;
    }

    logit($traceFile, 'STANDALONE_SYNC_DONE title=' . $title . ' url=' . $url . ' ok=' . ($allOk ? '1' : '0'));
    return $allOk;
}
