<?php

declare(strict_types=1);



class EntityTranslateProcessor
{
    private const CRON_INTERVAL         = 5;
    private const MAX_TICK_SECONDS      = 50;
    private const POOL_SIZE             = 5;
    private const STALE_SECONDS         = 300;
    private const COST_PER_ENTITY       = 0.5;   
    private const CONSECUTIVE_FAIL_PAUSE = 3;    
    private const PAUSE_SECONDS         = 60;    
    private const MAX_ITEM_ATTEMPTS     = 5;     

    public static function registerCron(): void
    {
        CronManager::register('entity_translate', self::CRON_INTERVAL, [self::class, 'tick']);
        CronManager::register('entity_autotranslate', 120, [self::class, 'autoTick']);
    }

    

    public static function autoTick(): void
    {
        $blockedMsg = null;

        foreach (['category', 'tag'] as $type) {
            if (Setting::get('entity_autotranslate_' . $type, '0') !== '1') {
                continue;
            }
             
            if (self::getActiveJob($type) !== null) {
                continue;
            }

            $languages = array_values(array_filter(
                Translator::available(),
                fn($l) => !($l['is_default'] ?? false)
            ));
            $langCodes = array_column($languages, 'code');
            $langCount = count($langCodes);
            if ($langCount === 0) {
                continue;
            }

            $mainTable = $type === 'tag' ? 'tags' : 'categories';
            $trTable   = $type === 'tag' ? 'tag_translations' : 'category_translations';
            $fk        = $type === 'tag' ? 'tag_id' : 'category_id';

             
            $ph = implode(',', array_fill(0, $langCount, '?'));
            $rows = Database::fetchAll(
                "SELECT m.id
                   FROM {$mainTable} m
                   LEFT JOIN {$trTable} t
                     ON t.{$fk} = m.id AND t.lang_code IN ({$ph}) AND t.name IS NOT NULL AND t.name <> ''
                  WHERE CHAR_LENGTH(m.name) <= 100
                  GROUP BY m.id
                 HAVING COUNT(DISTINCT t.lang_code) < ?
                  ORDER BY m.id ASC
                  LIMIT 200",
                array_merge($langCodes, [$langCount])
            );
            $ids = array_map(fn($r) => (int) $r['id'], $rows);
            if (empty($ids)) {
                continue;
            }

             
             
             
            $sig = md5(json_encode($ids));
            if ($sig === Setting::get('entity_autotranslate_sig_' . $type, '')) {
                continue;
            }

             
            if (!self::checkApiHealth()) {
                continue;
            }

            $totalCost = round(count($ids) * self::COST_PER_ENTITY, 2);
            $balance = CatalogueClient::getBalance();
            if ($balance === null) {
                continue;
            }
            $credits = (float) ($balance['credits'] ?? 0);
            if ($credits < $totalCost) {
                $blockedMsg = 'Auto-translation needs ' . number_format($totalCost, 2) . ' Cr but your balance is '
                    . number_format($credits, 2) . ' Cr. Top up to resume.';
                continue;  
            }

             
            $systemUserId = (int) (Database::fetchOne("SELECT id FROM users ORDER BY id ASC LIMIT 1")['id'] ?? 0);
            $pdo = Database::get();
            if (!$pdo->query("SELECT GET_LOCK('entity_translate_{$type}', 0)")->fetchColumn()) {
                continue;
            }
            $jobId = null;
            try {
                if (self::getActiveJob($type) === null) {
                    $jobId = self::createJob($type, $ids, $langCodes, $systemUserId, $totalCost);
                }
            } finally {
                $pdo->query("SELECT RELEASE_LOCK('entity_translate_{$type}')");
            }
            if ($jobId === null) {
                continue;
            }

            $debitOk = true;
            if ($totalCost > 0) {
                $refId = 'entity_translate:' . $type . ':job:' . $jobId;
                $debit = CatalogueClient::debitCredits(
                    $totalCost,
                    'Auto AI translate ' . count($ids) . ' ' . $type . '(ies) (job #' . $jobId . ')',
                    $refId
                );
                if ($debit === null || !empty($debit['_error'])) {
                    $debitOk = false;
                    Database::query("DELETE FROM entity_translate_jobs WHERE id = ?", [$jobId]);
                    if (($debit['_http_code'] ?? 0) === 402) {
                        $blockedMsg = 'Auto-translation could not run — insufficient credits. Top up to resume.';
                    }
                }
            }
            if ($debitOk) {
                 
                Setting::set('entity_autotranslate_sig_' . $type, $sig);
            }
        }

        if ($blockedMsg !== null) {
            Setting::set('entity_autotranslate_blocked', '1');
            Setting::set('entity_autotranslate_blocked_msg', $blockedMsg);
        } elseif (Setting::get('entity_autotranslate_blocked', '0') === '1') {
            Setting::set('entity_autotranslate_blocked', '0');
            Setting::set('entity_autotranslate_blocked_msg', '');
        }
    }

    

