<?php

declare(strict_types=1);

class CatalogueClient
{
    private const CONNECT_TIMEOUT = 15;
    private const API_TIMEOUT = 130;

     
    public const TRANSLATE_MAX_LANGS = 30;
    private const DOWNLOAD_MAX_SECONDS = 43200;
    private const DOWNLOAD_STALL_BYTES = 1024;
    private const DOWNLOAD_STALL_SECONDS = 300;
    private const MAX_RETRIES = 3;
    private const RETRY_BACKOFF = [5, 10, 15];
     
     
    private const PRICING_TTL = 300;
    private const PRICING_MIN_RETRY = 60;
    private static bool $pricingRefreshQueued = false;

    public static function isConfigured(): bool
    {
        $key = Setting::get('catalogue_api_key', '');
        $secret = Setting::get('catalogue_api_secret', '');
        return $key !== '' && $secret !== '';
    }

     
    public static function pingHealth(): bool
    {
        $apiUrl = Setting::get('catalogue_api_url', '');
        $baseUrl = $apiUrl !== '' ? preg_replace('#/v\d+/?$#', '', rtrim($apiUrl, '/')) : 'https://tubepress.io';
        $ch = curl_init($baseUrl . '/v1/health');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT        => 10,
            CURLOPT_USERAGENT      => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $response = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($httpCode === 200) {
            $data = json_decode((string) $response, true);
            return ($data['status'] ?? '') === 'ok';
        }
        return false;
    }

    public static function getBalance(): ?array
    {
         
        $cached = Setting::get('catalogue_balance_cache', '0');
        $updated = Setting::get('catalogue_balance_updated', '');
        if ($updated !== '' && (time() - strtotime($updated)) < 60) {
            return ['credits' => (float) $cached, 'cached' => true];
        }

        $response = self::request('GET', '/account/balance');
        if ($response === null) {
            return null;
        }

         
        Setting::set('catalogue_balance_cache', (string) ($response['credits'] ?? 0));
        Setting::set('catalogue_balance_updated', date('Y-m-d H:i:s'));

        return $response;
    }

    public static function browse(array $filters): array
    {
        $query = http_build_query(array_filter($filters, fn($v) => $v !== '' && $v !== null));
        if (self::isConfigured()) {
            $path = '/catalogue/browse' . ($query !== '' ? '?' . $query : '');
            $response = self::request('GET', $path);
        } else {
            $path = '/public/catalogue/browse' . ($query !== '' ? '?' . $query : '');
            $response = self::publicRequest('GET', $path);
        }
        if ($response === null) {
             
             
            return ['videos' => [], 'next_cursor' => null, '_error' => true, 'error' => 'catalogue_unreachable'];
        }
        $result = $response;

         
        if (!empty($result['videos'])) {
            foreach ($result['videos'] as &$video) {
                 
                $video['remote_video_id'] = (string) ($video['id'] ?? '');
                $video['source'] = $video['source_slug'] ?? ($video['source_name'] ?? '');
                $video['cost'] = $video['cost_credits'] ?? 0;

                 
                 
                $video['thumbnail']       = self::mediaProxyUrl($video['thumbnail_url'] ?? '', 'thumb');
                $video['thumbnail_small'] = self::mediaProxyUrl($video['thumbnail_small_url'] ?? '', 'thumb_small');
                $video['preview']         = self::mediaProxyUrl($video['preview_url'] ?? '', 'preview');
            }
            unset($video);
        }

         
         
        if (!empty($result['_error']) && empty($result['error'])) {
            $result['error'] = 'catalogue_error';
        }

        return $result;
    }

    

    public static function mediaProxyUrl(string $url, string $type): string
    {
        if ($url === '') {
            return '';
        }
         
        if (str_starts_with($url, '/v1/media/')) {
            return '/admin/catalogue/media/' . $type . '/' . substr($url, strlen('/v1/media/' . $type . '/'));
        }
         
        if (str_starts_with($url, 'https://cdn.tubepress.io/')) {
            return '/admin/catalogue/cdn/' . self::packCdnRef(substr($url, strlen('https://cdn.tubepress.io')));
        }
         
        return $url;
    }

     
    private static function packCdnRef(string $path): string
    {
        $b64 = rtrim(strtr(base64_encode($path), '+/', '-_'), '=');
        return $b64 . '.' . substr(hash_hmac('sha256', $path, self::cdnRefSecret()), 0, 16);
    }

    

    public static function unpackCdnRef(string $ref): ?string
    {
        $dot = strrpos($ref, '.');
        if ($dot === false) {
            return null;
        }
        $path = base64_decode(strtr(substr($ref, 0, $dot), '-_', '+/'), true);
        if ($path === false || $path === '' || $path[0] !== '/') {
            return null;
        }
        $expected = substr(hash_hmac('sha256', $path, self::cdnRefSecret()), 0, 16);
        return hash_equals($expected, substr($ref, $dot + 1)) ? $path : null;
    }

    private static function cdnRefSecret(): string
    {
        $secret = Setting::get('catalogue_api_secret', '');
        if ($secret !== '') {
            return $secret;
        }
         
         
        $fallback = Setting::get('cdn_ref_secret', '');
        if ($fallback === '') {
            $fallback = bin2hex(random_bytes(32));
            Setting::set('cdn_ref_secret', $fallback);
        }
        return $fallback;
    }

