<?php
declare(strict_types=1);

// ---------- Пути ----------
$rootDir   = __DIR__;
$dataDir   = $rootDir . DIRECTORY_SEPARATOR . 'data';
$tplDir    = $rootDir . DIRECTORY_SEPARATOR . 'templates';
$publicDir = $rootDir . DIRECTORY_SEPARATOR . 'public';
$imagesDir = $publicDir . DIRECTORY_SEPARATOR . 'images';
$cacheDir  = $rootDir . DIRECTORY_SEPARATOR . 'cache';

if (!is_dir($dataDir)) {
    mkdir($dataDir, 0777, true);
}
if (!is_dir($imagesDir)) {
    mkdir($imagesDir, 0777, true);
}
if (!is_dir($cacheDir)) {
    mkdir($cacheDir, 0777, true);
}

// Режим кэша в S3: только объявления, настройки читаются лениво — уже после load_config().
require_once $rootDir . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 's3cache.php';

// ---------- Файлы данных ----------
$productsFile = $dataDir . DIRECTORY_SEPARATOR . 'products.json';
$pagesFile    = $dataDir . DIRECTORY_SEPARATOR . 'pages.json';
$configFile   = $dataDir . DIRECTORY_SEPARATOR . 'config.json';
$requestsFile = $dataDir . DIRECTORY_SEPARATOR . 'requests.json';
$dbFile       = $dataDir . DIRECTORY_SEPARATOR . 'catalog.sqlite';
$categoriesMetaFile = $dataDir . DIRECTORY_SEPARATOR . 'categories.json';
$recrawlHistoryFile = $dataDir . DIRECTORY_SEPARATOR . 'recrawl_history.json';
$googleIndexingAccountsDir = $dataDir . DIRECTORY_SEPARATOR . 'google_indexing_accounts';
$googleIndexingSentFile = $dataDir . DIRECTORY_SEPARATOR . 'google_indexing_sent.json';
$experimentalIndexingFile = $dataDir . DIRECTORY_SEPARATOR . 'experimental_indexing.json';
$cronImportStateFile = $dataDir . DIRECTORY_SEPARATOR . 'cron_import.json';
$cronImportCsvFile   = $dataDir . DIRECTORY_SEPARATOR . 'cron_import.csv';
$indexnowCronStateFile = $dataDir . DIRECTORY_SEPARATOR . 'indexnow_cron.json';
$cacheWarmupCronStateFile = $dataDir . DIRECTORY_SEPARATOR . 'cache_warmup_cron.json';
$indexnowCronLogFile = $dataDir . DIRECTORY_SEPARATOR . 'indexnow_cron.log';
$categoriesCacheFile = $dataDir . DIRECTORY_SEPARATOR . 'categories_cache.json';
$categoriesByCountCacheFile = $dataDir . DIRECTORY_SEPARATOR . 'categories_by_count_cache.json';
$categoryCountsCacheFile = $dataDir . DIRECTORY_SEPARATOR . 'category_counts_cache.json';
$tagsCacheFile = $dataDir . DIRECTORY_SEPARATOR . 'tags_cache.json';
$searchQueriesFile = $dataDir . DIRECTORY_SEPARATOR . 'search_queries.json';
$botsStatsFile = $dataDir . DIRECTORY_SEPARATOR . 'bots_stats.json';
$merchantFeedMetaFile = $dataDir . DIRECTORY_SEPARATOR . 'merchant_feed_meta.json';

/** Лимит запросов в сутки на один аккаунт Google Indexing API (по умолчанию 200). */
const GOOGLE_INDEXING_DAILY_LIMIT = 200;

// ---------- Вспомогательные функции ----------

function read_json(string $file, $fallback) {
    if (!file_exists($file)) {
        return $fallback;
    }
    $raw = file_get_contents($file);
    if ($raw === false || $raw === '') {
        return $fallback;
    }
    $data = json_decode($raw, true);
    return $data ?? $fallback;
}

function write_json(string $file, $data): void {
    file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}

/**
 * Экспериментальная индексация: загрузка/сохранение конфига (домен, токен, список доменов, usedQlinks, redirectStats и т.д.).
 */
function experimental_indexing_load(string $file): array {
    $default = [
        'domain' => '',
        'yandexToken' => '',
        'redirectType' => '302',
        'yandexXmlUrl' => '',
        'domains' => [],
        'hostIds' => [],
        'usedQlinks' => [],
        'redirectStats' => [],
        'sentRecrawl' => [],
        'sentTargets' => [],
        'lastSumQuota' => null,
        'lastQuotas' => [],
    ];
    $data = read_json($file, $default);
    return array_merge($default, $data);
}

function experimental_indexing_save(string $file, array $data): void {
    write_json($file, $data);
}

/**
 * Подставить в основной .htaccess правила исключения экспериментальных доменов из кэша (блок между маркерами).
 * Учитывается и «Домен (для отображения)», и список доменов из текстового поля.
 */
function experimental_indexing_write_htaccess_exclude(array $domains, string $displayDomain = ''): void {
    global $dataDir;
    $rootDir = dirname($dataDir);
    $htaccessPath = $rootDir . DIRECTORY_SEPARATOR . '.htaccess';
    $displayDomain = experimental_indexing_normalize_host(trim($displayDomain));
    if ($displayDomain === '' && !empty($domains)) {
        $first = experimental_indexing_normalize_host(trim((string)$domains[0]));
        $displayDomain = experimental_indexing_base_from_host($first);
    }
    $block = "# BEGIN experimental_cache_exclude\n";
    $patterns = [];
    if ($displayDomain !== '') {
        $patterns[] = '^([a-z0-9-]+\\.)*' . preg_quote($displayDomain, '/') . '$';
    }
    foreach ($domains as $d) {
        $d = experimental_indexing_normalize_host($d);
        if ($d === '') {
            continue;
        }
        if ($displayDomain !== '' && ($d === $displayDomain || (strlen($d) > strlen($displayDomain) + 1 && substr($d, -strlen($displayDomain) - 1) === '.' . $displayDomain))) {
            continue;
        }
        $patterns[] = '^' . preg_quote($d, '/') . '$';
    }
    $patterns = array_unique($patterns);
    if (!empty($patterns)) {
        $block .= "RewriteCond %{HTTP_HOST} (" . implode('|', $patterns) . ") [NC]\n";
        $block .= "RewriteRule ^ - [E=SKIPCACHE:1]\n";
    }
    $block .= "# END experimental_cache_exclude";
    if (!is_file($htaccessPath)) {
        return;
    }
    $content = file_get_contents($htaccessPath);
    if ($content === false) {
        return;
    }
    $replaced = preg_replace(
        '/# BEGIN experimental_cache_exclude.*?# END experimental_cache_exclude/s',
        $block,
        $content
    );
    if ($replaced !== null && $replaced !== $content) {
        file_put_contents($htaccessPath, $replaced);
    }
}

/**
 * Нормализация хоста для сравнения (без порта): a.domain.com:443 → a.domain.com.
 */
function experimental_indexing_normalize_host(string $host): string {
    $host = strtolower(trim($host));
    return (string)preg_replace('/:\d+$/', '', $host);
}

/**
 * Базовый домен из хоста: a.taxitelefone.ru → taxitelefone.ru; taxitelefone.ru → taxitelefone.ru.
 */
function experimental_indexing_base_from_host(string $hostNorm): string {
    if ($hostNorm === '') {
        return '';
    }
    $pos = strpos($hostNorm, '.');
    return $pos !== false ? substr($hostNorm, $pos + 1) : $hostNorm;
}

/**
 * Проверка, что хост — экспериментальный: в списке доменов или сам базовый домен, или любой его поддомен (*.base).
 */
function experimental_indexing_host_is_experimental(string $hostNorm, array $domainsNorm, string $baseDomainNorm): bool {
    if (in_array($hostNorm, $domainsNorm, true)) {
        return true;
    }
    if ($baseDomainNorm === '') {
        return false;
    }
    if ($hostNorm === $baseDomainNorm) {
        return true;
    }
    $suffix = '.' . $baseDomainNorm;
    return strlen($hostNorm) > strlen($suffix) && substr($hostNorm, -strlen($suffix)) === $suffix;
}

/**
 * Канонический ключ для qlink: один и тот же URL всегда даёт один ключ (без фрагмента, нормализованный путь).
 */
function experimental_indexing_qlink_key(string $url): string {
    $url = trim($url);
    if ($url === '') {
        return '';
    }
    $parsed = parse_url($url);
    if ($parsed === false || !isset($parsed['host'])) {
        return $url;
    }
    $scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'https';
    $host = $parsed['host'];
    $path = isset($parsed['path']) && $parsed['path'] !== '' ? $parsed['path'] : '/';
    $path = rtrim($path, '/') ?: '/';
    return $scheme . '://' . $host . $path;
}

/**
 * Обработка запроса на экспериментальном домене.
 * Для этого домена/поддоменов основной сайт не отдаётся: только заглушка «Сайт в разработке» на главной,
 * редирект по ?qlink= (один раз до перехода робота), далее по этому qlink — 404. Все остальные пути — 404.
 */
function experimental_indexing_handle(string $host, string $uri, string $queryString, string $configPath): void {
    $data = experimental_indexing_load($configPath);
    $domains = $data['domains'] ?? [];
    if (!empty(trim((string)($data['domain'] ?? '')))) {
        $domains = array_merge([trim($data['domain'])], $domains);
        $domains = array_values(array_unique(array_map('experimental_indexing_normalize_host', $domains)));
    } else {
        $domains = array_map('experimental_indexing_normalize_host', $domains);
    }
    $hostNorm = experimental_indexing_normalize_host($host);
    $baseDomainNorm = experimental_indexing_normalize_host(trim((string)($data['domain'] ?? '')));
    if (!experimental_indexing_host_is_experimental($hostNorm, $domains, $baseDomainNorm)) {
        return;
    }
    $host = $hostNorm;
    $redirectType = ($data['redirectType'] ?? '302') === '301' ? '301' : '302';
    $usedQlinks = $data['usedQlinks'] ?? [];
    if (!isset($usedQlinks[$host])) {
        $usedQlinks[$host] = [];
    }
    parse_str($queryString, $params);
    $qlink = isset($params['qlink']) ? trim((string)$params['qlink']) : '';
    if ($qlink !== '') {
        $ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
        $isYandex = stripos($ua, 'yandex') !== false;

        $target = $qlink;
        $key = experimental_indexing_qlink_key($target);
        if ($key === '') {
            $key = $target;
        }
        $alreadyUsed = isset($usedQlinks[$host][$key]) || isset($usedQlinks[$host][$target]);

        // Только Яндекс тратит qlink: для всех прочих всегда 404, но без пометки как использованный
        if (!$isYandex) {
            header('Cache-Control: no-store, no-cache, must-revalidate');
            header('HTTP/1.1 404 Not Found', true, 404);
            header('Content-Type: text/html; charset=utf-8');
            echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>404</title></head><body><p>Not Found</p></body></html>';
            exit;
        }

        // Для Яндекса: первый заход — редирект, последующие — 404
        if ($alreadyUsed) {
            header('Cache-Control: no-store, no-cache, must-revalidate');
            header('HTTP/1.1 404 Not Found', true, 404);
            header('Content-Type: text/html; charset=utf-8');
            echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>404</title></head><body><p>Not Found</p></body></html>';
            exit;
        }

        $usedQlinks[$host][$key] = true;
        $redirectStats = $data['redirectStats'] ?? [];
        $today = date('Y-m-d');
        if (!isset($redirectStats[$today])) {
            $redirectStats[$today] = [];
        }
        if (!isset($redirectStats[$today][$host])) {
            $redirectStats[$today][$host] = 0;
        }
        $redirectStats[$today][$host]++;
        $data['usedQlinks'] = $usedQlinks;
        $data['redirectStats'] = $redirectStats;
        experimental_indexing_save($configPath, $data);
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('Location: ' . $target, true, (int)$redirectType);
        exit;
    }
    if ($uri === '' || $uri === '/') {
        header('Content-Type: text/html; charset=utf-8');
        echo '<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Сайт в разработке</title><style>body{font-family:system-ui,sans-serif;max-width:600px;margin:2rem auto;padding:0 1rem;color:#333;}h1{font-size:1.25rem;}p{color:#666;}</style></head><body><h1>Сайт в разработке</h1><p>Скоро здесь будет новая версия.</p></body></html>';
        exit;
    }
    header('HTTP/1.1 404 Not Found', true, 404);
    header('Content-Type: text/html; charset=utf-8');
    echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>404</title></head><body><p>Not Found</p></body></html>';
    exit;
}

function save_config(string $configFile, array $config): void {
    write_json($configFile, $config);
}

function load_config(string $configFile): array {
    $default = [
        'siteName' => 'Мини каталог',
        'baseUrl'  => 'https://example.com',
        'faviconUrl' => '',
        'indexNowKey' => '',
        'indexNowAutoImport' => true,
        'phone'    => '+7 (900) 000-00-00',
        'headerCallbackText' => 'Заказать звонок',
        'stockLocation' => 'Москва',
        'homeSeoTitle' => 'Оптовые поставки электронных комплектующих',
        'homeSeoDescription' => 'Оптовая поставка электронных комплектующих по Москве, области, России и СНГ. Большой каталог позиций, гибкие условия поставки и удобная доставка.',
        'categorySeoTitleTemplate' => '{category} — электронные комплектующие оптом',
        'categorySeoDescriptionTemplate' => 'Категория {category}: электронные компоненты оптом с доставкой по Москве, России и СНГ. Актуальные цены и удобные условия поставки.',
        'tagSeoTitleTemplate' => '{tag} — электронные комплектующие оптом',
        'tagSeoDescriptionTemplate' => 'Товары по тегу {tag}: электронные компоненты оптом с доставкой по Москве, России и СНГ. Актуальные цены и удобные условия поставки.',
        'productSeoTitleTemplate' => '{name} — оптовые поставки электронных комплектующих',
        'productSeoDescriptionTemplate' => '{name}: цена {price}. Категория: {category}. Оптовая поставка по России и СНГ.',
        'defaultRegionName' => 'Москва',
        'admin' => [
            'user' => 'admin',
            'password' => 'changeme',
        ],
        'deliveryHtml' => '<p>Осуществляем оптовую поставку электронных комплектующих. Обширная сеть партнёров в России и за рубежом. Доставим товар по Москве и области, а также в любой регион России и СНГ.</p><p>Сроки доставки от 3 до 6 недель. Стоимость индивидуальна и зависит от региона и объёма заказа. Доставка по России и СНГ осуществляется транспортными компаниями. Предусмотрены страхование и гарантийное обслуживание.</p><p>Доставка осуществляется транспортными компаниями: СДЭК, Деловые Линии, Major Express.</p>',
        'contacts' => [
            'phone' => '+7 904 369-82-09',
            'email' => 'info@laserzz.ru',
            'address' => 'Москва, Калибровская ул., дом 22Б, офис 52',
            'requisitesHtml' => '<p>ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ «ЭКОФРИДОМ»<br>ИНН 7717751927<br>КПП 771701001<br>ОГРН 1137746381431</p>',
            'mapEmbed' => '',
        ],
        'requestsEmail' => '',  // отдельный email для заявок с форм заказа звонка / оставить заявку
        'yandex'   => [
            'oauthToken' => '',
            'userId'     => '',
            'hostId'     => '',
            'metrikaId'  => '',
        ],
        'import' => [
            'priceMultiplier' => 1.0,
            'skuColumn' => 'sku',
            'nameColumn' => 'name',
            'nameTemplate' => '',
            'priceColumn' => 'price',
            'categoryColumn' => 'category',
            'tagsColumn' => 'tags',
            'descriptionColumn' => 'description',
            'descriptionTemplate' => '',
            'categoriesListColumn' => 'categories',
            'imageUrlColumn' => 'image_url',
            'imageLocalColumn' => 'image_local',
            'seoTitleTemplate' => '{name} — оптовые поставки электронных комплектующих',
            'seoDescriptionTemplate' => '{name}: цена {price}. Категория: {category}. Оптовая поставка по России и СНГ.',
        ],
        'importTranslate' => [
            'enabled' => false,
            'provider' => 'google',
            'googleApiKey' => '',
            'sourceLang' => 'auto',
            'targetLang' => 'ru',
            'translateName' => false,
            'translateDescription' => false,
            'translateCategory' => false,
            'translateTags' => false,
            'translateSeoTitle' => false,
            'translateSeoDescription' => false,
            // список прокси, по одному в строке: host:port или user:pass@host:port
            'proxies' => '',
        ],
        'catalogPerPage' => 50,
        'catalogView' => 'grid',
        'sidebarCategoriesTop' => 0,  // в сайдбаре каталога: 0 = все категории, N = топ N по количеству товаров
        'productImageCheckEnabled' => false, // проверять ли наличие файла картинки на карточке товара
        'aiAutoProductExtraOnFirstView' => false, // дописывать AI-блок в карточку при первом просмотре
        'aiAutoCategoryTextOnFirstView' => false, // генерировать SEO-текст категории при первом просмотре
        'criticalCssInline' => false,
        'mainJsInline' => false,
        'minifyHtml' => false,
        'regions' => [],
        'defaultRegionName' => 'Москва',
        'adminProductFields' => [
            'description' => true,
            'tags' => true,
            'seoTitle' => true,
            'seoDescription' => true,
        ],
        // HTML-кэш в объектном хранилище: настраивается на /admin/s3-cache
        's3cache' => S3CACHE_DEFAULTS,
        'ai' => [
            'provider' => '',       // gemini | openai
            'apiKey' => '',
            'endpoint' => '',       // необязательно: свой URL (для Gemini/OpenAI подставляется model в путь при необходимости)
            'model' => '',         // gemini-1.5-flash, gemini-1.5-pro, gpt-4o, gpt-4o-mini, gpt-4-turbo, o1 и т.д.
        ],
    ];
    $cfg = read_json($configFile, []);
    $config = array_replace_recursive($default, $cfg);
    // Гарантируем, что вложенные ключи — массивы (защита от битого config.json)
    foreach (['admin', 'contacts', 'yandex', 'import', 'importTranslate', 'ai', 's3cache'] as $key) {
        if (!is_array($config[$key] ?? null)) {
            $config[$key] = $default[$key];
        }
    }
    if (!is_array($config['regions'] ?? null)) {
        $config['regions'] = [];
    }
    if (!is_array($config['adminProductFields'] ?? null)) {
        $config['adminProductFields'] = $default['adminProductFields'];
    }
    foreach (['description', 'tags', 'seoTitle', 'seoDescription'] as $k) {
        if (!isset($config['adminProductFields'][$k])) {
            $config['adminProductFields'][$k] = true;
        }
    }
    return $config;
}

/**
 * Определяет регион по текущему хосту (поддомен или полный host).
 * Регион в config.regions[] может иметь:
 * - host: точное совпадение (например moscow.example.com)
 * - subdomain: поддомен, основной домен берётся из config.baseUrl (например moscow при baseUrl https://example.com)
 */
function find_region_by_host(array $config, string $currentHost): ?array {
    $regions = $config['regions'] ?? [];
    if (empty($regions)) {
        return null;
    }
    $currentHost = strtolower(trim($currentHost));
    if ($currentHost === '') {
        return null;
    }
    foreach ($regions as $region) {
        if (!is_array($region)) {
            continue;
        }
        if (!empty($region['host'])) {
            if (strtolower(trim((string)$region['host'])) === $currentHost) {
                return $region;
            }
            continue;
        }
        if (!empty($region['subdomain'])) {
            $sub = strtolower(trim((string)$region['subdomain']));
            $baseUrl = $config['baseUrl'] ?? '';
            $mainHost = preg_replace('#^https?://#i', '', rtrim($baseUrl, '/'));
            $mainHost = trim(explode('/', $mainHost)[0]);
            if ($mainHost === '') {
                continue;
            }
            $expectedHost = $sub . '.' . $mainHost;
            if ($currentHost === $expectedHost) {
                return $region;
            }
        }
    }
    return null;
}

/**
 * Применяет настройки региона поверх основного конфига.
 * В регионе можно переопределить: siteName, baseUrl, phone, headerCallbackText, stockLocation,
 * contacts (сливается рекурсивно), deliveryHtml, homeSeoTitle, homeSeoDescription и др.
 */
function apply_region_overrides(array $config, array $region): array {
    $allowed = [
        'siteName', 'baseUrl', 'phone', 'headerCallbackText', 'stockLocation',
        'homeSeoTitle', 'homeSeoDescription', 'deliveryHtml',
        'categorySeoTitleTemplate', 'categorySeoDescriptionTemplate',
        'tagSeoTitleTemplate', 'tagSeoDescriptionTemplate',
        'productSeoTitleTemplate', 'productSeoDescriptionTemplate',
        'regionName',
    ];
    foreach ($allowed as $key) {
        if (array_key_exists($key, $region) && $region[$key] !== '') {
            $config[$key] = $region[$key];
        }
    }
    if (!empty($region['contacts']) && is_array($region['contacts'])) {
        if (!is_array($config['contacts'] ?? null)) {
            $config['contacts'] = [];
        }
        $config['contacts'] = array_replace_recursive($config['contacts'], $region['contacts']);
    }
    return $config;
}

/**
 * Имя региона для шаблонов и SEO: на основном сайте — defaultRegionName (Москва), на поддомене — regionName региона.
 */
function get_region_name(array $config, ?array $currentRegion): string {
    if ($currentRegion !== null && isset($currentRegion['regionName']) && (string)$currentRegion['regionName'] !== '') {
        return trim((string)$currentRegion['regionName']);
    }
    return trim((string)($config['defaultRegionName'] ?? 'Москва'));
}

/**
 * Цена с учётом регионального коэффициента (+ или − доля от цены).
 * В регионе: priceCoefficient (число, например 0.02), priceCoefficientSign ('+' или '-').
 */
function get_display_price(string $priceRaw, ?array $currentRegion): string {
    $priceRaw = trim($priceRaw);
    if ($priceRaw === '') {
        return '';
    }
    $num = (float) preg_replace('/[^\d.,]/', '', str_replace(',', '.', $priceRaw));
    if ($num <= 0) {
        return $priceRaw;
    }
    if ($currentRegion === null) {
        return $priceRaw;
    }
    $coef = isset($currentRegion['priceCoefficient']) ? (float) $currentRegion['priceCoefficient'] : 0.0;
    if ($coef <= 0) {
        return $priceRaw;
    }
    $sign = isset($currentRegion['priceCoefficientSign']) ? (string) $currentRegion['priceCoefficientSign'] : '+';
    if ($sign === '-') {
        $num = $num * (1 - $coef);
    } else {
        $num = $num * (1 + $coef);
    }
    if ($num <= 0) {
        return $priceRaw;
    }
    return number_format(round($num, 2), 2, '.', ' ');
}

function load_products(string $productsFile): array {
    return read_json($productsFile, []);
}

function load_pages(string $pagesFile): array {
    $privacyPolicyContent = '<div class="entry-content"><p><strong>Политика обработки персональных данных</strong></p><p>Настоящая политика обработки персональных данных (далее по тексту — «Политика») ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ «ЭКОФРИДОМ» (ОГРН 1137746381431, ИНН 7717751927), далее по тексту — «Оператор», устанавливает объем, цели и способы обработки персональных данных на сайте <a href="https://vezemtech.ru">https://vezemtech.ru/</a> (далее по тексту — «Сайт») Пользователей (здесь и далее имеются ввиду только физические лица/граждане&nbsp;— субъекты персональных данных). Персональные данные Пользователей, сообщенные ими Оператору путем заполнения ими на Сайте Форм обратной связи, не публикуются Оператором в открытом доступе и не подлежат распространению. Приложением к настоящей Политике является&nbsp;— Сведения о реализуемых требованиях к защите персональных данных, изложенных ниже в 8.&nbsp;«Информация (Сведения) о реализуемых требованиях к защите персональных данных».</p><p>Данная Политика на условиях двухстороннего Соглашения (Пользователь&nbsp;— управомоченная сторона; Оператор&nbsp;—&nbsp;обязанная сторона) содержит весь комплекс обязанностей Оператора перед Пользователем (с учетом положений императивных норм Федерального закона от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»), которое (Соглашение) вступает в силу с момента оформления Пользователем на Сайте в Формах обратной связи своего Согласия на обработку своих персональных данных.</p><p>1. Правовые основания обработки персональных данных</p><p>Субъектами персональных данных, подлежащих защите в соответствие с <a href="https://ivo.garant.ru/#/document/12148567/entry/0:3">Федеральным законом от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»</a>, являются исключительно физические лица (граждане), в связи с чем адреса электронной почты, номера служебных телефонов (в том числе в мессенджерах) и другие реквизиты юридических лиц, сообщенные Пользователями путем заполнения Формы обратной связи на Сайте в качестве контактной информации юридических лиц&nbsp;— потенциальных приобретателей у Оператора оборудования, товаров, услуг&nbsp;— в категорию «персональные данные» не входят. «По умолчанию»&nbsp;— если иное не указал сам Пользователь в Форме обратной связи на Сайте&nbsp;— сообщенная Пользователем контактная информация относится к персональным данным самого Пользователя. Пользователи вправе на свой страх и риск сообщать Оператору в качестве контактной информации юридических лиц (работниками/руководителями или коммерческими представителями которых они являются) свои личные данные (адрес электронной почты, номера телефонов и т.п.), что означает неприменение к этой контактной информации мер защиты в порядке Федерального закона от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных».</p><p>Правовым основанием обработки предоставленных Пользователем Оператору своих персональных данных является исключительно только «<em>согласие субъекта персональных данных на обработку его персональных данных</em>» (пункт&nbsp;1 <a href="https://ivo.garant.ru/#/document/12148567/paragraph/22744:3">части&nbsp;1 статьи&nbsp;6 Федерального закона от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»</a>). Оператор не имеет технических возможностей и не собирается устанавливать персональные данные Пользователей, просматривающих Сайт и не отправивших свои персональные данные путем заполнения Форм обратной связи.</p><p>Полученные от Пользователей персональные данные обрабатывается исключительно Оператором, находящимся по адресу: 115230, г.&nbsp;Москва, Каширское шоссе, д.&nbsp;3, к.&nbsp;2, стр.&nbsp;9, этаж&nbsp;5, ком.&nbsp;11, и не подлежат передаче (распространение, предоставление, доступ) каким-либо способом каким-либо третьим лицам, за исключением случаев, прямо установленных <a href="https://ivo.garant.ru/#/document/12148567/entry/0:3">Федеральным законом от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»</a>.</p><p>2. Цели обработки персональных данных</p><p>Целью обработки персональных данных является предоставление Пользователю возможности установления связи с Оператором по телефону/смартфону (в том числе с использованием мессенджера) и/или электронной почте для начала переговоров:</p><ul><li>на предмет продажи Пользователю (или юридическому/физическому лицу, представителем которого является Пользователь) оборудования, товаров, оказания услуг, поставщиком которых является Оператор (или другие коммерческие организации, коммерческим представителем которых является Оператор);</li><li>по организации собеседования с Пользователем как кандидатом на замещение вакантной должности работника Оператора согласно размещенным на Сайте рабочим вакансиям.</li></ul><p>Полученные Оператором от Пользователей их персональные данные не подлежат трансграничной передаче и не будут переданы кому-либо, за исключением случаев, прямо установленных <a href="https://ivo.garant.ru/#/document/12148567/entry/0:3">Федеральным законом от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»</a>.</p><p>3. Согласие Пользователя на обработку персональных данных</p><p>Отправляя сообщение через Формы обратной связи, размещенные на Сайте и в Приложениях, Пользователь выражает свое согласие на обработку своих персональных данных в определенных настоящей Политикой целях и объеме путем самостоятельного проставления «галочки» в соответствующем поле Формы обратной связи. Отсутствие проставленной Пользователем «галочки» исключает возможность отправки Оператору персональных данных, указанных Пользователем в Форме обратной связи. Перед принятием решения о передаче своих персональных данных Оператору Пользователю предоставлена возможность ознакомиться с условиями его согласия (активная ссылка на текст Согласия) и положениями настоящей Политики (активная ссылка на локальный нормативный акт Оператора «Политика обработки персональных данных»).</p><p>4. Объем обрабатываемых персональных данных</p><p>1) Имя (Отчество и Фамилия не обязательны). Пользователь вправе в Формах обратной связи сообщить своё реальное Имя или воспользоваться «псевдонимом» по своему выбору и произвольно;</p><p>2) Контакт/Контакты (по выбору Пользователя): E-mail; Телефон; Мессенджер.</p><p>На Сайте не используется технология обработки куки (cookie), в связи с чем исключена возможность отслеживания действий Пользователя на Сайте и сбора/обработки информации о Пользователе.</p><p>5. Конфиденциальность персональных данных</p><p>Оператор не раскрывает третьим лицам и не распространяет персональные данные Пользователей без их согласия кроме случаев, предусмотренных федеральным законом.</p><p>6. Срок обработки персональных данных</p><p>Ввиду того, что обработка полученных на Сайте от Пользователей их персональных данных производится только с целью установления контактов с потенциальными покупателя/заказчиками и/или кандидатами на замещение вакантных должностей работников, срок обработки персональных данных строго ограничен временем на достижение этих целей (или утраты необходимости достижения этих целей). В связи с чем полученные от Пользователей их персональные данные подлежат уничтожению (действия, в результате которых становится невозможным восстановить содержание персональных данных в информационной системе Сайта):</p><ul><li>В течение Пяти рабочих дней после установления контакта с Пользователем на основании сообщённых им Оператору своих персональных данных в Формах обратной связи на Сайте.</li><li>В течение месяца после установления факта отсутствия необходимости или невозможности продолжения переговоров (собеседования) с Пользователем.</li><li>В течение одного рабочего дня после получения соответствующего требования Пользователя и/или отзыва Согласия на обработку своих персональных данных.</li><li>Одновременно с прекращением деятельности (существования) Сайта.</li></ul><p>Условия и порядок предоставления Пользователем своих персональных данных ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ «ЭКОФРИДОМ» (ОГРН 1137746381431, ИНН 7717751927) в связи с заключением гражданско-правовых сделок (договоров), их обработки и использовании будут определены в соответствующих разделах заключаемых Пользователем договорах (купли-продажи, подряда, оказание услуг, трудовой) или в отдельных Соглашениях исключительно на условии согласия Пользователя на обработку его персональных данных.</p><p>7. Права субъекта персональных данных</p><p>Права Пользователя определены в <a href="https://ivo.garant.ru/#/document/12148567/paragraph/106:2">Главе&nbsp;3 Федерального закона от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»</a>. В том числе Пользователь как Субъект персональных данных вправе направить запрос администратору Сайта на получение информации, касающейся обработки его персональных данных в соответствии с требованиями <a href="https://ivo.garant.ru/#/document/12148567/paragraph/22815:3">статьи&nbsp;14 Федерального закона от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»</a>.</p><p>Данный запрос может быть направлен по адресам, указанным в разделе «Контакты» на Сайте.</p><p>8.&nbsp;<a id="post-3-_Ref199246999"></a> Информация (Сведения) о реализуемых требованиях к защита персональных данных</p><p>Оператор принимает меры, необходимые и достаточные для обеспечения выполнения обязанностей, предусмотренных <a href="https://ivo.garant.ru/#/document/12148567/entry/0:3">Федеральным законом от&nbsp;27.07.2006 N&nbsp;152-ФЗ «О персональных данных»</a> и принятыми в соответствии с ним нормативными правовыми актами. Оператор самостоятельно определяет состав и перечень мер, необходимых и достаточных для обеспечения выполнения таких обязанностей.</p><p>Доступ к персональным данным имеют только уполномоченные сотрудники Оператора. Все сотрудники Оператора, имеющие доступ к персональным данным, в соответствие со своими должностными (трудовыми) обязанностями должны придерживаться политики по обеспечению конфиденциальности и защиты персональных данных. В целях обеспечения конфиденциальности информации и защиты персональных данных Оператор поддерживает соответствующую ИТ-среду и принимает все меры, необходимые для предотвращения несанкционированного доступа.</p></div>';

    $defaultPages = [
        [
            'slug' => '',
            'title' => 'Главная',
            'type' => 'system',
            'template' => 'index',
        ],
        [
            'slug' => 'catalog',
            'title' => 'Каталог',
            'type' => 'system',
            'template' => 'catalog',
        ],
        [
            'slug' => 'contacts',
            'title' => 'Контакты',
            'type' => 'page',
            'template' => 'page',
            'content' => '<p>Телефон: +7 (900) 000‑00‑00</p><p>Email: info@example.com</p>',
        ],
        [
            'slug' => 'delivery',
            'title' => 'Доставка и оплата',
            'type' => 'page',
            'template' => 'page',
            'content' => '<p>Условия доставки и оплаты.</p>',
        ],
        [
            'slug' => 'privacy-policy',
            'title' => 'Политика конфиденциальности',
            'type' => 'page',
            'template' => 'page',
            'content' => $privacyPolicyContent,
        ],
        [
            'slug' => 'soglasie',
            'title' => 'Согласие пользователя сайта на обработку персональных данных',
            'type' => 'page',
            'template' => 'page',
            'content' => '<div class="entry-content"><p>Настоящим в соответствии с Федеральным законом от 27.07.2006 №152-ФЗ «О персональных данных», <strong>Я, субъект персональных данных, именуемый в дальнейшем «Пользователь»,</strong>&nbsp;во время использования мною официального сайта (далее – Сайт, Сервис), свободно, своей волей и в своем интересе выражаю полное, безоговорочное и однозначное согласие на обработку моих персональных данных (далее – Согласие) на следующих условиях:</p><p>Согласие выдано на обработку следующих категорий персональных данных:</p><p>– данных, указанных в специальных формах, расположенных на Сайте, путем заполнения соответствующих текстовых полей и/или прикрепленных к формам файлов, а именно: фамилия, имя, адрес электронной почты (e-mail), номер телефона, другая аналогичная сообщенная информация, на основании которой возможна идентификация субъекта персональных данных;</p><p>– данных, которые автоматически передаются в процессе просмотра и при посещении страниц Сайта (cookie-файлы), а именно: дата и время доступа, адрес посещаемой страницы, источник входа, реферер (адрес предыдущей страницы), информация о поведении (включая количество и наименование просмотренных страниц), прочие технические данные (данные о технических средствах (в том числе, мобильных устройствах) и способах технологического взаимодействия с Сайтом и его сервисами (в т.ч. вид операционной системы Пользователя, тип браузера, географическое положение, данные о провайдере и иное), об активности Пользователя при использовании Сайта, об информации об ошибках, выдаваемых Пользователю, о скачанных файлах, инструментах, а также иные данные, получаемые установленными настоящим согласием способами;</p><p>– IP адрес и статистика о IP-адресах.</p><p>Согласие выдано на обработку персональных данных в целях:</p><p>– предоставления доступа к Сайту, его содержанию, к функционалу сервиса, для администрирования Сайта;</p><p>– идентификации при регистрации на Сайте и/или при использовании Сайта;</p><p>– подтверждения полноты предоставленных персональных данных;</p><p>– сбора Оператором статистики;</p><p>– улучшения качества работы Сайта и его сервисов, удобства их использования и разработки новых сервисов и услуг;</p><p>– предоставления эффективной клиентской и технической поддержки при возникновении проблем, связанных с использованием Сайта.</p><p>Согласие выдано на обработку персональных данных смешанным (автоматизированным и неавтоматизированным) способом с соблюдением мер, обеспечивающих их защиту от несанкционированного доступа.</p><p>В процессе обработки персональных данных Оператор вправе осуществлять: сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение персональных данных Пользователя.</p><p><strong>Настоящим, Пользователь подтверждает, что:</strong></p><p>1. Ознакомился с Политикой конфиденциальности персональных данных при их обработке на Сайте.</p><p>2. Ознакомлен и согласен с тем, что передача персональных данных Пользователя может осуществляться Оператором в объеме, необходимом для получения Пользователем доступа к Сайту, его содержанию и его сервисам, третьим лицам, в случаях, когда предоставление таких данных является обязательным в соответствии с федеральным законом.</p><p>3. Дает согласие на получение информационных материалов и ознакомлен Оператором о возможности и порядке совершения отказа от таковой.</p><p>Настоящее Согласие действует до достижения цели обработки персональных данных, а в случае отзыва согласия на&nbsp;обработку персональных данных, в срок, не превышающий тридцати дней с момента его получения.</p><p>Датой и временем формирования, подтверждения и предоставления Согласия прошу считать момент маркировки соответствующего поля и нажатия на соответствующую кнопку «Хорошо» в баннере о сборе cookie-файлов на любой странице Сайта.</p></div>',
        ],
    ];
    if (!is_file($pagesFile)) {
        write_json($pagesFile, $defaultPages);
    }
    return read_json($pagesFile, $defaultPages);
}

function load_requests(string $file): array {
    return read_json($file, []);
}

function save_requests(string $file, array $items): void {
    write_json($file, $items);
}

/**
 * Перевод массива строк через Google Translate API.
 * $texts: [ключ => строка], на выходе те же ключи.
 */