    public static function tick(): void
    {
        self::recoverStalled();
        self::reconcileRefunds();

        $job = Database::fetchOne(
            "SELECT * FROM entity_translate_jobs WHERE status IN ('pending','processing') ORDER BY id ASC LIMIT 1"
        );

        if (!$job) return;

         
        $cooldownUntil = $job['error_message'] ?? '';
        if (str_starts_with($cooldownUntil, 'cooldown:')) {
            $until = (int) substr($cooldownUntil, 9);
            if (time() < $until) return;  
             
            Database::query("UPDATE entity_translate_jobs SET error_message = NULL WHERE id = ?", [(int) $job['id']]);
        }

         
         
         
         
        $lockName = 'etrans_job_' . (int) $job['id'];
        if (!(Database::fetchOne('SELECT GET_LOCK(?, 0) AS l', [$lockName])['l'] ?? 0)) return;

        try {
            if ($job['status'] === 'pending') {
                Database::query(
                    "UPDATE entity_translate_jobs SET status = 'processing', started_at = COALESCE(started_at, NOW()) WHERE id = ?",
                    [(int) $job['id']]
                );
            }
            self::processJob((int) $job['id'], time());
        } catch (\Throwable $e) {
            error_log('EntityTranslateProcessor: Exception on job #' . $job['id'] . ': ' . $e->getMessage());
            Database::query(
                "UPDATE entity_translate_jobs SET error_message = ? WHERE id = ?",
                [mb_substr($e->getMessage(), 0, 1000), (int) $job['id']]
            );
        } finally {
            Database::query('SELECT RELEASE_LOCK(?)', [$lockName]);
        }
    }

    

     
    private static function fullyTranslatedSet(array $allIds, array $langCodes, string $translationTable, string $fkColumn): array
    {
        $allIds = array_values(array_unique(array_map('intval', $allIds)));
        if (empty($allIds) || empty($langCodes)) return [];
        $idph = implode(',', array_fill(0, count($allIds), '?'));
        $lph  = implode(',', array_fill(0, count($langCodes), '?'));
        $rows = Database::fetchAll(
            "SELECT {$fkColumn} AS eid FROM {$translationTable}
             WHERE {$fkColumn} IN ({$idph}) AND lang_code IN ({$lph}) AND name IS NOT NULL AND name != ''
             GROUP BY {$fkColumn} HAVING COUNT(DISTINCT lang_code) >= ?",
            array_merge($allIds, $langCodes, [count($langCodes)])
        );
        $set = [];
        foreach ($rows as $r) { $set[(int) $r['eid']] = true; }
        return $set;
    }

    