    public static function getFilters(array $params = []): array
    {
        $query = http_build_query(array_filter($params, fn($v) => $v !== '' && $v !== null));
        $qs = $query !== '' ? '?' . $query : '';
        if (self::isConfigured()) {
            $response = self::request('GET', '/catalogue/filters' . $qs);
        } else {
            $response = self::publicRequest('GET', '/public/catalogue/filters' . $qs);
        }
        $result = $response ?? ['sources' => [], 'categories' => [], 'qualities' => []];

         
        if (!empty($result['sources'])) {
            $baseUrl = preg_replace('#/v\d+/?$#', '', self::getApiBaseUrl());
            foreach ($result['sources'] as &$source) {
                $icon = $source['icon'] ?? ($source['icon_url'] ?? '');
                if ($icon !== '' && str_starts_with($icon, '/')) {
                    $source['icon'] = $baseUrl . $icon;
                }
            }
            unset($source);
        }

        return $result;
    }

     
     
     

     
    public static function browseGalleries(array $filters): array
    {
        $query = http_build_query(array_filter($filters, fn($v) => $v !== '' && $v !== null));
        if (self::isConfigured()) {
            $response = self::request('GET', '/catalogue/galleries/browse' . ($query !== '' ? '?' . $query : ''));
        } else {
            $response = self::publicRequest('GET', '/public/catalogue/galleries/browse' . ($query !== '' ? '?' . $query : ''));
        }
        if ($response === null) {
            return ['galleries' => [], 'total' => 0, '_error' => true, 'error' => 'catalogue_unreachable'];
        }
        $result = $response;
        if (!empty($result['galleries'])) {
            foreach ($result['galleries'] as &$g) {
                 
                 
                $g['cover'] = self::mediaProxyUrl($g['cover_url'] ?? '', 'thumb');
                $g['remote_gallery_id'] = (string) ($g['id'] ?? '');
            }
            unset($g);
        }
        if (!empty($result['_error']) && empty($result['error'])) {
            $result['error'] = 'catalogue_error';
        }
        return $result;
    }

     
    public static function getGalleryFilters(array $params = []): array
    {
        $query = http_build_query(array_filter($params, fn($v) => $v !== '' && $v !== null));
        $qs = $query !== '' ? '?' . $query : '';
        if (self::isConfigured()) {
            $response = self::request('GET', '/catalogue/galleries/filters' . $qs);
        } else {
            $response = self::publicRequest('GET', '/public/catalogue/galleries/filters' . $qs);
        }
        $result = $response ?? ['sources' => [], 'categories' => [], 'performers' => [], 'tags' => [], 'channels' => []];
        if (!empty($result['sources'])) {
            $baseUrl = preg_replace('#/v\d+/?$#', '', self::getApiBaseUrl());
            foreach ($result['sources'] as &$source) {
                $icon = $source['icon'] ?? ($source['icon_url'] ?? '');
                if ($icon !== '' && str_starts_with($icon, '/')) $source['icon'] = $baseUrl . $icon;
            }
            unset($source);
        }
        return $result;
    }

     
    public static function galleryLookup(string $facet, string $q): array
    {
        $qs = '?' . http_build_query(['facet' => $facet, 'q' => $q]);
        if (self::isConfigured()) {
            $response = self::request('GET', '/catalogue/galleries/lookup' . $qs);
        } else {
            $response = self::publicRequest('GET', '/public/catalogue/galleries/lookup' . $qs);
        }
        return $response ?? ['items' => []];
    }

     
    public static function galleryItem(int $id): ?array
    {
        $qs = '?id=' . $id;
        if (self::isConfigured()) {
            $response = self::request('GET', '/catalogue/galleries/item' . $qs);
        } else {
            $response = self::publicRequest('GET', '/public/catalogue/galleries/item' . $qs);
        }
        if ($response === null || isset($response['error'])) return $response;
        if (!empty($response['images'])) {
            foreach ($response['images'] as &$im) {
                $im['url'] = self::mediaProxyUrl($im['url'] ?? '', 'image');
            }
            unset($im);
        }
        $response['cover'] = isset($response['images'][0]['url']) ? $response['images'][0]['url'] : '';
        return $response;
    }

    

    public static function galleryItemRaw(int $id): ?array
    {
        $qs = '?id=' . $id;
        if (self::isConfigured()) {
            return self::request('GET', '/catalogue/galleries/item' . $qs);
        }
        return self::publicRequest('GET', '/public/catalogue/galleries/item' . $qs);
    }

    public static function getPacks(): ?array
    {
        return self::request('GET', '/account/packs');
    }

    

    public static function getByIds(array $ids): array
    {
        $ids = array_values(array_filter(array_map('strval', $ids), fn($v) => $v !== ''));
        if (!$ids) return [];
        $ids = array_slice($ids, 0, 500);  
        $result = self::browse(['ids' => implode(',', $ids), 'per_page' => count($ids), 'page' => 1]);
        return $result['videos'] ?? [];
    }

    public static function purchase(array $videoIds, float $aiCreditsPerVideo = 0.0, ?int $timeout = null, ?array $aiSettings = null): ?array
    {
        $body = ['video_ids' => $videoIds];
         
         
         
        if (is_array($aiSettings) && !empty($aiSettings['enabled'])) {
            $body['ai_settings'] = $aiSettings;
        }
        if ($aiCreditsPerVideo > 0) {
            $body['ai_credits_per_video'] = $aiCreditsPerVideo;
        }
        return self::request('POST', '/catalogue/purchase', $body, $timeout);
    }

    

    public static function galleryPurchase(array $galleryIds, ?array $aiSettings = null, ?int $timeout = null): ?array
    {
        $body = ['gallery_ids' => array_values($galleryIds)];
         
         
        if (is_array($aiSettings) && !empty($aiSettings['enabled'])) {
            $body['ai_settings'] = $aiSettings;
        }
        return self::request('POST', '/catalogue/galleries/purchase', $body, $timeout);
    }

    

    public static function cataloguePricing(): array
    {
        $defaults = [
            'video' => 0.50,
            'ai'    => ['rewrite_title' => 0.30, 'translate' => 0.40, 'rewrite_description' => 0.30, 'translate_description' => 0.40],
             
             
            'ai_per_lang'         => ['translate' => 0.05, 'translate_description' => 0.06],
            'translate_max_langs' => self::TRANSLATE_MAX_LANGS,
        ];
        $cached = Setting::get('catalogue_pricing_cache', '');
        $parsed = null;
        if ($cached !== '') {
            $p = json_decode($cached, true);
            if (is_array($p) && isset($p['video'], $p['ai'])) $parsed = $p;
        }
        if ($parsed !== null && (time() - (int) Setting::get('catalogue_pricing_cache_at', '0')) < self::PRICING_TTL) {
            return $parsed;
        }
         
         
         
        if ($parsed !== null && self::deferPricingRefresh()) {
            return $parsed;
        }
        return self::fetchPricing() ?? $parsed ?? $defaults;
    }

     