function google_translate_bulk(array $texts, array $cfg): array {
    // Глобальная переменная для последней ошибки перевода
    global $translationLastError;
    $translationLastError = null;
    $apiKey = trim((string)($cfg['googleApiKey'] ?? ''));
    $target = trim((string)($cfg['targetLang'] ?? ''));

    if ($apiKey === '' || $target === '' || empty($texts)) {
        if ($apiKey === '' || $target === '') {
            $translationLastError = 'Google Translate: не задан ключ API или целевой язык.';
        }
        return $texts;
    }
    if (!function_exists('curl_init')) {
        $translationLastError = 'Google Translate: на сервере недоступен cURL (нет функции curl_init).';
        return $texts;
    }

    // Google API принимает несколько q, порядок важен — сохраняем маппинг ключей
    $keys = array_keys($texts);
    $values = array_values($texts);

    $translated = $texts;

    // Ограничение Google: максимум 128 текстов за запрос
    $chunkSize = 128;
    $url = 'https://translation.googleapis.com/language/translate/v2?key=' . urlencode($apiKey);

    // Настройка прокси, если задано (одна строка на прокси: host:port или user:pass@host:port)
    $proxiesRaw = (string)($cfg['proxies'] ?? '');
    $proxies = [];
    foreach (preg_split('~\r\n|\n|\r~', $proxiesRaw) as $line) {
        $line = trim($line);
        if ($line === '') {
            continue;
        }
        $proxies[] = $line;
    }

    for ($offset = 0; $offset < count($values); $offset += $chunkSize) {
        $sliceValues = array_slice($values, $offset, $chunkSize);
        $sliceKeys   = array_slice($keys,   $offset, $chunkSize);

        // Собираем q=...&q=...&target=...
        $params = [];
        foreach ($sliceValues as $v) {
            $params[] = 'q=' . urlencode($v);
        }
        $params[] = 'target=' . urlencode($target);
        $params[] = 'format=text';

        $ch = curl_init($url);
        $curlOpts = [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded; charset=utf-8'],
            CURLOPT_POSTFIELDS => implode('&', $params),
            CURLOPT_TIMEOUT => 15,
        ];
        if (!empty($proxies)) {
            $proxyLine = $proxies[array_rand($proxies)];
            $auth = '';
            $hostPort = $proxyLine;
            if (strpos($proxyLine, '@') !== false) {
                [$auth, $hostPort] = explode('@', $proxyLine, 2);
            }
            $hostPort = trim($hostPort);
            if ($hostPort !== '') {
                $curlOpts[CURLOPT_PROXY] = $hostPort;
                if ($auth !== '') {
                    $curlOpts[CURLOPT_PROXYUSERPWD] = $auth;
                }
            }
        }
        curl_setopt_array($ch, $curlOpts);
        $resp = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        curl_close($ch);

        if ($resp === false) {
            $translationLastError = 'Google Translate: ошибка cURL: ' . ($err !== '' ? $err : 'неизвестная');
            return $translated;
        }
        if ($code >= 400) {
            $translationLastError = 'Google Translate: HTTP ' . $code . '. Ответ: ' . mb_substr((string)$resp, 0, 500);
            return $translated;
        }
        $data = json_decode((string)$resp, true);
        if (!isset($data['data']['translations']) || !is_array($data['data']['translations'])) {
            $translationLastError = 'Google Translate: неожиданный ответ API.';
            return $translated;
        }
        foreach ($data['data']['translations'] as $i => $tr) {
            if (!array_key_exists($i, $sliceKeys)) {
                continue;
            }
            $keyIndex = $sliceKeys[$i];
            if (!empty($tr['translatedText'])) {
                $translated[$keyIndex] = (string)$tr['translatedText'];
            }
        }
    }

    return $translated;
}

/**
 * Перевод массива строк через нейросеть (Gemini / OpenAI) по общим AI‑настройкам.
 * $texts: [ключ => строка], на выходе те же ключи.
 */
function ai_translate_bulk(array $texts, array $cfg, array $config): array {
    global $translationLastError;
    $translationLastError = null;

    if (empty($texts)) {
        return $texts;
    }

    $ai = $config['ai'] ?? [];
    $provider = $ai['provider'] ?? '';
    $apiKey = $ai['apiKey'] ?? '';
    $model = trim((string)($ai['model'] ?? ''));
    if ($provider === '' || $apiKey === '' || $model === '') {
        $translationLastError = 'ИИ-перевод: не настроен провайдер, API‑ключ или модель (раздел «ИИ» в настройках).';
        return $texts;
    }

    $source = trim((string)($cfg['sourceLang'] ?? 'auto'));
    $target = trim((string)($cfg['targetLang'] ?? 'ru'));

    $keys = array_keys($texts);
    $values = array_values($texts);
    $translated = $texts;

    // Чтобы не упереться в лимит токенов, переводим батчами
    $chunkSize = 50;

    for ($offset = 0; $offset < count($values); $offset += $chunkSize) {
        $sliceValues = array_slice($values, $offset, $chunkSize);
        $sliceKeys   = array_slice($keys,   $offset, $chunkSize);

        $jsonInput = json_encode(array_values($sliceValues), JSON_UNESCAPED_UNICODE);
        if ($jsonInput === false) {
            $translationLastError = 'ИИ-перевод: не удалось подготовить JSON для входных данных.';
            return $translated;
        }

        $sysSource = $source === '' ? 'auto' : $source;
        $sysTarget = $target === '' ? 'ru' : $target;

        $prompt = "Ты профессиональный переводчик.\n"
            . "Переведи каждый элемент переданного JSON-массива строк с языка \"{$sysSource}\" на язык \"{$sysTarget}\".\n"
            . "Сохрани порядок и количество элементов. Если исходный язык = \"auto\", определи его автоматически.\n"
            . "Ответь строго одним JSON-массивом строк перевода без пояснений, без markdown, без комментариев.\n\n"
            . "Входной массив: {$jsonInput}";

        $responseText = '';

        if ($provider === 'gemini') {
            $endpoint = trim((string)($ai['endpoint'] ?? ''));
            $url = $endpoint !== ''
                ? $endpoint
                : 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($model) . ':generateContent';
            $body = [
                'contents' => [['parts' => [['text' => $prompt]]]],
                'generationConfig' => [
                    'temperature' => 0.2,
                    'maxOutputTokens' => 2048,
                    'responseMimeType' => 'application/json',
                ],
            ];
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST => true,
                CURLOPT_HTTPHEADER => [
                    'Content-Type: application/json',
                    'x-goog-api-key: ' . $apiKey,
                ],
                CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
                CURLOPT_TIMEOUT => 60,
            ]);
            $response = curl_exec($ch);
            $err = curl_error($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($err !== '') {
                $translationLastError = 'ИИ-перевод (Gemini): ошибка cURL: ' . $err;
                return $translated;
            }
            $data = json_decode((string)$response, true);
            if ($code >= 400) {
                $msg = $data['error']['message'] ?? $data['error']['code'] ?? ('HTTP ' . $code);
                $translationLastError = 'ИИ-перевод (Gemini): ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE));
                return $translated;
            }
            $responseText = (string)($data['candidates'][0]['content']['parts'][0]['text'] ?? '');
        } elseif ($provider === 'openai') {
            $endpoint = trim((string)($ai['endpoint'] ?? ''));
            $url = $endpoint !== '' ? $endpoint : 'https://api.openai.com/v1/chat/completions';
            $body = [
                'model' => $model,
                'messages' => [
                    ['role' => 'system', 'content' => 'Ты профессиональный переводчик.'],
                    ['role' => 'user', 'content' => $prompt],
                ],
                'temperature' => 0.2,
                'max_tokens' => 2048,
            ];
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST => true,
                CURLOPT_HTTPHEADER => [
                    'Content-Type: application/json',
                    'Authorization: Bearer ' . $apiKey,
                ],
                CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
                CURLOPT_TIMEOUT => 60,
            ]);
            $response = curl_exec($ch);
            $err = curl_error($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($err !== '') {
                $translationLastError = 'ИИ-перевод (OpenAI): ошибка cURL: ' . $err;
                return $translated;
            }
            $data = json_decode((string)$response, true);
            if ($code >= 400) {
                $msg = $data['error']['message'] ?? $data['error']['code'] ?? ('HTTP ' . $code);
                $translationLastError = 'ИИ-перевод (OpenAI): ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE));
                return $translated;
            }
            $responseText = (string)($data['choices'][0]['message']['content'] ?? '');
        } else {
            $translationLastError = 'ИИ-перевод: неизвестный провайдер ИИ.';
            return $translated;
        }

        $responseText = trim($responseText);
        if ($responseText === '') {
            $translationLastError = 'ИИ-перевод: пустой ответ от модели.';
            return $translated;
        }
        $responseText = preg_replace('/^```\w*\s*|\s*```$/u', '', $responseText);
        $decoded = json_decode($responseText, true);
        if (!is_array($decoded)) {
            $translationLastError = 'ИИ-перевод: модель вернула не JSON-массив. Фрагмент: ' . mb_substr($responseText, 0, 200, 'UTF-8') . '…';
            return $translated;
        }

        $decodedValues = array_values($decoded);
        foreach ($decodedValues as $i => $val) {
            if (!array_key_exists($i, $sliceKeys)) {
                continue;
            }
            $keyIndex = $sliceKeys[$i];
            $translated[$keyIndex] = (string)$val;
        }
    }

    return $translated;
}

function load_categories_meta(string $file): array {
    static $migrated = false;
    $pdo = db();

    // Одноразовая миграция из legacy JSON в SQLite, если таблица пустая.
    if (!$migrated) {
        $countRow = $pdo->query('SELECT COUNT(*) AS c FROM categories_meta')->fetch(PDO::FETCH_ASSOC);
        $count = (int)($countRow['c'] ?? 0);
        if ($count === 0 && is_file($file)) {
            $legacy = read_json($file, []);
            if (is_array($legacy) && !empty($legacy)) {
                $stmt = $pdo->prepare('INSERT OR REPLACE INTO categories_meta (category_name, seo_title, seo_description, text_html, updated_at) VALUES (:category_name, :seo_title, :seo_description, :text_html, datetime(\'now\'))');
                foreach ($legacy as $categoryName => $row) {
                    if (!is_array($row)) {
                        continue;
                    }
                    $cat = trim((string)$categoryName);
                    if ($cat === '') {
                        continue;
                    }
                    $stmt->execute([
                        ':category_name' => $cat,
                        ':seo_title' => trim((string)($row['seoTitle'] ?? '')),
                        ':seo_description' => trim((string)($row['seoDescription'] ?? '')),
                        ':text_html' => trim((string)($row['textHtml'] ?? '')),
                    ]);
                }
            }
        }
        $migrated = true;
    }

    $stmt = $pdo->query('SELECT category_name, seo_title, seo_description, text_html FROM categories_meta');
    $meta = [];
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $cat = trim((string)($row['category_name'] ?? ''));
        if ($cat === '') {
            continue;
        }
        $meta[$cat] = [
            'seoTitle' => (string)($row['seo_title'] ?? ''),
            'seoDescription' => (string)($row['seo_description'] ?? ''),
            'textHtml' => (string)($row['text_html'] ?? ''),
        ];
    }
    return $meta;
}

function save_categories_meta(string $file, array $meta): void {
    $pdo = db();
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->prepare('INSERT OR REPLACE INTO categories_meta (category_name, seo_title, seo_description, text_html, updated_at) VALUES (:category_name, :seo_title, :seo_description, :text_html, datetime(\'now\'))');
        $keys = [];
        foreach ($meta as $categoryName => $row) {
            if (!is_array($row)) {
                continue;
            }
            $cat = trim((string)$categoryName);
            if ($cat === '') {
                continue;
            }
            $keys[] = $cat;
            $stmt->execute([
                ':category_name' => $cat,
                ':seo_title' => trim((string)($row['seoTitle'] ?? '')),
                ':seo_description' => trim((string)($row['seoDescription'] ?? '')),
                ':text_html' => trim((string)($row['textHtml'] ?? '')),
            ]);
        }
        if (empty($keys)) {
            $pdo->exec('DELETE FROM categories_meta');
        } else {
            $placeholders = implode(',', array_fill(0, count($keys), '?'));
            $del = $pdo->prepare("DELETE FROM categories_meta WHERE category_name NOT IN ($placeholders)");
            foreach ($keys as $i => $cat) {
                $del->bindValue($i + 1, $cat, PDO::PARAM_STR);
            }
            $del->execute();
        }
        $pdo->commit();
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }

    // Пишем legacy JSON как резервную копию/совместимость.
    write_json($file, $meta);
}

/**
 * Генерация SEO‑текстов для категории через Gemini или OpenAI.
 * Возвращает ['seoTitle' => '', 'seoDescription' => '', 'textHtml' => ''] или выбрасывает исключение.
 */
function ai_generate_category_texts(array $config, string $categoryName, array $productsContext): array {
    $ai = $config['ai'] ?? [];
    $provider = $ai['provider'] ?? '';
    $apiKey = $ai['apiKey'] ?? '';
    $model = trim($ai['model'] ?? '');
    if ($provider === '' || $apiKey === '' || $model === '') {
        throw new RuntimeException('ИИ не настроен: укажите провайдера, API‑ключ и модель в настройках.');
    }
    $contextLines = [];
    foreach (array_slice($productsContext, 0, 20) as $i => $p) {
        $name = trim((string)($p['name'] ?? ''));
        $desc = trim((string)($p['description'] ?? ''));
        $tags = trim((string)($p['tags'] ?? ''));
        $line = ($i + 1) . '. ' . $name;
        if ($desc !== '') {
            $line .= ' — ' . mb_substr($desc, 0, 300, 'UTF-8') . (mb_strlen($desc, 'UTF-8') > 300 ? '…' : '');
        }
        if ($tags !== '') {
            $line .= ' [теги: ' . $tags . ']';
        }
        $contextLines[] = $line;
    }
    $productsBlock = implode("\n", $contextLines);
    $regionName = $config['regionName'] ?? 'Москва';
    $prompt = <<<PROMPT
Ты — технический SEO-редактор B2B-каталога электронных компонентов. По названию категории и списку товаров сформируй три поля в формате JSON (строго один валидный JSON-объект, без markdown и пояснений):

1. seoTitle — заголовок страницы (title), до 60 символов, понятный и полезный. Обязательно используй плейсхолдер {region}. По желанию — {category}.
2. seoDescription — описание страницы (meta description), до 160 символов, конкретная польза и интент пользователя. Обязательно используй {region}. По желанию — {category}.
3. textHtml — блок для категории: 2 абзаца + список <ul><li> из 3 пунктов. Допустим только HTML: <p>, <ul>, <li>, <strong>. Обязательно используй {region}. По желанию — {category}.

Критические ограничения по стилю:
- НИКАКИХ клише: «в нашем интернет-магазине», «надежные решения», «гарантирует быстрое получение», «заказывайте оптом и экономьте».
- Никакой воды и общих рекламных обещаний.
- Только практическая польза: как выбрать, что проверить перед заказом, какие параметры критичны.
- Пиши нейтрально-профессионально, без восклицаний.

Примеры плейсхолдеров: {region} (название региона), {category} (название категории). Не заменяй их на конкретные значения — оставляй как есть в фигурных скобках.

Текущий пример региона для контекста (в текст не подставляй, только используй {region}): {$regionName}

Категория: {$categoryName}

Товары (для контекста):
{$productsBlock}

Ответь только одним JSON-объектом с ключами: seoTitle, seoDescription, textHtml.
PROMPT;

    $responseText = '';
    if ($provider === 'gemini') {
        $endpoint = trim($ai['endpoint'] ?? '');
        $url = $endpoint !== ''
            ? $endpoint
            : 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($model) . ':generateContent';
        $body = [
            'contents' => [['parts' => [['text' => $prompt]]]],
            'generationConfig' => [
                'temperature' => 0.4,
                'maxOutputTokens' => 2048,
                'responseMimeType' => 'application/json',
            ],
        ];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'x-goog-api-key: ' . $apiKey,
            ],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_TIMEOUT => 60,
        ]);
        $response = curl_exec($ch);
        $err = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($err !== '') {
            throw new RuntimeException('Ошибка запроса к Gemini: ' . $err);
        }
        $data = json_decode($response, true);
        if ($code >= 400) {
            $msg = $data['error']['message'] ?? $data['error']['code'] ?? 'HTTP ' . $code;
            throw new RuntimeException('Gemini API: ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE)));
        }
        $responseText = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
    } elseif ($provider === 'openai') {
        $endpoint = trim($ai['endpoint'] ?? '');
        $url = $endpoint !== '' ? $endpoint : 'https://api.openai.com/v1/chat/completions';
        $body = [
            'model' => $model,
            'messages' => [['role' => 'user', 'content' => $prompt]],
            'temperature' => 0.4,
            'max_tokens' => 2048,
        ];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . $apiKey,
            ],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_TIMEOUT => 60,
        ]);
        $response = curl_exec($ch);
        $err = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($err !== '') {
            throw new RuntimeException('Ошибка запроса к OpenAI: ' . $err);
        }
        $data = json_decode($response, true);
        if ($code >= 400) {
            $msg = $data['error']['message'] ?? $data['error']['code'] ?? 'HTTP ' . $code;
            throw new RuntimeException('OpenAI API: ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE)));
        }
        $responseText = $data['choices'][0]['message']['content'] ?? '';
    } else {
        throw new RuntimeException('Неизвестный провайдер ИИ.');
    }

    $responseText = trim($responseText);
    if ($responseText === '') {
        throw new RuntimeException('ИИ вернул пустой ответ.');
    }
    $responseText = preg_replace('/^```\w*\s*|\s*```$/u', '', $responseText);
    $decoded = json_decode($responseText, true);
    if (!is_array($decoded)) {
        throw new RuntimeException('ИИ вернул не JSON. Ответ: ' . mb_substr($responseText, 0, 200, 'UTF-8') . '…');
    }
    $seoTitle = trim((string)($decoded['seoTitle'] ?? ''));
    $seoDescription = trim((string)($decoded['seoDescription'] ?? ''));
    $textHtml = trim((string)($decoded['textHtml'] ?? ''));
    // На всякий случай нормализуем: регион в ответе должен быть плейсхолдером.
    if ($regionName !== '') {
        $seoTitle = str_replace($regionName, '{region}', $seoTitle);
        $seoDescription = str_replace($regionName, '{region}', $seoDescription);
        $textHtml = str_replace($regionName, '{region}', $textHtml);
    }
    return [
        'seoTitle' => $seoTitle,
        'seoDescription' => $seoDescription,
        'textHtml' => $textHtml,
    ];
}

/**
 * Генерация описания и SEO-полей для товара через ИИ. Возвращает description, tags, seoTitle, seoDescription.
 * В текстах используй плейсхолдер {region} (и по желанию {category}, {name}, {price}).
 */
function ai_generate_product_texts(array $config, array $product): array {
    $ai = $config['ai'] ?? [];
    $provider = $ai['provider'] ?? '';
    $apiKey = $ai['apiKey'] ?? '';
    $model = trim($ai['model'] ?? '');
    if ($provider === '' || $apiKey === '' || $model === '') {
        throw new RuntimeException('ИИ не настроен: укажите провайдера, API‑ключ и модель в настройках.');
    }
    $name = trim((string)($product['name'] ?? ''));
    $category = trim((string)($product['category'] ?? ''));
    $price = trim((string)($product['price'] ?? ''));
    $regionName = $config['regionName'] ?? 'Москва';
    $curDesc = trim((string)($product['description'] ?? ''));
    $curTags = trim((string)($product['tags'] ?? ''));

    $prompt = <<<PROMPT
Ты — копирайтер для интернет-магазина электронных комплектующих (опт). По данным товара сформируй один JSON-объект (без markdown и пояснений) с ключами:

1. description — развёрнутое описание товара для карточки (2–5 предложений). Характеристики, применение, польза. Обязательно используй плейсхолдер {region} (например: «доставка по {region}», «в {region}»). По желанию {category}, {name}.
2. tags — теги через запятую (5–15 ключевых слов/фраз для фильтра и поиска).
3. seoTitle — заголовок страницы (title), до 60 символов. Обязательно {region}. По желанию {name}, {category}.
4. seoDescription — meta description, до 160 символов. Обязательно {region}. По желанию {name}, {category}, {price}.

Плейсхолдеры не заменяй на значения — оставляй в фигурных скобках: {region}, {category}, {name}, {price}. Пример региона для контекста: {$regionName}.

Товар:
Название: {$name}
Категория: {$category}
Цена: {$price}
Текущее описание: {$curDesc}
Текущие теги: {$curTags}

Ответь только одним JSON с ключами: description, tags, seoTitle, seoDescription.
PROMPT;

    $responseText = '';
    if ($provider === 'gemini') {
        $endpoint = trim($ai['endpoint'] ?? '');
        $url = $endpoint !== '' ? $endpoint : 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($model) . ':generateContent';
        $body = [
            'contents' => [['parts' => [['text' => $prompt]]]],
            'generationConfig' => ['temperature' => 0.4, 'maxOutputTokens' => 2048, 'responseMimeType' => 'application/json'],
        ];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'x-goog-api-key: ' . $apiKey],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_TIMEOUT => 90,
        ]);
        $response = curl_exec($ch);
        $err = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($err !== '') {
            throw new RuntimeException('Ошибка запроса к Gemini: ' . $err);
        }
        $data = json_decode($response, true);
        if ($code >= 400) {
            $msg = $data['error']['message'] ?? $data['error']['code'] ?? 'HTTP ' . $code;
            throw new RuntimeException('Gemini API: ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE)));
        }
        $responseText = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
    } elseif ($provider === 'openai') {
        $endpoint = trim($ai['endpoint'] ?? '');
        $url = $endpoint !== '' ? $endpoint : 'https://api.openai.com/v1/chat/completions';
        $body = [
            'model' => $model,
            'messages' => [['role' => 'user', 'content' => $prompt]],
            'temperature' => 0.4,
            'max_tokens' => 2048,
        ];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_TIMEOUT => 90,
        ]);
        $response = curl_exec($ch);
        $err = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($err !== '') {
            throw new RuntimeException('Ошибка запроса к OpenAI: ' . $err);
        }
        $data = json_decode($response, true);
        if ($code >= 400) {
            $msg = $data['error']['message'] ?? $data['error']['code'] ?? 'HTTP ' . $code;
            throw new RuntimeException('OpenAI API: ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE)));
        }
        $responseText = $data['choices'][0]['message']['content'] ?? '';
    } else {
        throw new RuntimeException('Неизвестный провайдер ИИ.');
    }

    $responseText = trim($responseText);
    if ($responseText === '') {
        throw new RuntimeException('ИИ вернул пустой ответ.');
    }
    $responseText = preg_replace('/^```\w*\s*|\s*```$/u', '', $responseText);
    $decoded = json_decode($responseText, true);
    if (!is_array($decoded)) {
        throw new RuntimeException('ИИ вернул не JSON.');
    }
    return [
        'description' => trim((string)($decoded['description'] ?? '')),
        'tags' => trim((string)($decoded['tags'] ?? '')),
        'seoTitle' => trim((string)($decoded['seoTitle'] ?? '')),
        'seoDescription' => trim((string)($decoded['seoDescription'] ?? '')),
    ];
}

/**
 * Генерация дополнительного полезного блока для карточки товара.
 * Возвращает короткий HTML-блок (2-4 абзаца + маркированный список) без внешних ссылок.
 */
function ai_generate_product_extra_block(array $config, array $product): string {
    $ai = $config['ai'] ?? [];
    $provider = $ai['provider'] ?? '';
    $apiKey = $ai['apiKey'] ?? '';
    $model = trim($ai['model'] ?? '');
    if ($provider === '' || $apiKey === '' || $model === '') {
        throw new RuntimeException('ИИ не настроен: укажите провайдера, API‑ключ и модель в настройках.');
    }

    $name = trim((string)($product['name'] ?? ''));
    $sku = trim((string)($product['sku'] ?? ''));
    $category = trim((string)($product['category'] ?? ''));
    $desc = trim((string)($product['description'] ?? ''));
    $price = trim((string)($product['price'] ?? ''));
    $regionName = (string)($config['regionName'] ?? 'Москва');

    $prompt = <<<PROMPT
Ты технический редактор для B2B-каталога электронных компонентов.
Сгенерируй ТОЛЬКО валидный JSON с ключом "textHtml".

Требования к textHtml:
- 2 коротких абзаца + список <ul><li> из 3-4 пунктов.
- Максимум 1200 символов.
- Без внешних ссылок, без воды, без маркетингового спама.
- Практическая польза: критерии выбора аналога, проверка совместимости, риски при замене.
- Пиши на русском, нейтрально-профессионально.
- Можно использовать плейсхолдер {region}, но не обязательно.
- Не повторяй дословно исходное описание.

Данные товара:
Название: {$name}
Артикул: {$sku}
Категория: {$category}
Цена: {$price}
Текущее описание: {$desc}
Регион: {$regionName}

Ответ строго JSON:
{"textHtml":"..."}
PROMPT;

    $responseText = '';
    if ($provider === 'gemini') {
        $endpoint = trim((string)($ai['endpoint'] ?? ''));
        $url = $endpoint !== '' ? $endpoint : 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($model) . ':generateContent';
        $body = [
            'contents' => [['parts' => [['text' => $prompt]]]],
            'generationConfig' => ['temperature' => 0.35, 'maxOutputTokens' => 1400, 'responseMimeType' => 'application/json'],
        ];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'x-goog-api-key: ' . $apiKey],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_TIMEOUT => 90,
        ]);
        $response = curl_exec($ch);
        $err = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($err !== '') {
            throw new RuntimeException('Ошибка запроса к Gemini: ' . $err);
        }
        $data = json_decode((string)$response, true);
        if ($code >= 400) {
            $msg = $data['error']['message'] ?? $data['error']['code'] ?? ('HTTP ' . $code);
            throw new RuntimeException('Gemini API: ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE)));
        }
        $responseText = (string)($data['candidates'][0]['content']['parts'][0]['text'] ?? '');
    } elseif ($provider === 'openai') {
        $endpoint = trim((string)($ai['endpoint'] ?? ''));
        $url = $endpoint !== '' ? $endpoint : 'https://api.openai.com/v1/chat/completions';
        $body = [
            'model' => $model,
            'messages' => [['role' => 'user', 'content' => $prompt]],
            'temperature' => 0.35,
            'max_tokens' => 1400,
        ];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_TIMEOUT => 90,
        ]);
        $response = curl_exec($ch);
        $err = curl_error($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($err !== '') {
            throw new RuntimeException('Ошибка запроса к OpenAI: ' . $err);
        }
        $data = json_decode((string)$response, true);
        if ($code >= 400) {
            $msg = $data['error']['message'] ?? $data['error']['code'] ?? ('HTTP ' . $code);
            throw new RuntimeException('OpenAI API: ' . (is_string($msg) ? $msg : json_encode($msg, JSON_UNESCAPED_UNICODE)));
        }
        $responseText = (string)($data['choices'][0]['message']['content'] ?? '');
    } else {
        throw new RuntimeException('Неизвестный провайдер ИИ.');
    }

    $responseText = trim($responseText);
    if ($responseText === '') {
        throw new RuntimeException('ИИ вернул пустой ответ.');
    }
    $responseText = preg_replace('/^```\\w*\\s*|\\s*```$/u', '', $responseText) ?? $responseText;
    $decoded = json_decode($responseText, true);
    if (!is_array($decoded)) {
        throw new RuntimeException('ИИ вернул не JSON.');
    }
    $textHtml = trim((string)($decoded['textHtml'] ?? ''));
    if ($textHtml === '') {
        throw new RuntimeException('ИИ не вернул textHtml.');
    }
    return $textHtml;
}

/** Варианты slug категории (учёт апострофов: PLC's → plcs и plc-s). */
function category_slug_variants(string $categoryName): array {
    $variants = [slugify_ru($categoryName)];
    $noApos = str_replace(["'", "'", "`", "´"], '', $categoryName);
    if ($noApos !== $categoryName) {
        $variants[] = slugify_ru($noApos);
    }
    $sFold = preg_replace("~'s\b~iu", 's', $categoryName) ?? $categoryName;
    if ($sFold !== $categoryName) {
        $variants[] = slugify_ru($sFold);
    }
    return array_values(array_unique(array_filter($variants, static function ($v) {
        return $v !== '';
    })));
}

/** Найти название категории по slug URL (кэш + живая БД, несколько вариантов slug). */
function db_find_category_by_slug(string $slug): string {
    $slug = mb_strtolower(rawurldecode(trim($slug)), 'UTF-8');
    if ($slug === '') {
        return '';
    }
    $lists = [db_get_categories_cached(3600), db_get_categories()];
    $seen = [];
    foreach ($lists as $cats) {
        foreach ($cats as $c) {
            if (isset($seen[$c])) {
                continue;
            }
            $seen[$c] = true;
            foreach (category_slug_variants($c) as $variant) {
                if ($variant === $slug) {
                    return $c;
                }
            }
        }
    }
    return '';
}

function slugify_ru($str): string {
    // Приводим к строке, чтобы спокойно обрабатывать числа и другие скаляры
    $str = mb_strtolower(trim((string)$str), 'UTF-8');
    $map = [
        'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'e','ж'=>'zh','з'=>'z','и'=>'i',
        'й'=>'y','к'=>'k','л'=>'l','м'=>'m','н'=>'n','о'=>'o','п'=>'p','р'=>'r','с'=>'s','т'=>'t',
        'у'=>'u','ф'=>'f','х'=>'h','ц'=>'c','ч'=>'ch','ш'=>'sh','щ'=>'sch','ъ'=>'','ы'=>'y','ь'=>'',
        'э'=>'e','ю'=>'yu','я'=>'ya',
        ' '=>'-','_'=>'-',
    ];
    $str = strtr($str, $map);
    $str = preg_replace('~[^a-z0-9\-]+~', '-', $str);
    $str = preg_replace('~\-+~', '-', $str);
    return trim($str, '-');
}

/**
 * Slug для URL товара: без слэшей и небезопасных символов (чтобы не было 404 из-за / в пути).
 * Допустимы только a-z, 0-9, дефис. Слэши и прочее заменяются на дефис.
 */
function sanitize_product_slug(string $slug): string {
    $slug = trim($slug);
    if ($slug === '') {
        return 'product';
    }
    $slug = str_replace(['/', '\\', '#', '?', '%'], '-', $slug);
    $slug = mb_strtolower($slug, 'UTF-8');
    $map = [
        'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'e','ж'=>'zh','з'=>'z','и'=>'i',
        'й'=>'y','к'=>'k','л'=>'l','м'=>'m','н'=>'n','о'=>'o','п'=>'p','р'=>'r','с'=>'s','т'=>'t',
        'у'=>'u','ф'=>'f','х'=>'h','ц'=>'c','ч'=>'ch','ш'=>'sh','щ'=>'sch','ъ'=>'','ы'=>'y','ь'=>'',
        'э'=>'e','ю'=>'yu','я'=>'ya',
    ];
    $slug = strtr($slug, $map);
    $slug = preg_replace('~[^a-z0-9\-]+~', '-', $slug);
    $slug = preg_replace('~\-+~', '-', $slug);
    $slug = trim($slug, '-');
    return $slug !== '' ? $slug : 'product';
}

/**
 * Проверка, что slug безопасен для URL (нет слэшей и т.п.).
 */
function is_product_slug_safe(string $slug): bool {
    return $slug !== '' && strpos($slug, '/') === false && strpos($slug, '\\') === false
        && preg_match('~^[a-z0-9\-]+$~', $slug) === 1;
}

/**
 * База URL текущего запроса (схема + хост). Используется для canonical:
 * на каждом поддомене canonical ведёт на этот же поддомен.
 */
function get_current_base_url(): string {
    $scheme = 'http';
    if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
        $scheme = 'https';
    } elseif (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
        $scheme = 'https';
    } elseif (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] !== 'off') {
        $scheme = 'https';
    }
    $host = $_SERVER['HTTP_HOST'] ?? '';
    return $host !== '' ? $scheme . '://' . $host : '';
}

function build_seo(array $config, array $opts): array {
    $title = $opts['title'] ?? '';
    $description = $opts['description'] ?? '';
    $urlPath = $opts['urlPath'] ?? '';

    $fullTitle = $title ? $title . ' | ' . $config['siteName'] : $config['siteName'];
    // Canonical всегда на текущий хост (поддомен или основной), чтобы каноническая страница указывала сама на себя
    $base = rtrim($config['canonicalBase'] ?? $config['baseUrl'], '/');
    $canonical = $base . '/' . ltrim($urlPath, '/');

    return [
        'title' => $fullTitle,
        'description' => $description,
        'canonical' => $canonical,
    ];
}

/**
 * Отправка URL в IndexNow (Bing): GET https://api.indexnow.org/indexnow?url=...&key=...&keyLocation=...
 * Один GET-запрос на каждый URL. $baseUrlOverride — если задан, используется вместо config['baseUrl'].
 * Возвращает массив последнего запроса (url, key, keyLocation) или null.
 */
function notify_indexnow(array $config, array $urls, string $baseUrlOverride = ''): ?array {
    $base = $baseUrlOverride !== '' ? rtrim($baseUrlOverride, '/') : rtrim($config['baseUrl'] ?? '', '/');
    if (empty($config['indexNowKey']) || $base === '' || empty($urls)) {
        return null;
    }
    $key = $config['indexNowKey'];
    $keyLocation = $base . '/indexnow-' . $key . '.txt';
    $last = null;
    foreach ($urls as $url) {
        $params = [
            'url' => $url,
            'key' => $key,
            'keyLocation' => $keyLocation,
        ];
        $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
        $requestUrl = 'https://api.indexnow.org/indexnow?' . $query;
        $ch = curl_init($requestUrl);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPGET => true,
            CURLOPT_TIMEOUT => 5,
        ]);
        $body = curl_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err  = curl_error($ch);
        curl_close($ch);
        $last = $params + [
            'httpCode' => $code,
            'ok' => $code >= 200 && $code < 400 && $body !== false && $err === '',
        ];
    }
    return $last;
}

/**
 * Отправка URL в IndexNow Яндекса: GET https://yandex.com/indexnow?url=...&key=...&keyLocation=...
 * Один GET-запрос на один URL. Возвращает массив с полями запроса (url, key, keyLocation) или null.
 */
function notify_indexnow_yandex(array $config, array $urls, string $baseUrlOverride = ''): ?array {
    $base = $baseUrlOverride !== '' ? rtrim($baseUrlOverride, '/') : rtrim($config['baseUrl'] ?? '', '/');
    if (empty($config['indexNowKey']) || $base === '' || empty($urls)) {
        return null;
    }
    $url = $urls[0];
    $key = $config['indexNowKey'];
    $keyLocation = $base . '/indexnow-' . $key . '.txt';
    $params = [
        'url' => $url,
        'key' => $key,
        'keyLocation' => $keyLocation,
    ];
    $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
    $requestUrl = 'https://yandex.com/indexnow?' . $query;
    $ch = curl_init($requestUrl);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPGET => true,
        CURLOPT_TIMEOUT => 10,
    ]);
    $body = curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = curl_error($ch);
    curl_close($ch);
    return $params + [
        'httpCode' => $code,
        'ok' => $code >= 200 && $code < 400 && $body !== false && $err === '',
    ];
}

/**
 * Список базовых URL для IndexNow: основной сайт + все регионы (поддомены) с baseUrl/host/subdomain.
 */
function get_indexnow_bases(array $config): array {
    $mainBase = rtrim($config['baseUrl'] ?? '', '/');
    if ($mainBase === '') {
        return [];
    }
    $bases = [$mainBase];
    $regions = $config['regions'] ?? [];
    $scheme = (string)(parse_url($mainBase, PHP_URL_SCHEME) ?: 'https');
    $mainHost = (string)(parse_url($mainBase, PHP_URL_HOST) ?: '');
    foreach ($regions as $r) {
        if (!is_array($r)) {
            continue;
        }
        if (!empty($r['baseUrl'])) {
            $bases[] = rtrim((string)$r['baseUrl'], '/');
        } elseif (!empty($r['host'])) {
            $bases[] = $scheme . '://' . trim((string)$r['host']);
        } elseif (!empty($r['subdomain']) && $mainHost !== '') {
            $bases[] = $scheme . '://' . trim((string)$r['subdomain']) . '.' . $mainHost;
        }
    }
    return array_values(array_unique($bases));
}

/**
 * Полный список URL для одного базового URL (главная, каталог, категории, теги, страницы, товары).
 */
function build_indexnow_urls_for_base(string $base, array $pages): array {
    $base = rtrim($base, '/');
    if ($base === '') {
        return [];
    }
    $urls = [];
    $urls[] = $base . '/';
    $urls[] = $base . '/catalog';
    foreach (db_get_categories() as $cat) {
        $urls[] = $base . '/catalog/' . rawurlencode(slugify_ru($cat));
    }
    foreach (array_slice(db_get_all_tags(100000), 0, 10000) as $tag) {
        $urls[] = $base . '/tag/' . rawurlencode(slugify_ru($tag));
    }
    foreach ($pages as $p) {
        if (!empty($p['slug'])) {
            $urls[] = $base . '/' . $p['slug'];
        }
    }
    foreach (db_get_all_product_slugs() as $slug) {
        $urls[] = $base . '/product/' . rawurlencode($slug);
    }
    return array_values(array_unique($urls));
}

/**
 * Для ручной отправки: плоский список записей { url, baseUrl } по всем базам (основной + поддомены).
 */
function build_indexnow_entries(array $config, array $pages): array {
    $bases = get_indexnow_bases($config);
    $entries = [];
    foreach ($bases as $base) {
        $urls = build_indexnow_urls_for_base($base, $pages);
        foreach ($urls as $url) {
            $entries[] = ['url' => $url, 'baseUrl' => $base];
        }
    }
    return $entries;
}

// ---------- Google Indexing API (сервисные аккаунты из папки) ----------

/**
 * Загрузить все валидные сервисные аккаунты из папки (JSON-файлы).
 * Возвращает массив [ ['key' => id/имя, 'client_email' => ..., 'credentials' => массив из JSON ], ... ].
 */
function google_indexing_load_accounts(): array {
    global $googleIndexingAccountsDir;
    if (!is_dir($googleIndexingAccountsDir)) {
        mkdir($googleIndexingAccountsDir, 0755, true);
        return [];
    }
    $list = [];
    foreach (new DirectoryIterator($googleIndexingAccountsDir) as $f) {
        if ($f->isDot() || $f->getExtension() !== 'json') {
            continue;
        }
        $path = $f->getPathname();
        $raw = @file_get_contents($path);
        if ($raw === false || $raw === '') {
            continue;
        }
        $data = json_decode($raw, true);
        if (!is_array($data) || ($data['type'] ?? '') !== 'service_account' || empty($data['private_key']) || empty($data['client_email'])) {
            continue;
        }
        $list[] = [
            'key' => $f->getBasename('.json'),
            'client_email' => (string)$data['client_email'],
            'credentials' => $data,
        ];
    }
    return $list;
}

/**
 * Base64url без padding.
 */
function google_indexing_base64url_encode(string $data): string {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/**
 * Получить access_token для сервисного аккаунта (JWT -> token_uri).
 */
function google_indexing_get_token(array $credentials): ?string {
    $clientEmail = $credentials['client_email'] ?? '';
    $privateKey = $credentials['private_key'] ?? '';
    $tokenUri = $credentials['token_uri'] ?? 'https://oauth2.googleapis.com/token';
    if ($clientEmail === '' || $privateKey === '') {
        return null;
    }
    $now = time();
    $header = ['alg' => 'RS256', 'typ' => 'JWT'];
    $payload = [
        'iss' => $clientEmail,
        'scope' => 'https://www.googleapis.com/auth/indexing',
        'aud' => $tokenUri,
        'iat' => $now,
        'exp' => $now + 3600,
    ];
    $headerB64 = google_indexing_base64url_encode(json_encode($header));
    $payloadB64 = google_indexing_base64url_encode(json_encode($payload));
    $signatureInput = $headerB64 . '.' . $payloadB64;
    $key = openssl_pkey_get_private($privateKey);
    if ($key === false) {
        return null;
    }
    $signature = '';
    if (!openssl_sign($signatureInput, $signature, $key, OPENSSL_ALGO_SHA256)) {
        return null;
    }
    $signatureB64 = google_indexing_base64url_encode($signature);
    $jwt = $signatureInput . '.' . $signatureB64;
    $ch = curl_init($tokenUri);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' . urlencode($jwt),
        CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
        CURLOPT_TIMEOUT => 15,
    ]);
    $resp = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($resp === false || $code !== 200) {
        return null;
    }
    $data = json_decode((string)$resp, true);
    return isset($data['access_token']) ? (string)$data['access_token'] : null;
}