    private static function persistTranslation(string $translationTable, string $fkColumn, string $mainTable, int $entityId, string $videoType, string $langCode, string $text): void
    {
        $slug = Slug::uniqueEntityTranslation($translationTable, $fkColumn, $mainTable, $text, $langCode, $entityId, $videoType);
        $existing = Database::fetchOne(
            "SELECT id FROM {$translationTable} WHERE {$fkColumn} = ? AND lang_code = ?",
            [$entityId, $langCode]
        );
        if ($existing) {
            Database::query(
                "UPDATE {$translationTable} SET name = ?, slug = ? WHERE {$fkColumn} = ? AND lang_code = ?",
                [$text, $slug, $entityId, $langCode]
            );
        } else {
            Database::query(
                "INSERT INTO {$translationTable} ({$fkColumn}, lang_code, name, slug) VALUES (?, ?, ?, ?)",
                [$entityId, $langCode, $text, $slug]
            );
        }
    }

    

    private static function cachePrefill(array $entityIds, array $langCodes, string $translationTable, string $fkColumn, string $mainTable): void
    {
        if (empty($entityIds)) return;
        $ph = implode(',', array_fill(0, count($entityIds), '?'));
        $rows = Database::fetchAll("SELECT id, name, video_type FROM {$mainTable} WHERE id IN ({$ph})", $entityIds);
        if (empty($rows)) return;

         
         
         
        foreach (array_chunk($rows, 100) as $chunk) {
            $items = array_map(fn($r) => ['name' => $r['name'], 'video_type' => $r['video_type'] ?? ''], $chunk);
            $results = CatalogueClient::translateEntitiesLookup($items, $langCodes);
            if (!is_array($results)) continue;
            foreach ($chunk as $i => $r) {
                $translations = $results[$i]['translations'] ?? null;
                if (!is_array($translations) || empty($translations)) continue;
                $entityId = (int) $r['id'];
                $videoType = (string) ($r['video_type'] ?? '');
                foreach ($translations as $langCode => $text) {
                    $langCode = strtolower((string) $langCode);
                    $text = trim((string) $text);
                    if ($text === '' || mb_strlen($text) > 200) continue;
                    self::persistTranslation($translationTable, $fkColumn, $mainTable, $entityId, $videoType, $langCode, $text);
                }
            }
        }
    }