    private static function fetchPricing(): ?array
    {
        $resp = self::getFilters([]);
        if (is_array($resp) && isset($resp['pricing']) && is_array($resp['pricing']) && isset($resp['pricing']['video'], $resp['pricing']['ai'])) {
            Setting::set('catalogue_pricing_cache', json_encode($resp['pricing']));
            Setting::set('catalogue_pricing_cache_at', (string) time());
            return $resp['pricing'];
        }
        return null;
    }

     
     
     

    private static function deferPricingRefresh(): bool
    {
        if (PHP_SAPI === 'cli' || !function_exists('fastcgi_finish_request')) {
            return false;
        }
        if (self::$pricingRefreshQueued) {
            return true;
        }
         
         
        if ((time() - (int) Setting::get('catalogue_pricing_fetch_at', '0')) < self::PRICING_MIN_RETRY) {
            return true;
        }
        Setting::set('catalogue_pricing_fetch_at', (string) time());
        self::$pricingRefreshQueued = true;
        register_shutdown_function(static function (): void {
            @fastcgi_finish_request();
            try {
                self::fetchPricing();
            } catch (\Throwable) {
                 
            }
        });
        return true;
    }

    public static function refreshDownloadUrl(string $remoteVideoId): ?array
    {
        return self::request('POST', '/catalogue/refresh-url', ['remote_video_id' => $remoteVideoId]);
    }

    

    public static function refreshToken(int $videoId): ?array
    {
        return self::request('POST', '/catalogue/refresh-token', ['video_id' => $videoId]);
    }

    