/**
 * Отправить один URL в Google Indexing API. Возвращает ['success' => bool, 'error' => string|null].
 */
function google_indexing_publish_url(string $accessToken, string $url): array {
    $endpoint = 'https://indexing.googleapis.com/v3/urlNotifications:publish';
    $body = json_encode(['url' => $url, 'type' => 'URL_UPDATED'], JSON_UNESCAPED_SLASHES);
    $ch = curl_init($endpoint);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $accessToken,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_TIMEOUT => 15,
    ]);
    $resp = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code === 200) {
        return ['success' => true, 'error' => null];
    }
    $err = trim((string)$resp);
    if ($err !== '') {
        $dec = json_decode($err, true);
        if (is_array($dec) && isset($dec['error']['message'])) {
            $err = $dec['error']['message'];
        }
    }
    return ['success' => false, 'error' => $err ?: ('HTTP ' . $code)];
}

/** История отправок: { "client_email": { "Y-m-d": ["url", ...], ... }, ... } */
function google_indexing_sent_load(): array {
    global $googleIndexingSentFile;
    return read_json($googleIndexingSentFile, []);
}

function google_indexing_sent_save(array $data): void {
    global $googleIndexingSentFile;
    write_json($googleIndexingSentFile, $data);
}

function google_indexing_sent_today(string $clientEmail): array {
    $all = google_indexing_sent_load();
    $today = date('Y-m-d');
    $byEmail = $all[$clientEmail] ?? [];
    $urls = $byEmail[$today] ?? [];
    return ['count' => count($urls), 'urls' => $urls];
}

function google_indexing_sent_add(string $clientEmail, string $url): void {
    $all = google_indexing_sent_load();
    $today = date('Y-m-d');
    if (!isset($all[$clientEmail])) {
        $all[$clientEmail] = [];
    }
    if (!isset($all[$clientEmail][$today])) {
        $all[$clientEmail][$today] = [];
    }
    if (!in_array($url, $all[$clientEmail][$today], true)) {
        $all[$clientEmail][$today][] = $url;
    }
    google_indexing_sent_save($all);
}

/**
 * Отправить страницы в Google Indexing API с ротацией аккаунтов.
 * Возвращает ['sent' => int, 'errors' => [], 'by_account' => [email => count], 'message' => string].
 */
function google_indexing_submit_batch(array $config, array $pages): array {
    $entries = build_indexnow_entries($config, $pages);
    $urls = array_values(array_unique(array_column($entries, 'url')));
    $accounts = google_indexing_load_accounts();
    if (empty($urls)) {
        return ['sent' => 0, 'errors' => [], 'by_account' => [], 'message' => 'Нет URL для отправки (настройте Базовый URL и регионы).'];
    }
    if (empty($accounts)) {
        return ['sent' => 0, 'errors' => [], 'by_account' => [], 'message' => 'Нет сервисных аккаунтов. Положите JSON-файлы в папку data/google_indexing_accounts/.'];
    }
    $today = date('Y-m-d');
    $allSent = google_indexing_sent_load();
    foreach ($accounts as $acc) {
        $em = $acc['client_email'];
        if (!isset($allSent[$em])) {
            $allSent[$em] = [];
        }
        if (!isset($allSent[$em][$today])) {
            $allSent[$em][$today] = [];
        }
    }
    $byAccount = array_fill_keys(array_column($accounts, 'client_email'), 0);
    $sent = 0;
    $errors = [];
    $tokens = [];
    foreach ($urls as $url) {
        $alreadySentByAny = false;
        foreach ($accounts as $acc) {
            $em = $acc['client_email'];
            $accToday = $allSent[$em][$today] ?? [];
            if (in_array($url, $accToday, true)) {
                $alreadySentByAny = true;
                break;
            }
        }
        if ($alreadySentByAny) {
            continue;
        }
        $sentForUrl = false;
        foreach ($accounts as $acc) {
            $em = $acc['client_email'];
            $accToday = $allSent[$em][$today] ?? [];
            if (count($accToday) >= GOOGLE_INDEXING_DAILY_LIMIT) {
                continue;
            }
            if (in_array($url, $accToday, true)) {
                continue;
            }
            if (!isset($tokens[$em])) {
                $tokens[$em] = google_indexing_get_token($acc['credentials']);
            }
            if ($tokens[$em] === null) {
                continue;
            }
            $res = google_indexing_publish_url($tokens[$em], $url);
            if ($res['success']) {
                google_indexing_sent_add($em, $url);
                $allSent[$em][$today][] = $url;
                $byAccount[$em]++;
                $sent++;
                $sentForUrl = true;
                break;
            }
            $err = $res['error'] ?? '';
            if ($err !== '' && (strpos($err, '429') !== false || strpos($err, 'quota') !== false || strpos($err, 'RESOURCE_EXHAUSTED') !== false)) {
                $tokens[$em] = null;
                continue;
            }
            $errors[] = $url . ': ' . $err;
            break;
        }
        if (!$sentForUrl && !$alreadySentByAny) {
            $errors[] = $url . ': все аккаунты исчерпаны или ошибка';
        }
    }
    return [
        'sent' => $sent,
        'errors' => $errors,
        'by_account' => $byAccount,
        'message' => '',
    ];
}

/**
 * Нормализация адреса сайта из формата Вебмастера (https:vezemtech.ru:443) в обычный URL (https://vezemtech.ru).
 */
function yandex_host_id_to_url(string $hostId): string {
    $s = trim($hostId);
    if ($s === '') {
        return '';
    }
    if (preg_match('/^(https?):([^:]+)(?::(\d+))?$/i', $s, $m)) {
        $scheme = strtolower($m[1]);
        $host = $m[2];
        $port = isset($m[3]) ? (int)$m[3] : null;
        $defaultPort = ($scheme === 'https') ? 443 : 80;
        if ($port !== null && $port !== $defaultPort) {
            return $scheme . '://' . $host . ':' . $port;
        }
        return $scheme . '://' . $host;
    }
    return $s;
}

/**
 * Базовый URL сайта (из конфига), соответствующий хосту Вебмастера, или null.
 */
function get_base_for_yandex_host(array $config, string $hostId): ?string {
    $hostUrl = rtrim(yandex_host_id_to_url($hostId), '/');
    if ($hostUrl === '') {
        return null;
    }
    $bases = get_indexnow_bases($config);
    foreach ($bases as $base) {
        if (rtrim($base, '/') === $hostUrl) {
            return $base;
        }
    }
    return null;
}

/**
 * URL страниц сайта для переобхода по данному хосту (домен/поддомен).
 */
function build_recrawl_urls_for_host(array $config, string $hostId): array {
    $base = get_base_for_yandex_host($config, $hostId);
    if ($base === null) {
        return [];
    }
    global $pagesFile;
    $pages = load_pages($pagesFile);
    return build_indexnow_urls_for_base($base, $pages);
}

/**
 * Запрос к API Яндекс.Вебмастер (GET). $path — путь без ведущего слэша; $query — опциональная query-строка.
 */
function yandex_webmaster_api_get(string $oauthToken, string $path, string $query = ''): ?array {
    $url = 'https://api.webmaster.yandex.net/' . ltrim($path, '/');
    if ($query !== '') {
        $url .= (strpos($url, '?') !== false ? '&' : '?') . $query;
    }
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: OAuth ' . $oauthToken,
            'Content-Type: application/json',
        ],
        CURLOPT_TIMEOUT => 15,
    ]);
    $resp = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($resp === false || $code >= 400) {
        return null;
    }
    $data = json_decode((string)$resp, true);
    return is_array($data) ? $data : null;
}

/**
 * Получить user_id по OAuth-токену.
 */
function yandex_get_user_id(string $oauthToken): ?string {
    $data = yandex_webmaster_api_get($oauthToken, 'v4/user');
    if ($data === null || !isset($data['user_id'])) {
        return null;
    }
    return (string)$data['user_id'];
}

/**
 * Получить список сайтов (хостов) пользователя. Возвращает массив с host_id, url (нормализованный), ascii_host_url, verified.
 */
function yandex_get_hosts(string $oauthToken, string $userId): array {
    $data = yandex_webmaster_api_get($oauthToken, 'v4/user/' . $userId . '/hosts');
    if ($data === null || !isset($data['hosts']) || !is_array($data['hosts'])) {
        return [];
    }
    $list = [];
    foreach ($data['hosts'] as $h) {
        if (!is_array($h)) {
            continue;
        }
        $hostId = isset($h['host_id']) ? (string)$h['host_id'] : '';
        $list[] = [
            'host_id' => $hostId,
            'url' => yandex_host_id_to_url($hostId),
            'ascii_host_url' => isset($h['ascii_host_url']) ? (string)$h['ascii_host_url'] : '',
            'verified' => !empty($h['verified']),
        ];
    }
    return $list;
}

/**
 * Популярные поисковые запросы по хосту (TOP, показы/клики). Эндпоинт: search-queries/popular.
 */
function yandex_get_host_search_queries_popular(string $oauthToken, string $userId, string $hostId): array {
    $path = 'v4/user/' . rawurlencode($userId) . '/hosts/' . rawurlencode($hostId) . '/search-queries/popular';
    $query = 'order_by=TOTAL_SHOWS&query_indicator=TOTAL_SHOWS&query_indicator=TOTAL_CLICKS&limit=500';
    $data = yandex_webmaster_api_get($oauthToken, $path, $query);
    if ($data === null || !isset($data['queries']) || !is_array($data['queries'])) {
        return [];
    }
    $list = [];
    foreach ($data['queries'] as $q) {
        if (!is_array($q)) {
            continue;
        }
        $text = isset($q['query_text']) ? (string)$q['query_text'] : '';
        $indicators = isset($q['indicators']) && is_array($q['indicators']) ? $q['indicators'] : [];
        $list[] = [
            'query' => $text,
            'shows' => isset($indicators['TOTAL_SHOWS']) ? (float)$indicators['TOTAL_SHOWS'] : null,
            'clicks' => isset($indicators['TOTAL_CLICKS']) ? (float)$indicators['TOTAL_CLICKS'] : null,
        ];
    }
    return $list;
}

/**
 * Квота переобхода: GET recrawl/quota. Возвращает daily_quota и quota_remainder по API.
 */
function yandex_get_recrawl_quota(string $oauthToken, string $userId, string $hostId): array {
    $path = 'v4/user/' . rawurlencode($userId) . '/hosts/' . rawurlencode($hostId) . '/recrawl/quota';
    $data = yandex_webmaster_api_get($oauthToken, $path);
    if ($data === null) {
        return ['available' => false, 'daily_quota' => null, 'quota_remainder' => null, 'error' => 'Нет доступа или хост не подтверждён'];
    }
    if (isset($data['error_code'])) {
        return ['available' => false, 'daily_quota' => null, 'quota_remainder' => null, 'error' => $data['error_message'] ?? $data['error_code'] ?? 'Ошибка API'];
    }
    return [
        'available' => true,
        'daily_quota' => isset($data['daily_quota']) ? (int)$data['daily_quota'] : null,
        'quota_remainder' => isset($data['quota_remainder']) ? (int)$data['quota_remainder'] : null,
        'error' => null,
    ];
}

/**
 * Отправить один URL на переобход. POST recrawl/queue. Возвращает ['success' => bool, 'quota_remainder' => int|null, 'error' => string|null].
 */
function yandex_recrawl_submit_url(string $oauthToken, string $userId, string $hostId, string $url): array {
    $path = 'v4/user/' . rawurlencode($userId) . '/hosts/' . rawurlencode($hostId) . '/recrawl/queue';
    $fullUrl = 'https://api.webmaster.yandex.net/' . ltrim($path, '/');
    $ch = curl_init($fullUrl);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: OAuth ' . $oauthToken,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode(['url' => $url], JSON_UNESCAPED_UNICODE),
        CURLOPT_TIMEOUT => 15,
    ]);
    $resp = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($resp === false) {
        return ['success' => false, 'quota_remainder' => null, 'error' => 'Ошибка сети'];
    }
    $data = json_decode((string)$resp, true);
    if ($code === 202 && is_array($data)) {
        return ['success' => true, 'quota_remainder' => isset($data['quota_remainder']) ? (int)$data['quota_remainder'] : null, 'error' => null];
    }
    if ($code === 429) {
        return ['success' => false, 'quota_remainder' => 0, 'error' => 'Исчерпана суточная квота переобхода'];
    }
    if (is_array($data) && isset($data['error_code'])) {
        $err = $data['error_message'] ?? $data['error_code'];
        if (($data['error_code'] ?? '') === 'URL_ALREADY_ADDED') {
            return ['success' => true, 'quota_remainder' => null, 'error' => null];
        }
        return ['success' => false, 'quota_remainder' => null, 'error' => $err];
    }
    return ['success' => false, 'quota_remainder' => null, 'error' => 'HTTP ' . $code];
}

/**
 * Отфильтровать хосты: только целевой домен и его поддомены (по baseUrl из конфига).
 */
function yandex_filter_hosts_by_target(array $hosts, string $baseUrl): array {
    $mainHost = strtolower((string)parse_url($baseUrl, PHP_URL_HOST));
    if ($mainHost === '') {
        return $hosts;
    }
    $suffix = '.' . $mainHost;
    return array_filter($hosts, function ($h) use ($mainHost, $suffix) {
        $url = $h['url'] ?: $h['ascii_host_url'] ?: '';
        $host = strtolower((string)parse_url($url, PHP_URL_HOST));
        if ($host === '') {
            $host = strtolower($h['host_id']);
            if (preg_match('/^https?:([^:]+)(?::\d+)?$/i', $host, $m)) {
                $host = trim($m[1], '/');
            }
        }
        if ($host === '') {
            return false;
        }
        return $host === $mainHost || (strlen($host) > strlen($suffix) && substr($host, -strlen($suffix)) === $suffix);
    });
}

/** История отправок на переобход: { host_id: { "Y-m-d": [url, ...], ... } } */
function load_recrawl_history(): array {
    global $recrawlHistoryFile;
    return read_json($recrawlHistoryFile, []);
}

function save_recrawl_history(array $data): void {
    global $recrawlHistoryFile;
    write_json($recrawlHistoryFile, $data);
}

/** Для хоста за сегодня: количество и список URL. */
function recrawl_history_today(string $hostId): array {
    $all = load_recrawl_history();
    $today = date('Y-m-d');
    $byHost = $all[$hostId] ?? [];
    $urls = $byHost[$today] ?? [];
    return ['count' => count($urls), 'urls' => $urls];
}

/** Добавить URL в историю за сегодня по host_id. */
function recrawl_history_add(string $hostId, string $url): void {
    $all = load_recrawl_history();
    $today = date('Y-m-d');
    if (!isset($all[$hostId])) {
        $all[$hostId] = [];
    }
    if (!isset($all[$hostId][$today])) {
        $all[$hostId][$today] = [];
    }
    if (!in_array($url, $all[$hostId][$today], true)) {
        $all[$hostId][$today][] = $url;
    }
    save_recrawl_history($all);
}

/** Очистить историю отправок для хоста. */
function recrawl_history_clear(string $hostId): void {
    $all = load_recrawl_history();
    unset($all[$hostId]);
    save_recrawl_history($all);
}

/**
 * Собрать данные Вебмастера: user_id, только целевые хосты (домен + поддомены), по каждому — запросы, квота переобхода и история.
 */
function fetch_yandex_stats(array $config) {
    $y = $config['yandex'] ?? [];
    $token = isset($y['oauthToken']) ? trim((string)$y['oauthToken']) : '';
    if ($token === '') {
        return ['error' => 'Укажите OAuth‑токен в настройках (раздел «Яндекс.Вебмастер»).'];
    }
    $userId = yandex_get_user_id($token);
    if ($userId === null) {
        return ['error' => 'Не удалось получить user_id. Проверьте токен и права доступа к API Вебмастера.'];
    }
    $hosts = yandex_get_hosts($token, $userId);
    $baseUrl = rtrim($config['baseUrl'] ?? '', '/');
    $hosts = yandex_filter_hosts_by_target($hosts, $baseUrl);
    if (empty($hosts)) {
        return ['hosts' => [], 'user_id' => $userId, 'message' => 'В Вебмастере нет сайтов, совпадающих с целевым доменом (' . ($baseUrl ?: 'укажите Базовый URL') . ').'];
    }
    $result = ['user_id' => $userId, 'hosts' => []];
    foreach ($hosts as $host) {
        $searchQueries = yandex_get_host_search_queries_popular($token, $userId, $host['host_id']);
        $recrawl = yandex_get_recrawl_quota($token, $userId, $host['host_id']);
        $todayHistory = recrawl_history_today($host['host_id']);
        $result['hosts'][] = [
            'host_id' => $host['host_id'],
            'url' => $host['url'],
            'ascii_host_url' => $host['ascii_host_url'],
            'verified' => $host['verified'],
            'search_queries' => $searchQueries,
            'recrawl_available' => $recrawl['available'],
            'recrawl_daily_quota' => $recrawl['daily_quota'],
            'recrawl_quota_remainder' => $recrawl['quota_remainder'],
            'recrawl_error' => $recrawl['error'],
            'recrawl_sent_today' => $todayHistory['count'],
            'recrawl_sent_urls' => $todayHistory['urls'],
        ];
    }
    return $result;
}