    private static function processJob(int $jobId, int $startTime): void
    {
        $job = Database::fetchOne('SELECT * FROM entity_translate_jobs WHERE id = ?', [$jobId]);
        if (!$job || !in_array($job['status'], ['pending', 'processing'], true)) return;

        $entityType  = $job['entity_type'];
        $allIds      = json_decode($job['entity_ids'], true);
        $langCodes   = json_decode($job['languages'], true);

         
        if (!is_array($allIds) || !is_array($langCodes)) {
            self::failJob($jobId, 'Corrupted job data (invalid JSON).');
            return;
        }
        $totalItems  = (int) $job['total_items'];
        $attempts    = json_decode($job['attempt_map'] ?? '[]', true);
        if (!is_array($attempts)) $attempts = [];

        $isTag = $entityType === 'tag';
        $translationTable = $isTag ? 'tag_translations' : 'category_translations';
        $fkColumn         = $isTag ? 'tag_id' : 'category_id';
        $mainTable        = $isTag ? 'tags' : 'categories';
        $modelClass       = $isTag ? 'Tag' : 'Category';

         
        $doneSet = self::fullyTranslatedSet($allIds, $langCodes, $translationTable, $fkColumn);
        $translated = count($doneSet);
         
        $retryable = [];
        $maxed = 0;
        foreach ($allIds as $id) {
            $id = (int) $id;
            if (isset($doneSet[$id])) continue;
            if ((int) ($attempts[$id] ?? 0) >= self::MAX_ITEM_ATTEMPTS) { $maxed++; continue; }
            $retryable[] = $id;
        }
        $failed = $maxed;
        $processed = $translated + $maxed;

        if (empty($allIds) || empty($langCodes) || empty($retryable)) {
            self::finishJob($jobId, $job, $processed, $translated, $failed);
            return;
        }

         
         
         
         
        if ($entityType === 'category'
            && Setting::get('entity_translation_cache_enabled', '1') === '1') {
            self::cachePrefill($retryable, $langCodes, $translationTable, $fkColumn, $mainTable);

            $doneSet = self::fullyTranslatedSet($allIds, $langCodes, $translationTable, $fkColumn);
            $translated = count($doneSet);
            $retryable = [];
            $maxed = 0;
            foreach ($allIds as $id) {
                $id = (int) $id;
                if (isset($doneSet[$id])) continue;
                if ((int) ($attempts[$id] ?? 0) >= self::MAX_ITEM_ATTEMPTS) { $maxed++; continue; }
                $retryable[] = $id;
            }
            $failed = $maxed;
            $processed = $translated + $maxed;
            Database::query(
                "UPDATE entity_translate_jobs SET processed_items = ?, translated_count = ?, failed_count = ?, updated_at = NOW() WHERE id = ?",
                [$processed, $translated, $failed, $jobId]
            );

            if (empty($retryable)) {
                self::finishJob($jobId, $job, $processed, $translated, $failed);
                return;
            }
        }

         
        if (!self::checkApiHealth()) {
            self::pauseJob($jobId, 'API unreachable');
            return;
        }

        $languages = [];
        foreach (Translator::available() as $l) {
            if (in_array($l['code'], $langCodes, true)) $languages[] = $l;
        }
        if (empty($languages)) {
            self::failJob($jobId, 'Target languages no longer enabled.');
            return;
        }

        $aiConfig = CatalogueClient::getAiConfig();
        if ($aiConfig === null) {
             
            self::pauseJob($jobId, 'Cannot reach AI config');
            return;
        }

        $beginTpl = $aiConfig['ai_entity_translate_begin'] ?? "Translate this tag/category name into the requested languages.\nName: {name}";
        $endTpl   = $aiConfig['ai_entity_translate_end'] ?? '';
         
         
        $beginTpl = str_replace(['\\r\\n', '\\n', '\\t'], ["\n", "\n", "\t"], $beginTpl);
        $endTpl   = str_replace(['\\r\\n', '\\n', '\\t'], ["\n", "\n", "\t"], $endTpl);
         
         
        $formatLines = implode("\n", array_map(
            fn($l) => $l['name'] . ': <' . strtoupper($l['code']) . '></' . strtoupper($l['code']) . '>',
            $languages
        ));
         
         
         
        $promptTpl = str_replace('{format}', $formatLines, $beginTpl . ($endTpl !== '' ? "\n\n" . $endTpl : ''));

         
        $pendingIds = $retryable;
        $requestMap = [];
        $requestIdx = 0;
        $consecutiveFails = 0;
        $apiDown = false;
        $cacheWrites = [];

        $requestIterator = (function () use (
            &$requestMap, &$requestIdx, &$attempts, &$apiDown,
            $pendingIds, $modelClass, $promptTpl, $startTime
        ) {
            foreach ($pendingIds as $entityId) {
                if ((time() - $startTime) >= self::MAX_TICK_SECONDS) break;
                if ($apiDown) break;  

                $entity = $modelClass::find((int) $entityId);
                if (!$entity || mb_strlen($entity['name']) > 100) {
                     
                    $attempts[(int) $entityId] = self::MAX_ITEM_ATTEMPTS;
                    continue;
                }

                $fullPrompt = trim(str_replace('{name}', $entity['name'], $promptTpl));

                $idx = $requestIdx++;
                $requestMap[$idx] = [
                    'entity_id'  => (int) $entityId,
                    'name'       => $entity['name'],
                    'video_type' => $entity['video_type'] ?? '',
                ];

                yield $idx => ['messages' => [['role' => 'user', 'content' => $fullPrompt]], 'temperature' => 0.5];
            }
        })();

        CatalogueClient::aiCompletionPool(
            $requestIterator,
            function (int $idx, ?array $aiResult) use (
                &$attempts, &$consecutiveFails, &$apiDown,
                &$requestMap, $languages, $translationTable, $fkColumn, $mainTable, &$cacheWrites
            ) {
                $meta = $requestMap[$idx] ?? null;
                if (!$meta) return;
                unset($requestMap[$idx]);
                $eid = (int) $meta['entity_id'];

                if ($aiResult === null) {
                     
                    $consecutiveFails++;
                    if ($consecutiveFails >= self::CONSECUTIVE_FAIL_PAUSE) {
                        $apiDown = true;  
                    }
                    return;
                }
                $consecutiveFails = 0;  

                $content = $aiResult['choices'][0]['message']['content'] ?? null;
                if ($content === null) { $attempts[$eid] = (int) ($attempts[$eid] ?? 0) + 1; return; }

                $savedAny = false;
                foreach ($languages as $lang) {
                    $code = strtoupper($lang['code']);
                    if (preg_match('/<' . preg_quote($code, '/') . '>(.*?)<\/' . preg_quote($code, '/') . '>/si', $content, $m)) {
                        $text = trim($m[1]);
                        if ($text === '') continue;

                        $langCode = strtolower($lang['code']);
                        $slug = Slug::uniqueEntityTranslation($translationTable, $fkColumn, $mainTable, $text, $langCode, $meta['entity_id'], $meta['video_type']);
                        $existing = Database::fetchOne(
                            "SELECT id FROM {$translationTable} WHERE {$fkColumn} = ? AND lang_code = ?",
                            [$meta['entity_id'], $langCode]
                        );

                        if ($existing) {
                            Database::query("UPDATE {$translationTable} SET name = ?, slug = ? WHERE {$fkColumn} = ? AND lang_code = ?",
                                [$text, $slug, $meta['entity_id'], $langCode]);
                        } else {
                            Database::query("INSERT INTO {$translationTable} ({$fkColumn}, lang_code, name, slug) VALUES (?, ?, ?, ?)",
                                [$meta['entity_id'], $langCode, $text, $slug]);
                        }
                        $savedAny = true;
                        $cacheWrites[] = ['name' => $meta['name'], 'video_type' => $meta['video_type'], 'lang' => $langCode, 'translated' => $text];
                    }
                }

                if (!$savedAny) { $attempts[$eid] = (int) ($attempts[$eid] ?? 0) + 1; }
            },
            self::POOL_SIZE
        );

         
         
        if ($entityType === 'category' && !empty($cacheWrites)
            && Setting::get('entity_translation_cache_enabled', '1') === '1') {
            CatalogueClient::translateEntitiesStore($cacheWrites);
        }

         
        if ($apiDown) {
            Database::query("UPDATE entity_translate_jobs SET attempt_map = ? WHERE id = ?", [json_encode($attempts), $jobId]);
            self::pauseJob($jobId, 'API unreachable (' . $consecutiveFails . ' consecutive failures)');
            return;
        }

         
        $doneSet = self::fullyTranslatedSet($allIds, $langCodes, $translationTable, $fkColumn);
        $translated = count($doneSet);
        $remaining = 0; $maxed = 0;
        foreach ($allIds as $id) {
            $id = (int) $id;
            if (isset($doneSet[$id])) continue;
            if ((int) ($attempts[$id] ?? 0) >= self::MAX_ITEM_ATTEMPTS) { $maxed++; } else { $remaining++; }
        }
        $failed = $maxed;
        $processed = $translated + $maxed;

        Database::query(
            "UPDATE entity_translate_jobs SET processed_items = ?, translated_count = ?, failed_count = ?, attempt_map = ?, updated_at = NOW() WHERE id = ?",
            [$processed, $translated, $failed, json_encode($attempts), $jobId]
        );

         
        if ($remaining === 0) {
            self::finishJob($jobId, $job, $processed, $translated, $failed);
        } else {
             
            Database::query("UPDATE settings SET setting_value = '0' WHERE setting_key = 'cron_entity_translate_last'");
            self::asyncPing();
        }
    }

    