    public static function downloadWithToken(string $downloadToken, string $localPath, string $quality = '', ?callable $heartbeat = null, int $resumeFrom = 0): array
    {
        $dir = dirname($localPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }


        $apiUrl = self::getApiBaseUrl();
        $apiKey = Setting::get('catalogue_api_key', '');
        $apiSecret = Setting::get('catalogue_api_secret', '');

        if ($apiKey === '' || $apiSecret === '') {
            return ['success' => false, 'bytes' => 0, 'error' => 'API not configured', 'resumable' => false];
        }

        clearstatcache(true, $localPath);
        $onDisk = is_file($localPath) ? (int) filesize($localPath) : 0;
        if ($resumeFrom < 0 || $resumeFrom > $onDisk) {
            $resumeFrom = $onDisk;
        }

        $path = '/catalogue/download';
        $payload = ['download_token' => $downloadToken];
        if ($quality !== '') {
            $payload['quality'] = $quality;
        }
        $body = json_encode($payload);
        $url = rtrim($apiUrl, '/') . $path;
        $timestamp = (string) time();

        $signData = implode("\n", ['POST', $path, $timestamp, $body]);
        $signature = hash_hmac('sha256', $signData, $apiSecret);

        $headers = [
            'Content-Type: application/json',
            'X-Api-Key: ' . $apiKey,
            'X-Timestamp: ' . $timestamp,
            'X-Signature: ' . $signature,
        ];
        if ($resumeFrom > 0) {
            $headers[] = 'Range: bytes=' . $resumeFrom . '-';
        }

        $fp = fopen($localPath, $resumeFrom > 0 ? 'r+b' : 'wb');
        if (!$fp) {
            return ['success' => false, 'bytes' => 0, 'error' => 'Cannot open file for writing', 'resumable' => false];
        }
        if ($resumeFrom > 0) {
            ftruncate($fp, $resumeFrom);
            fseek($fp, $resumeFrom);
        }

        $status = 0;
        $expectedTotal = 0;
        $keepBody = true;
        $badRange = false;

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $body,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT        => self::DOWNLOAD_MAX_SECONDS,
            CURLOPT_LOW_SPEED_LIMIT => self::DOWNLOAD_STALL_BYTES,
            CURLOPT_LOW_SPEED_TIME  => self::DOWNLOAD_STALL_SECONDS,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_USERAGENT      => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS      => 5,
            CURLOPT_HEADERFUNCTION => function ($c, $header) use ($fp, $resumeFrom, &$status, &$expectedTotal, &$keepBody, &$badRange) {
                if (preg_match('#^HTTP/[\d.]+\s+(\d{3})#i', $header, $m)) {
                    $status = (int) $m[1];
                    $keepBody = ($status === 200 || $status === 206);
                    $expectedTotal = 0;
                    $badRange = false;
                    if ($resumeFrom > 0 && $status === 200) {
                        ftruncate($fp, 0);
                        fseek($fp, 0);
                    }
                } elseif (preg_match('#^Content-Range:\s*bytes\s+(\d+)-\d+/(\d+)#i', $header, $m)) {
                    if ((int) $m[1] !== $resumeFrom) {
                        $keepBody = false;
                        $badRange = true;
                    }
                    $expectedTotal = (int) $m[2];
                }
                return strlen($header);
            },
            CURLOPT_WRITEFUNCTION  => function ($c, $chunk) use ($fp, &$keepBody) {
                $len = strlen($chunk);
                if (!$keepBody) {
                    return $len;
                }
                $written = fwrite($fp, $chunk);
                return $written === false ? 0 : $written;
            },
        ]);

        if ($heartbeat !== null) {
            curl_setopt($ch, CURLOPT_NOPROGRESS, false);
            curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($c, $dlTotal, $dlNow, $ulTotal, $ulNow) use ($heartbeat) {
                $heartbeat();
                return 0;
            });
        }

        $result = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);
        fclose($fp);

        clearstatcache(true, $localPath);
        $fileSize = is_file($localPath) ? (int) filesize($localPath) : 0;
        $gotMedia = ($status === 200 || $status === 206);

        if ($result === false || $error !== '') {
            return ['success' => false, 'bytes' => $fileSize, 'error' => 'cURL error: ' . $error, 'resumable' => $gotMedia && !$badRange && $fileSize > 0];
        }

        if ($badRange) {
            return ['success' => false, 'bytes' => $fileSize, 'error' => 'Server answered with an unexpected byte range', 'resumable' => false];
        }

        if ($httpCode !== 200 && $httpCode !== 206) {
            return ['success' => false, 'bytes' => $fileSize, 'error' => 'HTTP ' . $httpCode, 'resumable' => false];
        }

        if ($expectedTotal > 0 && $fileSize !== $expectedTotal) {
            return ['success' => false, 'bytes' => $fileSize, 'error' => 'Incomplete download: ' . $fileSize . ' of ' . $expectedTotal . ' bytes', 'resumable' => $fileSize < $expectedTotal];
        }

        if ($fileSize === 0) {
            return ['success' => false, 'bytes' => 0, 'error' => 'Download endpoint returned an empty file (too small)', 'resumable' => false];
        }

        return ['success' => true, 'bytes' => $fileSize, 'error' => null, 'resumable' => false];
    }

    

    public static function fetchMedia(string $type, string $token): ?array
    {
        $apiUrl = self::getApiBaseUrl();
        $url = rtrim($apiUrl, '/') . '/media/' . $type . '/' . $token;

        $responseHeaders = [];

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT        => self::API_TIMEOUT,
            CURLOPT_USERAGENT      => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) {
                $parts = explode(':', $header, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($header);
            },
        ]);

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

        if ($body === false || $error !== '' || $httpCode >= 400) {
            return null;
        }

        return [
            'body'         => $body,
            'content_type' => $responseHeaders['content-type'] ?? 'application/octet-stream',
            'http_code'    => $httpCode,
        ];
    }

    

    public static function downloadFile(string $url, string $localPath, int $resumeFrom = 0): array
    {
        $dir = dirname($localPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $mode = $resumeFrom > 0 ? 'ab' : 'wb';
        $fp = fopen($localPath, $mode);
        if (!$fp) {
            return ['success' => false, 'bytes' => 0, 'error' => 'Cannot open file for writing'];
        }

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_FILE => $fp,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::DOWNLOAD_MAX_SECONDS,
            CURLOPT_LOW_SPEED_LIMIT => self::DOWNLOAD_STALL_BYTES,
            CURLOPT_LOW_SPEED_TIME => self::DOWNLOAD_STALL_SECONDS,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS => 5,
            CURLOPT_USERAGENT => 'TubePress/1.0',
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        if ($resumeFrom > 0) {
            curl_setopt($ch, CURLOPT_RESUME_FROM, $resumeFrom);
        }

        $result = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);
        fclose($fp);

        $fileSize = file_exists($localPath) ? filesize($localPath) : 0;

        if ($result === false || $error !== '') {
            return ['success' => false, 'bytes' => $fileSize, 'error' => 'cURL error: ' . $error];
        }

        if ($httpCode >= 400) {
            return ['success' => false, 'bytes' => $fileSize, 'error' => 'HTTP ' . $httpCode];
        }

        return ['success' => true, 'bytes' => $fileSize, 'error' => null];
    }

     

    public static function getMarketplacePlugins(): ?array
    {
        if (self::isConfigured()) {
            return self::request('GET', '/marketplace/plugins');
        }
        return self::publicRequest('GET', '/public/marketplace/plugins');
    }

    public static function getMarketplaceThemes(): ?array
    {
        if (self::isConfigured()) {
            return self::request('GET', '/marketplace/themes');
        }
        return self::publicRequest('GET', '/public/marketplace/themes');
    }

    public static function checkUpdates(): ?array
    {
         
        $plugins = [];
        $pluginDir = APP_ROOT . '/plugins';
        if (is_dir($pluginDir)) {
            foreach (scandir($pluginDir) as $slug) {
                if ($slug === '.' || $slug === '..') continue;
                $jsonPath = $pluginDir . '/' . $slug . '/plugin.json';
                if (file_exists($jsonPath)) {
                    $info = json_decode(file_get_contents($jsonPath), true);
                    if ($info && isset($info['version'])) {
                        $plugins[$slug] = $info['version'];
                    }
                }
            }
        }

         
        $themes = [];
        $themeDir = APP_ROOT . '/themes';
        if (is_dir($themeDir)) {
            foreach (scandir($themeDir) as $slug) {
                if ($slug === '.' || $slug === '..') continue;
                $jsonPath = $themeDir . '/' . $slug . '/theme.json';
                if (file_exists($jsonPath)) {
                    $info = json_decode(file_get_contents($jsonPath), true);
                    if ($info && isset($info['version'])) {
                        $themes[$slug] = $info['version'];
                    }
                }
            }
        }

         
         
         
        $host = strtolower((string) (parse_url((string) Setting::get('site_url', ''), PHP_URL_HOST) ?: ''));
        if ($host === '') {
            $host = self::getSiteDomain();
        }

        $query = http_build_query([
            'cms_version' => APP_VERSION,
            'php_version' => PHP_VERSION,
            'domain' => $host,
            'plugins' => json_encode($plugins),
            'themes' => json_encode($themes),
        ]);

        if (self::isConfigured()) {
            return self::request('GET', '/marketplace/check-updates?' . $query);
        }

         
        return self::publicRequest('GET', '/public/check-updates?' . $query);
    }

    public static function purchaseItem(string $type, string $slug, ?int $tierId = null): ?array
    {
        $body = [
            'item_type' => $type,
            'item_slug' => $slug,
        ];
        if ($tierId !== null) {
            $body['tier_id'] = $tierId;
        }
        return self::request('POST', '/marketplace/purchase', $body);
    }

    public static function paymentMethods(): ?array
    {
        return self::request('GET', '/credits/methods', null, 20, 1);
    }

    // pack_id 0 means "the amount the buyer named", carried in amount_cents. The
    // update server prices it; we never send a credit figure, only the amount.
    public static function cryptoEstimate(int $packId, string $coin, int $amountCents = 0): ?array
    {
        return self::request('GET', '/credits/crypto/estimate?pack_id=' . $packId
            . '&amount_cents=' . $amountCents . '&coin=' . urlencode($coin), null, 30, 1);
    }

    public static function cryptoCreate(int $packId, string $coin, int $amountCents = 0): ?array
    {
        return self::request('POST', '/credits/crypto/create', [
            'pack_id' => $packId,
            'amount_cents' => $amountCents,
            'coin' => $coin,
        ], 45, 1);
    }

    public static function cryptoStatus(int $paymentId, bool $withQr = false): ?array
    {
        return self::request('GET', '/credits/crypto/status?id=' . $paymentId . ($withQr ? '&qr=1' : ''), null, 25, 1);
    }

    public static function cryptoCancel(int $paymentId): ?array
    {
        return self::request('POST', '/credits/crypto/cancel', ['id' => $paymentId], 25, 1);
    }

    public static function createCheckout(int $packId, int $amountCents = 0): ?array
    {
        return self::request('POST', '/credits/create-checkout', [
            'pack_id' => $packId,
            'amount_cents' => $amountCents,
        ]);
    }

    public static function sessionStatus(string $sessionId): ?array
    {
        return self::request('GET', '/credits/session-status?session_id=' . urlencode($sessionId));
    }

    public static function billingStatus(): ?array
    {
        return self::request('GET', '/account/billing-status');
    }

    public static function saveBilling(array $data): ?array
    {
        return self::request('POST', '/account/save-billing', $data);
    }

    

    public static function verifyLicense(string $type, string $slug): ?array
    {
        $query = http_build_query(['type' => $type, 'slug' => $slug]);
        return self::request('GET', '/marketplace/verify-license?' . $query);
    }

    

    public static function licenseStatus(string $domain, array $items): ?array
    {
        return self::request('POST', '/marketplace/license-status', [
            'domain' => $domain,
            'items'  => array_values($items),
        ]);
    }

    

    public static function getSiteDomain(): string
    {
         
         
        return Domain::registrable($_SERVER['HTTP_HOST'] ?? 'localhost');
    }

    

    private static function getVerificationToken(): string
    {
        $token = Setting::get('licence_verification_token', '');
        if ($token === '') {
            $token = bin2hex(random_bytes(32));
            Setting::set('licence_verification_token', $token);
        }
        return $token;
    }

    

    public static function activateSite(string $type, string $slug, string $domain): ?array
    {
        return self::request('POST', '/marketplace/activate-site', [
            'item_type' => $type,
            'item_slug' => $slug,
            'domain' => $domain,
            'verification_token' => self::getVerificationToken(),
        ]);
    }

    

    public static function deactivateSite(string $type, string $slug, string $domain): ?array
    {
        return self::request('POST', '/marketplace/deactivate-site', [
            'item_type' => $type,
            'item_slug' => $slug,
            'domain' => $domain,
        ]);
    }

    public static function downloadPackage(string $type, string $slug, string $version, bool $isFree = false): array
    {
        $tmpDir = APP_ROOT . '/storage/tmp';
        if (!is_dir($tmpDir)) {
            mkdir($tmpDir, 0755, true);
        }

        $localPath = $tmpDir . '/' . $slug . '-' . $version . '.zip';

        if (self::isConfigured()) {
            $apiPath = '/marketplace/download/' . $type . '/' . $slug . '/' . $version;
            $result = self::requestBinary('GET', $apiPath, $localPath);
        } elseif ($isFree) {
            $apiPath = '/public/download/' . $type . '/' . $slug . '/' . $version;
            $result = self::publicRequestBinary('GET', $apiPath, $localPath);
        } else {
            return ['success' => false, 'error' => 'API not configured — paid items require an account', 'checksum' => '', 'local_path' => $localPath];
        }

        $result['local_path'] = $localPath;
        return $result;
    }

     

    public static function supportCreate(array $payload): ?array
    {
        return self::request('POST', '/support/tickets', $payload);
    }

    public static function supportList(): ?array
    {
        return self::request('GET', '/support/tickets');
    }

    public static function supportGet(int $id): ?array
    {
        return self::request('GET', '/support/tickets/' . $id);
    }

    public static function supportClose(int $id): ?array
    {
        return self::request('POST', '/support/tickets/' . $id . '/close', []);
    }

    public static function supportDelete(int $id): ?array
    {
        return self::request('POST', '/support/tickets/' . $id . '/delete', []);
    }

    public static function supportReply(int $id, array $payload): ?array
    {
        return self::request('POST', '/support/tickets/' . $id . '/reply', $payload);
    }

    public static function supportCreatePublic(array $payload): ?array
    {
        return self::publicPost('/public/support/tickets', $payload);
    }

    public static function supportGetPublic(string $token): ?array
    {
        return self::publicRequest('GET', '/public/support/thread?token=' . urlencode($token));
    }

    public static function supportReplyPublic(array $payload): ?array
    {
        return self::publicPost('/public/support/reply', $payload);
    }

     
    public static function supportFetchAttachment(int $id, ?string $guestToken = null): ?array
    {
        if ($guestToken !== null && $guestToken !== '') {
            return self::rawGet('/public/support/attachment/' . $id . '?token=' . urlencode($guestToken), false);
        }
        return self::rawGet('/support/attachment/' . $id, true);
    }

     
    private static function publicPost(string $path, array $body): ?array
    {
        $url = self::getApiBaseUrl() . $path;
        $json = json_encode($body);

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $json,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::API_TIMEOUT,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_USERAGENT => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);
        $resp = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err = curl_error($ch);
        curl_close($ch);

        if ($resp === false || $err !== '') {
            error_log('CatalogueClient: publicPost error on ' . $path . ': ' . $err);
            return null;
        }
        $decoded = json_decode((string) $resp, true);
        if ($code >= 400) {
            return is_array($decoded) ? array_merge($decoded, ['_http_code' => $code, '_error' => true]) : null;
        }
        return $decoded;
    }

     
    private static function rawGet(string $path, bool $signed): ?array
    {
        $url = self::getApiBaseUrl() . $path;
        $headers = [];
        if ($signed) {
            $apiKey = Setting::get('catalogue_api_key', '');
            $apiSecret = Setting::get('catalogue_api_secret', '');
            if ($apiKey === '' || $apiSecret === '') {
                return null;
            }
            $ts = (string) time();
            $signPath = parse_url($path, PHP_URL_PATH) ?: $path;
            $sig = hash_hmac('sha256', implode("\n", ['GET', $signPath, $ts, '']), $apiSecret);
            $headers = ['X-Api-Key: ' . $apiKey, 'X-Timestamp: ' . $ts, 'X-Signature: ' . $sig];
        }
        $respHeaders = [];
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::API_TIMEOUT,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_USERAGENT => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$respHeaders) {
                $p = explode(':', $h, 2);
                if (count($p) === 2) { $respHeaders[strtolower(trim($p[0]))] = trim($p[1]); }
                return strlen($h);
            },
        ]);
        $body = curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $err = curl_error($ch);
        curl_close($ch);
        if ($body === false || $err !== '' || $code >= 400) {
            return null;
        }
        return ['body' => $body, 'content_type' => $respHeaders['content-type'] ?? 'application/octet-stream'];
    }

    private static function getApiBaseUrl(): string
    {
        $url = Setting::get('catalogue_api_url', '');
        return $url !== '' ? rtrim($url, '/') : 'https://tubepress.io/v1';
    }

    private static function publicRequest(string $method, string $path): ?array
    {
        $url = self::getApiBaseUrl() . $path;

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::API_TIMEOUT,
            CURLOPT_USERAGENT => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

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

        if ($response === false || $error !== '') {
            error_log('CatalogueClient: public cURL error on ' . $path . ': ' . $error);
            return null;
        }

        if ($httpCode >= 400) {
            error_log('CatalogueClient: public HTTP ' . $httpCode . ' on ' . $path . ': ' . substr((string) $response, 0, 500));
            return null;
        }

        return json_decode((string) $response, true);
    }

    private static function publicRequestBinary(string $method, string $path, string $localPath): array
    {
        $apiUrl = self::getApiBaseUrl();
        $url = rtrim($apiUrl, '/') . $path;

        $dir = dirname($localPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $fp = fopen($localPath, 'wb');
        if (!$fp) {
            return ['success' => false, 'error' => 'Cannot open file for writing', 'checksum' => ''];
        }

        $responseHeaders = [];

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_FILE => $fp,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::DOWNLOAD_MAX_SECONDS,
            CURLOPT_LOW_SPEED_LIMIT => self::DOWNLOAD_STALL_BYTES,
            CURLOPT_LOW_SPEED_TIME => self::DOWNLOAD_STALL_SECONDS,
            CURLOPT_USERAGENT => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) {
                $parts = explode(':', $header, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($header);
            },
        ]);

        $result = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);
        fclose($fp);

        if ($result === false || $error !== '') {
            return ['success' => false, 'error' => 'cURL error: ' . $error, 'checksum' => ''];
        }

        if ($httpCode >= 400) {
             
             
             
            $errBody = @file_get_contents($localPath);
            @unlink($localPath);
            $j = json_decode((string) $errBody, true);
            return [
                'success'    => false,
                'error'      => (is_array($j) && !empty($j['message'])) ? (string) $j['message'] : 'HTTP ' . $httpCode,
                'error_code' => (is_array($j) && !empty($j['error'])) ? (string) $j['error'] : '',
                'renew_url'  => (is_array($j) && !empty($j['renew_url'])) ? (string) $j['renew_url'] : '',
                'http_code'  => $httpCode,
                'checksum'   => '',
            ];
        }

        return [
            'success' => true,
            'error' => null,
            'checksum' => $responseHeaders['x-checksum-sha256'] ?? '',
            'signature' => $responseHeaders['x-signature-ed25519'] ?? '',
        ];
    }

    private static function requestBinary(string $method, string $path, string $localPath): array
    {
        $apiUrl = self::getApiBaseUrl();
        $apiKey = Setting::get('catalogue_api_key', '');
        $apiSecret = Setting::get('catalogue_api_secret', '');

        if ($apiKey === '' || $apiSecret === '') {
            return ['success' => false, 'error' => 'API not configured', 'checksum' => ''];
        }

        $url = rtrim($apiUrl, '/') . $path;
        $timestamp = (string) time();

        $parsedPath = parse_url($path, PHP_URL_PATH) ?: $path;
        $signData = implode("\n", [$method, $parsedPath, $timestamp, '']);
        $signature = hash_hmac('sha256', $signData, $apiSecret);

        $dir = dirname($localPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $fp = fopen($localPath, 'wb');
        if (!$fp) {
            return ['success' => false, 'error' => 'Cannot open file for writing', 'checksum' => ''];
        }

        $responseHeaders = [];

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_FILE => $fp,
            CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
            CURLOPT_TIMEOUT => self::DOWNLOAD_MAX_SECONDS,
            CURLOPT_LOW_SPEED_LIMIT => self::DOWNLOAD_STALL_BYTES,
            CURLOPT_LOW_SPEED_TIME => self::DOWNLOAD_STALL_SECONDS,
            CURLOPT_HTTPHEADER => [
                'X-Api-Key: ' . $apiKey,
                'X-Timestamp: ' . $timestamp,
                'X-Signature: ' . $signature,
            ],
            CURLOPT_USERAGENT => 'TubePress/' . APP_VERSION,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) {
                $parts = explode(':', $header, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($header);
            },
        ]);

        $result = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);
        fclose($fp);

        if ($result === false || $error !== '') {
            return ['success' => false, 'error' => 'cURL error: ' . $error, 'checksum' => ''];
        }

        if ($httpCode >= 400) {
             
             
             
            $errBody = @file_get_contents($localPath);
            @unlink($localPath);
            $j = json_decode((string) $errBody, true);
            return [
                'success'    => false,
                'error'      => (is_array($j) && !empty($j['message'])) ? (string) $j['message'] : 'HTTP ' . $httpCode,
                'error_code' => (is_array($j) && !empty($j['error'])) ? (string) $j['error'] : '',
                'renew_url'  => (is_array($j) && !empty($j['renew_url'])) ? (string) $j['renew_url'] : '',
                'http_code'  => $httpCode,
                'checksum'   => '',
            ];
        }

        return [
            'success' => true,
            'error' => null,
            'checksum' => $responseHeaders['x-checksum-sha256'] ?? '',
            'signature' => $responseHeaders['x-signature-ed25519'] ?? '',
        ];
    }

    

    public static function getAiConfig(): ?array
    {
        return self::request('GET', '/ai/config');
    }

    

    public static function translateEntitiesLookup(array $items, array $langCodes): ?array
    {
        if (empty($items) || empty($langCodes)) {
            return null;
        }
        $res = self::request('POST', '/ai/translate-lookup', [
            'items'     => array_values($items),
            'languages' => array_values($langCodes),
        ]);
        if (!is_array($res) || !empty($res['_error']) || !isset($res['results']) || !is_array($res['results'])) {
            return null;
        }
        return $res['results'];
    }

    

    public static function translateQuote(array $fields, array $langCodes): ?array
    {
        if (empty($fields) || empty($langCodes)) {
            return null;
        }
        $res = self::request('POST', '/ai/translate-quote', [
            'fields'    => $fields,
            'languages' => array_values($langCodes),
        ]);
        return (is_array($res) && empty($res['_error'])) ? $res : null;
    }

    

    public static function translateCharge(array $fields, array $langCodes, string $referenceId, int $calls = 0): ?array
    {
        if (empty($fields) || empty($langCodes) || $referenceId === '') {
            return null;
        }
        $body = [
            'fields'       => $fields,
            'languages'    => array_values($langCodes),
            'reference_id' => $referenceId,
        ];
        if ($calls > 0) {
            $body['calls'] = $calls;
        }
        return self::request('POST', '/ai/translate-charge', $body);
    }

    

    public static function translateEntitiesStore(array $entries): void
    {
        if (empty($entries)) {
            return;
        }
        foreach (array_chunk(array_values($entries), 500) as $chunk) {
            self::request('POST', '/ai/translate-store', ['entries' => $chunk]);
        }
    }

    public static function aiCompletion(array $messages, string $model = 'auto', float $temperature = 0.7, ?int $maxTokens = null): ?array
    {
        $body = [
            'messages'    => $messages,
            'model'       => $model,
            'temperature' => $temperature,
        ];
        if ($maxTokens !== null && $maxTokens > 0) {
            $body['max_tokens'] = $maxTokens;
        }

        return self::request('POST', '/ai/completion', $body);
    }

    

    public static function aiCompletionParallel(array $requests): array
    {
        $apiKey = Setting::get('catalogue_api_key', '');
        $apiSecret = Setting::get('catalogue_api_secret', '');
        if ($apiKey === '' || $apiSecret === '') {
            return array_fill(0, count($requests), null);
        }

        $url = self::getApiBaseUrl() . '/ai/completion';
        $path = '/ai/completion';
        $mh = curl_multi_init();
        $handles = [];

        foreach ($requests as $i => $req) {
            $body = [
                'messages'    => $req['messages'],
                'model'       => $req['model'] ?? 'auto',
                'temperature' => $req['temperature'] ?? 0.7,
            ];
            $bodyJson = json_encode($body);
            $timestamp = (string) time();

            $signData = implode("\n", ['POST', $path, $timestamp, $bodyJson]);
            $signature = hash_hmac('sha256', $signData, $apiSecret);

            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => $bodyJson,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
                CURLOPT_TIMEOUT => self::API_TIMEOUT,
                CURLOPT_HTTPHEADER => [
                    'Content-Type: application/json',
                    'X-Api-Key: ' . $apiKey,
                    'X-Timestamp: ' . $timestamp,
                    'X-Signature: ' . $signature,
                ],
                CURLOPT_USERAGENT => 'TubePress/1.0',
                CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            ]);

            $handles[$i] = $ch;
            curl_multi_add_handle($mh, $ch);
        }

         
        do {
            $status = curl_multi_exec($mh, $active);
            if ($active) {
                curl_multi_select($mh, 1.0);
            }
        } while ($active && $status === CURLM_OK);

         
        $results = [];
        foreach ($handles as $i => $ch) {
            $response = curl_multi_getcontent($ch);
            $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $error = curl_error($ch);

            if ($error !== '' || $response === false || $httpCode !== 200) {
                $results[$i] = null;
            } else {
                $decoded = json_decode((string) $response, true);
                $results[$i] = $decoded ?: null;
            }

            curl_multi_remove_handle($mh, $ch);
            curl_close($ch);
        }

        curl_multi_close($mh);
        return $results;
    }

    

    public static function aiCompletionPool(iterable $requestIterator, callable $onComplete, int $poolSize = 5): void
    {
        $apiKey = Setting::get('catalogue_api_key', '');
        $apiSecret = Setting::get('catalogue_api_secret', '');
        if ($apiKey === '' || $apiSecret === '') {
            foreach ($requestIterator as $i => $req) { $onComplete($i, null); }
            return;
        }

        $url = self::getApiBaseUrl() . '/ai/completion';
        $path = '/ai/completion';
        $mh = curl_multi_init();
        $active = [];  
        $iterator = (function() use ($requestIterator) { yield from $requestIterator; })();
        $iterator->rewind();

        $createHandle = function () use ($iterator, $apiKey, $apiSecret, $url, $path, $mh, &$active): bool {
            if (!$iterator->valid()) return false;

            $i = $iterator->key();
            $req = $iterator->current();
            $iterator->next();

            $body = [
                'messages'    => $req['messages'],
                'model'       => $req['model'] ?? 'auto',
                'temperature' => $req['temperature'] ?? 0.7,
            ];
            $bodyJson = json_encode($body);
            $timestamp = (string) time();
            $signData = implode("\n", ['POST', $path, $timestamp, $bodyJson]);
            $signature = hash_hmac('sha256', $signData, $apiSecret);

            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => $bodyJson,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
                CURLOPT_TIMEOUT => self::API_TIMEOUT,
                CURLOPT_HTTPHEADER => [
                    'Content-Type: application/json',
                    'X-Api-Key: ' . $apiKey,
                    'X-Timestamp: ' . $timestamp,
                    'X-Signature: ' . $signature,
                ],
                CURLOPT_USERAGENT => 'TubePress/1.0',
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
            ]);

            curl_multi_add_handle($mh, $ch);
            $active[(int) $ch] = ['index' => $i, 'ch' => $ch];
            return true;
        };

         
        for ($s = 0; $s < $poolSize; $s++) {
            if (!$createHandle()) break;
        }

         
        while (!empty($active)) {
            curl_multi_exec($mh, $running);
            curl_multi_select($mh, 0.1);

             
            while ($info = curl_multi_info_read($mh)) {
                $ch = $info['handle'];
                $rid = (int) $ch;

                if (!isset($active[$rid])) continue;
                $slot = $active[$rid];
                unset($active[$rid]);

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

                curl_multi_remove_handle($mh, $ch);
                curl_close($ch);

                $result = null;
                if ($error === '' && $response !== false && $httpCode === 200) {
                    $result = json_decode((string) $response, true) ?: null;
                }

                 
                $onComplete($slot['index'], $result);

                 
                $createHandle();
            }
        }

        curl_multi_close($mh);
    }

    

    public static function refundCredits(float $amount, string $description, string $originalReferenceId, ?int $retries = null): ?array
    {
        return self::request('POST', '/ai/refund', [
            'amount'                => $amount,
            'description'           => $description,
            'original_reference_id' => $originalReferenceId,
        ], null, $retries);
    }

    

    public static function debitCredits(float $amount, string $description, ?string $referenceId = null): ?array
    {
        $body = [
            'amount'      => $amount,
            'description' => $description,
        ];
        if ($referenceId !== null) {
            $body['reference_id'] = $referenceId;
        }
        return self::request('POST', '/ai/debit', $body);
    }

    private static function request(string $method, string $path, ?array $body = null, ?int $timeout = null, ?int $retries = null): ?array
    {
        $apiKey = Setting::get('catalogue_api_key', '');
        $apiSecret = Setting::get('catalogue_api_secret', '');

        if ($apiKey === '' || $apiSecret === '') {
            return null;
        }

        $url = self::getApiBaseUrl() . $path;
        $effectiveTimeout = $timeout ?? self::API_TIMEOUT;
        $maxRetries = $retries !== null ? max(1, $retries) : self::MAX_RETRIES;

        for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
            $timestamp = (string) time();
            $bodyJson = $body !== null ? json_encode($body) : '';
            $parsedPath = parse_url($path, PHP_URL_PATH) ?: $path;

            $signData = implode("\n", [$method, $parsedPath, $timestamp, $bodyJson]);
            $signature = hash_hmac('sha256', $signData, $apiSecret);

            $headers = [
                'Content-Type: application/json',
                'X-Api-Key: ' . $apiKey,
                'X-Timestamp: ' . $timestamp,
                'X-Signature: ' . $signature,
            ];

            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
                CURLOPT_TIMEOUT => $effectiveTimeout,
                CURLOPT_HTTPHEADER => $headers,
                CURLOPT_USERAGENT => 'TubePress/1.0',
                CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            ]);

            if ($method === 'POST') {
                curl_setopt($ch, CURLOPT_POST, true);
                curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
            }

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

             
            if ($response === false || $error !== '') {
                error_log("CatalogueClient: cURL error on {$path} (attempt {$attempt}/{$maxRetries}): {$error}");
                if ($attempt < $maxRetries) {
                    sleep(self::RETRY_BACKOFF[$attempt - 1] ?? 15);
                    continue;
                }
                return null;
            }

             
            if ($httpCode === 400 || $httpCode === 401) {
                error_log("CatalogueClient: HTTP {$httpCode} on {$path} — not retrying: " . substr((string) $response, 0, 500));
                $decoded = json_decode((string) $response, true);
                if ($decoded !== null) {
                    return array_merge($decoded, ['_http_code' => $httpCode, '_error' => true]);
                }
                return null;
            }

             
            if ($httpCode === 402) {
                error_log("CatalogueClient: HTTP 402 on {$path} — insufficient credits");
                $decoded = json_decode((string) $response, true);
                if ($decoded !== null) {
                    return array_merge($decoded, ['_http_code' => 402, '_error' => true]);
                }
                return null;
            }

             
            if ($httpCode === 429 || $httpCode >= 500) {
                error_log("CatalogueClient: HTTP {$httpCode} on {$path} (attempt {$attempt}/{$maxRetries})");
                if ($attempt < $maxRetries) {
                    sleep(self::RETRY_BACKOFF[$attempt - 1] ?? 15);
                    continue;
                }
                $decoded = json_decode((string) $response, true);
                if ($decoded !== null) {
                    return array_merge($decoded, ['_http_code' => $httpCode, '_error' => true]);
                }
                return null;
            }

             
            if ($httpCode >= 400) {
                error_log("CatalogueClient: HTTP {$httpCode} on {$path}: " . substr((string) $response, 0, 500));
                $decoded = json_decode((string) $response, true);
                if ($decoded !== null) {
                    return array_merge($decoded, ['_http_code' => $httpCode, '_error' => true]);
                }
                return null;
            }

             
            $decoded = json_decode((string) $response, true);
            if ($decoded === null && trim((string) $response) !== '') {
                error_log("CatalogueClient: Invalid JSON on {$path} (attempt {$attempt}/{$maxRetries})");
                if ($attempt < $maxRetries) {
                    sleep(self::RETRY_BACKOFF[$attempt - 1] ?? 15);
                    continue;
                }
                return null;
            }

            return $decoded;
        }

        return null;
    }
}