function db(): PDO {
    static $pdo = null;
    global $dbFile;
    if ($pdo instanceof PDO) {
        return $pdo;
    }

    // Диагностика окружения БД прямо в браузере, чтобы не ловить белый экран
    if (!class_exists('PDO')) {
        die('Ошибка: на сервере не установлено расширение PDO (class PDO не найден).');
    }
    $drivers = PDO::getAvailableDrivers();
    if (!in_array('sqlite', $drivers, true)) {
        die('Ошибка: на сервере не установлен драйвер PDO_SQLITE. Доступные драйверы: ' . implode(', ', $drivers));
    }

    $dbDir = dirname($dbFile);
    if (!is_dir($dbDir)) {
        if (!mkdir($dbDir, 0777, true) && !is_dir($dbDir)) {
            die('Ошибка: не удалось создать каталог для базы данных: ' . htmlspecialchars($dbDir, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
        }
    }

    try {
        $pdo = new PDO('sqlite:' . $dbFile);
    } catch (Throwable $e) {
        die('Ошибка подключения к SQLite: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
    }

    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

    $pdo->exec('CREATE TABLE IF NOT EXISTS products (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        sku TEXT UNIQUE,
        slug TEXT,
        name TEXT,
        price TEXT,
        category TEXT,
        tags TEXT,
        description TEXT,
        seoTitle TEXT,
        seoDescription TEXT,
        imageUrl TEXT,
        imageLocal TEXT,
        categoriesExtra TEXT,
        aiExtraText TEXT
    )');
    $pdo->exec('CREATE TABLE IF NOT EXISTS categories_meta (
        category_name TEXT PRIMARY KEY,
        seo_title TEXT,
        seo_description TEXT,
        text_html TEXT,
        updated_at TEXT
    )');
    // миграция для существующих таблиц
    try {
        $pdo->exec('ALTER TABLE products ADD COLUMN sku TEXT');
    } catch (Throwable $e) {
        // колонка уже существует
    }
    try {
        $pdo->exec('ALTER TABLE products ADD COLUMN imageUrl TEXT');
    } catch (Throwable $e) {
        // уже есть
    }
    try {
        $pdo->exec('ALTER TABLE products ADD COLUMN imageLocal TEXT');
    } catch (Throwable $e) {
        // уже есть
    }
    try {
        $pdo->exec('ALTER TABLE products ADD COLUMN categoriesExtra TEXT');
    } catch (Throwable $e) {
        // уже есть
    }
    try {
        $pdo->exec('ALTER TABLE products ADD COLUMN updated_at TEXT');
        $pdo->exec("UPDATE products SET updated_at = datetime('now') WHERE updated_at IS NULL");
    } catch (Throwable $e) {
        // уже есть
    }
    try {
        $pdo->exec('ALTER TABLE products ADD COLUMN aiExtraText TEXT');
    } catch (Throwable $e) {
        // уже есть
    }
    $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sku ON products (sku)');
    $pdo->exec('CREATE INDEX IF NOT EXISTS idx_products_slug ON products (slug)');
    $pdo->exec('CREATE INDEX IF NOT EXISTS idx_products_category ON products (category)');
    // Составной индекс для быстрой выборки по категории с сортировкой по id (страницы категорий)
    $pdo->exec('CREATE INDEX IF NOT EXISTS idx_products_category_id ON products (category, id)');

    // Оптимизация SQLite для больших БД (12–30 ГБ): кэш страниц, WAL, mmap
    $pdo->exec('PRAGMA journal_mode=WAL');
    $pdo->exec('PRAGMA temp_store=MEMORY');
    $pdo->exec('PRAGMA cache_size=-262144');   // 256 МБ кэш страниц
    $pdo->exec('PRAGMA mmap_size=268435456'); // 256 МБ mmap (для больших таблиц)

    // FTS5 для быстрого полнотекстового поиска (вместо медленного LIKE по 12–30 ГБ)
    $pdo->exec("CREATE VIRTUAL TABLE IF NOT EXISTS products_fts USING fts5(name, description, sku, content='products', content_rowid='id')");
    $pdo->exec("CREATE TRIGGER IF NOT EXISTS products_fts_ai AFTER INSERT ON products BEGIN
        INSERT INTO products_fts(rowid, name, description, sku) VALUES (new.id, COALESCE(new.name,''), COALESCE(new.description,''), COALESCE(new.sku,''));
    END");
    $pdo->exec("CREATE TRIGGER IF NOT EXISTS products_fts_ad AFTER DELETE ON products BEGIN
        INSERT INTO products_fts(products_fts, rowid, name, description, sku) VALUES ('delete', old.id, COALESCE(old.name,''), COALESCE(old.description,''), COALESCE(old.sku,''));
    END");
    $pdo->exec("CREATE TRIGGER IF NOT EXISTS products_fts_au AFTER UPDATE ON products BEGIN
        INSERT INTO products_fts(products_fts, rowid, name, description, sku) VALUES ('delete', old.id, COALESCE(old.name,''), COALESCE(old.description,''), COALESCE(old.sku,''));
        INSERT INTO products_fts(rowid, name, description, sku) VALUES (new.id, COALESCE(new.name,''), COALESCE(new.description,''), COALESCE(new.sku,''));
    END");

    return $pdo;
}

function db_get_products_for_index(int $limit = 12): array {
    $pdo = db();
    $stmt = $pdo->prepare('SELECT id, sku, slug, name, price, category, imageUrl, imageLocal FROM products ORDER BY id DESC LIMIT :limit');
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll();
}

/** Товары категории для контекста ИИ: название, описание, теги (до 20 шт.). */
function db_get_category_products_for_ai(string $category, int $limit = 20): array {
    $pdo = db();
    $stmt = $pdo->prepare('SELECT name, description, tags FROM products WHERE category = :cat AND (name IS NOT NULL OR description IS NOT NULL OR tags IS NOT NULL) ORDER BY id DESC LIMIT :limit');
    $stmt->bindValue(':cat', $category, PDO::PARAM_STR);
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

/** Список товаров для админки с полями для редактирования (в т.ч. описание, теги, SEO). При поиске использует FTS5. */
function db_get_products_for_admin(?string $q, int $limit, int $offset): array {
    $pdo = db();
    $cols = 'id, sku, slug, name, price, category, imageLocal, description, tags, seoTitle, seoDescription';
    if ($q !== null && $q !== '') {
        $ftsQuery = db_fts_escape_query(trim($q));
        if ($ftsQuery !== '') {
            try {
                $sql = "SELECT p.id, p.sku, p.slug, p.name, p.price, p.category, p.imageLocal, p.description, p.tags, p.seoTitle, p.seoDescription
                        FROM products p
                        INNER JOIN (SELECT rowid AS id FROM products_fts WHERE products_fts MATCH :q ORDER BY rowid DESC LIMIT :limit OFFSET :offset) fts ON p.id = fts.id
                        ORDER BY p.id DESC";
                $stmt = $pdo->prepare($sql);
                $stmt->bindValue(':q', $ftsQuery, PDO::PARAM_STR);
                $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
                $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
                $stmt->execute();
                return $stmt->fetchAll(PDO::FETCH_ASSOC);
            } catch (Throwable $e) {
                // fallback на LIKE
            }
        }
        $qNorm = '%' . mb_strtolower($q, 'UTF-8') . '%';
        $stmt = $pdo->prepare("SELECT {$cols} FROM products WHERE LOWER(name) LIKE :q OR LOWER(COALESCE(sku,'')) LIKE :q OR LOWER(COALESCE(description,'')) LIKE :q ORDER BY id DESC LIMIT :limit OFFSET :offset");
        $stmt->bindValue(':q', $qNorm, PDO::PARAM_STR);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
    $stmt = $pdo->prepare("SELECT {$cols} FROM products ORDER BY id DESC LIMIT :limit OFFSET :offset");
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

function db_get_catalog_products(?string $category, ?string $q, int $limit, int $offset): array {
    return db_get_catalog_products_filtered(
        $category !== null && $category !== '' ? [$category] : [],
        [],
        $q,
        $limit,
        $offset
    );
}

/** Экранирование строки запроса для FTS5 MATCH (кавычки). */
function db_fts_escape_query(string $q): string {
    $q = trim(preg_replace('/\s+/', ' ', $q));
    return str_replace('"', '""', $q);
}

/** Быстрый подсчёт по FTS5 (только при поиске без фильтров по категории/тегам). */
function db_count_products_fts(string $ftsQuery): int {
    $pdo = db();
    $stmt = $pdo->prepare('SELECT COUNT(*) AS c FROM products_fts WHERE products_fts MATCH :q');
    $stmt->bindValue(':q', $ftsQuery, PDO::PARAM_STR);
    $stmt->execute();
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return (int)($row['c'] ?? 0);
}

/** Перестроить полнотекстовый индекс поиска (FTS5). Нужно один раз после обновления или для уже существующей БД. */
function db_fts_rebuild(): void {
    $pdo = db();
    $pdo->exec("INSERT INTO products_fts(products_fts) VALUES('rebuild')");
}

/** Быстрая выборка по FTS5 с пагинацией (только при поиске без фильтров по категории/тегам). */
function db_get_catalog_products_fts(string $ftsQuery, int $limit, int $offset): array {
    $pdo = db();
    $sql = 'SELECT p.id, p.sku, p.slug, p.name, p.price, p.category, p.imageUrl, p.imageLocal
            FROM products p
            INNER JOIN (SELECT rowid AS id FROM products_fts WHERE products_fts MATCH :q ORDER BY rowid DESC LIMIT :limit OFFSET :offset) fts ON p.id = fts.id
            ORDER BY p.id DESC';
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':q', $ftsQuery, PDO::PARAM_STR);
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

/** Фильтрация: категории — ИЛИ (товар в любой из), теги — И (товар содержит все выбранные теги). При одном только поисковом запросе используется FTS5. */
function db_get_catalog_products_filtered(array $categoryNames, array $tagNames, ?string $q, int $limit, int $offset): array {
    $pdo = db();
    $where = [];
    $params = [];

    if (!empty($categoryNames)) {
        $catOr = [];
        foreach ($categoryNames as $i => $c) {
            $key = ':cat' . $i;
            $keyLike = ':catLike' . $i;
            $catOr[] = "(category = {$key} OR categoriesExtra LIKE {$keyLike})";
            $params[$key] = $c;
            $params[$keyLike] = '%|' . $c . '|%';
        }
        $where[] = '(' . implode(' OR ', $catOr) . ')';
    }

    if (!empty($tagNames)) {
        foreach ($tagNames as $i => $tag) {
            $tagNorm = ',' . preg_replace('/\s+/', '', $tag) . ',';
            $key = ':tagPat' . $i;
            $where[] = "(',' || REPLACE(COALESCE(tags,''), ' ', '') || ',') LIKE {$key}";
            $params[$key] = '%' . $tagNorm . '%';
        }
    }

    if ($q !== null && $q !== '') {
        $qTrim = trim($q);
        if ($qTrim !== '' && empty($categoryNames) && empty($tagNames)) {
            $ftsQuery = db_fts_escape_query($qTrim);
            if ($ftsQuery !== '') {
                try {
                    return db_get_catalog_products_fts($ftsQuery, $limit, $offset);
                } catch (Throwable $e) {
                    // индекс не построен или ошибка FTS — fallback на LIKE
                }
            }
        }
        $where[] = '(LOWER(name) LIKE :q OR LOWER(description) LIKE :q)';
        $params[':q'] = '%' . mb_strtolower($q, 'UTF-8') . '%';
    }

    $sql = 'SELECT id, sku, slug, name, price, category, imageUrl, imageLocal FROM products';
    if ($where) {
        $sql .= ' WHERE ' . implode(' AND ', $where);
    }
    $sql .= ' ORDER BY id DESC LIMIT :limit OFFSET :offset';
    $stmt = $pdo->prepare($sql);
    foreach ($params as $k => $v) {
        $stmt->bindValue($k, $v, PDO::PARAM_STR);
    }
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll();
}

function db_get_all_product_slugs(): array {
    $pdo = db();
    $stmt = $pdo->query('SELECT slug FROM products');
    $slugs = [];
    foreach ($stmt as $row) {
        if (!empty($row['slug'])) {
            $slugs[] = (string)$row['slug'];
        }
    }
    return $slugs;
}

function db_count_products(?string $category, ?string $q): int {
    return db_count_products_filtered(
        $category !== null && $category !== '' ? [$category] : [],
        [],
        $q
    );
}

/** Кэш количества товаров по категории (без поиска), чтобы не выполнять тяжёлый COUNT(*) на каждую загрузку страницы. */
function db_count_products_cached(?string $category, ?string $q, int $ttlSeconds = 600): int {
    if ($q !== null && $q !== '') {
        return db_count_products($category, $q);
    }
    global $categoryCountsCacheFile;
    $cacheKey = ($category !== null && $category !== '') ? $category : '__all__';
    $now = time();
    $cache = read_json($categoryCountsCacheFile, []);
    if (isset($cache[$cacheKey]['count'], $cache[$cacheKey]['generatedAt'])) {
        $ts = strtotime((string)$cache[$cacheKey]['generatedAt']);
        if ($ts !== false && ($now - $ts) <= $ttlSeconds) {
            return (int)$cache[$cacheKey]['count'];
        }
    }
    $count = db_count_products($category, $q);
    $cache[$cacheKey] = ['count' => $count, 'generatedAt' => date('c')];
    write_json($categoryCountsCacheFile, $cache);
    return $count;
}

function db_count_products_filtered(array $categoryNames, array $tagNames, ?string $q): int {
    if ($q !== null && $q !== '' && empty($categoryNames) && empty($tagNames)) {
        $qTrim = trim($q);
        if ($qTrim !== '') {
            $ftsQuery = db_fts_escape_query($qTrim);
            if ($ftsQuery !== '') {
                try {
                    return db_count_products_fts($ftsQuery);
                } catch (Throwable $e) {
                    // индекс не построен или ошибка FTS — fallback на LIKE ниже
                }
            }
        }
    }

    $pdo = db();
    $where = [];
    $params = [];

    if (!empty($categoryNames)) {
        $catOr = [];
        foreach ($categoryNames as $i => $c) {
            $key = ':cat' . $i;
            $keyLike = ':catLike' . $i;
            $catOr[] = "(category = {$key} OR categoriesExtra LIKE {$keyLike})";
            $params[$key] = $c;
            $params[$keyLike] = '%|' . $c . '|%';
        }
        $where[] = '(' . implode(' OR ', $catOr) . ')';
    }

    if (!empty($tagNames)) {
        foreach ($tagNames as $i => $tag) {
            $tagNorm = ',' . preg_replace('/\s+/', '', $tag) . ',';
            $key = ':tagPat' . $i;
            $where[] = "(',' || REPLACE(COALESCE(tags,''), ' ', '') || ',') LIKE {$key}";
            $params[$key] = '%' . $tagNorm . '%';
        }
    }

    if ($q !== null && $q !== '') {
        $where[] = '(LOWER(name) LIKE :q OR LOWER(description) LIKE :q)';
        $params[':q'] = '%' . mb_strtolower($q, 'UTF-8') . '%';
    }

    $sql = 'SELECT COUNT(*) AS c FROM products';
    if ($where) {
        $sql .= ' WHERE ' . implode(' AND ', $where);
    }
    $stmt = $pdo->prepare($sql);
    foreach ($params as $k => $v) {
        $stmt->bindValue($k, $v, PDO::PARAM_STR);
    }
    $stmt->execute();
    $row = $stmt->fetch();
    return (int)($row['c'] ?? 0);
}

function db_count_all_products(): int {
    $pdo = db();
    $stmt = $pdo->query('SELECT COUNT(*) AS c FROM products');
    $row = $stmt->fetch();
    return (int)($row['c'] ?? 0);
}

function db_get_categories(): array {
    $pdo = db();
    $catsSet = [];

    // Основная категория
    $stmt = $pdo->query('SELECT DISTINCT category FROM products WHERE category IS NOT NULL AND category != \'\' ORDER BY category');
    foreach ($stmt as $row) {
        $name = trim((string)$row['category']);
        if ($name !== '') {
            $catsSet[$name] = true;
        }
    }

    // Дополнительные категории из categoriesExtra: строка вида |Cat1|Cat2|...
    $stmt2 = $pdo->query('SELECT DISTINCT categoriesExtra FROM products WHERE categoriesExtra IS NOT NULL AND categoriesExtra != \'\'');    
    foreach ($stmt2 as $row) {
        $ce = trim((string)$row['categoriesExtra']);
        if ($ce === '') {
            continue;
        }
        $parts = explode('|', $ce);
        foreach ($parts as $p) {
            $p = trim($p);
            if ($p !== '') {
                $catsSet[$p] = true;
            }
        }
    }

    $cats = array_keys($catsSet);
    sort($cats, SORT_FLAG_CASE | SORT_NATURAL);
    return $cats;
}

/** Кэшируем список категорий в JSON, чтобы не считать его на каждый запрос. $ttlSeconds = время жизни кэша. */
function db_get_categories_cached(int $ttlSeconds = 3600): array {
    global $categoriesCacheFile;
    $now = time();
    $cache = read_json($categoriesCacheFile, null);
    if (is_array($cache) && isset($cache['categories'], $cache['generatedAt'])) {
        $ts = strtotime((string)$cache['generatedAt']);
        if ($ts !== false && ($now - $ts) <= $ttlSeconds && is_array($cache['categories'])) {
            return $cache['categories'];
        }
    }
    $cats = db_get_categories();
    write_json($categoriesCacheFile, [
        'categories' => $cats,
        'generatedAt' => date('c'),
    ]);
    return $cats;
}

/** Категории с наибольшим количеством товаров (по основной категории), для сайдбара. $limit 0 = не ограничивать. */
function db_get_categories_by_product_count(int $limit): array {
    if ($limit <= 0) {
        return db_get_categories();
    }
    $pdo = db();
    $stmt = $pdo->prepare('SELECT category FROM products WHERE category IS NOT NULL AND category != \'\' GROUP BY category ORDER BY COUNT(*) DESC LIMIT :limit');
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->execute();
    $cats = [];
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $c = trim((string)($row['category'] ?? ''));
        if ($c !== '') {
            $cats[] = $c;
        }
    }
    return $cats;
}

/** Кэш для топ-N категорий по количеству товаров (тяжёлый GROUP BY на больших БД). */
function db_get_categories_by_product_count_cached(int $limit, int $ttlSeconds = 3600): array {
    if ($limit <= 0) {
        return db_get_categories_cached($ttlSeconds);
    }
    global $categoriesByCountCacheFile;
    $now = time();
    $cache = read_json($categoriesByCountCacheFile, []);
    $key = (string)$limit;
    if (isset($cache[$key]['list'], $cache[$key]['generatedAt']) && is_array($cache[$key]['list'])) {
        $ts = strtotime((string)$cache[$key]['generatedAt']);
        if ($ts !== false && ($now - $ts) <= $ttlSeconds) {
            return $cache[$key]['list'];
        }
    }
    $cats = db_get_categories_by_product_count($limit);
    $cache[$key] = ['list' => $cats, 'generatedAt' => date('c')];
    write_json($categoriesByCountCacheFile, $cache);
    return $cats;
}

/** Уникальные теги из колонки tags (значения через запятую). Для больших БД ограничено выборкой. */
function db_get_all_tags(int $maxProducts = 50000): array {
    $pdo = db();
    $stmt = $pdo->prepare('SELECT tags FROM products WHERE tags IS NOT NULL AND tags != \'\' LIMIT :limit');
    $stmt->bindValue(':limit', $maxProducts, PDO::PARAM_INT);
    $stmt->execute();
    $all = [];
    while ($row = $stmt->fetch()) {
        $parts = array_map('trim', explode(',', (string)$row['tags']));
        foreach ($parts as $t) {
            if ($t !== '') {
                $all[$t] = true;
            }
        }
    }
    $tags = array_keys($all);
    sort($tags, SORT_FLAG_CASE | SORT_NATURAL);
    return $tags;
}

/** Кэшируем теги для сайдбара. Кэш учитывает maxProducts и ttl, чтобы не грузить БД на каждый просмотр. */
function db_get_all_tags_cached(int $maxProducts = 50000, int $ttlSeconds = 3600): array {
    global $tagsCacheFile;
    $now = time();
    $cache = read_json($tagsCacheFile, null);
    if (is_array($cache) && isset($cache['tags'], $cache['generatedAt'], $cache['maxProducts'])) {
        $ts = strtotime((string)$cache['generatedAt']);
        if ($ts !== false && ($now - $ts) <= $ttlSeconds && (int)$cache['maxProducts'] >= $maxProducts && is_array($cache['tags'])) {
            return $cache['tags'];
        }
    }
    $tags = db_get_all_tags($maxProducts);
    write_json($tagsCacheFile, [
        'tags' => $tags,
        'maxProducts' => $maxProducts,
        'generatedAt' => date('c'),
    ]);
    return $tags;
}

// ---------- Статистика ботов и людей по User-Agent ----------

/** Определение типа клиента по User-Agent. */
function classify_user_agent(string $ua): string {
    $uaLower = mb_strtolower($ua, 'UTF-8');
    if ($uaLower === '') {
        return 'unknown';
    }
    if (strpos($uaLower, 'yandex') !== false) {
        return 'bot_yandex';
    }
    if (strpos($uaLower, 'googlebot') !== false || strpos($uaLower, 'google') !== false) {
        return 'bot_google';
    }
    if (strpos($uaLower, 'bingbot') !== false || strpos($uaLower, 'bingpreview') !== false) {
        return 'bot_bing';
    }
    if (strpos($uaLower, 'duckduckbot') !== false) {
        return 'bot_duckduckgo';
    }
    if (strpos($uaLower, 'baiduspider') !== false) {
        return 'bot_baidu';
    }
    if (strpos($uaLower, 'sogou') !== false) {
        return 'bot_sogou';
    }
    if (strpos($uaLower, 'bot') !== false || strpos($uaLower, 'spider') !== false || strpos($uaLower, 'crawler') !== false) {
        return 'bot_other';
    }
    return 'human';
}

/**
 * Безопасное чтение статистики ботов с учётом возможной порчи файла при одновременной записи.
 * Используем .bak как резерв на случай битого JSON.
 */
function bots_stats_safe_read(): array {
    global $botsStatsFile;
    $data = read_json($botsStatsFile, null);
    if (is_array($data)) {
        return $data;
    }
    $backup = $botsStatsFile . '.bak';
    $data = read_json($backup, null);
    return is_array($data) ? $data : [];
}

/**
 * Безопасная запись статистики: атомарно через временный файл + резервная копия .bak.
 */
function bots_stats_safe_write(array $data): void {
    global $botsStatsFile;
    $tmp = $botsStatsFile . '.tmp';
    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    if ($json === false) {
        return;
    }
    $fp = @fopen($tmp, 'cb');
    if ($fp === false) {
        return;
    }
    if (@flock($fp, LOCK_EX)) {
        ftruncate($fp, 0);
        fwrite($fp, $json);
        fflush($fp);
        flock($fp, LOCK_UN);
    }
    fclose($fp);
    @rename($tmp, $botsStatsFile);
    // Обновляем .bak (не критично, если не удалось)
    @copy($botsStatsFile, $botsStatsFile . '.bak');
}

/** Зафиксировать один запрос в статистике по ботам/людям. */
function bots_stats_track_request(): void {
    global $botsStatsFile;
    $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    if ($method !== 'GET') {
        return;
    }
    $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
    // Не считаем запросы к админке и служебным маршрутам
    if (strpos($uri, '/admin') === 0) {
        return;
    }
    $ua = (string)$_SERVER['HTTP_USER_AGENT'] ?? '';
    $type = classify_user_agent($ua);
    $today = date('Y-m-d');
    $stats = bots_stats_safe_read();
    if (!isset($stats[$today]) || !is_array($stats[$today])) {
        $stats[$today] = [
            'total' => 0,
            'bot_yandex' => 0,
            'bot_google' => 0,
            'bot_bing' => 0,
            'bot_duckduckgo' => 0,
            'bot_baidu' => 0,
            'bot_sogou' => 0,
            'bot_other' => 0,
            'human' => 0,
            'unknown' => 0,
        ];
    }
    $stats[$today]['total']++;
    if (!isset($stats[$today][$type])) {
        $stats[$today][$type] = 0;
    }
    $stats[$today][$type]++;
    bots_stats_safe_write($stats);
}

// ---------- Поисковые запросы (лог + статические страницы /q/slug) ----------

function load_search_queries(): array {
    global $searchQueriesFile;
    $data = read_json($searchQueriesFile, []);
    return is_array($data) ? $data : [];
}

function save_search_queries(array $queries): void {
    global $searchQueriesFile;
    write_json($searchQueriesFile, $queries);
}

/** Найти запись поискового запроса по slug. */
function find_search_query_by_slug(string $slug): ?array {
    $all = load_search_queries();
    foreach ($all as $norm => $row) {
        if (!is_array($row)) {
            continue;
        }
        if (($row['slug'] ?? '') === $slug) {
            $row['_key'] = $norm;
            return $row;
        }
    }
    return null;
}

/** Список статических поисковых запросов для сайдбара (до $limit штук случайно). */
function get_static_search_queries_for_sidebar(int $limit = 10): array {
    $all = load_search_queries();
    $static = [];
    foreach ($all as $norm => $row) {
        if (!is_array($row)) {
            continue;
        }
        if (!empty($row['static']) && !empty($row['slug']) && !empty($row['q'])) {
            $static[] = [
                'q' => (string)$row['q'],
                'slug' => (string)$row['slug'],
                'url' => '/q/' . rawurlencode((string)$row['slug']),
                'count' => (int)($row['count'] ?? 0),
            ];
        }
    }
    if (count($static) <= $limit) {
        return $static;
    }
    $keys = array_rand($static, $limit);
    if (!is_array($keys)) {
        $keys = [$keys];
    }
    $res = [];
    foreach ($keys as $k) {
        $res[] = $static[$k];
    }
    return $res;
}

function db_count_products_by_tag(string $tag, ?string $q): int {
    $pdo = db();
    $tagNorm = ',' . preg_replace('/\s+/', '', $tag) . ',';
    $where = ["(',' || REPLACE(COALESCE(tags,''), ' ', '') || ',') LIKE :tagPat"];
    $params = [':tagPat' => '%' . $tagNorm . '%'];
    if ($q !== null && $q !== '') {
        $where[] = '(LOWER(name) LIKE :q OR LOWER(description) LIKE :q)';
        $params[':q'] = '%' . mb_strtolower($q, 'UTF-8') . '%';
    }
    $sql = 'SELECT COUNT(*) AS c FROM products WHERE ' . implode(' AND ', $where);
    $stmt = $pdo->prepare($sql);
    foreach ($params as $k => $v) {
        $stmt->bindValue($k, $v, PDO::PARAM_STR);
    }
    $stmt->execute();
    $row = $stmt->fetch();
    return (int)($row['c'] ?? 0);
}

function db_get_products_by_tag(string $tag, ?string $q, int $limit, int $offset): array {
    $pdo = db();
    $tagNorm = ',' . preg_replace('/\s+/', '', $tag) . ',';
    $where = ["(',' || REPLACE(COALESCE(tags,''), ' ', '') || ',') LIKE :tagPat"];
    $params = [':tagPat' => '%' . $tagNorm . '%'];
    if ($q !== null && $q !== '') {
        $where[] = '(LOWER(name) LIKE :q OR LOWER(description) LIKE :q)';
        $params[':q'] = '%' . mb_strtolower($q, 'UTF-8') . '%';
    }
    $sql = 'SELECT id, sku, slug, name, price, category, imageUrl, imageLocal FROM products WHERE ' . implode(' AND ', $where) . ' ORDER BY id DESC LIMIT :limit OFFSET :offset';
    $stmt = $pdo->prepare($sql);
    foreach ($params as $k => $v) {
        $stmt->bindValue($k, $v, PDO::PARAM_STR);
    }
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll();
}

function db_clear_all_products(): void {
    $pdo = db();
    $pdo->exec('DELETE FROM products');
    global $categoryCountsCacheFile;
    if (file_exists($categoryCountsCacheFile)) {
        @unlink($categoryCountsCacheFile);
    }
}

/** Удалить товары, у которых updated_at попадает на сегодня (по UTC). */
function db_clear_products_added_today(): int {
    $pdo = db();
    $stmt = $pdo->prepare("DELETE FROM products WHERE date(updated_at) = date('now')");
    $stmt->execute();
    return $stmt->rowCount();
}

/** Удалить товары, у которых updated_at за последний час (по UTC). */
function db_clear_products_added_last_hour(): int {
    $pdo = db();
    $stmt = $pdo->prepare("DELETE FROM products WHERE updated_at >= datetime('now', '-1 hour')");
    $stmt->execute();
    return $stmt->rowCount();
}

function db_get_product_by_slug(string $slug): ?array {
    $pdo = db();
    $stmt = $pdo->prepare('SELECT * FROM products WHERE slug = :slug LIMIT 1');
    $stmt->bindValue(':slug', $slug, PDO::PARAM_STR);
    $stmt->execute();
    $row = $stmt->fetch();
    return $row !== false ? $row : null;
}

/** Сохранить AI-блок карточки товара. */
function db_update_product_ai_extra_text(int $id, string $textHtml): void {
    $pdo = db();
    $stmt = $pdo->prepare('UPDATE products SET aiExtraText = :txt, updated_at = datetime(\'now\') WHERE id = :id');
    $stmt->bindValue(':txt', $textHtml, PDO::PARAM_STR);
    $stmt->bindValue(':id', $id, PDO::PARAM_INT);
    $stmt->execute();
}

/** Количество товаров с некорректным slug (содержат /, \, #, ? — дают 404 или битые ссылки). */
function db_count_invalid_slugs(): int {
    $pdo = db();
    $stmt = $pdo->query("SELECT COUNT(*) AS c FROM products WHERE slug LIKE '%/%' OR slug LIKE '%\\%' OR slug LIKE '%#%' OR slug LIKE '%?%'");
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return (int)($row['c'] ?? 0);
}

/**
 * Исправить одну порцию товаров с некорректными slug (пакетная обработка для большой БД).
 * Возвращает ['fixed' => число исправленных, 'remaining' => сколько ещё осталось с ошибками].
 */
function db_fix_invalid_slugs_batch(int $batchSize = 500): array {
    $pdo = db();
    $stmt = $pdo->prepare("SELECT id, slug FROM products WHERE slug LIKE '%/%' OR slug LIKE '%\\%' OR slug LIKE '%#%' OR slug LIKE '%?%' LIMIT :limit");
    $stmt->bindValue(':limit', $batchSize, PDO::PARAM_INT);
    $stmt->execute();
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
    $fixed = 0;
    $updateStmt = $pdo->prepare('UPDATE products SET slug = :newSlug WHERE id = :id');
    $checkStmt = $pdo->prepare('SELECT id FROM products WHERE slug = :slug AND id != :excludeId LIMIT 1');
    foreach ($rows as $row) {
        $id = (int)$row['id'];
        $slug = (string)$row['slug'];
        $newSlug = sanitize_product_slug($slug);
        if ($newSlug === '' || $newSlug === $slug) {
            $newSlug = 'product-' . $id;
        }
        while (true) {
            $checkStmt->bindValue(':slug', $newSlug, PDO::PARAM_STR);
            $checkStmt->bindValue(':excludeId', $id, PDO::PARAM_INT);
            $checkStmt->execute();
            if ($checkStmt->fetch() === false) {
                break;
            }
            $newSlug .= '-' . $id;
        }
        $updateStmt->bindValue(':newSlug', $newSlug, PDO::PARAM_STR);
        $updateStmt->bindValue(':id', $id, PDO::PARAM_INT);
        $updateStmt->execute();
        if ($updateStmt->rowCount() > 0) {
            $fixed++;
        }
    }
    $remaining = db_count_invalid_slugs();
    return ['fixed' => $fixed, 'remaining' => $remaining];
}

/** Очистить изображение товара (imageLocal, imageUrl) по id. */
function db_clear_product_image(int $id): void {
    $pdo = db();
    $stmt = $pdo->prepare('UPDATE products SET imageLocal = NULL, imageUrl = NULL WHERE id = :id');
    $stmt->bindValue(':id', $id, PDO::PARAM_INT);
    $stmt->execute();
}

function db_get_product_by_id(int $id): ?array {
    $pdo = db();
    $stmt = $pdo->prepare('SELECT * FROM products WHERE id = :id LIMIT 1');
    $stmt->bindValue(':id', $id, PDO::PARAM_INT);
    $stmt->execute();
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row !== false ? $row : null;
}

function db_get_related_products(?string $category, ?string $categoriesExtra, int $excludeId, int $limit = 6): array {
    $pdo = db();
    $result = [];
    $excludeIds = [$excludeId];

    // 1) Из основной категории
    if ($category !== null && $category !== '') {
        $stmt = $pdo->prepare('SELECT id, slug, name, price, imageUrl, imageLocal, category FROM products WHERE category = :category AND id != :excludeId ORDER BY RANDOM() LIMIT :limit');
        $stmt->bindValue(':category', $category, PDO::PARAM_STR);
        $stmt->bindValue(':excludeId', $excludeId, PDO::PARAM_INT);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->execute();
        $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
        $excludeIds = array_merge($excludeIds, array_column($result, 'id'));
    }

    // 2) Если не набрали лимит — из дополнительных категорий (categoriesExtra у текущего товара)
    $extraCats = [];
    if ($categoriesExtra !== null && $categoriesExtra !== '') {
        foreach (explode('|', $categoriesExtra) as $p) {
            $p = trim($p);
            if ($p !== '' && $p !== $category) {
                $extraCats[] = $p;
            }
        }
    }
    if (count($result) < $limit && !empty($extraCats)) {
        $need = $limit - count($result);
        $placeholders = implode(',', array_fill(0, count($extraCats), '?'));
        $excludePlaceholders = implode(',', array_fill(0, count($excludeIds), '?'));
        $likeConds = [];
        foreach ($extraCats as $c) {
            $likeConds[] = 'categoriesExtra LIKE ?';
        }
        $sql = "SELECT id, slug, name, price, imageUrl, imageLocal, category FROM products WHERE id NOT IN ($excludePlaceholders) AND (category IN ($placeholders) OR " . implode(' OR ', $likeConds) . ") ORDER BY RANDOM() LIMIT ?";
        $stmt = $pdo->prepare($sql);
        $n = 0;
        foreach ($excludeIds as $id) {
            $stmt->bindValue(++$n, $id, PDO::PARAM_INT);
        }
        foreach ($extraCats as $c) {
            $stmt->bindValue(++$n, $c, PDO::PARAM_STR);
        }
        foreach ($extraCats as $c) {
            $stmt->bindValue(++$n, '%|' . $c . '|%', PDO::PARAM_STR);
        }
        $stmt->bindValue(++$n, $need, PDO::PARAM_INT);
        $stmt->execute();
        $extra = $stmt->fetchAll(PDO::FETCH_ASSOC);
        $result = array_merge($result, $extra);
        $excludeIds = array_merge($excludeIds, array_column($extra, 'id'));
    }

    // 3) Если всё ещё не хватает — случайные товары
    if (count($result) < $limit) {
        $need = $limit - count($result);
        $excludePlaceholders = implode(',', array_fill(0, count($excludeIds), '?'));
        $stmt = $pdo->prepare("SELECT id, slug, name, price, imageUrl, imageLocal, category FROM products WHERE id NOT IN ($excludePlaceholders) ORDER BY RANDOM() LIMIT ?");
        $n = 0;
        foreach ($excludeIds as $id) {
            $stmt->bindValue(++$n, $id, PDO::PARAM_INT);
        }
        $stmt->bindValue(++$n, $need, PDO::PARAM_INT);
        $stmt->execute();
        $random = $stmt->fetchAll(PDO::FETCH_ASSOC);
        $result = array_merge($result, $random);
    }

    return $result;
}

function normalize_price(string $raw, float $multiplier): string {
    $clean = preg_replace('~[^0-9,\.]+~', '', $raw);
    if ($clean === null || $clean === '') {
        return '';
    }
    $clean = str_replace(',', '.', $clean);
    $value = (float)$clean;
    if ($value <= 0) {
        return '';
    }
    $value = $value * $multiplier;
    // формат как строка без лишних нулей
    $formatted = $value == (int)$value ? (string)(int)$value : number_format($value, 2, '.', '');
    return $formatted;
}

function ensure_local_webp_for_image(?string $imageUrl, ?string $imageLocal): array {
    global $imagesDir;
    $finalUrl = (string)$imageUrl;
    $finalLocal = '';

    $sourcePath = '';
    $relPath = '';

    if (!empty($imageLocal)) {
        $relPath = ltrim($imageLocal, '/');
        $sourcePath = $imagesDir . DIRECTORY_SEPARATOR . $relPath;
        // Если исходный файл (jpg/png) уже удалён, но есть только webp‑версия от предыдущего импорта,
        // используем её, чтобы не считать, что картинки "нет"
        if (!file_exists($sourcePath)) {
            $webpRel = preg_replace('~\.[^.]+$~', '.webp', $relPath) ?: ($relPath . '.webp');
            $webpPath = $imagesDir . DIRECTORY_SEPARATOR . $webpRel;
            if (file_exists($webpPath)) {
                $finalLocal = $webpRel;
                $finalUrl = '';
                return [$finalUrl, $finalLocal];
            }
        }
    } elseif (!empty($imageUrl)) {
        $name = basename(parse_url($imageUrl, PHP_URL_PATH) ?: '');
        if ($name === '' || $name === '/' || $name === '.') {
            $name = md5($imageUrl) . '.jpg';
        }
        $relPath = ltrim($name, '/');
        $sourcePath = $imagesDir . DIRECTORY_SEPARATOR . $relPath;
        if (!file_exists($sourcePath)) {
            $data = @file_get_contents($imageUrl);
            if ($data !== false) {
                $dir = dirname($sourcePath);
                if (!is_dir($dir)) {
                    mkdir($dir, 0777, true);
                }
                @file_put_contents($sourcePath, $data);
            }
        }
    }

    if ($sourcePath !== '' && file_exists($sourcePath)) {
        $ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION));
        if ($ext === 'webp') {
            $finalLocal = $relPath;
        } else {
            $webpRel = preg_replace('~\.[^.]+$~', '.webp', $relPath) ?: ($relPath . '.webp');
            $webpPath = $imagesDir . DIRECTORY_SEPARATOR . $webpRel;
            $ok = false;
            if (function_exists('imagewebp')) {
                $info = @getimagesize($sourcePath);
                $mime = $info[2] ?? null;
                $im = null;
                if ($mime === IMAGETYPE_JPEG) {
                    $im = @imagecreatefromjpeg($sourcePath);
                } elseif ($mime === IMAGETYPE_PNG) {
                    $im = @imagecreatefrompng($sourcePath);
                    if ($im) {
                        imagepalettetotruecolor($im);
                        imagealphablending($im, true);
                        imagesavealpha($im, true);
                    }
                } elseif ($mime === IMAGETYPE_WEBP) {
                    $im = @imagecreatefromwebp($sourcePath);
                }
                if ($im) {
                    $dir = dirname($webpPath);
                    if (!is_dir($dir)) {
                        mkdir($dir, 0777, true);
                    }
                    if (@imagewebp($im, $webpPath, 80)) {
                        $ok = true;
                    }
                    imagedestroy($im);
                }
            }
            if ($ok) {
                @unlink($sourcePath);
                $finalLocal = $webpRel;
            } else {
                $finalLocal = $relPath;
            }
        }
    }

    return [$finalUrl, $finalLocal];
}

/**
 * Заменяет вхождение "от N руб" на "от {price} руб" в тексте, если цена известна.
 */
function replace_old_price_in_text(string $text, string $price): string {
    $price = trim($price);
    if ($text === '' || $price === '') {
        return $text;
    }
    return preg_replace('~от\s+[\d\s.,]+(?:\s*руб\.?)?~iu', 'от ' . $price . ' руб', $text) ?? $text;
}

/**
 * Санитарная очистка импортируемых текстов от типичных артефактов автогенерации.
 * Не "переписывает" текст, только исправляет явный мусор.
 */
function sanitize_import_text(string $text): string {
    $text = trim($text);
    if ($text === '') {
        return '';
    }
    // "рубдней", "рубдн." и похожие склейки
    $text = preg_replace('~руб\.?\s*дн(?:ей|я|ь)?~iu', 'руб., срок поставки', $text) ?? $text;
    // Кривые шаблоны вида "Срок от 238.80 руб, срок поставки..." => нормализуем.
    $text = preg_replace(
        '~Срок\s+от\s*([\d\s.,]+)\s*руб\.?\s*,?\s*срок\s+поставки\.?\s*~iu',
        'Цена от $1 руб. Срок поставки уточняйте. ',
        $text
    ) ?? $text;
    // Если фраза начинается с "Срок от ... руб" без нормальной части про дни — тоже нормализуем.
    $text = preg_replace(
        '~^Срок\s+от\s*([\d\s.,]+)\s*руб\.?(?!\s*в\s*\d)~iu',
        'Цена от $1 руб. Срок поставки уточняйте.',
        $text
    ) ?? $text;
    // Сжимаем повторяющиеся знаки/пробелы
    $text = preg_replace('~[ \t]{2,}~u', ' ', $text) ?? $text;
    $text = preg_replace('~\s+([,.;:!?])~u', '$1', $text) ?? $text;
    $text = preg_replace('~([,.;:!?]){2,}~u', '$1', $text) ?? $text;
    // Удаляем одиночные "обрывки" вида " .", ", .", "--", висячие скобки.
    $text = preg_replace('~(?:,\s*)?\.\s*$~u', '', $text) ?? $text;
    $text = preg_replace('~\s*--+\s*~u', ' - ', $text) ?? $text;
    $text = preg_replace('~\(\s*$~u', '', $text) ?? $text;
    return trim($text);
}

/** Очистка значения характеристики (единицы измерения, хвостовые артефакты). */
function sanitize_spec_value(string $value): string {
    $v = trim($value);
    if ($v === '') {
        return '';
    }
    // Хвостовые одиночные кавычки после единиц: 100MHz" -> 100MHz
    $v = preg_replace('~([A-Za-zА-Яа-я0-9%])["”]+$~u', '$1', $v) ?? $v;
    // Нормализуем пробел перед единицей: 500mA -> 500 mA, 100MHz -> 100 MHz
    $v = preg_replace('~(\d)([A-Za-zА-Яа-яµΩ°]+)~u', '$1 $2', $v) ?? $v;
    // Схлопываем лишние пробелы.
    $v = preg_replace('~\s{2,}~u', ' ', $v) ?? $v;
    return trim($v);
}

/** Нормализация карточки товара при выводе (без изменения данных в БД). */
function sanitize_product_for_output(array $p): array {
    foreach (['name', 'category', 'description', 'seoTitle', 'seoDescription'] as $k) {
        if (isset($p[$k])) {
            $p[$k] = sanitize_import_text((string)$p[$k]);
        }
    }
    return $p;
}

/** Нормализация списка товаров при выводе. */
function sanitize_products_for_output(array $items): array {
    foreach ($items as $i => $p) {
        if (is_array($p)) {
            $items[$i] = sanitize_product_for_output($p);
        }
    }
    return $items;
}

/**
 * Преобразует строку характеристик вида
 * "Заголовок,Ключ1,Значение1,Ключ2,Значение2,..." в HTML-таблицу.
 * Возвращает готовый HTML или пустую строку.
 */
function build_specs_table_html(string $raw): string {
    $raw = trim($raw);
    if ($raw === '') {
        return '';
    }
    // Разбиваем по запятым/точкам с запятой
    $parts = preg_split('~[,;]~', $raw);
    if ($parts === false) {
        return '';
    }
    $parts = array_values(array_filter(array_map('trim', $parts), static function ($v) {
        return $v !== '';
    }));
    if (count($parts) < 3) {
        return '';
    }
    // Первый элемент — заголовок блока
    $title = array_shift($parts);
    $rows = [];
    for ($i = 0; $i + 1 < count($parts); $i += 2) {
        $key = $parts[$i];
        $val = sanitize_spec_value((string)$parts[$i + 1]);
        if ($key === '' && $val === '') {
            continue;
        }
        $rows[] = [$key, $val];
    }
    if (empty($rows)) {
        return '';
    }

    $html = '<h3>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h3>';
    $html .= '<div class="product-specs-wrap"><table class="product-specs"><tbody>';
    foreach ($rows as $row) {
        [$key, $val] = $row;
        $html .= '<tr><th>' . htmlspecialchars((string)$key, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</th><td>' . nl2br(htmlspecialchars((string)$val, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')) . '</td></tr>';
    }
    $html .= '</tbody></table></div>';
    return $html;
}

function db_generate_test_data(int $productsTotal, int $categoriesTotal, string $imageUrl): array {
    $pdo = db();
    // очищаем текущие товары
    $pdo->exec('DELETE FROM products');

    // одна картинка для всех
    [$finalImageUrl, $finalImageLocal] = ensure_local_webp_for_image($imageUrl, null);

    // набор категорий
    $categories = [];
    for ($i = 1; $i <= $categoriesTotal; $i++) {
        $categories[] = 'Категория ' . $i;
    }

    $stmt = $pdo->prepare('INSERT INTO products (sku, slug, name, price, category, tags, description, seoTitle, seoDescription, imageUrl, imageLocal)
        VALUES (:sku, :slug, :name, :price, :category, :tags, :description, :seoTitle, :seoDescription, :imageUrl, :imageLocal)
        ON CONFLICT(sku) DO UPDATE SET
            slug = excluded.slug,
            name = excluded.name,
            price = excluded.price,
            category = excluded.category,
            tags = excluded.tags,
            description = excluded.description,
            seoTitle = excluded.seoTitle,
            seoDescription = excluded.seoDescription,
            imageUrl = excluded.imageUrl,
            imageLocal = excluded.imageLocal
    ');

    $batchSize = 10000;
    $inserted = 0;
    $pdo->beginTransaction();
    for ($i = 1; $i <= $productsTotal; $i++) {
        $sku = 'TEST-' . $i;
        $name = 'Тестовый товар ' . $i;
        $category = $categories[($i - 1) % $categoriesTotal];
        $price = (string)(100 + ($i % 10000));
        $desc = 'Тестовое описание для товара ' . $i . ' в категории ' . $category . '.';
        $seoTitle = $name . ' — тестовый товар';
        $seoDescription = $name . ' по тестовой цене ' . $price . ' в категории ' . $category . '.';

        $stmt->execute([
            ':sku' => $sku,
            ':slug' => slugify_ru($name) . '-' . $sku,
            ':name' => $name,
            ':price' => $price,
            ':category' => $category,
            ':tags' => '',
            ':description' => $desc,
            ':seoTitle' => $seoTitle,
            ':seoDescription' => $seoDescription,
            ':imageUrl' => $finalImageUrl,
            ':imageLocal' => $finalImageLocal,
        ]);

        $inserted++;
        if ($inserted % $batchSize === 0) {
            $pdo->commit();
            $pdo->beginTransaction();
        }
    }
    $pdo->commit();

    return [
        'products' => $inserted,
        'categories' => $categoriesTotal,
        'imageLocal' => $finalImageLocal,
    ];
}

function db_import_products(array $items): void {
    $pdo = db();
    $pdo->beginTransaction();
    $stmt = $pdo->prepare('INSERT INTO products (sku, slug, name, price, category, tags, description, seoTitle, seoDescription, imageUrl, imageLocal, categoriesExtra, updated_at)
        VALUES (:sku, :slug, :name, :price, :category, :tags, :description, :seoTitle, :seoDescription, :imageUrl, :imageLocal, :categoriesExtra, datetime(\'now\'))
        ON CONFLICT(sku) DO UPDATE SET
            slug = excluded.slug,
            name = excluded.name,
            price = excluded.price,
            category = excluded.category,
            tags = excluded.tags,
            description = excluded.description,
            seoTitle = excluded.seoTitle,
            seoDescription = excluded.seoDescription,
            imageUrl = excluded.imageUrl,
            imageLocal = excluded.imageLocal,
            categoriesExtra = excluded.categoriesExtra,
            updated_at = datetime(\'now\')
    ');
    foreach ($items as $p) {
        $tagsStr = '';
        if (!empty($p['tags']) && is_array($p['tags'])) {
            $tagsStr = implode(',', $p['tags']);
        }
        $name = sanitize_import_text((string)($p['name'] ?? ''));
        $category = sanitize_import_text((string)($p['category'] ?? ''));
        $description = sanitize_import_text((string)($p['description'] ?? ''));
        $seoTitle = sanitize_import_text((string)($p['seoTitle'] ?? ''));
        $seoDescription = sanitize_import_text((string)($p['seoDescription'] ?? ''));
        $stmt->execute([
            ':sku' => (string)($p['sku'] ?? ''),
            ':slug' => sanitize_product_slug((string)($p['slug'] ?? '')),
            ':name' => $name,
            ':price' => (string)($p['price'] ?? ''),
            ':category' => $category,
            ':tags' => $tagsStr,
            ':description' => $description,
            ':seoTitle' => $seoTitle,
            ':seoDescription' => $seoDescription,
            ':imageUrl' => (string)($p['imageUrl'] ?? ''),
            ':imageLocal' => (string)($p['imageLocal'] ?? ''),
            ':categoriesExtra' => (string)($p['categoriesExtra'] ?? ''),
        ]);
    }
    $pdo->commit();
}

function is_cacheable_request(string $uri): bool {
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
        return false;
    }
    if (isset($_GET['nocache'])) {
        return false;
    }
    if (!empty($_SERVER['QUERY_STRING'] ?? '')) {
        return false;
    }
    if (strpos($uri, '/admin') === 0) {
        return false;
    }
    return true;
}

/**
 * Каталог кэша для URI. hostHint — хост без порта (cache/HOST/URI_PATH/).
 * 200 OK: index.html (отдаёт Apache напрямую). 404: 404.html (только через PHP, с корректным статусом).
 */
function cache_dir_for_uri(string $uri, string $hostHint = ''): string {
    global $cacheDir;
    $prefix = '';
    if ($hostHint !== '') {
        $safe = preg_replace('/[^a-z0-9.\-]/', '_', strtolower($hostHint));
        if ($safe !== '') {
            $prefix = DIRECTORY_SEPARATOR . $safe;
        }
    }
    $path = $uri === '' ? '/' : $uri;
    if ($path === '/') {
        return $cacheDir . $prefix;
    }
    return $cacheDir . $prefix . rtrim($path, '/');
}

/** Путь к файлу кэша (index.html для 200, 404.html для 404). */
function cache_path_for_uri(string $uri, string $hostHint = '', int $httpStatus = 200): string {
    $name = $httpStatus === 404 ? '404.html' : 'index.html';
    return cache_dir_for_uri($uri, $hostHint) . DIRECTORY_SEPARATOR . $name;
}

function cache_is_stale_404_html(string $html): bool {
    return str_contains($html, '<h1>Страница не найдена</h1>');
}

function cache_try_serve(string $uri, string $hostHint = ''): void {
    if (!is_cacheable_request($uri)) {
        return;
    }
    $dir = cache_dir_for_uri($uri, $hostHint);
    $file200 = $dir . DIRECTORY_SEPARATOR . 'index.html';
    $file404 = $dir . DIRECTORY_SEPARATOR . '404.html';

    if (is_file($file200)) {
        $html = @file_get_contents($file200);
        if ($html !== false && cache_is_stale_404_html($html)) {
            @unlink($file200);
        } else {
            readfile($file200);
            exit;
        }
    }
    if (is_file($file404)) {
        http_response_code(404);
        readfile($file404);
        exit;
    }

    // Локально пусто — пробуем S3. Штатно сюда не доходит: при настроенном nginx
    // попадание в бакет отдаётся без запуска PHP. Нужно на время миграции,
    // в режиме «только S3» и на веб-серверах без проксирования в бакет.
    s3cache_try_serve($uri, $hostHint);
}

function cache_store(string $uri, string $html, string $hostHint = '', int $httpStatus = 200): void {
    if (!is_cacheable_request($uri)) {
        return;
    }
    if ($httpStatus === 200 && cache_is_stale_404_html($html)) {
        return;
    }
    global $config;
    if (!empty($config['minifyHtml'])) {
        $html = preg_replace('~>\s+<~', '><', $html);
    }
    // 200 уходит в S3: именно эти объекты забирает nginx вместо запуска PHP.
    // 404 остаются только на диске — из бакета nginx получил бы промах и всё
    // равно ушёл бы в PHP, а мусорные URL от сканеров раздули бы хранилище.
    if ($httpStatus !== 404) {
        s3cache_put($uri, $html, $hostHint);
    }
    // В режиме «только S3» локальные копии не создаём: ради этого режим и нужен.
    if ($httpStatus !== 404 && s3cache_is_s3_only()) {
        return;
    }

    $dir = cache_dir_for_uri($uri, $hostHint);
    if (!is_dir($dir)) {
        mkdir($dir, 0777, true);
    }
    $file200 = $dir . DIRECTORY_SEPARATOR . 'index.html';
    $file404 = $dir . DIRECTORY_SEPARATOR . '404.html';
    if ($httpStatus === 404) {
        @file_put_contents($file404, $html);
        if (is_file($file200)) {
            @unlink($file200);
        }
    } else {
        @file_put_contents($file200, $html);
        if (is_file($file404)) {
            @unlink($file404);
        }
    }
}

function cache_clear_all(): void {
    global $cacheDir;
    // S3 чистится не здесь: в бакете могут лежать миллионы объектов, а эта
    // функция дёргается из обычных действий админки. Ставим задание в очередь,
    // разбирает её /admin/s3-purge-cron или кнопка на странице «Кэш в S3».
    s3cache_purge_all_queue();
    if (!is_dir($cacheDir)) {
        return;
    }
    $it = new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS);
    $ri = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
    foreach ($ri as $file) {
        /** @var SplFileInfo $file */
        if ($file->isDir()) {
            @rmdir($file->getPathname());
        } else {
            @unlink($file->getPathname());
        }
    }
}

/**
 * Очистка HTML‑кэша по списку URL (товары, категории и любые страницы).
 * В urls можно передавать как полные ссылки, так и относительные пути.
 */
function cache_clear_by_urls(array $urls, string $hostHint = ''): array {
    $cleared = [];
    $s3Paths = [];
    foreach ($urls as $u) {
        $u = trim((string)$u);
        if ($u === '') {
            continue;
        }
        $path = parse_url($u, PHP_URL_PATH) ?: '/';
        // Хост берём из самой ссылки, если он там есть: список URL обычно
        // приходит с абсолютными адресами поддоменов-регионов.
        $urlHost = (string)(parse_url($u, PHP_URL_HOST) ?: '');
        $s3Paths[$urlHost !== '' ? $urlHost : $hostHint][] = rtrim($path, '/') ?: '/';
        $dir = cache_dir_for_uri($path, $hostHint);
        $removed = false;
        foreach (['index.html', '404.html'] as $cacheName) {
            $file = $dir . DIRECTORY_SEPARATOR . $cacheName;
            if (is_file($file)) {
                @unlink($file);
                $removed = true;
            }
        }
        if ($removed) {
            @rmdir($dir);
            $cleared[] = $path;
        }
    }
    foreach ($s3Paths as $host => $paths) {
        $removedS3 = s3cache_delete_paths($paths, (string)$host);
        // В режиме «только S3» локальных файлов нет, и без этой ветки отчёт
        // об очистке всегда был бы пустым.
        if ($removedS3 > 0 && s3cache_is_s3_only()) {
            foreach ($paths as $p) {
                if (!in_array($p, $cleared, true)) {
                    $cleared[] = $p;
                }
            }
        }
    }
    return $cleared;
}

/** Путь к статическому XML-фиду Google Merchant Center. */
function merchant_feed_file_path(): string {
    global $publicDir;
    return $publicDir . DIRECTORY_SEPARATOR . 'feeds' . DIRECTORY_SEPARATOR . 'google-merchant.xml';
}

/** Публичный URL фида (для Merchant Center и админки). */
function merchant_feed_public_url(array $config): string {
    $base = rtrim($config['baseUrl'] ?? '', '/');
    return $base !== '' ? $base . '/public/feeds/google-merchant.xml' : '/public/feeds/google-merchant.xml';
}

function merchant_feed_load_meta(): array {
    global $merchantFeedMetaFile;
    $meta = read_json($merchantFeedMetaFile, []);
    return is_array($meta) ? $meta : [];
}

function merchant_feed_save_meta(array $meta): void {
    global $merchantFeedMetaFile;
    write_json($merchantFeedMetaFile, $meta);
}

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

/** Абсолютный URL изображения товара для фида (без плейсхолдера). */
function merchant_feed_absolute_image_url(string $baseUrl, ?string $imageUrl, ?string $imageLocal): string {
    global $imagesDir;
    $imageUrl = trim((string)$imageUrl);
    if ($imageUrl !== '' && preg_match('~^https?://~i', $imageUrl)) {
        return $imageUrl;
    }
    $imageLocal = trim((string)$imageLocal);
    if ($imageLocal === '') {
        return '';
    }
    $rel = ltrim(str_replace('\\', '/', $imageLocal), '/');
    $path = $imagesDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
    if (!is_file($path)) {
        $webpRel = preg_replace('~\.[^.]+$~', '.webp', $rel) ?: ($rel . '.webp');
        $webpPath = $imagesDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $webpRel);
        if (!is_file($webpPath)) {
            return '';
        }
        $rel = $webpRel;
    }
    $src = image_src_or_placeholder($rel);
    if ($src === '/public/images/placeholder.svg') {
        return '';
    }
    return rtrim($baseUrl, '/') . $src;
}

/** Цена для g:price — «123.45 RUB» или пустая строка, если цену не распознать. */
function merchant_feed_format_price(string $priceRaw, array $config): string {
    $multiplier = (float)($config['import']['priceMultiplier'] ?? 1.0);
    $normalized = normalize_price($priceRaw, $multiplier);
    if ($normalized === '') {
        return '';
    }
    $num = (float)$normalized;
    if ($num <= 0) {
        return '';
    }
    $formatted = number_format($num, 2, '.', '');
    return $formatted . ' RUB';
}

function merchant_feed_plain_text(string $html, int $maxLen): string {
    $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = sanitize_import_text($text);
    $text = preg_replace('~\s+~u', ' ', $text) ?? $text;
    $text = trim($text);
    if ($text === '') {
        return '';
    }
    if (mb_strlen($text, 'UTF-8') > $maxLen) {
        $text = mb_substr($text, 0, $maxLen - 1, 'UTF-8') . '…';
    }
    return $text;
}

/**
 * Сгенерировать статический XML-фид (RSS 2.0 + namespace Google).
 * Возвращает ['items' => int, 'skipped' => int, 'total' => int].
 */
function merchant_feed_generate(array $config): array {
    global $publicDir;

    $baseUrl = rtrim($config['baseUrl'] ?? '', '/');
    if ($baseUrl === '') {
        throw new RuntimeException('Укажите «Базовый URL» в настройках сайта.');
    }

    $feedDir = $publicDir . DIRECTORY_SEPARATOR . 'feeds';
    if (!is_dir($feedDir)) {
        mkdir($feedDir, 0777, true);
    }

    $outPath = merchant_feed_file_path();
    $tmpPath = $outPath . '.tmp';
    $fp = fopen($tmpPath, 'wb');
    if ($fp === false) {
        throw new RuntimeException('Не удалось создать временный файл фида.');
    }

    $siteName = trim((string)($config['siteName'] ?? 'Каталог'));
    $brand = trim((string)($config['merchantFeed']['brand'] ?? ''));
    if ($brand === '') {
        $brand = $siteName;
    }

    $header = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
        . '<rss xmlns:g="http://base.google.com/ns/1.0" version="2.0">' . "\n"
        . '<channel>' . "\n"
        . '<title>' . merchant_feed_xml_escape($siteName) . '</title>' . "\n"
        . '<link>' . merchant_feed_xml_escape($baseUrl) . '</link>' . "\n"
        . '<description>' . merchant_feed_xml_escape('Товары ' . $siteName) . '</description>' . "\n";
    fwrite($fp, $header);

    $pdo = db();
    $batchSize = 2000;
    $lastId = 0;
    $items = 0;
    $skipped = 0;
    $total = 0;

    while (true) {
        $stmt = $pdo->prepare(
            'SELECT id, sku, slug, name, price, category, description, imageUrl, imageLocal
             FROM products WHERE id > :lastId ORDER BY id ASC LIMIT :limit'
        );
        $stmt->bindValue(':lastId', $lastId, PDO::PARAM_INT);
        $stmt->bindValue(':limit', $batchSize, PDO::PARAM_INT);
        $stmt->execute();
        $rows = $stmt->fetchAll();
        if (empty($rows)) {
            break;
        }

        foreach ($rows as $row) {
            $total++;
            $lastId = (int)$row['id'];
            $slug = trim((string)($row['slug'] ?? ''));
            if ($slug === '' || strpos($slug, '/') !== false || strpos($slug, '\\') !== false) {
                $skipped++;
                continue;
            }
            $name = merchant_feed_plain_text((string)($row['name'] ?? ''), 150);
            if ($name === '') {
                $skipped++;
                continue;
            }
            $price = merchant_feed_format_price((string)($row['price'] ?? ''), $config);
            if ($price === '') {
                $skipped++;
                continue;
            }
            $imageLink = merchant_feed_absolute_image_url($baseUrl, $row['imageUrl'] ?? null, $row['imageLocal'] ?? null);
            if ($imageLink === '') {
                $skipped++;
                continue;
            }

            $sku = trim((string)($row['sku'] ?? ''));
            $feedId = $sku !== '' ? $sku : ('p' . (string)$row['id']);
            $link = $baseUrl . '/product/' . rawurlencode($slug);
            $description = merchant_feed_plain_text((string)($row['description'] ?? ''), 5000);
            if ($description === '') {
                $description = $name;
            }
            $category = merchant_feed_plain_text((string)($row['category'] ?? ''), 750);

            $itemXml = '<item>' . "\n"
                . '<g:id>' . merchant_feed_xml_escape($feedId) . '</g:id>' . "\n"
                . '<g:title>' . merchant_feed_xml_escape($name) . '</g:title>' . "\n"
                . '<g:description>' . merchant_feed_xml_escape($description) . '</g:description>' . "\n"
                . '<g:link>' . merchant_feed_xml_escape($link) . '</g:link>' . "\n"
                . '<g:image_link>' . merchant_feed_xml_escape($imageLink) . '</g:image_link>' . "\n"
                . '<g:condition>new</g:condition>' . "\n"
                . '<g:availability>in stock</g:availability>' . "\n"
                . '<g:price>' . merchant_feed_xml_escape($price) . '</g:price>' . "\n";
            if ($brand !== '') {
                $itemXml .= '<g:brand>' . merchant_feed_xml_escape($brand) . '</g:brand>' . "\n";
            }
            if ($category !== '') {
                $itemXml .= '<g:product_type>' . merchant_feed_xml_escape($category) . '</g:product_type>' . "\n";
            }
            if ($sku !== '') {
                $itemXml .= '<g:mpn>' . merchant_feed_xml_escape($sku) . '</g:mpn>' . "\n";
            }
            $itemXml .= '<g:identifier_exists>false</g:identifier_exists>' . "\n"
                . '</item>' . "\n";
            fwrite($fp, $itemXml);
            $items++;
        }

        if (count($rows) < $batchSize) {
            break;
        }
    }

    fwrite($fp, '</channel>' . "\n</rss>\n");
    fclose($fp);

    if (!rename($tmpPath, $outPath)) {
        @unlink($tmpPath);
        throw new RuntimeException('Не удалось сохранить файл фида.');
    }

    $meta = [
        'generatedAt' => date('c'),
        'items' => $items,
        'skipped' => $skipped,
        'total' => $total,
        'fileSize' => is_file($outPath) ? filesize($outPath) : 0,
        'publicUrl' => merchant_feed_public_url($config),
    ];
    merchant_feed_save_meta($meta);

    return ['items' => $items, 'skipped' => $skipped, 'total' => $total];
}

/** Безопасный URL локальной картинки (с кодированием сегментов пути), либо плейсхолдер. */
function image_src_or_placeholder(?string $imageLocal): string {
    $imageLocal = trim((string)$imageLocal);
    if ($imageLocal === '') {
        return '/public/images/placeholder.svg';
    }
    $rel = ltrim(str_replace('\\', '/', $imageLocal), '/');
    if ($rel === '') {
        return '/public/images/placeholder.svg';
    }
    $parts = array_values(array_filter(explode('/', $rel), static function ($v) {
        return $v !== '';
    }));
    if (empty($parts)) {
        return '/public/images/placeholder.svg';
    }
    $encoded = array_map('rawurlencode', $parts);
    return '/public/images/' . implode('/', $encoded);
}
function require_admin(array $config): void {
    $admin = $config['admin'] ?? ['user' => 'admin', 'password' => 'changeme'];
    $user = $_SERVER['PHP_AUTH_USER'] ?? '';
    $pass = $_SERVER['PHP_AUTH_PW'] ?? '';

    if ($user !== (string)$admin['user'] || $pass !== (string)$admin['password']) {
        header('WWW-Authenticate: Basic realm="Admin Area"');
        header('HTTP/1.0 401 Unauthorized');
        echo 'Требуется авторизация для доступа к админке.';
        exit;
    }
}

function require_cron_import_auth(array $config): void {
    $cron = $config['cronImportAuth'] ?? null;
    $admin = $config['admin'] ?? ['user' => 'admin', 'password' => 'changeme'];
    $user = $_SERVER['PHP_AUTH_USER'] ?? '';
    $pass = $_SERVER['PHP_AUTH_PW'] ?? '';

    // Если заданы отдельные логин/пароль для крона — используем их,
    // иначе по умолчанию используем админские.
    $expectedUser = isset($cron['user']) && $cron['user'] !== '' ? (string)$cron['user'] : (string)$admin['user'];
    $expectedPass = isset($cron['password']) && $cron['password'] !== '' ? (string)$cron['password'] : (string)$admin['password'];

    if ($user !== $expectedUser || $pass !== $expectedPass) {
        header('WWW-Authenticate: Basic realm="Cron Import"');
        header('HTTP/1.0 401 Unauthorized');
        echo 'Требуется авторизация для доступа к импорту по крону.';
        exit;
    }
}

// ---------- Глобальные данные запроса ----------

$config = load_config($configFile);
$baseUrl = $config['baseUrl'] ?? '';
$baseUrl = rtrim($baseUrl, '/');
$mainSiteBaseUrl = $baseUrl;

$host = $_SERVER['HTTP_HOST'] ?? '';
$hostOnly = $host;
if ($host !== '' && strpos($host, ':') !== false) {
    $hostOnly = substr($host, 0, strpos($host, ':'));
}
$hostLower = $hostOnly !== '' ? strtolower(trim($hostOnly)) : '';

$isHttpsRequest = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
    || (!empty($_SERVER['REQUEST_SCHEME']) && $_SERVER['REQUEST_SCHEME'] === 'https')
    || (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] === '443');
if ($baseUrl !== '' && strpos($baseUrl, 'https://') === 0 && !$isHttpsRequest) {
    $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
    $query = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY);
    $target = 'https://' . $host . $path . ($query !== null && $query !== '' ? '?' . $query : '');
    header('Location: ' . $target, true, 301);
    exit;
}

$currentRegion = null;
if ($hostLower !== '' && !empty($config['regions'])) {
    $currentRegion = find_region_by_host($config, $hostOnly);
    if ($currentRegion !== null) {
        $config = apply_region_overrides($config, $currentRegion);
        $baseUrl = rtrim($config['baseUrl'] ?? $baseUrl, '/');
    }
}
$mainHost = $mainSiteBaseUrl !== '' ? strtolower(trim(explode('/', preg_replace('#^https?://#i', '', $mainSiteBaseUrl))[0])) : '';
if ($mainHost !== '' && strpos($mainHost, ':') !== false) {
    $mainHost = substr($mainHost, 0, strpos($mainHost, ':'));
}
$mainHostSuffix = $mainHost !== '' ? ('.' . $mainHost) : '';
$isSubdomainOfMain = $mainHost !== '' && $hostLower !== '' && $hostLower !== $mainHost
    && strlen($hostLower) > strlen($mainHostSuffix) && substr($hostLower, -strlen($mainHostSuffix)) === $mainHostSuffix;
if ($isSubdomainOfMain && $currentRegion === null) {
    $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
    $query = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY);
    $target = $mainSiteBaseUrl . $path . ($query !== null && $query !== '' ? '?' . $query : '');
    header('Location: ' . $target, true, 301);
    exit;
}
$config['regionName'] = get_region_name($config, $currentRegion);
// База для canonical = текущий хост (поддомен или основной), чтобы канонические URL вели на свой поддомен
$currentBase = get_current_base_url();
$config['canonicalBase'] = $currentBase !== '' ? $currentBase : rtrim($config['baseUrl'], '/');
$cacheHostHint = $hostOnly;
$pages = load_pages($pagesFile);

// ---------- Простая маршрутизация ----------

$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uri = rtrim($uri, '/') ?: '/';

// Статистика ботов/людей по User-Agent (считаем до кэша, чтобы видеть реальные хиты)
bots_stats_track_request();

// Экспериментальные домены/поддомены: только заглушка на главной, редирект по ?qlink= (1 раз), остальное 404. Основной сайт не отдаётся.
$earlyHost = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($earlyHost !== '') {
    global $experimentalIndexingFile;
    $expData = experimental_indexing_load($experimentalIndexingFile);
    $expDomainsRaw = $expData['domains'] ?? [];
    $expBaseRaw = trim((string)($expData['domain'] ?? ''));
    $expBaseNorm = experimental_indexing_normalize_host($expBaseRaw);
    if ($expBaseNorm === '' && !empty($expDomainsRaw)) {
        $first = experimental_indexing_normalize_host(trim((string)$expDomainsRaw[0]));
        $expBaseNorm = experimental_indexing_base_from_host($first);
    }
    $expDomainsNorm = array_map('experimental_indexing_normalize_host', $expDomainsRaw);
    $earlyHostNorm = experimental_indexing_normalize_host($earlyHost);

    // Неизвестные поддомены базового экспериментального домена — редирект на основной экспериментальный домен
    if ($expBaseNorm !== '' && $earlyHostNorm !== $expBaseNorm) {
        $suffix = '.' . $expBaseNorm;
        if (
            strlen($earlyHostNorm) > strlen($suffix)
            && substr($earlyHostNorm, -strlen($suffix)) === $suffix
            && !in_array($earlyHostNorm, $expDomainsNorm, true)
        ) {
            $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
            $requestUri = (string)($_SERVER['REQUEST_URI'] ?? '/');
            $target = $scheme . '://' . $expBaseNorm . $requestUri;
            header('Location: ' . $target, true, 301);
            exit;
        }
    }

    // Основной экспериментальный домен и явным образом добавленные поддомены — обрабатываются экспериментальной логикой
    if ($expBaseNorm !== '' && ($earlyHostNorm === $expBaseNorm || in_array($earlyHostNorm, $expDomainsNorm, true))) {
        $queryString = (string)($_SERVER['QUERY_STRING'] ?? '');
        if ($queryString === '' && isset($_SERVER['REQUEST_URI'])) {
            $queryString = (string)(parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY) ?? '');
        }
        experimental_indexing_handle($earlyHostNorm, $uri, $queryString, $experimentalIndexingFile);
        exit;
    }
}

cache_try_serve($uri, $cacheHostHint);

// robots.txt — для каждого хоста (основной сайт и поддомены) свой: Sitemap ведёт на sitemap этого хоста
if ($uri === '/robots.txt') {
    $base = rtrim($config['canonicalBase'] ?? $config['baseUrl'], '/');
    header('Content-Type: text/plain; charset=utf-8');
    echo "User-agent: *\n";
    echo "Allow: /\n";
    echo "Disallow: /privacy-policy\n";
    echo "User-agent: GPTBot\nAllow: /\n";
    echo "User-agent: ChatGPT-User\nAllow: /\n";
    echo "User-agent: PerplexityBot\nAllow: /\n";
    echo "User-agent: Google-Extended\nAllow: /\n";
    echo "User-agent: Anthropic-AI\nAllow: /\n";
    echo "User-agent: Claude-Web\nAllow: /\n";
    echo "\nSitemap: {$base}/sitemap.xml\n";
    exit;
}

// llms.txt — краткое описание сайта и ключевые ссылки для ИИ (формат llmstxt.org), на каждом поддомене — ссылки на этот поддомен
if ($uri === '/llms.txt') {
    $base = rtrim($config['canonicalBase'] ?? $config['baseUrl'], '/');
    $siteName = $config['siteName'] ?? 'Каталог';
    $regionName = $config['regionName'] ?? 'Москва';
    header('Content-Type: text/plain; charset=utf-8');
    header('X-Robots-Tag: noindex'); // не индексировать сам файл как страницу
    $lines = [];
    $lines[] = '# ' . $siteName;
    $lines[] = '';
    $lines[] = '> Оптовые поставки электронных комплектующих. Каталог товаров с ценами, доставка по ' . $regionName . ', России и СНГ.';
    $lines[] = '';
    $lines[] = '## Основные страницы';
    $lines[] = '- [Главная](' . $base . '/): описание компании, товары из каталога, доставка';
    $lines[] = '- [Каталог](' . $base . '/catalog): все товары, категории и теги';
    $lines[] = '- [Доставка и оплата](' . $base . '/delivery): условия доставки и оплаты';
    $lines[] = '- [Контакты](' . $base . '/contacts): телефон, email, адрес, реквизиты';
    $lines[] = '- [Политика конфиденциальности](' . $base . '/privacy-policy): обработка персональных данных';
    $categories = db_get_categories();
    if (!empty($categories)) {
        $lines[] = '';
        $lines[] = '## Категории каталога';
        foreach (array_slice($categories, 0, 50) as $cat) {
            $slug = rawurlencode(slugify_ru($cat));
            $lines[] = '- [' . $cat . '](' . $base . '/catalog/' . $slug . '): товары в категории «' . $cat . '»';
        }
        if (count($categories) > 50) {
            $lines[] = '- [Все категории](' . $base . '/catalog): полный каталог';
        }
    }
    $otherPages = [];
    foreach ($pages as $p) {
        $slug = trim($p['slug'] ?? '');
        if ($slug === '' || in_array($slug, ['delivery', 'contacts', 'privacy-policy'], true)) {
            continue;
        }
        $otherPages[] = '- [' . ($p['title'] ?? $slug) . '](' . $base . '/' . $slug . ')';
    }
    if (!empty($otherPages)) {
        $lines[] = '';
        $lines[] = '## Другие страницы';
        $lines[] = implode("\n", $otherPages);
    }
    $lines[] = '';
    $lines[] = '## Для ИИ';
    $lines[] = 'Сайт открыт для обхода ИИ-краулерами. Структурированные данные: Schema.org Organization, WebSite, Product (на страницах товаров), BreadcrumbList. Sitemap: ' . $base . '/sitemap.xml';
    echo implode("\n", $lines);
    exit;
}

// sitemap.xml — для каждого хоста свой индекс (ссылки на sitemap-pages и sitemap-products-* этого хоста)
if ($uri === '/sitemap.xml') {
    $base = rtrim($config['canonicalBase'] ?? $config['baseUrl'], '/');
    $sitemapIndexUrls = [];
    $sitemapIndexUrls[] = $base . '/sitemap-pages.xml';

    $totalProducts = db_count_all_products();
    $perSitemap = 50000; // лимит по спецификации sitemap
    $productSitemapsCount = $totalProducts > 0 ? (int)ceil($totalProducts / $perSitemap) : 0;
    for ($i = 1; $i <= $productSitemapsCount; $i++) {
        $sitemapIndexUrls[] = $base . '/sitemap-products-' . $i . '.xml';
    }

    $indexLastmod = date('c');
    header('Content-Type: application/xml; charset=utf-8');
    echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    echo "<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
    foreach ($sitemapIndexUrls as $u) {
        echo "  <sitemap><loc>" . htmlspecialchars($u, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</loc><lastmod>" . $indexLastmod . "</lastmod></sitemap>\n";
    }
    echo "</sitemapindex>";
    exit;
}

// sitemap-pages.xml — URL страниц текущего хоста (lastmod по дате изменения pages.json)
if ($uri === '/sitemap-pages.xml') {
    $base = rtrim($config['canonicalBase'] ?? $config['baseUrl'], '/');
    $urls = [];
    $urls[] = $base . '/';
    $urls[] = $base . '/catalog';
    foreach (db_get_categories() as $cat) {
        $urls[] = $base . '/catalog/' . rawurlencode(slugify_ru($cat));
    }
    foreach (array_slice(db_get_all_tags(10000), 0, 1000) as $tag) {
        $urls[] = $base . '/tag/' . rawurlencode(slugify_ru($tag));
    }
    foreach ($pages as $p) {
        $slug = trim($p['slug'] ?? '');
        if ($slug !== '' && $slug !== 'privacy-policy') {
            $urls[] = $base . '/' . $slug;
        }
    }

    $pagesLastmod = (is_file($pagesFile) && filemtime($pagesFile)) ? date('c', (int)filemtime($pagesFile)) : date('c');
    header('Content-Type: application/xml; charset=utf-8');
    echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    echo "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
    foreach ($urls as $u) {
        echo "  <url><loc>" . htmlspecialchars($u, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</loc><lastmod>" . $pagesLastmod . "</lastmod></url>\n";
    }
    echo "</urlset>";
    exit;
}

// sitemap-products-N.xml — товары порциями по 50 000 (lastmod из updated_at)
if (preg_match('~^/sitemap-products-(\d+)\.xml$~', $uri, $sitemapM)) {
    $chunkNum = (int)$sitemapM[1];
    if ($chunkNum < 1) {
        header('HTTP/1.0 404 Not Found');
        exit;
    }
    $base = rtrim($config['canonicalBase'] ?? $config['baseUrl'], '/');
    $perSitemap = 50000;
    $offset = ($chunkNum - 1) * $perSitemap;

    $pdo = db();
    $stmt = $pdo->prepare('SELECT slug, updated_at FROM products WHERE slug IS NOT NULL AND slug != \'\' ORDER BY id ASC LIMIT :limit OFFSET :offset');
    $stmt->bindValue(':limit', $perSitemap, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();

    $rows = [];
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $rows[] = $row;
    }

    if (empty($rows)) {
        header('HTTP/1.0 404 Not Found');
        exit;
    }

    header('Content-Type: application/xml; charset=utf-8');
    echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    echo "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
    foreach ($rows as $row) {
        $loc = $base . '/product/' . rawurlencode((string)$row['slug']);
        $lm = !empty($row['updated_at']) ? date('c', (int)strtotime($row['updated_at'])) : date('c');
        echo "  <url><loc>" . htmlspecialchars($loc, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</loc><lastmod>" . $lm . "</lastmod></url>\n";
    }
    echo "</urlset>";
    exit;
}

// indexnow key
if (preg_match('~^/indexnow\-([A-Za-z0-9]+)\.txt$~', $uri, $m)) {
    header('Content-Type: text/plain; charset=utf-8');
    echo $m[1];
    exit;
}

// Обработка форм и специальных GET‑маршрутов по крону
if (
    $_SERVER['REQUEST_METHOD'] === 'POST'
    || $uri === '/admin/import-cron'
    || $uri === '/admin/indexnow-cron'
    || $uri === '/admin/cache-warmup-cron'
    || $uri === '/admin/s3-purge-cron'
) {
    if ($uri === '/request-callback') {
        $honeypot = trim($_POST['website'] ?? '');
        $fromUrl = trim($_POST['fromUrl'] ?? ($_SERVER['HTTP_REFERER'] ?? ''));
        if ($honeypot !== '') {
            header('Location: ' . ($fromUrl ?: '/'));
            exit;
        }
        $name   = trim($_POST['name'] ?? '');
        $phone  = trim($_POST['phone'] ?? '');
        $email  = trim($_POST['email'] ?? '');
        if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $email = '';
        }
        $message = trim($_POST['message'] ?? '');
        $productSlug = trim($_POST['productSlug'] ?? '');
        if ($phone !== '') {
            $reqs = load_requests($requestsFile);
            $reqs[] = [
                'id' => count($reqs) + 1,
                'name' => $name,
                'phone' => $phone,
                'email' => $email,
                'message' => $message,
                'productSlug' => $productSlug,
                'fromUrl' => $fromUrl,
                'createdAt' => date('c'),
            ];
            save_requests($requestsFile, $reqs);
            $to = trim($config['requestsEmail'] ?? '');
            if ($to !== '' && filter_var($to, FILTER_VALIDATE_EMAIL)) {
                $siteName = $config['siteName'] ?? 'Сайт';
                $subject = 'Заявка с сайта: ' . $siteName;
                $body = "Поступила заявка с сайта.\n\n";
                $body .= "Имя: " . $name . "\n";
                $body .= "Телефон: " . $phone . "\n";
                if ($email !== '') {
                    $body .= "E-mail: " . $email . "\n";
                }
                if ($message !== '') {
                    $body .= "Комментарий: " . $message . "\n";
                }
                if ($productSlug !== '') {
                    $body .= "Товар (slug): " . $productSlug . "\n";
                }
                $body .= "Страница: " . $fromUrl . "\n";
                $body .= "Дата: " . date('d.m.Y H:i:s') . "\n";
                $headers = [
                    'From: ' . ($config['contacts']['email'] ?? 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost')),
                    'Content-Type: text/plain; charset=UTF-8',
                    'X-Mailer: PHP/' . PHP_VERSION,
                ];
                @mail($to, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers));
            }
        }
        header('Location: ' . ($fromUrl ?: '/'));
        exit;
    }

    if ($uri === '/admin/import') {
        require_admin($config);
        $raw = $_POST['csv'] ?? '';
        if (!empty($_FILES['csv_file']['tmp_name']) && is_uploaded_file($_FILES['csv_file']['tmp_name'])) {
            $fileRaw = file_get_contents($_FILES['csv_file']['tmp_name']);
            if ($fileRaw !== false) {
                $raw = $fileRaw;
            }
        }
        $raw = trim((string)$raw);
        if ($raw === '') {
            header('Location: /admin/import?error=empty');
            exit;
        }
        $lines = preg_split('~\r\n|\n|\r~', $raw);
        $header = [];
        $rows = [];
        $delimiter = ';';
        if (!empty($lines)) {
            $firstLine = trim($lines[0]);
            if ($firstLine !== '') {
                foreach ([';', ',', "\t"] as $d) {
                    $fp = fopen('php://temp', 'rb+');
                    fwrite($fp, $firstLine);
                    rewind($fp);
                    $test = fgetcsv($fp, 0, $d);
                    fclose($fp);
                    if ($test !== false && count($test) >= 2) {
                        $delimiter = $d;
                        break;
                    }
                }
            }
        }
        foreach ($lines as $rowNum => $line) {
            if (trim($line) === '') {
                continue;
            }
            $fp = fopen('php://temp', 'rb+');
            fwrite($fp, $line);
            rewind($fp);
            $cols = fgetcsv($fp, 0, $delimiter);
            fclose($fp);
            if ($cols === false) {
                continue;
            }
            if ($rowNum === 0) {
                $header = array_map('trim', $cols);
            } else {
                // По требованию: первую строку данных (после заголовка) пропускаем,
                // чтобы не импортировать "левый" товар-пример
                if ($rowNum === 1) {
                    continue;
                }
                if (!$header) {
                    $header = range(0, count($cols) - 1);
                }
                $cols = array_pad(array_slice($cols, 0, count($header)), count($header), '');
                $rows[] = array_combine($header, $cols);
            }
        }

        $importCfg = $config['import'] ?? [];
        $skuCol = $importCfg['skuColumn'] ?? 'sku';
        $nameCol = $importCfg['nameColumn'] ?? 'name';
        $nameTpl = trim((string)($importCfg['nameTemplate'] ?? ''));
        $priceCol = $importCfg['priceColumn'] ?? 'price';
        $categoryCol = $importCfg['categoryColumn'] ?? 'category';
        $tagsCol = $importCfg['tagsColumn'] ?? 'tags';
        $descCol = $importCfg['descriptionColumn'] ?? 'description';
        $descTpl = trim((string)($importCfg['descriptionTemplate'] ?? ''));
        $catsListCol = $importCfg['categoriesListColumn'] ?? 'categories';
        $catsListDelimiter = $importCfg['categoriesListDelimiter'] ?? '|';
        $multiplier = (float)($importCfg['priceMultiplier'] ?? 1.0);
        $seoTitleTpl = (string)($importCfg['seoTitleTemplate'] ?? '');
        $seoDescTpl = (string)($importCfg['seoDescriptionTemplate'] ?? '');

        $imageUrlCol = $importCfg['imageUrlColumn'] ?? 'image_url';
        $imageLocalCol = $importCfg['imageLocalColumn'] ?? 'image_local';

        $specsCol = $importCfg['specsColumn'] ?? '';
        $specsToTable = !empty($importCfg['specsToTable']);
        $fixPriceText = !empty($importCfg['fixPriceText']);

        $translateCfg = $config['importTranslate'] ?? [];
        $translateEnabled = !empty($translateCfg['enabled']);

        $normalized = [];
        foreach ($rows as $idx => $r) {
            $rowVars = [];
            foreach ($r as $colName => $val) {
                $rowVars['{' . trim((string)$colName) . '}'] = trim((string)$val);
            }
            $priceRaw = $r[$priceCol] ?? ($r['price'] ?? ($r['Цена'] ?? ''));
            $price = $priceRaw !== '' ? normalize_price((string)$priceRaw, $multiplier) : '';
            $rowVars['{price}'] = $price;

            if ($nameTpl !== '') {
                $name = trim(strtr($nameTpl, $rowVars));
            } else {
                $name = trim($r[$nameCol] ?? ($r['name'] ?? ($r['title'] ?? ($r['Название'] ?? ('Товар ' . ($idx + 1))))));
            }
            if ($name === '') {
                $name = 'Товар ' . ($idx + 1);
            }
            if ($fixPriceText && $price !== '') {
                $name = replace_old_price_in_text($name, $price);
            }
            // Специальный плейсхолдер {name}: итоговое название, независимо от того, как называлась колонка
            $rowVars['{name}'] = $name;

            $sku  = trim($r[$skuCol] ?? ($r['sku'] ?? ($r['product_id'] ?? ($r['SKU'] ?? ($r['Артикул'] ?? (string)($idx + 1))))));
            $slugRaw = trim($r['slug'] ?? '');
            if ($slugRaw === '') {
                $slugRaw = slugify_ru($name) . '-' . $sku;
            }
            $slugRaw = sanitize_product_slug($slugRaw);

            $category = trim($r[$categoryCol] ?? ($r['category'] ?? ($r['category_last'] ?? ($r['Категория'] ?? ''))));
            $tagsSource = $r[$tagsCol] ?? ($r['tags'] ?? ($r['manufacturer'] ?? ($r['Теги'] ?? '')));

            if ($descTpl !== '') {
                $description = trim(strtr($descTpl, $rowVars));
            } else {
                $description = trim($r[$descCol] ?? ($r['description'] ?? ($r['short'] ?? ($r['Описание'] ?? ''))));
            }
            if ($fixPriceText && $price !== '') {
                $description = replace_old_price_in_text($description, $price);
            }

            $specHtml = '';
            if ($specsToTable && $specsCol !== '') {
                $specRaw = trim($r[$specsCol] ?? ($r['specs'] ?? ''));
                if ($specRaw !== '') {
                    $specHtml = build_specs_table_html($specRaw);
                }
            }

            $specHtml = '';
            if ($specsToTable && $specsCol !== '') {
                $specRaw = trim($r[$specsCol] ?? ($r['specs'] ?? ''));
                if ($specRaw !== '') {
                    $specHtml = build_specs_table_html($specRaw);
                }
            }

            $tags = array_values(array_filter(array_map('trim', explode(',', (string)$tagsSource))));
            $seoTitle = trim($r['seoTitle'] ?? ($r['SEO Title'] ?? ''));
            $seoDescription = trim($r['seoDescription'] ?? ($r['SEO Description'] ?? ''));
            if ($seoTitle === '' && $seoTitleTpl !== '') {
                $seoTitle = strtr($seoTitleTpl, $rowVars);
            }
            if ($seoDescription === '' && $seoDescTpl !== '') {
                $seoDescription = strtr($seoDescTpl, $rowVars);
            }
            if ($fixPriceText && $price !== '') {
                $seoTitle = replace_old_price_in_text($seoTitle, $price);
                $seoDescription = replace_old_price_in_text($seoDescription, $price);
            }

            $imageUrl = trim($r[$imageUrlCol] ?? ($r['image_url'] ?? ''));
            $imageLocal = trim($r[$imageLocalCol] ?? ($r['image_local'] ?? ''));
            $imageLocal = str_replace('\\', '/', $imageLocal);
            $imageLocal = preg_replace('~^images/~', '', $imageLocal);

            $catsListRaw = trim((string)($r[$catsListCol] ?? $r['categories'] ?? $r['category'] ?? $r['Категория'] ?? $r['category_last'] ?? ''));
            $catsFromList = [];
            if ($catsListRaw !== '') {
                $pattern = '~\s*\|\s*~';
                if ($catsListDelimiter === ',') {
                    $pattern = '~\s*,\s*~';
                } elseif ($catsListDelimiter === 'both') {
                    $pattern = '~\s*[|,]\s*~';
                }
                $parts = preg_split($pattern, (string)$catsListRaw);
                foreach ($parts as $c) {
                    $c = trim($c);
                    if ($c !== '' && !in_array($c, $catsFromList, true)) {
                        $catsFromList[] = $c;
                    }
                }
            }
            if ($category === '' && !empty($catsFromList)) {
                $category = $catsFromList[0];
            }
            $catsAll = $catsFromList;
            if ($category !== '' && !in_array($category, $catsAll, true)) {
                $catsAll[] = $category;
            }
            $categoriesExtra = '';
            if (!empty($catsAll)) {
                $categoriesExtra = '|' . implode('|', $catsAll) . '|';
            }

            [$finalImageUrl, $finalImageLocal] = ensure_local_webp_for_image(
                $imageUrl !== '' ? (string)$imageUrl : null,
                $imageLocal !== '' ? (string)$imageLocal : null
            );

            $normalized[] = [
                'sku' => $sku,
                'name' => $name,
                'slug' => $slugRaw,
                'price' => $price,
                'category' => $category,
                'tags' => $tags,
                'description' => $description,
                'seoTitle' => $seoTitle,
                'seoDescription' => $seoDescription,
                'imageUrl' => $finalImageUrl,
                'imageLocal' => $finalImageLocal,
                'categoriesExtra' => $categoriesExtra,
                'specHtml' => $specHtml,
            ];
        }

        // Перевод выбранных полей: Google Translate или ИИ (один запрос на поле)
        $translationError = null;
        if ($translateEnabled && !empty($normalized)) {
            $fieldsToTranslate = [
                'name' => !empty($translateCfg['translateName']),
                'description' => !empty($translateCfg['translateDescription']),
                'category' => !empty($translateCfg['translateCategory']),
                // Дополнительные категории (строка с |) переводим тем же флагом, что и основную категорию
                'categoriesExtra' => !empty($translateCfg['translateCategory']),
                'tags' => !empty($translateCfg['translateTags']),
                'seoTitle' => !empty($translateCfg['translateSeoTitle']),
                'seoDescription' => !empty($translateCfg['translateSeoDescription']),
            ];

            foreach ($fieldsToTranslate as $field => $flag) {
                if (!$flag) {
                    continue;
                }
                $sourceTexts = [];
                foreach ($normalized as $i => $p) {
                    if ($field === 'tags') {
                        $val = isset($p['tags']) && is_array($p['tags']) ? implode(', ', $p['tags']) : '';
                    } else {
                        $val = $p[$field] ?? '';
                    }
                    $val = trim((string)$val);
                    if ($val === '') {
                        continue;
                    }
                    $sourceTexts[$i] = $val;
                }
                if (empty($sourceTexts)) {
                    continue;
                }

                $useAiTranslate = !empty($translateCfg['useAi']);
                if ($useAiTranslate) {
                    $translated = ai_translate_bulk($sourceTexts, $translateCfg, $config);
                } else {
                    $translated = google_translate_bulk($sourceTexts, $translateCfg);
                }
                global $translationLastError;
                if ($translationLastError !== null) {
                    $translationError = $translationLastError;
                    // При ошибке перевода перестаём пытаться переводить остальные поля
                    break;
                }
                foreach ($translated as $i => $val) {
                    if (!isset($normalized[$i])) {
                        continue;
                    }
                    if ($field === 'tags') {
                        // переводим строку, затем режем по запятой обратно в массив
                        $tagsStr = (string)$val;
                        $parts = array_values(array_filter(array_map('trim', explode(',', $tagsStr))));
                        $normalized[$i]['tags'] = $parts;
                    } else {
                        $normalized[$i][$field] = (string)$val;
                    }
                }
            }
        }

        // Добавляем HTML-таблицу характеристик в конец описания (после перевода)
        if (!empty($normalized)) {
            foreach ($normalized as $i => $p) {
                if (!empty($p['specHtml'])) {
                    $desc = (string)($p['description'] ?? '');
                    $block = (string)$p['specHtml'];
                    $normalized[$i]['description'] = $desc !== '' ? $desc . "\n\n" . $block : $block;
                }
            }
        }

        // Если нажали "Предпросмотр" — показываем только первые N товаров без импорта в БД
        if (!empty($_POST['preview'])) {
            $importedCount = count($normalized);
            $previewCount = 20;
            $preview = array_slice($normalized, 0, $previewCount);
            $rawCsv = $raw ?? '';
             // сообщение об ошибке перевода, если было
            if (!empty($translationError)) {
                $translationErrorMessage = $translationError;
            }
            include $tplDir . DIRECTORY_SEPARATOR . 'admin_import_preview.php';
            exit;
        }

        db_import_products($normalized);

        cache_clear_all();
        $productPaths = [];
        foreach ($normalized as $p) {
            $productPaths[] = '/product/' . rawurlencode((string)$p['slug']);
        }
        if (!empty($config['indexNowAutoImport']) && !empty($productPaths)) {
            foreach (get_indexnow_bases($config) as $base) {
                $urls = array_map(function ($path) use ($base) {
                    return rtrim($base, '/') . $path;
                }, $productPaths);
                notify_indexnow($config, $urls, $base);
            }
        }

        $importedCount = count($normalized);
        if (!empty($translationError)) {
            $translationErrorMessage = $translationError;
        }
        include $tplDir . DIRECTORY_SEPARATOR . 'admin_import_done.php';
        exit;
    }

    if ($uri === '/admin/import-ajax' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        header('Content-Type: application/json; charset=utf-8');
        $rawInput = file_get_contents('php://input');
        $data = json_decode((string)$rawInput, true);
        if (!is_array($data)) {
            echo json_encode(['ok' => false, 'error' => 'Некорректный JSON.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $raw = trim((string)($data['csv'] ?? ''));
        $offset = max(0, (int)($data['offset'] ?? 0));
        $limit = (int)($data['limit'] ?? 20);
        if ($limit <= 0) {
            $limit = 20;
        }
        if ($limit > 200) {
            $limit = 200;
        }
        if ($raw === '') {
            echo json_encode(['ok' => false, 'error' => 'Пустой CSV.'], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $lines = preg_split('~\r\n|\n|\r~', $raw);
        $header = [];
        $rows = [];
        $delimiter = ';';
        if (!empty($lines)) {
            $firstLine = trim($lines[0]);
            if ($firstLine !== '') {
                foreach ([';', ',', "\t"] as $d) {
                    $fp = fopen('php://temp', 'rb+');
                    fwrite($fp, $firstLine);
                    rewind($fp);
                    $test = fgetcsv($fp, 0, $d);
                    fclose($fp);
                    if ($test !== false && count($test) >= 2) {
                        $delimiter = $d;
                        break;
                    }
                }
            }
        }
        foreach ($lines as $rowNum => $line) {
            if (trim($line) === '') {
                continue;
            }
            $fp = fopen('php://temp', 'rb+');
            fwrite($fp, $line);
            rewind($fp);
            $cols = fgetcsv($fp, 0, $delimiter);
            fclose($fp);
            if ($cols === false) {
                continue;
            }
            if ($rowNum === 0) {
                $header = array_map('trim', $cols);
            } else {
                if ($rowNum === 1) {
                    continue;
                }
                if (!$header) {
                    $header = range(0, count($cols) - 1);
                }
                $cols = array_pad(array_slice($cols, 0, count($header)), count($header), '');
                $rows[] = array_combine($header, $cols);
            }
        }

        $totalRows = count($rows);
        if ($totalRows === 0) {
            echo json_encode(['ok' => true, 'imported' => 0, 'totalRows' => 0, 'nextOffset' => 0, 'done' => true], JSON_UNESCAPED_UNICODE);
            exit;
        }
        if ($offset >= $totalRows) {
            echo json_encode(['ok' => true, 'imported' => 0, 'totalRows' => $totalRows, 'nextOffset' => $totalRows, 'done' => true], JSON_UNESCAPED_UNICODE);
            exit;
        }

        $sliceRows = array_slice($rows, $offset, $limit, true);

        $importCfg = $config['import'] ?? [];
        $skuCol = $importCfg['skuColumn'] ?? 'sku';
        $nameCol = $importCfg['nameColumn'] ?? 'name';
        $nameTpl = trim((string)($importCfg['nameTemplate'] ?? ''));
        $priceCol = $importCfg['priceColumn'] ?? 'price';
        $categoryCol = $importCfg['categoryColumn'] ?? 'category';
        $tagsCol = $importCfg['tagsColumn'] ?? 'tags';
        $descCol = $importCfg['descriptionColumn'] ?? 'description';
        $descTpl = trim((string)($importCfg['descriptionTemplate'] ?? ''));
        $catsListCol = $importCfg['categoriesListColumn'] ?? 'categories';
        $multiplier = (float)($importCfg['priceMultiplier'] ?? 1.0);
        $seoTitleTpl = (string)($importCfg['seoTitleTemplate'] ?? '');
        $seoDescTpl = (string)($importCfg['seoDescriptionTemplate'] ?? '');

        $imageUrlCol = $importCfg['imageUrlColumn'] ?? 'image_url';
        $imageLocalCol = $importCfg['imageLocalColumn'] ?? 'image_local';

        $translateCfg = $config['importTranslate'] ?? [];
        $translateEnabled = !empty($translateCfg['enabled']);

        $normalized = [];
        foreach ($sliceRows as $idx => $r) {
            $rowVars = [];
            foreach ($r as $colName => $val) {
                $rowVars['{' . trim((string)$colName) . '}'] = trim((string)$val);
            }
            $priceRaw = $r[$priceCol] ?? ($r['price'] ?? ($r['Цена'] ?? ''));
            $price = $priceRaw !== '' ? normalize_price((string)$priceRaw, $multiplier) : '';
            $rowVars['{price}'] = $price;

            if ($nameTpl !== '') {
                $name = trim(strtr($nameTpl, $rowVars));
            } else {
                $name = trim($r[$nameCol] ?? ($r['name'] ?? ($r['title'] ?? ($r['Название'] ?? ('Товар ' . ($idx + 1))))));
            }
            if ($name === '') {
                $name = 'Товар ' . ($idx + 1);
            }
            $rowVars['{name}'] = $name;

            $sku  = trim($r[$skuCol] ?? ($r['sku'] ?? ($r['product_id'] ?? ($r['SKU'] ?? ($r['Артикул'] ?? (string)($offset + $idx + 1))))));
            $slugRaw = trim($r['slug'] ?? '');
            if ($slugRaw === '') {
                $slugRaw = slugify_ru($name) . '-' . $sku;
            }
            $slugRaw = sanitize_product_slug($slugRaw);

            $category = trim($r[$categoryCol] ?? ($r['category'] ?? ($r['category_last'] ?? ($r['Категория'] ?? ''))));
            $tagsSource = $r[$tagsCol] ?? ($r['tags'] ?? ($r['manufacturer'] ?? ($r['Теги'] ?? '')));

            if ($descTpl !== '') {
                $description = trim(strtr($descTpl, $rowVars));
            } else {
                $description = trim($r[$descCol] ?? ($r['description'] ?? ($r['short'] ?? ($r['Описание'] ?? ''))));
            }

            $tags = array_values(array_filter(array_map('trim', explode(',', (string)$tagsSource))));
            $seoTitle = trim($r['seoTitle'] ?? ($r['SEO Title'] ?? ''));
            $seoDescription = trim($r['seoDescription'] ?? ($r['SEO Description'] ?? ''));
            if ($seoTitle === '' && $seoTitleTpl !== '') {
                $seoTitle = strtr($seoTitleTpl, $rowVars);
            }
            if ($seoDescription === '' && $seoDescTpl !== '') {
                $seoDescription = strtr($seoDescTpl, $rowVars);
            }

            $imageUrl = trim($r[$imageUrlCol] ?? ($r['image_url'] ?? ''));
            $imageLocal = trim($r[$imageLocalCol] ?? ($r['image_local'] ?? ''));
            $imageLocal = str_replace('\\', '/', $imageLocal);
            $imageLocal = preg_replace('~^images/~', '', $imageLocal);

            $catsListRaw = trim((string)($r[$catsListCol] ?? $r['categories'] ?? $r['category'] ?? $r['Категория'] ?? $r['category_last'] ?? ''));
            $catsFromList = [];
            if ($catsListRaw !== '') {
                $pattern = '~\s*\|\s*~';
                if ($catsListDelimiter === ',') {
                    $pattern = '~\s*,\s*~';
                } elseif ($catsListDelimiter === 'both') {
                    $pattern = '~\s*[|,]\s*~';
                }
                $parts = preg_split($pattern, (string)$catsListRaw);
                foreach ($parts as $c) {
                    $c = trim($c);
                    if ($c !== '' && !in_array($c, $catsFromList, true)) {
                        $catsFromList[] = $c;
                    }
                }
            }
            if ($category === '' && !empty($catsFromList)) {
                $category = $catsFromList[0];
            }
            $catsAll = $catsFromList;
            if ($category !== '' && !in_array($category, $catsAll, true)) {
                $catsAll[] = $category;
            }
            $categoriesExtra = '';
            if (!empty($catsAll)) {
                $categoriesExtra = '|' . implode('|', $catsAll) . '|';
            }

            [$finalImageUrl, $finalImageLocal] = ensure_local_webp_for_image(
                $imageUrl !== '' ? (string)$imageUrl : null,
                $imageLocal !== '' ? (string)$imageLocal : null
            );

            $normalized[] = [
                'sku' => $sku,
                'name' => $name,
                'slug' => $slugRaw,
                'price' => $price,
                'category' => $category,
                'tags' => $tags,
                'description' => $description,
                'seoTitle' => $seoTitle,
                'seoDescription' => $seoDescription,
                'imageUrl' => $finalImageUrl,
                'imageLocal' => $finalImageLocal,
                'categoriesExtra' => $categoriesExtra,
                'specHtml' => $specHtml,
            ];
        }

        $translationError = null;
        if ($translateEnabled && !empty($normalized)) {
            $fieldsToTranslate = [
                'name' => !empty($translateCfg['translateName']),
                'description' => !empty($translateCfg['translateDescription']),
                'category' => !empty($translateCfg['translateCategory']),
                'categoriesExtra' => !empty($translateCfg['translateCategory']),
                'tags' => !empty($translateCfg['translateTags']),
                'seoTitle' => !empty($translateCfg['translateSeoTitle']),
                'seoDescription' => !empty($translateCfg['translateSeoDescription']),
            ];

            foreach ($fieldsToTranslate as $field => $flag) {
                if (!$flag) {
                    continue;
                }
                $sourceTexts = [];
                foreach ($normalized as $i => $p) {
                    if ($field === 'tags') {
                        $val = isset($p['tags']) && is_array($p['tags']) ? implode(', ', $p['tags']) : '';
                    } else {
                        $val = $p[$field] ?? '';
                    }
                    $val = trim((string)$val);
                    if ($val === '') {
                        continue;
                    }
                    $sourceTexts[$i] = $val;
                }
                if (empty($sourceTexts)) {
                    continue;
                }

                $useAiTranslate = !empty($translateCfg['useAi']);
                if ($useAiTranslate) {
                    $translated = ai_translate_bulk($sourceTexts, $translateCfg, $config);
                } else {
                    $translated = google_translate_bulk($sourceTexts, $translateCfg);
                }
                global $translationLastError;
                if ($translationLastError !== null) {
                    $translationError = $translationLastError;
                    break;
                }
                foreach ($translated as $i => $val) {
                    if (!isset($normalized[$i])) {
                        continue;
                    }
                    if ($field === 'tags') {
                        $tagsStr = (string)$val;
                        $parts = array_values(array_filter(array_map('trim', explode(',', $tagsStr))));
                        $normalized[$i]['tags'] = $parts;
                    } else {
                        $normalized[$i][$field] = (string)$val;
                    }
                }
            }
        }

        if (!empty($normalized)) {
            foreach ($normalized as $i => $p) {
                if (!empty($p['specHtml'])) {
                    $desc = (string)($p['description'] ?? '');
                    $block = (string)$p['specHtml'];
                    $normalized[$i]['description'] = $desc !== '' ? $desc . "\n\n" . $block : $block;
                }
            }
        }

        if (!empty($normalized)) {
            db_import_products($normalized);
            cache_clear_all();
            $productPaths = [];
            foreach ($normalized as $p) {
                $productPaths[] = '/product/' . rawurlencode((string)$p['slug']);
            }
            if (!empty($config['indexNowAutoImport']) && !empty($productPaths)) {
                foreach (get_indexnow_bases($config) as $base) {
                    $urls = array_map(function ($path) use ($base) {
                        return rtrim($base, '/') . $path;
                    }, $productPaths);
                    notify_indexnow($config, $urls, $base);
                }
            }
        }

        $nextOffset = $offset + $limit;
        if ($nextOffset > $totalRows) {
            $nextOffset = $totalRows;
        }
        $done = $nextOffset >= $totalRows;

        echo json_encode([
            'ok' => true,
            'imported' => count($normalized),
            'totalRows' => $totalRows,
            'nextOffset' => $nextOffset,
            'done' => $done,
            'translationError' => $translationError,
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($uri === '/admin/import-cron-setup' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        global $cronImportStateFile, $cronImportCsvFile;
        $useExisting = !empty($_POST['cron_use_existing']);
        $raw = $_POST['cron_csv'] ?? '';
        if (!$useExisting && !empty($_FILES['cron_csv_file']['tmp_name']) && is_uploaded_file($_FILES['cron_csv_file']['tmp_name'])) {
            $fileRaw = file_get_contents($_FILES['cron_csv_file']['tmp_name']);
            if ($fileRaw !== false) {
                $raw = $fileRaw;
            }
        }
        $raw = trim((string)$raw);
        $limit = isset($_POST['cron_limit']) ? (int)$_POST['cron_limit'] : 20;
        if ($limit <= 0) {
            $limit = 20;
        }
        if ($limit > 100000) {
            $limit = 100000;
        }
        if ($useExisting) {
            if (!is_file($cronImportCsvFile)) {
                header('Location: /admin/import?cron_error=nofile');
                exit;
            }
        } else {
            if ($raw === '') {
                header('Location: /admin/import?cron_error=empty');
                exit;
            }
            // Сохраняем CSV для последующей обработки по крону
            file_put_contents($cronImportCsvFile, $raw);
        }

        $state = [
            'totalRows' => 0,
            'offset' => 0,
            'limit' => $limit,
            'createdAt' => date('c'),
            'lastRun' => null,
            'lastImported' => 0,
            'lastError' => null,
        ];
        write_json($cronImportStateFile, $state);

        header('Location: /admin/import?cron_prepared=1');
        exit;
    }

    if ($uri === '/admin/import-cron-clear' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        global $cronImportStateFile;
        if (is_file($cronImportStateFile)) {
            @unlink($cronImportStateFile);
        }
        header('Location: /admin/import?cron_cleared=1');
        exit;
    }

    if ($uri === '/admin/import-cron') {
        require_cron_import_auth($config);
        global $cronImportStateFile, $cronImportCsvFile;
        header('Content-Type: text/plain; charset=utf-8');
        $state = read_json($cronImportStateFile, []);
        $offset = (int)($state['offset'] ?? 0);
        $limit = (int)($state['limit'] ?? 20);
        if ($limit <= 0) {
            $limit = 20;
        }
        if ($limit > 100000) {
            $limit = 100000;
        }
        if (!is_file($cronImportCsvFile)) {
            echo "Нет подготовленного CSV для импорта по крону.\n";
            exit;
        }
        $fp = fopen($cronImportCsvFile, 'rb');
        if ($fp === false) {
            echo "Не удалось открыть CSV для импорта по крону.\n";
            exit;
        }

        $fileOffset = isset($state['fileOffset']) ? (int)$state['fileOffset'] : null;
        $header = $state['header'] ?? null;
        $delimiter = $state['delimiter'] ?? ';';

        if ($fileOffset === null) {
            // Первый запуск: читаем заголовок и строку-пример через fgetcsv (учитывает кавычки и переносы внутри полей)
            rewind($fp);
            $header = null;
            foreach ([';', ',', "\t"] as $d) {
                rewind($fp);
                $row = fgetcsv($fp, 0, $d);
                if ($row !== false && count($row) >= 2) {
                    $delimiter = $d;
                    $header = array_map('trim', $row);
                    break;
                }
            }
            if (empty($header)) {
                fclose($fp);
                echo "Файл CSV для импорта по крону пустой или неверный разделитель.\n";
                exit;
            }
            // Пропускаем вторую строку (пример)
            fgetcsv($fp, 0, $delimiter);
            $fileOffset = ftell($fp);
        } else {
            // Продолжаем с сохранённой позиции
            fseek($fp, $fileOffset);
            if ($header === null || !is_array($header)) {
                $header = range(0, 50);
            }
        }

        $rows = [];
        for ($i = 0; $i < $limit; $i++) {
            $cols = fgetcsv($fp, 0, $delimiter);
            if ($cols === false) break;
            $cols = array_pad(array_slice($cols, 0, count($header)), count($header), '');
            $rows[] = array_combine($header, $cols);
        }
        $newFileOffset = ftell($fp);
        fclose($fp);

        if (empty($rows)) {
            echo "Импорт по крону уже завершён. Обработано строк: {$offset}.\n";
            $state['lastRun'] = date('c');
            $state['offset'] = $offset;
            write_json($cronImportStateFile, $state);
            exit;
        }

        $state['fileOffset'] = $newFileOffset;
        $state['header'] = $header;
        $state['delimiter'] = $delimiter;
        $totalRows = (int)($state['totalRows'] ?? 0);

        $sliceRows = $rows;

        $importCfg = $config['import'] ?? [];
        $skuCol = $importCfg['skuColumn'] ?? 'sku';
        $nameCol = $importCfg['nameColumn'] ?? 'name';
        $nameTpl = trim((string)($importCfg['nameTemplate'] ?? ''));
        $priceCol = $importCfg['priceColumn'] ?? 'price';
        $categoryCol = $importCfg['categoryColumn'] ?? 'category';
        $tagsCol = $importCfg['tagsColumn'] ?? 'tags';
        $descCol = $importCfg['descriptionColumn'] ?? 'description';
        $descTpl = trim((string)($importCfg['descriptionTemplate'] ?? ''));
        $catsListCol = $importCfg['categoriesListColumn'] ?? 'categories';
        $catsListDelimiter = $importCfg['categoriesListDelimiter'] ?? '|';
        $multiplier = (float)($importCfg['priceMultiplier'] ?? 1.0);
        $seoTitleTpl = (string)($importCfg['seoTitleTemplate'] ?? '');
        $seoDescTpl = (string)($importCfg['seoDescriptionTemplate'] ?? '');

        $imageUrlCol = $importCfg['imageUrlColumn'] ?? 'image_url';
        $imageLocalCol = $importCfg['imageLocalColumn'] ?? 'image_local';
        $specsCol = $importCfg['specsColumn'] ?? '';
        $specsToTable = !empty($importCfg['specsToTable']);
        $fixPriceText = !empty($importCfg['fixPriceText']);

        $translateCfg = $config['importTranslate'] ?? [];
        $translateEnabled = !empty($translateCfg['enabled']);

        $normalized = [];
        foreach ($sliceRows as $idx => $r) {
            $rowVars = [];
            foreach ($r as $colName => $val) {
                $rowVars['{' . trim((string)$colName) . '}'] = trim((string)$val);
            }
            $priceRaw = $r[$priceCol] ?? ($r['price'] ?? ($r['Цена'] ?? ''));
            $price = $priceRaw !== '' ? normalize_price((string)$priceRaw, $multiplier) : '';
            $rowVars['{price}'] = $price;

            if ($nameTpl !== '') {
                $name = trim(strtr($nameTpl, $rowVars));
            } else {
                $name = trim($r[$nameCol] ?? ($r['name'] ?? ($r['title'] ?? ($r['Название'] ?? ('Товар ' . ($offset + $idx + 1))))));
            }
            if ($name === '') {
                $name = 'Товар ' . ($offset + $idx + 1);
            }
            if ($fixPriceText && $price !== '') {
                $name = replace_old_price_in_text($name, $price);
            }
            $rowVars['{name}'] = $name;

            $sku  = trim($r[$skuCol] ?? ($r['sku'] ?? ($r['product_id'] ?? ($r['SKU'] ?? ($r['Артикул'] ?? (string)($offset + $idx + 1))))));
            $slugRaw = trim($r['slug'] ?? '');
            if ($slugRaw === '') {
                $slugRaw = slugify_ru($name) . '-' . $sku;
            }
            $slugRaw = sanitize_product_slug($slugRaw);

            $category = trim($r[$categoryCol] ?? ($r['category'] ?? ($r['category_last'] ?? ($r['Категория'] ?? ''))));
            $tagsSource = $r[$tagsCol] ?? ($r['tags'] ?? ($r['manufacturer'] ?? ($r['Теги'] ?? '')));

            if ($descTpl !== '') {
                $description = trim(strtr($descTpl, $rowVars));
            } else {
                $description = trim($r[$descCol] ?? ($r['description'] ?? ($r['short'] ?? ($r['Описание'] ?? ''))));
            }
            if ($fixPriceText && $price !== '') {
                $description = replace_old_price_in_text($description, $price);
            }

            $specHtml = '';
            if ($specsToTable && $specsCol !== '') {
                $specRaw = trim($r[$specsCol] ?? ($r['specs'] ?? ''));
                if ($specRaw !== '') {
                    $specHtml = build_specs_table_html($specRaw);
                }
            }

            $tags = array_values(array_filter(array_map('trim', explode(',', (string)$tagsSource))));
            $seoTitle = trim($r['seoTitle'] ?? ($r['SEO Title'] ?? ''));
            $seoDescription = trim($r['seoDescription'] ?? ($r['SEO Description'] ?? ''));
            if ($seoTitle === '' && $seoTitleTpl !== '') {
                $seoTitle = strtr($seoTitleTpl, $rowVars);
            }
            if ($seoDescription === '' && $seoDescTpl !== '') {
                $seoDescription = strtr($seoDescTpl, $rowVars);
            }
            if ($fixPriceText && $price !== '') {
                $seoTitle = replace_old_price_in_text($seoTitle, $price);
                $seoDescription = replace_old_price_in_text($seoDescription, $price);
            }

            $imageUrl = trim($r[$imageUrlCol] ?? ($r['image_url'] ?? ''));
            $imageLocal = trim($r[$imageLocalCol] ?? ($r['image_local'] ?? ''));
            $imageLocal = str_replace('\\', '/', $imageLocal);
            $imageLocal = preg_replace('~^images/~', '', $imageLocal);

            $catsListRaw = trim((string)($r[$catsListCol] ?? $r['categories'] ?? $r['category'] ?? $r['Категория'] ?? $r['category_last'] ?? ''));
            $catsFromList = [];
            if ($catsListRaw !== '') {
                $pattern = '~\s*\|\s*~';
                if ($catsListDelimiter === ',') {
                    $pattern = '~\s*,\s*~';
                } elseif ($catsListDelimiter === 'both') {
                    $pattern = '~\s*[|,]\s*~';
                }
                $parts = preg_split($pattern, (string)$catsListRaw);
                foreach ($parts as $c) {
                    $c = trim($c);
                    if ($c !== '' && !in_array($c, $catsFromList, true)) {
                        $catsFromList[] = $c;
                    }
                }
            }
            if ($category === '' && !empty($catsFromList)) {
                $category = $catsFromList[0];
            }
            $catsAll = $catsFromList;
            if ($category !== '' && !in_array($category, $catsAll, true)) {
                $catsAll[] = $category;
            }
            $categoriesExtra = '';
            if (!empty($catsAll)) {
                $categoriesExtra = '|' . implode('|', $catsAll) . '|';
            }

            [$finalImageUrl, $finalImageLocal] = ensure_local_webp_for_image(
                $imageUrl !== '' ? (string)$imageUrl : null,
                $imageLocal !== '' ? (string)$imageLocal : null
            );

            $normalized[] = [
                'sku' => $sku,
                'name' => $name,
                'slug' => $slugRaw,
                'price' => $price,
                'category' => $category,
                'tags' => $tags,
                'description' => $description,
                'seoTitle' => $seoTitle,
                'seoDescription' => $seoDescription,
                'imageUrl' => $finalImageUrl,
                'imageLocal' => $finalImageLocal,
                'categoriesExtra' => $categoriesExtra,
                'specHtml' => $specHtml,
            ];
        }

        $translationError = null;
        if ($translateEnabled && !empty($normalized)) {
            $fieldsToTranslate = [
                'name' => !empty($translateCfg['translateName']),
                'description' => !empty($translateCfg['translateDescription']),
                'category' => !empty($translateCfg['translateCategory']),
                'categoriesExtra' => !empty($translateCfg['translateCategory']),
                'tags' => !empty($translateCfg['translateTags']),
                'seoTitle' => !empty($translateCfg['translateSeoTitle']),
                'seoDescription' => !empty($translateCfg['translateSeoDescription']),
            ];

            foreach ($fieldsToTranslate as $field => $flag) {
                if (!$flag) {
                    continue;
                }
                $sourceTexts = [];
                foreach ($normalized as $i => $p) {
                    if ($field === 'tags') {
                        $val = isset($p['tags']) && is_array($p['tags']) ? implode(', ', $p['tags']) : '';
                    } else {
                        $val = $p[$field] ?? '';
                    }
                    $val = trim((string)$val);
                    if ($val === '') {
                        continue;
                    }
                    $sourceTexts[$i] = $val;
                }
                if (empty($sourceTexts)) {
                    continue;
                }

                $useAiTranslate = !empty($translateCfg['useAi']);
                if ($useAiTranslate) {
                    $translated = ai_translate_bulk($sourceTexts, $translateCfg, $config);
                } else {
                    $translated = google_translate_bulk($sourceTexts, $translateCfg);
                }
                global $translationLastError;
                if ($translationLastError !== null) {
                    $translationError = $translationLastError;
                    break;
                }
                foreach ($translated as $i => $val) {
                    if (!isset($normalized[$i])) {
                        continue;
                    }
                    if ($field === 'tags') {
                        $tagsStr = (string)$val;
                        $parts = array_values(array_filter(array_map('trim', explode(',', $tagsStr))));
                        $normalized[$i]['tags'] = $parts;
                    } else {
                        $normalized[$i][$field] = (string)$val;
                    }
                }
            }
        }

        if (!empty($normalized)) {
            foreach ($normalized as $i => $p) {
                if (!empty($p['specHtml'])) {
                    $desc = (string)($p['description'] ?? '');
                    $block = (string)$p['specHtml'];
                    $normalized[$i]['description'] = $desc !== '' ? $desc . "\n\n" . $block : $block;
                }
            }
        }

        if (!empty($normalized)) {
            db_import_products($normalized);
            cache_clear_all();
            $productPaths = [];
            foreach ($normalized as $p) {
                $productPaths[] = '/product/' . rawurlencode((string)$p['slug']);
            }
            if (!empty($config['indexNowAutoImport']) && !empty($productPaths)) {
                foreach (get_indexnow_bases($config) as $base) {
                    $urls = array_map(function ($path) use ($base) {
                        return rtrim($base, '/') . $path;
                    }, $productPaths);
                    notify_indexnow($config, $urls, $base);
                }
            }
        }

        $importedCount = count($normalized);
        $offset += $importedCount;

        $state['offset'] = $offset;
        $state['limit'] = $limit;
        $state['lastRun'] = date('c');
        $state['lastImported'] = $importedCount;
        $state['lastError'] = $translationError;
        write_json($cronImportStateFile, $state);

        $totalRowsStr = $totalRows > 0 ? "{$offset} / {$totalRows}" : "{$offset}";
        echo "Обработано по крону: {$importedCount} товаров. Всего обработано: {$totalRowsStr}.\n";
        if ($translationError) {
            echo "Предупреждение перевода: {$translationError}\n";
        }
        exit;
    }

    if ($uri === '/admin/indexnow-ping' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        header('Content-Type: application/json; charset=utf-8');
        if (empty($config['indexNowKey']) || empty($config['baseUrl'])) {
            echo json_encode(['ok' => false, 'error' => 'IndexNow не настроен. Укажите ключ и базовый URL в настройках.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $raw = file_get_contents('php://input');
        $data = json_decode((string)$raw, true);
        $url = isset($data['url']) ? trim((string)$data['url']) : '';
        if ($url === '') {
            echo json_encode(['ok' => false, 'error' => 'Пустой URL'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $baseUrlOverride = isset($data['baseUrl']) ? trim((string)$data['baseUrl']) : '';
        $request = notify_indexnow($config, [$url], $baseUrlOverride);
        echo json_encode(['ok' => true, 'request' => $request], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($uri === '/admin/indexnow-ping-yandex' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        header('Content-Type: application/json; charset=utf-8');
        if (empty($config['indexNowKey']) || empty($config['baseUrl'])) {
            echo json_encode(['ok' => false, 'error' => 'IndexNow не настроен. Укажите ключ и базовый URL в настройках.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $raw = file_get_contents('php://input');
        $data = json_decode((string)$raw, true);
        $url = isset($data['url']) ? trim((string)$data['url']) : '';
        if ($url === '') {
            echo json_encode(['ok' => false, 'error' => 'Пустой URL'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $baseUrlOverride = isset($data['baseUrl']) ? trim((string)$data['baseUrl']) : '';
        $request = notify_indexnow_yandex($config, [$url], $baseUrlOverride);
        echo json_encode(['ok' => true, 'request' => $request], JSON_UNESCAPED_UNICODE);
        exit;
    }

    // Инициализация/остановка автокэширования из админки
    if ($uri === '/admin/cache-warmup-cron-init' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        header('Content-Type: application/json; charset=utf-8');
        global $cacheWarmupCronStateFile;
        $raw = file_get_contents('php://input');
        $data = json_decode((string)$raw, true);
        $catLimit = isset($data['catLimit']) ? (int)$data['catLimit'] : 50;
        $prodLimit = isset($data['prodLimit']) ? (int)$data['prodLimit'] : 200;
        if ($catLimit < 0) {
            $catLimit = 0;
        } elseif ($catLimit > 10000) {
            $catLimit = 10000;
        }
        if ($prodLimit < 0) {
            $prodLimit = 0;
        } elseif ($prodLimit > 10000) {
            $prodLimit = 10000;
        }
        $allCats = db_get_categories();
        $totalCategories = count($allCats);
        $totalProducts = db_count_all_products();
        if ($totalCategories === 0 && $totalProducts === 0) {
            echo json_encode(['ok' => false, 'error' => 'Нет категорий и товаров для автокэширования.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        write_json($cacheWarmupCronStateFile, [
            'running' => true,
            'phase' => 'categories',
            'catOffset' => 0,
            'prodOffset' => 0,
            'catLimit' => $catLimit,
            'prodLimit' => $prodLimit,
            'totalCategories' => $totalCategories,
            'totalProducts' => $totalProducts,
            'startedAt' => date('c'),
            'lastRun' => null,
            'lastTouchedCategories' => 0,
            'lastTouchedProducts' => 0,
        ]);
        echo json_encode([
            'ok' => true,
            'catLimit' => $catLimit,
            'prodLimit' => $prodLimit,
            'totalCategories' => $totalCategories,
            'totalProducts' => $totalProducts,
        ], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($uri === '/admin/cache-warmup-cron-stop' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        header('Content-Type: application/json; charset=utf-8');
        global $cacheWarmupCronStateFile;
        $state = read_json($cacheWarmupCronStateFile, []);
        $state['running'] = false;
        write_json($cacheWarmupCronStateFile, $state);
        echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
        exit;
    }

    // Инициация/остановка IndexNow по крону из админки
    if ($uri === '/admin/indexnow-cron-init' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        header('Content-Type: application/json; charset=utf-8');
        if (empty($config['indexNowKey']) || empty($config['baseUrl'])) {
            echo json_encode(['ok' => false, 'error' => 'IndexNow не настроен. Укажите ключ и базовый URL в настройках.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        global $pagesFile, $indexnowCronStateFile;
        $pages = load_pages($pagesFile);
        $entries = build_indexnow_entries($config, $pages);
        $total = count($entries);
        if ($total === 0) {
            echo json_encode(['ok' => false, 'error' => 'Нет URL для отправки в IndexNow.'], JSON_UNESCAPED_UNICODE);
            exit;
        }
        $existing = read_json($indexnowCronStateFile, []);
        $limit = isset($existing['limit']) ? (int)$existing['limit'] : 500;
        if ($limit <= 0) {
            $limit = 500;
        } elseif ($limit > 5000) {
            $limit = 5000;
        }
        $bodyRaw = file_get_contents('php://input');
        $body = json_decode((string)$bodyRaw, true);
        if (is_array($body) && isset($body['limit'])) {
            $limitReq = (int)$body['limit'];
            if ($limitReq > 0 && $limitReq <= 5000) {
                $limit = $limitReq;
            }
        }
        write_json($indexnowCronStateFile, [
            'running'   => true,
            'offset'    => 0,
            'limit'     => $limit,
            'total'     => $total,
            'startedAt' => date('c'),
            'lastRun'   => null,
            'lastSent'  => 0,
        ]);
        echo json_encode(['ok' => true, 'total' => $total, 'limit' => $limit], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($uri === '/admin/indexnow-cron-stop' && $_SERVER['REQUEST_METHOD'] === 'POST') {
        require_admin($config);
        header('Content-Type: application/json; charset=utf-8');
        global $indexnowCronStateFile;
        $state = read_json($indexnowCronStateFile, []);
        $state['running'] = false;
        write_json($indexnowCronStateFile, $state);
        echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
        exit;
    }

    if ($uri === '/admin/indexnow-cron') {
        require_cron_import_auth($config);
        header('Content-Type: text/plain; charset=utf-8');
        if (empty($config['indexNowKey']) || empty($config['baseUrl'])) {
            echo "IndexNow не настроен. Укажите ключ и базовый URL в настройках.\n";
            exit;
        }
        global $pagesFile, $indexnowCronStateFile, $indexnowCronLogFile;
        set_time_limit(600);
        $pages = load_pages($pagesFile);
        $entries = build_indexnow_entries($config, $pages);
        $total = count($entries);
        if ($total === 0) {
            echo "Нет URL для отправки в IndexNow.\n";
            exit;
        }
        $state = read_json($indexnowCronStateFile, []);
        if (empty($state['running'])) {
            echo "IndexNow по крону: не запущен. Инициализируйте задание в админке.\n";
            exit;
        }
        $limit = (int)($state['limit'] ?? 500);
        if ($limit <= 0) {
            $limit = 500;
        }
        if ($limit > 5000) {
            $limit = 5000;
        }
        $offset = (int)($state['offset'] ?? 0);
        if ($offset >= $total) {
            // Всё уже отправлено — останавливаем задание
            $state['running'] = false;
            $state['offset'] = 0;
            $state['total'] = $total;
            $state['lastRun'] = date('c');
            $state['lastSent'] = 0;
            write_json($indexnowCronStateFile, $state);
            echo "IndexNow по крону: все {$total} URL уже были отправлены, задание остановлено.\n";
            exit;
        }
        $slice = array_slice($entries, $offset, $limit);
        $sent = 0;
        foreach ($slice as $entry) {
            $url = $entry['url'] ?? '';
            $baseUrl = $entry['baseUrl'] ?? '';
            if ($url === '') {
                continue;
            }
            $resBing = notify_indexnow($config, [$url], $baseUrl);
            $resYa = notify_indexnow_yandex($config, [$url], $baseUrl);
            $sent++;
            $codeB = $resBing['httpCode'] ?? null;
            $codeY = $resYa['httpCode'] ?? null;
            $okB = isset($resBing['ok']) ? (bool)$resBing['ok'] : null;
            $okY = isset($resYa['ok']) ? (bool)$resYa['ok'] : null;
            $line = sprintf(
                "[%s] URL: %s | Bing: %s (HTTP %s) | Yandex: %s (HTTP %s)\n",
                date('c'),
                $url,
                $okB === null ? 'n/a' : ($okB ? 'OK' : 'FAIL'),
                $codeB === null ? '-' : $codeB,
                $okY === null ? 'n/a' : ($okY ? 'OK' : 'FAIL'),
                $codeY === null ? '-' : $codeY
            );
            echo $line . "\n";
            @file_put_contents($indexnowCronLogFile, $line, FILE_APPEND);
        }
        $offset += $sent;
        $running = $offset < $total;
        write_json($indexnowCronStateFile, [
            'running' => $running,
            'offset' => $offset,
            'limit' => $limit,
            'total' => $total,
            'lastRun' => date('c'),
            'lastSent' => $sent,
        ]);
        if ($running) {
            echo "IndexNow по крону: отправлено {$sent} URL в Bing и Яндекс. Всего URL: {$total}, следующая позиция: {$offset}.\n";
        } else {
            echo "IndexNow по крону: отправлено {$sent} URL, всего обработано {$total}. Задание завершено и остановлено.\n";
        }
        exit;
    }

    // Крон‑паук для автокэширования: обходит категории и товары, прогревая кэш
    if ($uri === '/admin/cache-warmup-cron') {
        require_cron_import_auth($config);
        header('Content-Type: text/plain; charset=utf-8');
        global $cacheWarmupCronStateFile;
        $state = read_json($cacheWarmupCronStateFile, []);
        if (empty($state['running'])) {
            echo "Автокэширование: не запущено. Инициализируйте задание в админке.\n";
            exit;
        }
        $phase = $state['phase'] ?? 'categories';
        $catOffset = (int)($state['catOffset'] ?? 0);
        $prodOffset = (int)($state['prodOffset'] ?? 0);
        $catLimit = max(0, (int)($state['catLimit'] ?? 50));
        $prodLimit = max(0, (int)($state['prodLimit'] ?? 200));
        if ($catLimit === 0 && $prodLimit === 0) {
            $catLimit = 50;
            $prodLimit = 200;
        }

        $base = rtrim($config['canonicalBase'] ?? $config['baseUrl'] ?? get_current_base_url(), '/');
        if ($base === '') {
            echo "Автокэширование: не указан базовый URL (baseUrl). Настройте в админке.\n";
            exit;
        }

        $touchedCategories = 0;
        $touchedProducts = 0;

        // Функция запроса страницы для прогрева кэша
        $hit = function (string $url): bool {
            // Предпочитаем cURL — работает даже при выключенном allow_url_fopen
            if (function_exists('curl_init')) {
                $ch = curl_init($url);
                curl_setopt_array($ch, [
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_TIMEOUT => 10,
                    CURLOPT_USERAGENT => 'katalogcms-cache-warmup-bot',
                ]);
                curl_exec($ch);
                $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);
                return $code >= 200 && $code < 400;
            }
            // Fallback через file_get_contents
            $ctx = stream_context_create([
                'http' => [
                    'method' => 'GET',
                    'timeout' => 10,
                    'header' => "User-Agent: katalogcms-cache-warmup-bot\r\n",
                ],
            ]);
            $res = @file_get_contents($url, false, $ctx);
            return $res !== false;
        };

        // Этап категорий
        if ($phase === 'categories') {
            $allCats = db_get_categories();
            $totalCats = count($allCats);
            $state['totalCategories'] = $totalCats;
            if ($catOffset >= $totalCats) {
                $phase = 'products';
            } else {
                if ($catLimit > 0) {
                    $slice = array_slice($allCats, $catOffset, $catLimit);
                    foreach ($slice as $catName) {
                        $slug = slugify_ru($catName);
                        $url = $base . '/catalog/' . rawurlencode($slug);
                        if ($hit($url)) {
                            $touchedCategories++;
                        }
                    }
                    $catOffset += count($slice);
                    if ($catOffset >= $totalCats) {
                        $phase = 'products';
                    }
                } else {
                    $phase = 'products';
                }
            }
        }

        // Этап товаров
        if ($phase === 'products') {
            $pdo = db();
            // Считаем общее количество товаров один раз
            $totalProducts = isset($state['totalProducts']) ? (int)$state['totalProducts'] : db_count_all_products();
            $state['totalProducts'] = $totalProducts;
            if ($prodOffset >= $totalProducts) {
                $state['running'] = false;
                $state['phase'] = 'done';
                $state['lastRun'] = date('c');
                $state['lastTouchedCategories'] = $touchedCategories;
                $state['lastTouchedProducts'] = $touchedProducts;
                write_json($cacheWarmupCronStateFile, $state);
                $totalCatsDone = isset($state['totalCategories']) ? (int)$state['totalCategories'] : 0;
                echo "Автокэширование: все {$totalProducts} товаров и {$totalCatsDone} категорий уже прогреты, задание завершено.\n";
                exit;
            }
            if ($prodLimit > 0) {
                $stmt = $pdo->prepare('SELECT slug FROM products WHERE slug IS NOT NULL AND slug != \'\' ORDER BY id ASC LIMIT :limit OFFSET :offset');
                $stmt->bindValue(':limit', $prodLimit, PDO::PARAM_INT);
                $stmt->bindValue(':offset', $prodOffset, PDO::PARAM_INT);
                $stmt->execute();
                $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
                foreach ($rows as $row) {
                    $slug = (string)($row['slug'] ?? '');
                    if ($slug === '') {
                        continue;
                    }
                    $url = $base . '/product/' . rawurlencode($slug);
                    if ($hit($url)) {
                        $touchedProducts++;
                    }
                }
                $prodOffset += count($rows);
            }
            $phase = ($prodOffset >= $state['totalProducts']) ? 'done' : 'products';
        }

        $running = ($phase !== 'done');
        $state['running'] = $running;
        $state['phase'] = $phase;
        $state['catOffset'] = $catOffset;
        $state['prodOffset'] = $prodOffset;
        $state['catLimit'] = $catLimit;
        $state['prodLimit'] = $prodLimit;
        $state['lastRun'] = date('c');
        $state['lastTouchedCategories'] = $touchedCategories;
        $state['lastTouchedProducts'] = $touchedProducts;
        write_json($cacheWarmupCronStateFile, $state);

        if ($running) {
            echo "Автокэширование: категории +{$touchedCategories}, товары +{$touchedProducts}. Позиции — категории: {$catOffset} / " . (int)($state['totalCategories'] ?? 0) . ", товары: {$prodOffset} / {$state['totalProducts']}.\n";
        } else {
            echo "Автокэширование: завершено. Всего категорий: " . (int)($state['totalCategories'] ?? 0) . ", товаров: {$state['totalProducts']}.\n";
        }
        exit;
    }

    // ---------- Кэш в S3 ----------

    // Разбор очереди полной очистки бакета. Ставится в очередь любым сбросом
    // кэша, а удаляет объекты порциями — из крона, раз в несколько минут.
    if ($uri === '/admin/s3-purge-cron') {
        require_cron_import_auth($config);
        header('Content-Type: text/plain; charset=utf-8');
        $res = s3cache_purge_run(50, 50);
        echo $res['message'] . "\n";
        exit;
    }

    // Перенос уже готовых страниц из cache/ в бакет порциями.
    if ($uri === '/admin/s3-import-cron') {
        require_cron_import_auth($config);
        header('Content-Type: text/plain; charset=utf-8');
        if (!s3cache_enabled()) {
            echo "S3-кэш выключен или не настроен.\n";
            exit;
        }
        $res = s3cache_import_run([
            'limit'       => max(1, (int)($_GET['limit'] ?? 500)),
            'deleteLocal' => !empty($_GET['delete']),
            'maxSeconds'  => 50,
        ]);
        echo 'Перенос в S3: загружено ' . $res['uploaded']
            . ', пропущено ' . $res['skipped']
            . ', ошибок ' . $res['errors']
            . ', просмотрено ' . $res['seen']
            . ', ' . $res['elapsed'] . " c.\n";
        echo $res['finished'] ? "Локальный кэш перенесён полностью.\n" : "Проход не окончен.\n";
        exit;
    }

    if ($uri === '/admin/s3-cache') {
        require_admin($config);
        $s3 = is_array($config['s3cache'] ?? null) ? $config['s3cache'] : [];
        $s3['enabled']    = !empty($_POST['s3_enabled']);
        $s3['endpoint']   = trim($_POST['s3_endpoint'] ?? '');
        $s3['region']     = trim($_POST['s3_region'] ?? '');
        $s3['bucket']     = trim($_POST['s3_bucket'] ?? '');
        $s3['prefix']     = trim($_POST['s3_prefix'] ?? '');
        $s3['addressing'] = (($_POST['s3_addressing'] ?? 'path') === 'virtual') ? 'virtual' : 'path';
        $s3['mode']       = (($_POST['s3_mode'] ?? 'mirror') === 's3') ? 's3' : 'mirror';
        $s3['gzip']       = !empty($_POST['s3_gzip']);
        $s3['phpServe']   = !empty($_POST['s3_phpServe']);
        $s3['acl']        = trim($_POST['s3_acl'] ?? 'public-read');
        $s3['timeout']    = max(1, (int)($_POST['s3_timeout'] ?? 15));
        $s3['accessKey']  = trim($_POST['s3_accessKey'] ?? '');
        // Секрет можно не менять — пустое поле не затирает сохранённый ключ.
        $newSecret = trim($_POST['s3_secretKey'] ?? '');
        if ($newSecret !== '') {
            $s3['secretKey'] = $newSecret;
        }
        $config['s3cache'] = array_replace(S3CACHE_DEFAULTS, $s3);
        save_config($configFile, $config);
        header('Location: /admin/s3-cache?saved=1');
        exit;
    }

    if ($uri === '/admin/s3-cache-purge') {
        require_admin($config);
        s3cache_purge_all_queue();
        $res = s3cache_purge_run(20, 25);
        header('Location: /admin/s3-cache?purged=' . rawurlencode((string)$res['total']) . '&done=' . ($res['done'] ? '1' : '0'));
        exit;
    }

    if ($uri === '/admin/s3-cache-purge-continue') {
        require_admin($config);
        $res = s3cache_purge_run(20, 25);
        header('Location: /admin/s3-cache?purged=' . rawurlencode((string)$res['total']) . '&done=' . ($res['done'] ? '1' : '0'));
        exit;
    }

    if ($uri === '/admin/s3-cache-import') {
        require_admin($config);
        $res = s3cache_import_run([
            'limit'       => max(1, (int)($_POST['limit'] ?? 300)),
            'deleteLocal' => !empty($_POST['deleteLocal']),
            'maxSeconds'  => 25,
        ]);
        header('Location: /admin/s3-cache?imported=' . $res['uploaded'] . '&import_errors=' . $res['errors'] . '&import_done=' . ($res['finished'] ? '1' : '0'));
        exit;
    }

    if ($uri === '/admin/settings') {
        require_admin($config);
        $siteName = trim($_POST['siteName'] ?? '');
        $baseUrl = trim($_POST['baseUrl'] ?? '');
        $defaultRegionName = trim($_POST['defaultRegionName'] ?? '');
        $faviconUrl = trim($_POST['faviconUrl'] ?? '');
        $phone = trim($_POST['phone'] ?? '');
        $headerCallbackText = trim($_POST['headerCallbackText'] ?? '');
        $indexNowKey = trim($_POST['indexNowKey'] ?? '');
        $indexNowAutoImport = !empty($_POST['indexNowAutoImport']);
        $criticalCssInline = !empty($_POST['criticalCssInline']);
        $mainJsInline = !empty($_POST['mainJsInline']);
        $minifyHtml = !empty($_POST['minifyHtml']);
        $yOauth = trim($_POST['yandex_oauthToken'] ?? '');
        $yUserId = trim($_POST['yandex_userId'] ?? '');
        $yHostId = trim($_POST['yandex_hostId'] ?? '');
        $yMetrikaId = trim($_POST['yandex_metrikaId'] ?? '');
        $aiProvider = trim($_POST['ai_provider'] ?? '');
        $aiApiKey = trim($_POST['ai_apiKey'] ?? '');
        $aiEndpoint = trim($_POST['ai_endpoint'] ?? '');
        $aiModel = trim($_POST['ai_model'] ?? '');
        $adminProductFieldsDesc = !empty($_POST['adminProductFields_description']);
        $adminProductFieldsTags = !empty($_POST['adminProductFields_tags']);
        $adminProductFieldsSeoTitle = !empty($_POST['adminProductFields_seoTitle']);
        $adminProductFieldsSeoDesc = !empty($_POST['adminProductFields_seoDescription']);
        $adminUser = trim($_POST['admin_user'] ?? '');
        $adminPass = trim($_POST['admin_password'] ?? '');
        $contactsPhone = trim($_POST['contacts_phone'] ?? '');
        $contactsEmail = trim($_POST['contacts_email'] ?? '');
        $requestsEmail = trim($_POST['requestsEmail'] ?? '');
        $contactsAddress = trim($_POST['contacts_address'] ?? '');
        $contactsRequisites = trim($_POST['contacts_requisites'] ?? '');
        $contactsMap = trim($_POST['contacts_map'] ?? '');
        $deliveryHtml = trim($_POST['delivery_html'] ?? '');
        $importPriceMultiplier = isset($_POST['import_priceMultiplier']) ? (float)$_POST['import_priceMultiplier'] : null;
        $importSkuColumn = trim($_POST['import_skuColumn'] ?? '');
        $importNameColumn = trim($_POST['import_nameColumn'] ?? '');
        $importPriceColumn = trim($_POST['import_priceColumn'] ?? '');
        $importCategoryColumn = trim($_POST['import_categoryColumn'] ?? '');
        $importTagsColumn = trim($_POST['import_tagsColumn'] ?? '');
        $importDescriptionColumn = trim($_POST['import_descriptionColumn'] ?? '');
        $importNameTemplate = trim($_POST['import_nameTemplate'] ?? '');
        $importDescriptionTemplate = trim($_POST['import_descriptionTemplate'] ?? '');
        $importImageUrlColumn = trim($_POST['import_imageUrlColumn'] ?? '');
        $importImageLocalColumn = trim($_POST['import_imageLocalColumn'] ?? '');
        $importSpecsColumn = trim($_POST['import_specsColumn'] ?? '');
        $importSpecsToTable = !empty($_POST['import_specsToTable']);
        $importFixPriceText = !empty($_POST['import_fixPriceText']);
        $importCatsListDelimiter = trim($_POST['import_categoriesListDelimiter'] ?? '');
        $importSeoTitleTpl = trim($_POST['import_seoTitleTemplate'] ?? '');
        $importSeoDescTpl = trim($_POST['import_seoDescriptionTemplate'] ?? '');
        $catalogPerPage = isset($_POST['catalogPerPage']) ? (int)$_POST['catalogPerPage'] : null;
        $catalogView = trim($_POST['catalogView'] ?? '');
        $sidebarCategoriesTop = isset($_POST['sidebarCategoriesTop']) ? (int)$_POST['sidebarCategoriesTop'] : null;
        $importCatsListCol = trim($_POST['import_categoriesListColumn'] ?? '');
        // Настройки перевода при импорте
        $importTranslateEnabled = !empty($_POST['import_translate_enabled']);
        $importTranslateUseAi = !empty($_POST['import_translate_use_ai']);
        $importTranslateApiKey = trim($_POST['import_translate_googleKey'] ?? '');
        $importTranslateSource = trim($_POST['import_translate_source'] ?? '');
        $importTranslateTarget = trim($_POST['import_translate_target'] ?? '');
        $importTranslateProxies = trim($_POST['import_translate_proxies'] ?? '');
        $importTranslateName = !empty($_POST['import_translate_name']);
        $importTranslateDescription = !empty($_POST['import_translate_description']);
        $importTranslateCategory = !empty($_POST['import_translate_category']);
        $importTranslateTags = !empty($_POST['import_translate_tags']);
        $importTranslateSeoTitle = !empty($_POST['import_translate_seoTitle']);
        $importTranslateSeoDescription = !empty($_POST['import_translate_seoDescription']);
        // Доступ для импорта по крону
        $cronUser = trim($_POST['cron_import_user'] ?? '');
        $cronPass = trim($_POST['cron_import_password'] ?? '');

        $homeSeoTitle = trim($_POST['homeSeoTitle'] ?? '');
        $homeSeoDesc = trim($_POST['homeSeoDescription'] ?? '');
        $categorySeoTitleTpl = trim($_POST['categorySeoTitleTemplate'] ?? '');
        $categorySeoDescTpl = trim($_POST['categorySeoDescriptionTemplate'] ?? '');
        $tagSeoTitleTpl = trim($_POST['tagSeoTitleTemplate'] ?? '');
        $tagSeoDescTpl = trim($_POST['tagSeoDescriptionTemplate'] ?? '');
        $productSeoTitleTpl = trim($_POST['productSeoTitleTemplate'] ?? '');
        $productSeoDescTpl = trim($_POST['productSeoDescriptionTemplate'] ?? '');
        $productImageCheckEnabled = !empty($_POST['productImageCheckEnabled']);
        $aiAutoProductExtraOnFirstView = !empty($_POST['aiAutoProductExtraOnFirstView']);
        $aiAutoCategoryTextOnFirstView = !empty($_POST['aiAutoCategoryTextOnFirstView']);

        if ($siteName !== '') {
            $config['siteName'] = $siteName;
        }
        if ($baseUrl !== '') {
            $config['baseUrl'] = $baseUrl;
        }
        if ($defaultRegionName !== '') {
            $config['defaultRegionName'] = $defaultRegionName;
        }
        // favicon можно очистить, поэтому сохраняем как есть (включая пустую строку)
        $config['faviconUrl'] = $faviconUrl;
        if ($phone !== '') {
            $config['phone'] = $phone;
        }
        if ($headerCallbackText !== '') {
            $config['headerCallbackText'] = $headerCallbackText;
        }
        $config['indexNowKey'] = $indexNowKey;
        $config['indexNowAutoImport'] = $indexNowAutoImport;
        $config['criticalCssInline'] = $criticalCssInline;
        $config['mainJsInline'] = $mainJsInline;
        $config['minifyHtml'] = $minifyHtml;
        $config['yandex']['oauthToken'] = $yOauth;
        $config['yandex']['userId'] = $yUserId;
        $config['yandex']['hostId'] = $yHostId;
        $config['yandex']['metrikaId'] = $yMetrikaId;
        $config['ai']['provider'] = in_array($aiProvider, ['gemini', 'openai'], true) ? $aiProvider : '';
        if ($aiApiKey !== '') {
            $config['ai']['apiKey'] = $aiApiKey;
        }
        $config['ai']['endpoint'] = $aiEndpoint;
        $config['ai']['model'] = $aiModel;
        $config['adminProductFields']['description'] = $adminProductFieldsDesc;
        $config['adminProductFields']['tags'] = $adminProductFieldsTags;
        $config['adminProductFields']['seoTitle'] = $adminProductFieldsSeoTitle;
        $config['adminProductFields']['seoDescription'] = $adminProductFieldsSeoDesc;
        if ($adminUser !== '') {
            $config['admin']['user'] = $adminUser;
        }
        if ($adminPass !== '') {
            $config['admin']['password'] = $adminPass;
        }
        if (!isset($config['contacts']) || !is_array($config['contacts'])) {
            $config['contacts'] = [];
        }
        if ($contactsPhone !== '') {
            $config['contacts']['phone'] = $contactsPhone;
        }
        if ($contactsEmail !== '') {
            $config['contacts']['email'] = $contactsEmail;
        }
        $config['requestsEmail'] = $requestsEmail;
        if ($contactsAddress !== '') {
            $config['contacts']['address'] = $contactsAddress;
        }
        if ($contactsRequisites !== '') {
            $config['contacts']['requisitesHtml'] = $contactsRequisites;
        }
        $config['contacts']['mapEmbed'] = $contactsMap;
        if (!isset($config['stockLocation'])) {
            $config['stockLocation'] = 'Москва';
        }
        if (!empty($_POST['stockLocation'])) {
            $config['stockLocation'] = trim((string)$_POST['stockLocation']);
        }
        if ($deliveryHtml !== '') {
            $config['deliveryHtml'] = $deliveryHtml;
        }
        if (!isset($config['import']) || !is_array($config['import'])) {
            $config['import'] = [];
        }
        if ($importPriceMultiplier !== null && $importPriceMultiplier > 0) {
            $config['import']['priceMultiplier'] = $importPriceMultiplier;
        }
        if ($importSkuColumn !== '') {
            $config['import']['skuColumn'] = $importSkuColumn;
        }
        if ($importNameColumn !== '') {
            $config['import']['nameColumn'] = $importNameColumn;
        }
        if ($importPriceColumn !== '') {
            $config['import']['priceColumn'] = $importPriceColumn;
        }
        if ($importCategoryColumn !== '') {
            $config['import']['categoryColumn'] = $importCategoryColumn;
        }
        if ($importTagsColumn !== '') {
            $config['import']['tagsColumn'] = $importTagsColumn;
        }
        if ($importDescriptionColumn !== '') {
            $config['import']['descriptionColumn'] = $importDescriptionColumn;
        }
        $config['import']['nameTemplate'] = $importNameTemplate;
        $config['import']['descriptionTemplate'] = $importDescriptionTemplate;
        if ($importImageUrlColumn !== '') {
            $config['import']['imageUrlColumn'] = $importImageUrlColumn;
        }
        if ($importImageLocalColumn !== '') {
            $config['import']['imageLocalColumn'] = $importImageLocalColumn;
        }
        if ($importSpecsColumn !== '') {
            $config['import']['specsColumn'] = $importSpecsColumn;
        }
        $config['import']['specsToTable'] = $importSpecsToTable;
        if ($importCatsListCol !== '') {
            $config['import']['categoriesListColumn'] = $importCatsListCol;
        }
        $config['import']['categoriesListDelimiter'] = $importCatsListDelimiter !== '' ? $importCatsListDelimiter : '|';
        $config['import']['fixPriceText'] = $importFixPriceText;
        $config['import']['seoTitleTemplate'] = $importSeoTitleTpl;
        $config['import']['seoDescriptionTemplate'] = $importSeoDescTpl;

        // Перевод при импорте
        if (!isset($config['importTranslate']) || !is_array($config['importTranslate'])) {
            $config['importTranslate'] = [];
        }
        $config['importTranslate']['enabled'] = $importTranslateEnabled;
        $config['importTranslate']['useAi'] = $importTranslateUseAi;
        if ($importTranslateApiKey !== '') {
            $config['importTranslate']['googleApiKey'] = $importTranslateApiKey;
        }
        if ($importTranslateSource !== '') {
            $config['importTranslate']['sourceLang'] = $importTranslateSource;
        }
        if ($importTranslateTarget !== '') {
            $config['importTranslate']['targetLang'] = $importTranslateTarget;
        }
        $config['importTranslate']['proxies'] = $importTranslateProxies;
        $config['importTranslate']['translateName'] = $importTranslateName;
        $config['importTranslate']['translateDescription'] = $importTranslateDescription;
        $config['importTranslate']['translateCategory'] = $importTranslateCategory;
        $config['importTranslate']['translateTags'] = $importTranslateTags;
        $config['importTranslate']['translateSeoTitle'] = $importTranslateSeoTitle;
        $config['importTranslate']['translateSeoDescription'] = $importTranslateSeoDescription;

        if (!isset($config['cronImportAuth']) || !is_array($config['cronImportAuth'])) {
            $config['cronImportAuth'] = [];
        }
        if ($cronUser !== '') {
            $config['cronImportAuth']['user'] = $cronUser;
        }
        // Пароль можно не менять — пустое значение не трогаем
        if ($cronPass !== '') {
            $config['cronImportAuth']['password'] = $cronPass;
        }
        if ($catalogPerPage !== null && $catalogPerPage > 0) {
            $config['catalogPerPage'] = $catalogPerPage;
        }
        if (in_array($catalogView, ['grid', 'table'], true)) {
            $config['catalogView'] = $catalogView;
        }
        if ($sidebarCategoriesTop !== null && $sidebarCategoriesTop >= 0) {
            $config['sidebarCategoriesTop'] = min(500, $sidebarCategoriesTop);
        }
        $config['productImageCheckEnabled'] = $productImageCheckEnabled;
        $config['aiAutoProductExtraOnFirstView'] = $aiAutoProductExtraOnFirstView;
        $config['aiAutoCategoryTextOnFirstView'] = $aiAutoCategoryTextOnFirstView;
        if ($homeSeoTitle !== '') {
            $config['homeSeoTitle'] = $homeSeoTitle;
        }
        if ($homeSeoDesc !== '') {
            $config['homeSeoDescription'] = $homeSeoDesc;
        }
        if ($categorySeoTitleTpl !== '') {
            $config['categorySeoTitleTemplate'] = $categorySeoTitleTpl;
        }
        if ($categorySeoDescTpl !== '') {
            $config['categorySeoDescriptionTemplate'] = $categorySeoDescTpl;
        }
        if ($tagSeoTitleTpl !== '') {
            $config['tagSeoTitleTemplate'] = $tagSeoTitleTpl;
        }
        if ($tagSeoDescTpl !== '') {
            $config['tagSeoDescriptionTemplate'] = $tagSeoDescTpl;
        }
        $config['productSeoTitleTemplate'] = $productSeoTitleTpl;
        $config['productSeoDescriptionTemplate'] = $productSeoDescTpl;

        $regionsJson = trim((string)($_POST['regions_json'] ?? ''));
        if ($regionsJson !== '') {
            $decoded = json_decode($regionsJson, true);
            if (is_array($decoded)) {
                $config['regions'] = $decoded;
            }
        } else {
            $config['regions'] = [];
        }

        save_config($configFile, $config);
        // после изменения SEO‑шаблонов очищаем HTML‑кэш, чтобы применились новые тайтлы/description
        cache_clear_all();
        // перечитаем конфиг на всякий случай
        $config = load_config($configFile);

        header('Location: /admin/settings');
        exit;
    }
}

// ---------- Публичные маршруты ----------

if ($uri === '/') {
    $homeRegionVar = ['{region}' => (string)($config['regionName'] ?? '')];
    $homeTitle = strtr((string)($config['homeSeoTitle'] ?? 'Главная'), $homeRegionVar);
    $homeDesc = strtr((string)($config['homeSeoDescription'] ?? 'Мини‑каталог товаров.'), $homeRegionVar);
    $seo = build_seo($config, [
        'title' => $homeTitle,
        'description' => $homeDesc,
        'urlPath' => '/',
    ]);
    $productsShort = sanitize_products_for_output(db_get_products_for_index(12));
    $topCategories = array_slice(db_get_categories(), 0, 20);
    ob_start();
    include $tplDir . DIRECTORY_SEPARATOR . 'index.php';
    $html = ob_get_clean();
    cache_store($uri, $html, $cacheHostHint);
    echo $html;
    exit;
}

// Каталог: /catalog или /catalog/{categorySlug} (ЧПУ)
$catalogMatch = preg_match('~^/catalog(?:/([^/]+))?$~', $uri, $catalogM);
if ($catalogMatch) {
    $categorySlugFromPath = isset($catalogM[1]) ? rawurldecode(trim($catalogM[1])) : '';
    $categoryParam = trim($_GET['category'] ?? '');

    // Редирект со старого URL на ЧПУ: /catalog?category=slug -> /catalog/slug
    if ($categorySlugFromPath === '' && $categoryParam !== '' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'GET' && ($_SERVER['QUERY_STRING'] ?? '') !== '') {
        $allCats = db_get_categories();
        $resolved = null;
        if (in_array($categoryParam, $allCats, true)) {
            $resolved = $categoryParam;
        } else {
            foreach ($allCats as $c) {
                if (slugify_ru($c) === $categoryParam) {
                    $resolved = $c;
                    break;
                }
            }
        }
        if ($resolved !== null) {
            $target = '/catalog/' . rawurlencode(slugify_ru($resolved));
            $q = trim($_GET['q'] ?? '');
            if ($q !== '') {
                $target .= '?q=' . rawurlencode($q);
            }
            $page = (int)($_GET['page'] ?? 1);
            if ($page > 1) {
                $target .= (strpos($target, '?') !== false ? '&' : '?') . 'page=' . $page;
            }
            header('Location: ' . $target, true, 301);
            exit;
        }
    }

    $qRaw = trim((string)($_GET['q'] ?? ''));
    $q = mb_strtolower($qRaw, 'UTF-8');
    $pageNum = (int)($_GET['page'] ?? 1);
    if ($pageNum < 1) {
        $pageNum = 1;
    }
    $perPage = (int)($config['catalogPerPage'] ?? 50);
    if ($perPage < 10) {
        $perPage = 10;
    } elseif ($perPage > 200) {
        $perPage = 200;
    }

    $allCategories = db_get_categories_cached(3600);
    $categoryName = '';
    if ($categorySlugFromPath !== '') {
        $categoryName = db_find_category_by_slug($categorySlugFromPath);
        if ($categoryName === '') {
            http_response_code(404);
            $seo = build_seo($config, ['title' => 'Категория не найдена', 'urlPath' => $uri]);
            ob_start();
            include $tplDir . DIRECTORY_SEPARATOR . '404.php';
            $html = ob_get_clean();
            cache_store($uri, $html, $cacheHostHint, 404);
            echo $html;
            exit;
        }
    } elseif ($categoryParam !== '') {
        $categoryName = db_find_category_by_slug($categoryParam);
        if ($categoryName === '' && in_array($categoryParam, $allCategories, true)) {
            $categoryName = $categoryParam;
        }
    }

    // Логируем поисковый запрос и, при наличии статической страницы /q/{slug}, делаем 301 на неё
    if ($q !== '') {
        $queries = load_search_queries();
        $norm = $q;
        if (!isset($queries[$norm]) || !is_array($queries[$norm])) {
            // генерируем slug на основе текста запроса
            $baseSlug = slugify_ru($qRaw !== '' ? $qRaw : $q);
            if ($baseSlug === '') {
                $baseSlug = 'q';
            }
            $slug = $baseSlug;
            $i = 1;
            while (find_search_query_by_slug($slug) !== null) {
                $slug = $baseSlug . '-' . $i;
                $i++;
            }
            $queries[$norm] = [
                'q' => $qRaw,
                'slug' => $slug,
                'count' => 1,
                'static' => false,
                'createdAt' => date('c'),
                'lastAt' => date('c'),
            ];
        } else {
            $queries[$norm]['count'] = (int)($queries[$norm]['count'] ?? 0) + 1;
            $queries[$norm]['lastAt'] = date('c');
        }
        save_search_queries($queries);

        // Если для запроса уже есть статическая страница — редирект на /q/{slug}
        $current = $queries[$norm];
        if (!empty($current['static']) && !empty($current['slug'])) {
            $target = '/q/' . rawurlencode((string)$current['slug']);
            $page = (int)($_GET['page'] ?? 1);
            if ($page > 1) {
                $target .= '?page=' . $page;
            }
            header('Location: ' . $target, true, 301);
            exit;
        }
    }

    // При вводе поискового запроса ищем по всем товарам БД; без запроса — в рамках текущей категории (если открыта)
    $searchCategory = ($q !== '') ? null : ($categoryName ?: null);
    $total = db_count_products_cached($searchCategory, $q, 600);
    // Для поиска во фронтенде ограничиваем максимум 100 результатов, чтобы не гонять тысячи товаров и страниц
    if ($q !== '' && $total > 100) {
        $total = 100;
    }
    $pagesTotal = max(1, (int)ceil($total / $perPage));
    if ($pageNum > $pagesTotal) {
        $pageNum = $pagesTotal;
    }
    $offset = ($pageNum - 1) * $perPage;

    $filtered = db_get_catalog_products($searchCategory, $q, $perPage, $offset);
    if (empty($filtered) && $total > 0) {
        $filtered = db_get_catalog_products($searchCategory, $q, $perPage, 0);
    }
    $products = sanitize_products_for_output($filtered);
    $sidebarTop = (int)($config['sidebarCategoriesTop'] ?? 0);
    // Категории и теги для сайдбара берём из кэша, чтобы не грузить БД при каждом просмотре категории
    $categories = $sidebarTop > 0 ? db_get_categories_by_product_count_cached($sidebarTop, 3600) : db_get_categories_cached(3600);
    $tagsForSidebar = array_slice(db_get_all_tags_cached(5000, 86400), 0, 100);

    $categoriesMeta = load_categories_meta($categoriesMetaFile);
    $catTitleTpl = $config['categorySeoTitleTemplate'] ?? '';
    $catDescTpl = $config['categorySeoDescriptionTemplate'] ?? '';
    $categoryMetaText = '';

    // Автогенерация текста категории при первом открытии:
    // только для индексируемых страниц (есть товары, без поиска, 1-я страница).
    $canAutoGenerateCategoryText = !empty($config['aiAutoCategoryTextOnFirstView'])
        && $categoryName !== ''
        && $q === ''
        && $pageNum === 1
        && $total >= 5;
    if ($canAutoGenerateCategoryText) {
        $existingMeta = isset($categoriesMeta[$categoryName]) && is_array($categoriesMeta[$categoryName]) ? $categoriesMeta[$categoryName] : [];
        $existingText = trim((string)($existingMeta['textHtml'] ?? ''));
        if ($existingText === '') {
            try {
                $productsContext = db_get_category_products_for_ai($categoryName, 20);
                $generated = ai_generate_category_texts($config, $categoryName, $productsContext);
                if (!empty($generated['textHtml'])) {
                    if (!isset($categoriesMeta[$categoryName]) || !is_array($categoriesMeta[$categoryName])) {
                        $categoriesMeta[$categoryName] = [];
                    }
                    // Заполняем только пустые SEO-поля, чтобы не перетирать ручные настройки.
                    if (empty($categoriesMeta[$categoryName]['seoTitle']) && !empty($generated['seoTitle'])) {
                        $categoriesMeta[$categoryName]['seoTitle'] = (string)$generated['seoTitle'];
                    }
                    if (empty($categoriesMeta[$categoryName]['seoDescription']) && !empty($generated['seoDescription'])) {
                        $categoriesMeta[$categoryName]['seoDescription'] = (string)$generated['seoDescription'];
                    }
                    $categoriesMeta[$categoryName]['textHtml'] = (string)$generated['textHtml'];
                    save_categories_meta($categoriesMetaFile, $categoriesMeta);
                }
            } catch (Throwable $e) {
                // Ошибка ИИ не должна ломать загрузку страницы категории.
            }
        }
    }

    if ($categoryName !== '') {
        $vars = [
            '{category}' => (string)$categoryName,
            '{region}'   => (string)($config['regionName'] ?? ''),
        ];
        if (isset($categoriesMeta[$categoryName])) {
            $meta = $categoriesMeta[$categoryName];
            $title = isset($meta['seoTitle']) && $meta['seoTitle'] !== '' ? strtr($meta['seoTitle'], $vars) : ($catTitleTpl ? strtr($catTitleTpl, $vars) : ('Каталог: ' . $categoryName));
            $desc = isset($meta['seoDescription']) && $meta['seoDescription'] !== '' ? strtr($meta['seoDescription'], $vars) : ($catDescTpl ? strtr($catDescTpl, $vars) : ('Каталог товаров в категории ' . $categoryName . '.'));
            $categoryMetaText = isset($meta['textHtml']) && $meta['textHtml'] !== '' ? strtr($meta['textHtml'], $vars) : '';
        } else {
            $title = $catTitleTpl ? strtr($catTitleTpl, $vars) : ('Каталог: ' . $categoryName);
            $desc = $catDescTpl ? strtr($catDescTpl, $vars) : ('Каталог товаров в категории ' . $categoryName . '.');
        }
    } else {
        $title = 'Каталог';
        $desc = 'Каталог товаров.';
    }

    if ($pageNum > 1) {
        $title .= ' — Страница ' . $pageNum;
        $desc .= ' Страница ' . $pageNum . '.';
    }

    $currentCategory = $categoryName;
    $currentCategorySlug = $categoryName !== '' ? slugify_ru($categoryName) : '';
    $catalogUrlPath = $currentCategorySlug !== '' ? '/catalog/' . $currentCategorySlug : '/catalog';
    // canonical для страницы категории — всегда URL категории, а не общий каталог
    $seoUrlPath = ($uri !== '/catalog' && strpos($uri, '/catalog/') === 0) ? $uri : $catalogUrlPath;

    $seo = build_seo($config, [
        'title' => $title,
        'description' => $desc,
        'urlPath' => $seoUrlPath,
    ]);
    if ($pageNum > 1) {
        $seo['robots'] = 'noindex,follow';
    }
    // Тонкие страницы каталога (мало карточек) в индекс не отдаем.
    if ($total < 5) {
        $seo['robots'] = 'noindex,follow';
    }

    ob_start();
    include $tplDir . DIRECTORY_SEPARATOR . 'catalog.php';
    $html = ob_get_clean();
    cache_store($uri, $html, $cacheHostHint);
    echo $html;
    exit;
}

// Подборки по тегу: /tag/{tagSlug}
if (preg_match('~^/tag/([^/]+)$~', $uri, $tagM)) {
    $tagSlugFromPath = trim($tagM[1]);
    $allTags = db_get_all_tags();
    $tagName = '';
    foreach ($allTags as $t) {
        if (slugify_ru($t) === $tagSlugFromPath) {
            $tagName = $t;
            break;
        }
    }
    if ($tagName === '') {
        http_response_code(404);
        $seo = build_seo($config, ['title' => 'Тег не найден', 'urlPath' => $uri]);
        ob_start();
        include $tplDir . DIRECTORY_SEPARATOR . '404.php';
        $html = ob_get_clean();
        cache_store($uri, $html, $cacheHostHint, 404);
        echo $html;
        exit;
    }

    $q = mb_strtolower(trim((string)($_GET['q'] ?? '')), 'UTF-8');
    $pageNum = max(1, (int)($_GET['page'] ?? 1));
    $perPage = (int)($config['catalogPerPage'] ?? 50);
    $perPage = max(10, min(200, $perPage));

    $total = db_count_products_by_tag($tagName, $q);
    // Для поиска по тегу во фронтенде также ограничиваем максимум 100 результатов
    if ($q !== '' && $total > 100) {
        $total = 100;
    }
    $pagesTotal = max(1, (int)ceil($total / $perPage));
    if ($pageNum > $pagesTotal) {
        $pageNum = $pagesTotal;
    }
    $offset = ($pageNum - 1) * $perPage;
    $products = sanitize_products_for_output(db_get_products_by_tag($tagName, $q, $perPage, $offset));
    if (empty($products) && $total > 0) {
        $products = sanitize_products_for_output(db_get_products_by_tag($tagName, $q, $perPage, 0));
    }

    $tagTitleTpl = $config['tagSeoTitleTemplate'] ?? '';
    $tagDescTpl = $config['tagSeoDescriptionTemplate'] ?? '';
    $vars = [
        '{tag}'    => (string)$tagName,
        '{region}' => (string)($config['regionName'] ?? ''),
    ];
    $title = $tagTitleTpl ? strtr($tagTitleTpl, $vars) : ('Тег: ' . $tagName);
    $desc = $tagDescTpl ? strtr($tagDescTpl, $vars) : ('Товары по тегу ' . $tagName . '.');
    if ($pageNum > 1) {
        $title .= ' — Страница ' . $pageNum;
        $desc .= ' Страница ' . $pageNum . '.';
    }

    $currentCategory = '';
    $currentCategorySlug = '';
    $currentTag = $tagName;
    $currentTagSlug = $tagSlugFromPath;
    $categories = db_get_categories_cached(3600);
    $tagsForSidebar = array_slice(db_get_all_tags_cached(50000, 86400), 0, 100);
    $categoryMetaText = '';

    // canonical — всегда URL страницы тега (на пагинации — без ?page=, т.к. $uri только путь)
    $seo = build_seo($config, [
        'title' => $title,
        'description' => $desc,
        'urlPath' => $uri,
    ]);
    if ($pageNum > 1) {
        $seo['robots'] = 'noindex,follow';
    }
    // Тонкие тег-страницы в индекс не отдаем.
    if ($total < 5) {
        $seo['robots'] = 'noindex,follow';
    }

    ob_start();
    include $tplDir . DIRECTORY_SEPARATOR . 'catalog.php';
    $html = ob_get_clean();
    cache_store($uri, $html, $cacheHostHint);
    echo $html;
    exit;
}

// Статические поисковые страницы: /q/{slug}
if (preg_match('~^/q/([^/]+)$~', $uri, $qM)) {
    $slug = urldecode($qM[1]);
    $sq = find_search_query_by_slug($slug);
    if ($sq === null) {
        http_response_code(404);
        $seo = build_seo($config, ['title' => 'Страница не найдена', 'urlPath' => $uri]);
        ob_start();
        include $tplDir . DIRECTORY_SEPARATOR . '404.php';
        $html = ob_get_clean();
        cache_store($uri, $html, $cacheHostHint, 404);
        echo $html;
        exit;
    }
    $qRaw = (string)($sq['q'] ?? '');
    $q = mb_strtolower($qRaw, 'UTF-8');
    $pageNum = max(1, (int)($_GET['page'] ?? 1));
    $perPage = (int)($config['catalogPerPage'] ?? 50);
    $perPage = max(10, min(200, $perPage));

    // Для статической поисковой страницы всегда ищем по всем товарам
    $total = db_count_products(null, $q);
    if ($total > 100) {
        $total = 100;
    }
    $pagesTotal = max(1, (int)ceil($total / $perPage));
    if ($pageNum > $pagesTotal) {
        $pageNum = $pagesTotal;
    }
    $offset = ($pageNum - 1) * $perPage;

    $products = sanitize_products_for_output(db_get_catalog_products(null, $q, $perPage, $offset));
    if (empty($products) && $total > 0) {
        $products = sanitize_products_for_output(db_get_catalog_products(null, $q, $perPage, 0));
    }

    $sidebarTop = (int)($config['sidebarCategoriesTop'] ?? 0);
    $categories = $sidebarTop > 0 ? db_get_categories_by_product_count_cached($sidebarTop, 3600) : db_get_categories_cached(3600);
    $tagsForSidebar = array_slice(db_get_all_tags_cached(5000, 86400), 0, 100);
    $searchQueriesSidebar = get_static_search_queries_for_sidebar(10);

    // SEO для статических поисковых страниц строим по тем же шаблонам, что и для категорий,
    // только вместо названия категории подставляем текст запроса
    $catTitleTpl = $config['categorySeoTitleTemplate'] ?? '';
    $catDescTpl = $config['categorySeoDescriptionTemplate'] ?? '';
    $vars = [
        '{category}' => (string)($qRaw !== '' ? $qRaw : $slug),
        '{region}'   => (string)($config['regionName'] ?? ''),
    ];
    $title = $catTitleTpl ? strtr($catTitleTpl, $vars) : ('Каталог: ' . $vars['{category}']);
    $desc = $catDescTpl ? strtr($catDescTpl, $vars) : ('Каталог товаров в категории ' . $vars['{category}'] . '.');
    if ($pageNum > 1) {
        $title .= ' — Страница ' . $pageNum;
        $desc .= ' Страница ' . $pageNum . '.';
    }

    $currentCategory = '';
    $currentCategorySlug = '';
    $currentTag = '';
    $currentTagSlug = '';
    $categoryMetaText = '';

    $seo = build_seo($config, [
        'title' => $title,
        'description' => $desc,
        'urlPath' => $uri,
    ]);
    if ($pageNum > 1) {
        $seo['robots'] = 'noindex,follow';
    }
    // Статические поисковые страницы часто тонкие — не индексируем малополезные.
    if ($total < 5) {
        $seo['robots'] = 'noindex,follow';
    }

    ob_start();
    include $tplDir . DIRECTORY_SEPARATOR . 'catalog.php';
    $html = ob_get_clean();
    cache_store($uri, $html, $cacheHostHint);
    echo $html;
    exit;
}

if (preg_match('~^/product/([^/]+)$~', $uri, $m)) {
    $slug = urldecode($m[1]);
    $product = db_get_product_by_slug($slug);
    if (!$product) {
        http_response_code(404);
        $seo = build_seo($config, [
            'title' => 'Страница не найдена',
            'urlPath' => $uri,
        ]);
        ob_start();
        include $tplDir . DIRECTORY_SEPARATOR . '404.php';
        $html = ob_get_clean();
        cache_store($uri, $html, $cacheHostHint, 404);
        echo $html;
        exit;
    }
    // При включенной опции и пустом AI-блоке — генерируем при первом открытии карточки и сохраняем в БД.
    if (!empty($config['aiAutoProductExtraOnFirstView']) && trim((string)($product['aiExtraText'] ?? '')) === '') {
        try {
            $generated = ai_generate_product_extra_block($config, $product);
            if ($generated !== '') {
                db_update_product_ai_extra_text((int)($product['id'] ?? 0), $generated);
                $product['aiExtraText'] = $generated;
            }
        } catch (Throwable $e) {
            // Ошибку генерации не показываем пользователю; карточка должна открываться всегда.
        }
    }

    $product = sanitize_product_for_output($product);
    // SEO для товара: из полей товара или из базового шаблона (как у категорий/тегов)
    $vars = [
        '{name}'     => (string)($product['name'] ?? ''),
        '{price}'    => get_display_price((string)($product['price'] ?? ''), $currentRegion),
        '{category}' => (string)($product['category'] ?? ''),
        '{region}'   => (string)($config['regionName'] ?? ''),
    ];
    $seoTitle = trim((string)($product['seoTitle'] ?? ''));
    if ($seoTitle === '') {
        $seoTitleTpl = (string)($config['productSeoTitleTemplate'] ?? '');
        $seoTitle = $seoTitleTpl !== '' ? strtr($seoTitleTpl, $vars) : (string)($product['name'] ?? '');
    }

    $seoDesc = trim((string)($product['seoDescription'] ?? ''));
    if ($seoDesc === '') {
        $seoDescTpl = (string)($config['productSeoDescriptionTemplate'] ?? '');
        if ($seoDescTpl !== '') {
            $seoDesc = strtr($seoDescTpl, $vars);
        } else {
            $seoDesc = (string)($product['description'] ?? '');
        }
    }

    // При включённой опции проверяем, существует ли локальное изображение; если нет — один раз очищаем его в БД,
    // чтобы в дальнейшем всегда показывать плейсхолдер.
    $imgLocal = (string)($product['imageLocal'] ?? '');
    if (!empty($config['productImageCheckEnabled']) && $imgLocal !== '') {
        global $imagesDir;
        $path = $imagesDir . DIRECTORY_SEPARATOR . ltrim($imgLocal, '/');
        if (!file_exists($path)) {
            db_clear_product_image((int)($product['id'] ?? 0));
            $product['imageLocal'] = '';
            $imgLocal = '';
        }
    }

    $seo = build_seo($config, [
        'title' => $seoTitle,
        'description' => $seoDesc,
        'urlPath' => $uri,
    ]);
    $base = rtrim($config['baseUrl'], '/');
    $seo['ogImage'] = $imgLocal !== '' ? ($base . '/public/images/' . $imgLocal) : '';
    $related = sanitize_products_for_output(db_get_related_products($product['category'] ?? null, $product['categoriesExtra'] ?? null, (int)($product['id'] ?? 0), 4));
    $preloadImageUrl = $imgLocal !== '' ? '/public/images/' . $imgLocal : '/public/images/placeholder.svg';
    ob_start();
    include $tplDir . DIRECTORY_SEPARATOR . 'product.php';
    $html = ob_get_clean();
    cache_store($uri, $html, $cacheHostHint);
    echo $html;
    exit;
}

// Страницы по slug
if ($uri !== '/') {
    $slug = ltrim($uri, '/');
    $page = null;
    foreach ($pages as $p) {
        if (($p['slug'] ?? '') === $slug) {
            $page = $p;
            break;
        }
    }
    if ($page) {
        $desc = '';
        $slugLower = $page['slug'] ?? '';
        if ($slugLower === 'contacts') {
            $desc = 'Контакты компании: телефон, e‑mail, адрес офиса и реквизиты. Свяжитесь с нами для подбора и заказа электронных комплектующих.';
        } elseif ($slugLower === 'delivery') {
            $desc = 'Условия доставки и оплаты: сроки поставки, транспортные компании, страхование и гарантийное обслуживание при оптовых поставках электронных комплектующих.';
        } elseif ($slugLower === 'privacy-policy') {
            $desc = 'Политика конфиденциальности: как мы обрабатываем персональные данные посетителей и клиентов сайта.';
        }
        $seo = build_seo($config, [
            'title' => $page['title'] ?? '',
            'description' => $desc,
            'urlPath' => $uri,
        ]);
        if ($slugLower === 'privacy-policy') {
            $seo['robots'] = 'noindex,nofollow';
        }
        ob_start();
        include $tplDir . DIRECTORY_SEPARATOR . 'page.php';
        $html = ob_get_clean();
        cache_store($uri, $html, $cacheHostHint);
        echo $html;
        exit;
    }
}

// ---------- Админка ----------

if ($uri === '/admin') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Админка',
        'urlPath' => '/admin',
    ]);
    $productsCount = db_count_all_products();
    $cacheCleared = isset($_GET['cache_cleared']) ? (int)$_GET['cache_cleared'] : 0;
    $productsCleared = isset($_GET['products_cleared']) ? (int)$_GET['products_cleared'] : 0;
    $productsClearedToday = isset($_GET['products_cleared_today']) ? (int)$_GET['products_cleared_today'] : 0;
    $productsClearedHour = isset($_GET['products_cleared_hour']) ? (int)$_GET['products_cleared_hour'] : 0;
    $searchIndexRebuilt = isset($_GET['search_index_rebuilt']) ? (int)$_GET['search_index_rebuilt'] : null;
    $searchIndexRebuiltError = isset($_GET['error']) ? (string)$_GET['error'] : '';
    $merchantFeedMeta = merchant_feed_load_meta();
    $merchantFeedUrl = merchant_feed_public_url($config);
    $merchantFeedOk = isset($_GET['merchant_feed_ok']) ? (int)$_GET['merchant_feed_ok'] : null;
    $merchantFeedItems = isset($_GET['merchant_feed_items']) ? (int)$_GET['merchant_feed_items'] : 0;
    $merchantFeedSkipped = isset($_GET['merchant_feed_skipped']) ? (int)$_GET['merchant_feed_skipped'] : 0;
    $merchantFeedError = isset($_GET['merchant_feed_error']) ? (string)$_GET['merchant_feed_error'] : '';
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_index.php';
    exit;
}

if ($uri === '/admin/indexing' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Индексация',
        'urlPath' => '/admin/indexing',
    ]);
    $indexingBase = rtrim($config['canonicalBase'] ?? $config['baseUrl'] ?? '', '/');
    $indexingBases = get_indexnow_bases($config);
    $cacheWarmupState = read_json($cacheWarmupCronStateFile, []);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_indexing.php';
    exit;
}

if ($uri === '/admin/bots-stats') {
    require_admin($config);
    $stats = read_json($botsStatsFile, []);
    // Ограничим вывод последними 60 днями
    krsort($stats);
    $statsLimited = array_slice($stats, 0, 60, true);
    $seo = build_seo($config, [
        'title' => 'Статистика ботов и людей',
        'urlPath' => '/admin/bots-stats',
    ]);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_bots_stats.php';
    exit;
}

if ($uri === '/admin/search-queries') {
    require_admin($config);
    $message = '';
    $queries = load_search_queries();
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $posted = $_POST['queries'] ?? [];
        if (is_array($posted)) {
            foreach ($queries as $norm => &$row) {
                if (!is_array($row)) {
                    continue;
                }
                $rowData = $posted[$norm] ?? [];
                $slug = isset($rowData['slug']) ? trim((string)$rowData['slug']) : (string)($row['slug'] ?? '');
                if ($slug === '') {
                    $slug = slugify_ru((string)($row['q'] ?? ''));
                    if ($slug === '') {
                        $slug = 'q';
                    }
                }
                $row['slug'] = $slug;
                $row['static'] = !empty($rowData['static']);
            }
            unset($row);
            save_search_queries($queries);
            $message = 'Изменения сохранены.';
        }
    }
    // Сортируем по количеству запросов по убыванию
    uasort($queries, function ($a, $b) {
        $ca = (int)($a['count'] ?? 0);
        $cb = (int)($b['count'] ?? 0);
        return $cb <=> $ca;
    });
    $seo = build_seo($config, [
        'title' => 'Поисковые запросы',
        'urlPath' => '/admin/search-queries',
    ]);
    $searchQueries = $queries;
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_search_queries.php';
    exit;
}

// ---------- Индексация экспериментальная ----------
global $experimentalIndexingFile;

if ($uri === '/admin/indexing-experimental' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Индексация экспериментальная',
        'urlPath' => '/admin/indexing-experimental',
    ]);
    $expData = experimental_indexing_load($experimentalIndexingFile);
    experimental_indexing_write_htaccess_exclude($expData['domains'] ?? [], $expData['domain'] ?? '');
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_indexing_experimental.php';
    exit;
}

if ($uri === '/admin/indexing-experimental' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    require_admin($config);
    $expData = experimental_indexing_load($experimentalIndexingFile);
    $expData['domain'] = trim((string)($_POST['exp_domain'] ?? ''));
    $expData['yandexToken'] = trim((string)($_POST['exp_yandex_token'] ?? ''));
    $expData['redirectType'] = (isset($_POST['exp_redirect_type']) && $_POST['exp_redirect_type'] === '301') ? '301' : '302';
    $expData['yandexXmlUrl'] = trim((string)($_POST['exp_yandex_xml_url'] ?? ''));
    $domainsRaw = trim((string)($_POST['exp_domains'] ?? ''));
    $expData['domains'] = array_values(array_unique(array_filter(array_map('trim', preg_split('/\r?\n/', $domainsRaw, -1, PREG_SPLIT_NO_EMPTY)))));
    if (!isset($expData['hostIds'])) {
        $expData['hostIds'] = [];
    }
    if (!isset($expData['usedQlinks'])) {
        $expData['usedQlinks'] = [];
    }
    if (!isset($expData['redirectStats'])) {
        $expData['redirectStats'] = [];
    }
    if (!isset($expData['sentRecrawl'])) {
        $expData['sentRecrawl'] = [];
    }
    if (!isset($expData['sentTargets'])) {
        $expData['sentTargets'] = [];
    }
    experimental_indexing_save($experimentalIndexingFile, $expData);
    experimental_indexing_write_htaccess_exclude($expData['domains'] ?? [], $expData['domain'] ?? '');
    header('Location: /admin/indexing-experimental?saved=1');
    exit;
}

if ($uri === '/admin/indexing-experimental-sync' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    require_admin($config);
    header('Content-Type: application/json; charset=utf-8');
    $expData = experimental_indexing_load($experimentalIndexingFile);
    $token = $expData['yandexToken'] ?? '';
    $domains = $expData['domains'] ?? [];
    if ($token === '' || empty($domains)) {
        echo json_encode(['ok' => false, 'error' => 'Укажите токен и список доменов.'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $userId = yandex_get_user_id($token);
    if ($userId === null) {
        echo json_encode(['ok' => false, 'error' => 'Не удалось получить user_id по токену.'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $hosts = yandex_get_hosts($token, $userId);
    $hostIds = [];
    $quotas = [];
    $sumQuota = 0;
    foreach ($domains as $d) {
        $dLower = strtolower($d);
        foreach ($hosts as $h) {
            $url = $h['url'] ?? '';
            $hostFromUrl = strtolower((string)parse_url($url, PHP_URL_HOST));
            if ($hostFromUrl === $dLower) {
                $hostIds[$d] = $h['host_id'];
                $quota = yandex_get_recrawl_quota($token, $userId, $h['host_id']);
                $rem = $quota['quota_remainder'] ?? 0;
                $quotas[$d] = $rem;
                $sumQuota += $rem;
                break;
            }
        }
    }
    $expData['hostIds'] = $hostIds;
    $expData['lastSumQuota'] = $sumQuota;
    $expData['lastQuotas'] = $quotas ?? [];
    experimental_indexing_save($experimentalIndexingFile, $expData);
    echo json_encode(['ok' => true, 'sumQuota' => $sumQuota, 'quotas' => $quotas, 'hostIds' => array_keys($hostIds)], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($uri === '/admin/indexing-experimental-send' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    require_admin($config);
    header('Content-Type: application/json; charset=utf-8');
    set_time_limit(120);
    $raw = file_get_contents('php://input');
    $data = json_decode((string)$raw, true);
    $urls = isset($data['urls']) && is_array($data['urls']) ? array_values(array_filter(array_map('trim', $data['urls']))) : [];
    $urls = array_values(array_unique($urls));
    $expData = experimental_indexing_load($experimentalIndexingFile);
    $token = $expData['yandexToken'] ?? '';
    $hostIds = $expData['hostIds'] ?? [];
    $domains = $expData['domains'] ?? [];
    $sentRecrawl = $expData['sentRecrawl'] ?? [];
    $sentTargets = $expData['sentTargets'] ?? [];
    $today = date('Y-m-d');
    if ($token === '' || empty($domains) || empty($hostIds)) {
        echo json_encode(['ok' => false, 'error' => 'Сохраните настройки и нажмите «Синхронизировать с Вебмастером».'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $userId = yandex_get_user_id($token);
    if ($userId === null) {
        echo json_encode(['ok' => false, 'error' => 'Ошибка токена.'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $log = [];
    $sent = 0;
    foreach ($urls as $targetUrl) {
        if ($targetUrl === '' || !preg_match('/^https?:\/\//i', $targetUrl)) {
            $log[] = ['url' => $targetUrl, 'status' => 'skip', 'msg' => 'Неверный URL'];
            continue;
        }
        $picked = null;
        foreach ($domains as $d) {
            $hostId = $hostIds[$d] ?? null;
            if ($hostId === null) {
                continue;
            }
            $sentToday = isset($sentRecrawl[$d][$today]) ? count($sentRecrawl[$d][$today]) : 0;
            $quota = yandex_get_recrawl_quota($token, $userId, $hostId);
            $remainder = $quota['quota_remainder'] ?? 0;
            if ($remainder > $sentToday) {
                $picked = ['domain' => $d, 'hostId' => $hostId];
                break;
            }
        }
        if ($picked === null) {
            $log[] = ['url' => $targetUrl, 'status' => 'error', 'msg' => 'Нет квоты'];
            continue;
        }
        $qlinkUrl = 'https://' . $picked['domain'] . '/?qlink=' . rawurlencode($targetUrl);
        $res = yandex_recrawl_submit_url($token, $userId, $picked['hostId'], $qlinkUrl);
        if ($res['success']) {
            if (!isset($sentRecrawl[$picked['domain']])) {
                $sentRecrawl[$picked['domain']] = [];
            }
            if (!isset($sentRecrawl[$picked['domain']][$today])) {
                $sentRecrawl[$picked['domain']][$today] = [];
            }
            $sentRecrawl[$picked['domain']][$today][] = $qlinkUrl;
            $sentTargets[$targetUrl] = true;
            $sent++;
            $log[] = ['url' => $targetUrl, 'status' => 'ok', 'domain' => $picked['domain']];
        } else {
            $log[] = ['url' => $targetUrl, 'status' => 'error', 'msg' => $res['error'] ?? 'Ошибка API'];
        }
    }
    $expData['sentRecrawl'] = $sentRecrawl;
    $expData['sentTargets'] = $sentTargets;
    experimental_indexing_save($experimentalIndexingFile, $expData);
    echo json_encode(['ok' => true, 'sent' => $sent, 'log' => $log], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($uri === '/admin/indexing-experimental-check-xml' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    require_admin($config);
    header('Content-Type: application/json; charset=utf-8');
    $raw = file_get_contents('php://input');
    $data = json_decode((string)$raw, true);
    $xmlUrl = isset($data['xmlUrl']) ? trim((string)$data['xmlUrl']) : '';
    $expData = experimental_indexing_load($experimentalIndexingFile);
    if ($xmlUrl === '') {
        $xmlUrl = $expData['yandexXmlUrl'] ?? '';
    }
    if ($xmlUrl === '') {
        echo json_encode(['ok' => false, 'error' => 'Укажите URL XML.'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $ch = curl_init($xmlUrl);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 30]);
    $xmlBody = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($xmlBody === false || $code !== 200) {
        echo json_encode(['ok' => false, 'error' => 'Не удалось загрузить XML (HTTP ' . $code . ')'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $sentTargets = array_keys($expData['sentTargets'] ?? []);
    preg_match_all('/<url>\\s*<loc>([^<]+)<\\/loc>/iu', (string)$xmlBody, $m);
    $indexedUrls = isset($m[1]) ? array_map('trim', $m[1]) : [];
    $indexedNorm = array_flip(array_map(function ($u) {
        return rtrim(strtolower($u), '/');
    }, $indexedUrls));
    $found = [];
    foreach ($sentTargets as $u) {
        $norm = rtrim(strtolower($u), '/');
        if (isset($indexedNorm[$norm])) {
            $found[] = $u;
        }
    }
    echo json_encode([
        'ok' => true,
        'sentTotal' => count($sentTargets),
        'indexedInXml' => count($indexedUrls),
        'ourSentIndexed' => count($found),
        'found' => $found,
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($uri === '/admin/import' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Импорт товаров',
        'urlPath' => '/admin/import',
    ]);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_import.php';
    exit;
}

if ($uri === '/admin/indexnow' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'IndexNow — отправка URL',
        'urlPath' => '/admin/indexnow',
    ]);
    $indexNowUiLimit = 10000;
    $indexNowEntriesAll = build_indexnow_entries($config, $pages);
    $indexNowTotalAll = count($indexNowEntriesAll);
    $indexNowEntries = array_slice($indexNowEntriesAll, 0, $indexNowUiLimit);
    $indexnowCronState = read_json($indexnowCronStateFile, []);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_indexnow.php';
    exit;
}

if ($uri === '/admin/google-indexing' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Google Indexing API',
        'urlPath' => '/admin/google-indexing',
    ]);
    $googleAccounts = google_indexing_load_accounts();
    $googleSentToday = [];
    foreach ($googleAccounts as $acc) {
        $googleSentToday[$acc['client_email']] = google_indexing_sent_today($acc['client_email']);
    }
    $googleIndexingEntries = build_indexnow_entries($config, $pages);
    $googleIndexingUrlCount = count(array_unique(array_column($googleIndexingEntries, 'url')));
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_google_indexing.php';
    exit;
}

if ($uri === '/admin/google-indexing' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    require_admin($config);
    header('Content-Type: application/json; charset=utf-8');
    set_time_limit(300);
    $result = google_indexing_submit_batch($config, $pages);
    echo json_encode([
        'ok' => true,
        'sent' => $result['sent'],
        'errors' => $result['errors'],
        'by_account' => $result['by_account'],
        'message' => $result['message'],
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($uri === '/admin/yandex') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Яндекс.Вебмастер',
        'urlPath' => '/admin/yandex',
    ]);
    $stats = fetch_yandex_stats($config);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_yandex.php';
    exit;
}

if ($uri === '/admin/yandex-recrawl' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    require_admin($config);
    header('Content-Type: application/json; charset=utf-8');
    $hostId = trim((string)($_POST['host_id'] ?? ''));
    $y = $config['yandex'] ?? [];
    $token = isset($y['oauthToken']) ? trim((string)$y['oauthToken']) : '';
    $userId = yandex_get_user_id($token);
    if ($userId === null) {
        echo json_encode(['ok' => false, 'error' => 'Не удалось получить user_id.', 'sent' => 0, 'quota_remainder' => null], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $urls = build_recrawl_urls_for_host($config, $hostId);
    if (empty($urls)) {
        echo json_encode(['ok' => false, 'error' => 'Для этого хоста нет страниц сайта (проверьте Базовый URL и регионы).', 'sent' => 0, 'quota_remainder' => null], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $todayUrls = recrawl_history_today($hostId)['urls'];
    $urls = array_values(array_diff($urls, $todayUrls));
    $quota = yandex_get_recrawl_quota($token, $userId, $hostId);
    $limit = $quota['quota_remainder'] !== null ? (int)$quota['quota_remainder'] : null;
    if ($limit !== null && $limit <= 0) {
        echo json_encode(['ok' => true, 'sent' => 0, 'quota_remainder' => 0, 'errors' => [], 'message' => 'Квота на сегодня исчерпана.']);
        exit;
    }
    if ($limit !== null && count($urls) > $limit) {
        $urls = array_slice($urls, 0, $limit);
    }
    $sent = 0;
    $errors = [];
    $quotaRemainder = null;
    foreach ($urls as $url) {
        if ($url === '') {
            continue;
        }
        $res = yandex_recrawl_submit_url($token, $userId, $hostId, $url);
        if ($res['success']) {
            recrawl_history_add($hostId, $url);
            $sent++;
            if ($res['quota_remainder'] !== null) {
                $quotaRemainder = $res['quota_remainder'];
            }
            if ($quotaRemainder === 0) {
                break;
            }
        } else {
            $errors[] = $url . ': ' . ($res['error'] ?? 'ошибка');
            if (strpos($res['error'] ?? '', '429') !== false || strpos($res['error'] ?? '', 'квот') !== false) {
                break;
            }
        }
    }
    echo json_encode([
        'ok' => true,
        'sent' => $sent,
        'quota_remainder' => $quotaRemainder,
        'errors' => $errors,
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($uri === '/admin/yandex-recrawl-clear' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    require_admin($config);
    $hostId = trim((string)($_POST['host_id'] ?? ''));
    if ($hostId !== '') {
        recrawl_history_clear($hostId);
    }
    header('Location: /admin/yandex');
    exit;
}

if ($uri === '/admin/settings' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Настройки сайта',
        'urlPath' => '/admin/settings',
    ]);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_settings.php';
    exit;
}

if ($uri === '/admin/s3-cache' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $seo = build_seo($config, [
        'title' => 'Кэш в S3',
        'urlPath' => '/admin/s3-cache',
    ]);
    $s3cacheCfg   = s3cache_config();
    $s3cacheState = s3cache_state();
    $s3cachePurge = s3cache_purge_state();
    $s3cacheImport = s3cache_import_state();
    $s3cacheKeyExample = s3cache_key('/catalog/primer', $cacheHostHint);
    $s3cacheTest = null;
    if (!empty($_GET['test'])) {
        $s3cacheTest = [];
        $client = s3cache_client();
        if (!$client->isConfigured()) {
            $s3cacheTest[] = ['ok' => false, 'text' => 'Не заданы endpoint / бакет / ключи.'];
        } else {
            $ok = $client->testConnection(s3cache_html_prefix());
            $s3cacheTest[] = ['ok' => $ok, 'text' => 'Листинг префикса: ' . ($ok ? 'ок' : $client->error())];
            if ($ok) {
                $probe = s3cache_prefix() . '_s3-probe.txt';
                $put = $client->putString($probe, 'ok ' . date('c'), 'text/plain', ['acl' => (string)$s3cacheCfg['acl']]);
                $s3cacheTest[] = ['ok' => $put !== false, 'text' => 'Запись объекта: ' . ($put !== false ? 'ок' : $client->error())];
                if ($put !== false) {
                    $get = $client->get($probe);
                    $s3cacheTest[] = ['ok' => $get !== false, 'text' => 'Чтение объекта: ' . ($get !== false ? 'ок' : $client->error())];
                    $client->delete($probe);
                }
            }
        }
    }
    $s3cacheStats = !empty($_GET['stats']) ? s3cache_stats(20) : null;
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_s3_cache.php';
    exit;
}

if ($uri === '/admin/generate-test' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $defaultImage = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/SMD_capacitor.jpg/250px-SMD_capacitor.jpg';
    $productsRequested = isset($_GET['products']) ? (int)$_GET['products'] : 1000000;
    $categoriesRequested = isset($_GET['categories']) ? (int)$_GET['categories'] : 10000;
    $imageUrl = $_GET['imageUrl'] ?? $defaultImage;
    $resultMessage = '';

    if (isset($_GET['run']) && (int)$_GET['run'] === 1) {
        $productsRequested = max(1, min($productsRequested, 1000000));
        $categoriesRequested = max(1, min($categoriesRequested, 50000));
        cache_clear_all();
        $res = db_generate_test_data($productsRequested, $categoriesRequested, $imageUrl);
        $resultMessage = 'Сгенерировано товаров: ' . (int)$res['products'] . ', категорий: ' . (int)$res['categories'] . '.';
    }

    $seo = build_seo($config, [
        'title' => 'Генерация тестового каталога',
        'urlPath' => '/admin/generate-test',
    ]);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_generate_test.php';
    exit;
}

if ($uri === '/admin/clear-cache' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    cache_clear_all();
    header('Location: /admin?cache_cleared=1');
    exit;
}

if ($uri === '/admin/clear-cache-urls') {
    require_admin($config);
    $message = '';
    $cleared = [];
    $urlsRaw = (string)($_POST['urls'] ?? '');
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
        $lines = preg_split('~\r?\n~', $urlsRaw);
        $cleared = cache_clear_by_urls(is_array($lines) ? $lines : [], '');
        $message = 'Очищено записей кэша: ' . count($cleared);
    }
    $seo = build_seo($config, [
        'title' => 'Очистка кэша по URL',
        'urlPath' => '/admin/clear-cache-urls',
    ]);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_clear_cache_urls.php';
    exit;
}

if ($uri === '/admin/clear-products' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    db_clear_all_products();
    cache_clear_all();
    header('Location: /admin?products_cleared=1');
    exit;
}

if ($uri === '/admin/clear-products-today' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $deleted = db_clear_products_added_today();
    cache_clear_all();
    header('Location: /admin?products_cleared_today=' . (int)$deleted);
    exit;
}

if ($uri === '/admin/clear-products-last-hour' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $deleted = db_clear_products_added_last_hour();
    cache_clear_all();
    header('Location: /admin?products_cleared_hour=' . (int)$deleted);
    exit;
}

if ($uri === '/admin/rebuild-search-index' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    try {
        db_fts_rebuild();
        header('Location: /admin?search_index_rebuilt=1');
    } catch (Throwable $e) {
        header('Location: /admin?search_index_rebuilt=0&error=' . rawurlencode($e->getMessage()));
    }
    exit;
}

if ($uri === '/admin/generate-merchant-feed' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    @ini_set('memory_limit', '512M');
    set_time_limit(0);
    try {
        $stats = merchant_feed_generate($config);
        $q = http_build_query([
            'merchant_feed_ok' => 1,
            'merchant_feed_items' => (int)$stats['items'],
            'merchant_feed_skipped' => (int)$stats['skipped'],
        ]);
        header('Location: /admin?' . $q);
    } catch (Throwable $e) {
        header('Location: /admin?merchant_feed_ok=0&merchant_feed_error=' . rawurlencode($e->getMessage()));
    }
    exit;
}

if ($uri === '/admin/fix-slugs' && $_SERVER['REQUEST_METHOD'] === 'GET') {
    require_admin($config);
    $batchSize = max(100, min(2000, (int)($_GET['batch'] ?? 500)));
    try {
        $result = db_fix_invalid_slugs_batch($batchSize);
        header('Location: /admin?fix_slugs_fixed=' . (int)$result['fixed'] . '&fix_slugs_remaining=' . (int)$result['remaining']);
    } catch (Throwable $e) {
        header('Location: /admin?fix_slugs_error=' . rawurlencode($e->getMessage()));
    }
    exit;
}

if ($uri === '/admin/categories/ai-generate' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
    require_admin($config);
    header('Content-Type: application/json; charset=utf-8');
    $category = trim($_POST['category'] ?? '');
    if ($category === '') {
        echo json_encode(['ok' => false, 'error' => 'Не указана категория.'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $allCats = db_get_categories();
    if (!in_array($category, $allCats, true)) {
        echo json_encode(['ok' => false, 'error' => 'Категория не найдена.'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $productsContext = db_get_category_products_for_ai($category, 20);
    try {
        $result = ai_generate_category_texts($config, $category, $productsContext);
        echo json_encode(['ok' => true, 'data' => $result], JSON_UNESCAPED_UNICODE);
    } catch (Throwable $e) {
        echo json_encode(['ok' => false, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
    }
    exit;
}

if ($uri === '/admin/categories') {
    require_admin($config);
    $message = '';
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $action = $_POST['action'] ?? '';
        $cat = trim($_POST['category'] ?? '');
        $meta = load_categories_meta($categoriesMetaFile);
        if ($action === 'save_meta' && $cat !== '') {
            $meta[$cat] = [
                'seoTitle' => trim($_POST['seoTitle'] ?? ''),
                'seoDescription' => trim($_POST['seoDescription'] ?? ''),
                'textHtml' => trim($_POST['textHtml'] ?? ''),
            ];
            save_categories_meta($categoriesMetaFile, $meta);
            $message = 'SEO‑текст для категории сохранён.';
        } elseif ($action === 'rename' && $cat !== '') {
            $newName = trim($_POST['newName'] ?? '');
            if ($newName !== '') {
                $pdo = db();
                $stmt = $pdo->prepare('UPDATE products SET category = :new WHERE category = :old');
                $stmt->execute([':new' => $newName, ':old' => $cat]);
                $meta = load_categories_meta($categoriesMetaFile);
                if (isset($meta[$cat])) {
                    $meta[$newName] = $meta[$cat];
                    unset($meta[$cat]);
                    save_categories_meta($categoriesMetaFile, $meta);
                }
                cache_clear_all();
                $message = 'Категория переименована.';
            }
        } elseif ($action === 'clear' && $cat !== '') {
            $pdo = db();
            $stmt = $pdo->prepare('UPDATE products SET category = \'\' WHERE category = :old');
            $stmt->execute([':old' => $cat]);
            $meta = load_categories_meta($categoriesMetaFile);
            if (isset($meta[$cat])) {
                unset($meta[$cat]);
                save_categories_meta($categoriesMetaFile, $meta);
            }
            cache_clear_all();
            $message = 'Категория очищена у товаров.';
        } elseif ($action === 'delete' && $cat !== '') {
            // Удаляем категорию полностью: и основную, и из доп. списков categoriesExtra
            $pdo = db();
            // Сброс основной категории
            $stmt = $pdo->prepare('UPDATE products SET category = \'\' WHERE category = :old');
            $stmt->execute([':old' => $cat]);
            // Удаляем категорию из списка доп. категорий
            $stmt = $pdo->prepare("UPDATE products SET categoriesExtra = REPLACE(categoriesExtra, :pattern, '|') WHERE categoriesExtra LIKE :likePat");
            $pattern = '|' . $cat . '|';
            $stmt->execute([':pattern' => $pattern, ':likePat' => '%' . $pattern . '%']);
            // Чистим возможные артефакты || и одиночных |
            $pdo->exec("UPDATE products SET categoriesExtra = NULL WHERE categoriesExtra = '' OR categoriesExtra = '|'");
            $pdo->exec("UPDATE products SET categoriesExtra = REPLACE(categoriesExtra, '||', '|') WHERE categoriesExtra LIKE '%||%'");

            $meta = load_categories_meta($categoriesMetaFile);
            if (isset($meta[$cat])) {
                unset($meta[$cat]);
                save_categories_meta($categoriesMetaFile, $meta);
            }
            cache_clear_all();
            $message = 'Категория удалена из каталога.';
        } elseif ($action === 'delete_contains') {
            $needle = trim((string)($_POST['needle'] ?? ''));
            if ($needle === '') {
                $message = 'Не указана строка для поиска по категориям.';
            } else {
                $allCats = db_get_categories();
                $needleLower = mb_strtolower($needle, 'UTF-8');
                $toDelete = [];
                foreach ($allCats as $catName) {
                    $catLower = mb_strtolower($catName, 'UTF-8');
                    if (strpos($catLower, $needleLower) !== false) {
                        $toDelete[] = $catName;
                    }
                }
                if (empty($toDelete)) {
                    $message = 'Категории с таким вхождением не найдены.';
                } else {
                    $pdo = db();
                    $meta = load_categories_meta($categoriesMetaFile);
                    $deleted = 0;
                    foreach ($toDelete as $catName) {
                        // Сброс основной категории
                        $stmt = $pdo->prepare('UPDATE products SET category = \'\' WHERE category = :old');
                        $stmt->execute([':old' => $catName]);
                        // Удаляем категорию из списка доп. категорий
                        $stmt = $pdo->prepare("UPDATE products SET categoriesExtra = REPLACE(categoriesExtra, :pattern, '|') WHERE categoriesExtra LIKE :likePat");
                        $pattern = '|' . $catName . '|';
                        $stmt->execute([':pattern' => $pattern, ':likePat' => '%' . $pattern . '%']);
                        if (isset($meta[$catName])) {
                            unset($meta[$catName]);
                        }
                        $deleted++;
                    }
                    // Чистим возможные артефакты || и одиночных |
                    $pdo->exec("UPDATE products SET categoriesExtra = NULL WHERE categoriesExtra = '' OR categoriesExtra = '|'");
                    $pdo->exec("UPDATE products SET categoriesExtra = REPLACE(categoriesExtra, '||', '|') WHERE categoriesExtra LIKE '%||%'");
                    save_categories_meta($categoriesMetaFile, $meta);
                    cache_clear_all();
                    $message = 'Удалено категорий по вхождению «' . $needle . '»: ' . (int)$deleted . '.';
                }
            }
        } elseif ($action === 'bulk_delete' && !empty($_POST['categories']) && is_array($_POST['categories'])) {
            $pdo = db();
            $deleted = 0;
            // Загружаем мета ещё раз, чтобы работать с актуальной версией
            $meta = load_categories_meta($categoriesMetaFile);
            $seen = [];
            foreach ($_POST['categories'] as $rawCat) {
                $catName = trim((string)$rawCat);
                if ($catName === '' || isset($seen[$catName])) {
                    continue;
                }
                $seen[$catName] = true;
                // Сброс основной категории
                $stmt = $pdo->prepare('UPDATE products SET category = \'\' WHERE category = :old');
                $stmt->execute([':old' => $catName]);
                // Удаляем категорию из списка доп. категорий
                $stmt = $pdo->prepare("UPDATE products SET categoriesExtra = REPLACE(categoriesExtra, :pattern, '|') WHERE categoriesExtra LIKE :likePat");
                $pattern = '|' . $catName . '|';
                $stmt->execute([':pattern' => $pattern, ':likePat' => '%' . $pattern . '%']);
                if (isset($meta[$catName])) {
                    unset($meta[$catName]);
                }
                $deleted++;
            }
            // Чистим возможные артефакты || и одиночных |
            $pdo->exec("UPDATE products SET categoriesExtra = NULL WHERE categoriesExtra = '' OR categoriesExtra = '|'");
            $pdo->exec("UPDATE products SET categoriesExtra = REPLACE(categoriesExtra, '||', '|') WHERE categoriesExtra LIKE '%||%'");
            save_categories_meta($categoriesMetaFile, $meta);
            cache_clear_all();
            $message = 'Удалено категорий: ' . (int)$deleted . '.';
        }
    }
    $categoriesAll = db_get_categories();
    $catPage = max(1, (int)($_GET['page'] ?? 1));
    $catPerPage = 50;
    $catsTotal = count($categoriesAll);
    $catPagesTotal = max(1, (int)ceil($catsTotal / $catPerPage));
    if ($catPage > $catPagesTotal) {
        $catPage = $catPagesTotal;
    }
    $catOffset = ($catPage - 1) * $catPerPage;
    $categories = array_slice($categoriesAll, $catOffset, $catPerPage);
    $categoriesMeta = load_categories_meta($categoriesMetaFile);
    $seo = build_seo($config, [
        'title' => 'Категории каталога',
        'urlPath' => '/admin/categories',
    ]);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_categories.php';
    exit;
}

if ($uri === '/admin/products/ai-generate' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
    require_admin($config);
    header('Content-Type: application/json; charset=utf-8');
    $ids = [];
    if (isset($_POST['id']) && (string)$_POST['id'] !== '') {
        $ids[] = (int)$_POST['id'];
    }
    if (!empty($_POST['ids']) && is_array($_POST['ids'])) {
        foreach ($_POST['ids'] as $v) {
            $v = (int)$v;
            if ($v > 0 && !in_array($v, $ids, true)) {
                $ids[] = $v;
            }
        }
    }
    if (empty($ids)) {
        echo json_encode(['ok' => false, 'error' => 'Не указан id товара или ids[].'], JSON_UNESCAPED_UNICODE);
        exit;
    }
    $items = [];
    foreach ($ids as $productId) {
        $product = db_get_product_by_id($productId);
        if (!$product) {
            $items[] = ['id' => $productId, 'error' => 'Товар не найден.'];
            continue;
        }
        try {
            $data = ai_generate_product_texts($config, $product);
            $items[] = ['id' => $productId, 'data' => $data];
        } catch (Throwable $e) {
            $items[] = ['id' => $productId, 'error' => $e->getMessage()];
        }
    }
    echo json_encode(['ok' => true, 'items' => $items], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($uri === '/admin/products') {
    require_admin($config);
    $message = '';
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $action = $_POST['action'] ?? '';
        if ($action === 'delete_by_query') {
            $qRaw = trim((string)($_POST['q'] ?? ''));
            if ($qRaw === '') {
                $message = 'Не задан поисковый запрос для удаления.';
            } else {
                $pdo = db();
                $qNorm = '%' . mb_strtolower($qRaw, 'UTF-8') . '%';
                $stmt = $pdo->prepare("DELETE FROM products WHERE LOWER(name) LIKE :q OR LOWER(COALESCE(sku,'')) LIKE :q OR LOWER(COALESCE(description,'')) LIKE :q");
                $stmt->execute([':q' => $qNorm]);
                $deleted = $stmt->rowCount();
                cache_clear_all();
                $message = 'Удалено товаров по запросу «' . $qRaw . '»: ' . (int)$deleted . '.';
            }
        } elseif ($action === 'bulk_delete') {
            $ids = [];
            if (!empty($_POST['ids']) && is_array($_POST['ids'])) {
                foreach ($_POST['ids'] as $v) {
                    $v = (int)$v;
                    if ($v > 0 && !in_array($v, $ids, true)) {
                        $ids[] = $v;
                    }
                }
            }
            if ($ids) {
                $pdo = db();
                $placeholders = implode(',', array_fill(0, count($ids), '?'));
                $stmt = $pdo->prepare("DELETE FROM products WHERE id IN ($placeholders)");
                $stmt->execute($ids);
                cache_clear_all();
                $deleted = $stmt->rowCount();
                if ($deleted <= 0) {
                    $deleted = count($ids);
                }
                $message = 'Удалено товаров: ' . (int)$deleted . '.';
            } else {
                $message = 'Не выбраны товары для удаления.';
            }
        } else {
            $id = (int)($_POST['id'] ?? 0);
            if ($id > 0) {
                $pdo = db();
                if ($action === 'delete') {
                    $stmt = $pdo->prepare('DELETE FROM products WHERE id = :id');
                    $stmt->execute([':id' => $id]);
                    cache_clear_all();
                    $message = 'Товар удалён.';
                } elseif ($action === 'update') {
                    $name = trim($_POST['name'] ?? '');
                    $category = trim($_POST['category'] ?? '');
                    $price = trim($_POST['price'] ?? '');
                    $imageLocal = trim($_POST['imageLocal'] ?? '');
                    $description = trim($_POST['description'] ?? '');
                    $tags = trim($_POST['tags'] ?? '');
                    $seoTitle = trim($_POST['seoTitle'] ?? '');
                    $seoDescription = trim($_POST['seoDescription'] ?? '');
                    $stmt = $pdo->prepare('UPDATE products SET name = :name, category = :category, price = :price, imageLocal = :imageLocal, description = :description, tags = :tags, seoTitle = :seoTitle, seoDescription = :seoDescription, updated_at = datetime(\'now\') WHERE id = :id');
                    $stmt->execute([
                        ':name' => $name,
                        ':category' => $category,
                        ':price' => $price,
                        ':imageLocal' => $imageLocal,
                        ':description' => $description,
                        ':tags' => $tags,
                        ':seoTitle' => $seoTitle,
                        ':seoDescription' => $seoDescription,
                        ':id' => $id,
                    ]);
                    cache_clear_all();
                    $message = 'Товар обновлён.';
                }
            }
        }
    }
    $q = trim($_GET['q'] ?? '');
    $pageNum = max(1, (int)($_GET['page'] ?? 1));
    $perPage = 50;

    $total = $q !== '' ? db_count_products(null, mb_strtolower($q, 'UTF-8')) : db_count_all_products();
    $pagesTotal = max(1, (int)ceil($total / $perPage));
    if ($pageNum > $pagesTotal) {
        $pageNum = $pagesTotal;
    }
    $offset = ($pageNum - 1) * $perPage;
    $products = db_get_products_for_admin($q !== '' ? mb_strtolower($q, 'UTF-8') : null, $perPage, $offset);

    $seo = build_seo($config, [
        'title' => 'Товары',
        'urlPath' => '/admin/products',
    ]);
    include $tplDir . DIRECTORY_SEPARATOR . 'admin_products.php';
    exit;
}

// ---------- 404 ----------
http_response_code(404);
$seo = build_seo($config, [
    'title' => 'Страница не найдена',
    'urlPath' => $uri,
]);
$__404_tpl = $tplDir . DIRECTORY_SEPARATOR . '404.php';
ob_start();
include $__404_tpl;
$html = ob_get_clean();
cache_store($uri, $html, $cacheHostHint, 404);
echo $html;
exit;