    private static function checkApiHealth(): bool
    {
        if (!CatalogueClient::isConfigured()) return false;
         
         
        return CatalogueClient::pingHealth();
    }

    private static function pauseJob(int $jobId, string $reason): void
    {
        $resumeAt = time() + self::PAUSE_SECONDS;
        Database::query(
            "UPDATE entity_translate_jobs SET error_message = ?, updated_at = NOW() WHERE id = ?",
            ['cooldown:' . $resumeAt, $jobId]
        );
        error_log("EntityTranslateProcessor: Job #{$jobId} paused — {$reason}. Will retry at " . date('H:i:s', $resumeAt));
    }

    


    public static function createJob(string $entityType, array $entityIds, array $langCodes, int $userId, float $creditsDebited): int
    {
        $id = Database::insert('entity_translate_jobs', [
            'entity_type'     => $entityType,
            'entity_ids'      => json_encode(array_values($entityIds)),
            'languages'       => json_encode($langCodes),
            'total_items'     => count($entityIds),
            'credits_debited' => $creditsDebited,
            'user_id'         => $userId,
        ]);

         
        Database::query("UPDATE settings SET setting_value = '0' WHERE setting_key = 'cron_entity_translate_last'");
        self::asyncPing();

        return $id;
    }

    public static function getJob(int $jobId): ?array
    {
        return Database::fetchOne('SELECT * FROM entity_translate_jobs WHERE id = ?', [$jobId]);
    }

    

    private static function finishJob(int $jobId, array $job, int $processed, int $translated, int $failed): void
    {
         
         
        $totalItems = (int) $job['total_items'];
        $notTranslated = max(0, $totalItems - $translated);
        $refundAmount = 0.0;
        if ($notTranslated > 0) {
            $refundAmount = round($notTranslated * self::COST_PER_ENTITY, 2);
            $maxRefund = round((float) $job['credits_debited'], 2);
            if ($refundAmount > $maxRefund) $refundAmount = $maxRefund;
        }

        Database::query(
            "UPDATE entity_translate_jobs
             SET status = 'completed', processed_items = ?, translated_count = ?, failed_count = ?,
                 refund_amount = ?, refunded_at = NULL,
                 completed_at = NOW(), updated_at = NOW()
             WHERE id = ?",
            [$processed, $translated, $failed, $refundAmount, $jobId]
        );
        SitemapController::markDirty();

         
         
        if ($refundAmount > 0) {
            $refId = 'entity_translate:' . $job['entity_type'] . ':job:' . $jobId;
            $result = CatalogueClient::refundCredits(
                $refundAmount,
                'Auto-refund ' . $notTranslated . ' untranslated ' . $job['entity_type'] . '(s) (job #' . $jobId . ')',
                $refId
            );
            if ($result !== null && empty($result['_error'])) {
                Database::query("UPDATE entity_translate_jobs SET refunded_at = NOW() WHERE id = ?", [$jobId]);
                error_log("EntityTranslateProcessor: Auto-refunded {$refundAmount} Cr for job #{$jobId} ({$notTranslated} untranslated)");
            }
        }
    }

    public static function cancelJob(int $jobId, int $requestingUserId = 0): array
    {
        $pdo = Database::get();
        $pdo->beginTransaction();

        try {
            $stmt = $pdo->prepare("SELECT * FROM entity_translate_jobs WHERE id = ? FOR UPDATE");
            $stmt->execute([$jobId]);
            $job = $stmt->fetch(\PDO::FETCH_ASSOC);

            if (!$job || !in_array($job['status'], ['pending', 'processing'], true)) {
                $pdo->rollBack();
                return ['cancelled' => false, 'refunded' => 0];
            }

            if ($requestingUserId > 0 && (int) $job['user_id'] !== $requestingUserId) {
                $pdo->rollBack();
                return ['cancelled' => false, 'refunded' => 0, 'error' => 'Not your job'];
            }

            $totalItems = (int) $job['total_items'];
            $translated = (int) $job['translated_count'];
            $notTranslated = max(0, $totalItems - $translated);

            Database::query(
                "UPDATE entity_translate_jobs SET status = 'cancelled', completed_at = NOW() WHERE id = ?",
                [$jobId]
            );

            $pdo->commit();
        } catch (\Throwable $e) {
            $pdo->rollBack();
            error_log('EntityTranslateProcessor::cancelJob error: ' . $e->getMessage());
            return ['cancelled' => false, 'refunded' => 0];
        }

         
        $refundAmount = 0.0;
        if ($notTranslated > 0) {
            $refundAmount = round($notTranslated * self::COST_PER_ENTITY, 2);
            $maxRefund = round((float) $job['credits_debited'], 2);
            if ($refundAmount > $maxRefund) $refundAmount = $maxRefund;

            $refId = 'entity_translate:' . $job['entity_type'] . ':job:' . $jobId;
            $result = CatalogueClient::refundCredits(
                $refundAmount,
                'Refund ' . $notTranslated . ' cancelled ' . $job['entity_type'] . ' translation(s) (job #' . $jobId . ')',
                $refId
            );

            if ($result === null || !empty($result['_error'])) {
                error_log('EntityTranslateProcessor: Refund failed for job #' . $jobId . ': ' . ($result['error'] ?? 'null'));
                $refundAmount = 0;
            }
        }

        return ['cancelled' => true, 'refunded' => $refundAmount, 'remaining' => $notTranslated];
    }

    public static function getActiveJob(string $entityType): ?array
    {
        return Database::fetchOne(
            "SELECT * FROM entity_translate_jobs WHERE entity_type = ? AND status IN ('pending','processing') ORDER BY id DESC LIMIT 1",
            [$entityType]
        );
    }

    private static function failJob(int $jobId, string $error): void
    {
        Database::query(
            "UPDATE entity_translate_jobs SET status = 'failed', error_message = ?, completed_at = NOW() WHERE id = ?",
            [mb_substr($error, 0, 1000), $jobId]
        );
    }

    


    

    private static function asyncPing(): void
    {
        $url = rtrim(Setting::get('site_url', ''), '/') . '/';
        if ($url === '/') return;
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT_MS => 500,
            CURLOPT_CONNECTTIMEOUT_MS => 500,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_USERAGENT => 'TubePress-Cron/1.0',
        ]);
        @curl_exec($ch);
        curl_close($ch);
    }

    private static function recoverStalled(): void
    {
        Database::query(
            "UPDATE entity_translate_jobs SET status = 'pending'
             WHERE status = 'processing' AND updated_at < DATE_SUB(NOW(), INTERVAL ? SECOND)",
            [self::STALE_SECONDS]
        );
    }

    

    private static function reconcileRefunds(): void
    {
        $rows = Database::fetchAll(
            "SELECT id, entity_type, refund_amount FROM entity_translate_jobs
             WHERE status = 'completed' AND refund_amount > 0 AND refunded_at IS NULL
             LIMIT 20"
        );
        foreach ($rows as $r) {
            $jobId = (int) $r['id'];
            $amount = (float) $r['refund_amount'];
            $refId = 'entity_translate:' . $r['entity_type'] . ':job:' . $jobId;
            $result = CatalogueClient::refundCredits($amount, 'Auto-refund (reconcile) job #' . $jobId, $refId);
            if ($result !== null && empty($result['_error'])) {
                Database::query("UPDATE entity_translate_jobs SET refunded_at = NOW() WHERE id = ?", [$jobId]);
            }
        }
    }
}
