<?php

declare(strict_types=1);



class SitemapController
{
     
    private const URLS_PER_FILE = 25000;

     
    private const BATCH_SIZE = 500;

     
    private const CACHE_DIR = APP_ROOT . '/storage/sitemaps';

     
    private const CACHE_TTL = 3600;

     
    private const CRON_INTERVAL = 3600;

     

    public static function routes(): void
    {
        Router::get('/sitemap.xml', [self::class, 'index']);
        Router::get('/sitemap-{name}.xml', [self::class, 'sub']);
        Router::get('/robots.txt', [self::class, 'robots']);
    }

    

    public static function registerCron(): void
    {
        CronManager::register('sitemap_regen', self::CRON_INTERVAL, [self::class, 'cronRegenerate']);
    }

     

    

    public static function robots(): void
    {
        $siteUrl = SEO::siteUrl();
        header('Content-Type: text/plain; charset=UTF-8');
        header('Cache-Control: public, max-age=86400');

         
        if (Setting::get('seo_noindex_entire_site', '0') === '1') {
            $default = "User-agent: *\n"
                . "Disallow: /\n";
            echo HookSystem::applyFilter('sitemap.robots_txt', $default);
            exit;
        }

        $default = "User-agent: *\n"
            . "Disallow: /admin/\n"
            . "Disallow: /install/\n"
            . "Disallow: /search\n"
            . "Disallow: /login\n"
            . "Disallow: /register\n"
            . "Disallow: /forgot-password\n"
            . "Disallow: /embed/\n"
            . "Disallow: /profile\n"
            . "Disallow: /logout\n"
            . "Disallow: /api/\n"
            . "Disallow: /storage/\n"
            . "Disallow: /*?sort=\n"
            . "Disallow: /*?page=\n"
            . "\n"
            . "# Allow CSS/JS for rendering\n"
            . "Allow: /themes/\n"
            . "Allow: /templates/admin/assets/\n"
            . "\n"
            . "# Aggressive bots\n"
            . "User-agent: AhrefsBot\n"
            . "Crawl-delay: 10\n"
            . "\n"
            . "User-agent: SemrushBot\n"
            . "Crawl-delay: 10\n"
            . "\n"
            . "User-agent: MJ12bot\n"
            . "Crawl-delay: 10\n"
            . "\n"
            . "Sitemap: {$siteUrl}/sitemap.xml\n";
        echo HookSystem::applyFilter('sitemap.robots_txt', $default);
        exit;
    }

    

    public static function index(): void
    {
        $siteUrl = self::siteUrl();
        $types = Video::availableTypes();
        $multiType = count($types) >= 2;
        $defaultType = $types[0] ?? Video::DEFAULT_TYPE;
        $opts = self::getOptions();
        $maxUrls = $opts['max_urls'];

        header('Content-Type: application/xml; charset=UTF-8');
        header('X-Robots-Tag: noindex');
        header('Cache-Control: public, max-age=3600');

        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        echo '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

         
        if (!self::shouldSkipEntity('videos')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne(
                    "SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM videos WHERE status = 'published' AND video_type = ?",
                    [$type]
                );
                $total = (int) ($row['c'] ?? 0);
                if ($total === 0) continue;
                $lastmod = $row['m'] ?? '';
                $pages = (int) ceil($total / $maxUrls);
                for ($i = 1; $i <= $pages; $i++) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'videos-' . $i . '.xml', $lastmod);
                }
            }
        }

         
        if (!self::shouldSkipEntity('categories')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(created_at) AS m FROM categories WHERE video_type = ?', [$type]);
                if ((int) ($row['c'] ?? 0) > 0) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'categories.xml', $row['m'] ?? '');
                }
            }
        }

         
        if (!self::shouldSkipEntity('tags')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(created_at) AS m FROM tags WHERE video_type = ?', [$type]);
                $total = (int) ($row['c'] ?? 0);
                if ($total > 0) {
                    $pages = (int) ceil($total / $maxUrls);
                    for ($i = 1; $i <= $pages; $i++) {
                        self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'tags-' . $i . '.xml', $row['m'] ?? '');
                    }
                }
            }
        }

         
        if (!self::shouldSkipEntity('performers')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM performers WHERE video_type = ?', [$type]);
                if ((int) ($row['c'] ?? 0) > 0) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'performers.xml', $row['m'] ?? '');
                }
            }
        }

         
        if (!self::shouldSkipEntity('channels')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM channels WHERE video_type = ?', [$type]);
                if ((int) ($row['c'] ?? 0) > 0) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'channels.xml', $row['m'] ?? '');
                }
            }
        }

         
        if (!self::shouldSkipEntity('albums')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne("SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM albums WHERE video_type = ? AND status = 'published'", [$type]);
                if ((int) ($row['c'] ?? 0) > 0) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'albums.xml', $row['m'] ?? '');
                }
            }
        }

         
        if (!self::shouldSkipEntity('album-categories')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(created_at) AS m FROM album_categories WHERE video_type = ? AND album_count > 0', [$type]);
                if ((int) ($row['c'] ?? 0) > 0) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'album-categories.xml', $row['m'] ?? '');
                }
            }
        }
        if (!self::shouldSkipEntity('album-tags')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(created_at) AS m FROM album_tags WHERE video_type = ? AND album_count > 0', [$type]);
                $total = (int) ($row['c'] ?? 0);
                if ($total > 0) {
                    $pages = (int) ceil($total / $maxUrls);
                    for ($i = 1; $i <= $pages; $i++) {
                        self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'album-tags-' . $i . '.xml', $row['m'] ?? '');
                    }
                }
            }
        }
        if (!self::shouldSkipEntity('album-performers')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(created_at) AS m FROM album_performers WHERE video_type = ? AND album_count > 0', [$type]);
                if ((int) ($row['c'] ?? 0) > 0) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'album-performers.xml', $row['m'] ?? '');
                }
            }
        }
        if (!self::shouldSkipEntity('album-channels')) {
            foreach ($types as $type) {
                $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';
                $row = Database::fetchOne('SELECT COUNT(*) AS c, MAX(created_at) AS m FROM album_channels WHERE video_type = ? AND album_count > 0', [$type]);
                if ((int) ($row['c'] ?? 0) > 0) {
                    self::writeEntry($siteUrl . '/sitemap-' . $typeKey . 'album-channels.xml', $row['m'] ?? '');
                }
            }
        }

         
        if (!self::shouldSkipEntity('pages')) {
            $row = Database::fetchOne("SELECT COUNT(*) AS c, MAX(updated_at) AS m FROM pages WHERE status = 'published'");
            if ((int) ($row['c'] ?? 0) > 0) {
                self::writeEntry($siteUrl . '/sitemap-pages.xml', $row['m'] ?? '');
            }
        }

        echo '</sitemapindex>';
        exit;
    }

    

    public static function sub(): void
    {
        $name = Router::param('name');

         
        if (!preg_match('/^[a-z0-9\-]+$/', $name)) {
            http_response_code(404);
            header('Content-Type: text/html');
            echo '<h1>Sitemap not found</h1>';
            exit;
        }

        self::ensureCacheDir();
        $cacheFile = self::CACHE_DIR . '/' . $name . '.xml.gz';

         
        $mtime = @filemtime($cacheFile);
        if ($mtime !== false && (time() - $mtime) < self::CACHE_TTL) {
            self::serveGzip($cacheFile);
            exit;
        }

         
        $ok = self::generateFile($name, $cacheFile);
        if ($ok && file_exists($cacheFile)) {
            self::serveGzip($cacheFile);
        } else {
            http_response_code(404);
            header('Content-Type: text/html');
            echo '<h1>Sitemap not found</h1>';
        }
        exit;
    }

     

    

    public static function markDirty(): void
    {
        Setting::set('sitemaps_dirty', '1');
    }

     

    

    public static function cronRegenerate(): void
    {
         
        if (Setting::getDirect('sitemaps_dirty', '1') !== '1') {
            if (is_dir(self::CACHE_DIR) && !empty(glob(self::CACHE_DIR . '/*.xml.gz'))) {
                return;
            }
        }

        self::generateAll();
        Setting::set('sitemaps_dirty', '0');
    }

    

    public static function generateAll(): void
    {
        self::ensureCacheDir();
        $opts = self::getOptions();
        $maxUrls = $opts['max_urls'];

        $types = Video::availableTypes();
        $multiType = count($types) >= 2;
        $defaultType = $types[0] ?? Video::DEFAULT_TYPE;

        foreach ($types as $type) {
            $typeKey = ($multiType && $type !== $defaultType) ? $type . '-' : '';

             
            if (!self::shouldSkipEntity('videos')) {
                $total = (int) Database::fetchOne(
                    "SELECT COUNT(*) AS c FROM videos WHERE status = 'published' AND video_type = ?",
                    [$type]
                )['c'];
                if ($total > 0) {
                    $pages = (int) ceil($total / $maxUrls);
                    for ($i = 1; $i <= $pages; $i++) {
                        self::generateVideos($type, $i, self::CACHE_DIR . '/' . $typeKey . 'videos-' . $i . '.xml.gz');
                    }
                    for ($i = $pages + 1; ; $i++) {
                        $old = self::CACHE_DIR . '/' . $typeKey . 'videos-' . $i . '.xml.gz';
                        if (file_exists($old)) { unlink($old); } else { break; }
                    }
                }
            }

             
            if (!self::shouldSkipEntity('categories')) {
                $cnt = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM categories WHERE video_type = ?', [$type])['c'];
                if ($cnt > 0) {
                    self::generateEntities('categories', 'category_translations', 'category_id', '/category/',
                        $type, self::CACHE_DIR . '/' . $typeKey . 'categories.xml.gz');
                }
            }

             
            if (!self::shouldSkipEntity('tags')) {
                $total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM tags WHERE video_type = ?', [$type])['c'];
                if ($total > 0) {
                    $pages = (int) ceil($total / $maxUrls);
                    for ($i = 1; $i <= $pages; $i++) {
                        self::generateEntities('tags', 'tag_translations', 'tag_id', '/tag/',
                            $type, self::CACHE_DIR . '/' . $typeKey . 'tags-' . $i . '.xml.gz',
                            $maxUrls, ($i - 1) * $maxUrls);
                    }
                    for ($i = $pages + 1; ; $i++) {
                        $old = self::CACHE_DIR . '/' . $typeKey . 'tags-' . $i . '.xml.gz';
                        if (file_exists($old)) { unlink($old); } else { break; }
                    }
                }
            }

             
            if (!self::shouldSkipEntity('performers')) {
                $cnt = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM performers WHERE video_type = ?', [$type])['c'];
                if ($cnt > 0) {
                    self::generateEntities('performers', 'performer_translations', 'performer_id', '/pornstar/',
                        $type, self::CACHE_DIR . '/' . $typeKey . 'performers.xml.gz');
                }
            }

             
            if (!self::shouldSkipEntity('channels')) {
                $cnt = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM channels WHERE video_type = ?', [$type])['c'];
                if ($cnt > 0) {
                    self::generateEntities('channels', 'channel_translations', 'channel_id', '/channel/',
                        $type, self::CACHE_DIR . '/' . $typeKey . 'channels.xml.gz');
                }
            }

            if (!self::shouldSkipEntity('albums')) {
                $cnt = (int) Database::fetchOne("SELECT COUNT(*) AS c FROM albums WHERE video_type = ? AND status = 'published'", [$type])['c'];
                if ($cnt > 0) {
                    self::generateEntities('albums', 'album_translations', 'album_id', '/album/',
                        $type, self::CACHE_DIR . '/' . $typeKey . 'albums.xml.gz', 0, 0, "status = 'published'");
                }
            }

             
            if (!self::shouldSkipEntity('album-categories')) {
                $cnt = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM album_categories WHERE video_type = ? AND album_count > 0', [$type])['c'];
                if ($cnt > 0) {
                    self::generateEntities('album_categories', 'album_category_translations', 'category_id', '/album-category/',
                        $type, self::CACHE_DIR . '/' . $typeKey . 'album-categories.xml.gz', 0, 0, 'album_count > 0');
                }
            }
            if (!self::shouldSkipEntity('album-tags')) {
                $total = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM album_tags WHERE video_type = ? AND album_count > 0', [$type])['c'];
                if ($total > 0) {
                    $pages = (int) ceil($total / $maxUrls);
                    for ($i = 1; $i <= $pages; $i++) {
                        self::generateEntities('album_tags', 'album_tag_translations', 'tag_id', '/album-tag/',
                            $type, self::CACHE_DIR . '/' . $typeKey . 'album-tags-' . $i . '.xml.gz',
                            $maxUrls, ($i - 1) * $maxUrls, 'album_count > 0');
                    }
                    for ($i = $pages + 1; ; $i++) {
                        $old = self::CACHE_DIR . '/' . $typeKey . 'album-tags-' . $i . '.xml.gz';
                        if (file_exists($old)) { unlink($old); } else { break; }
                    }
                }
            }
            if (!self::shouldSkipEntity('album-performers')) {
                $cnt = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM album_performers WHERE video_type = ? AND album_count > 0', [$type])['c'];
                if ($cnt > 0) {
                    self::generateEntities('album_performers', 'album_performer_translations', 'performer_id', '/album-pornstar/',
                        $type, self::CACHE_DIR . '/' . $typeKey . 'album-performers.xml.gz', 0, 0, 'album_count > 0');
                }
            }
            if (!self::shouldSkipEntity('album-channels')) {
                $cnt = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM album_channels WHERE video_type = ? AND album_count > 0', [$type])['c'];
                if ($cnt > 0) {
                    self::generateEntities('album_channels', 'album_channel_translations', 'channel_id', '/album-channel/',
                        $type, self::CACHE_DIR . '/' . $typeKey . 'album-channels.xml.gz', 0, 0, 'album_count > 0');
                }
            }
        }

         
        if (!self::shouldSkipEntity('pages')) {
            $cnt = (int) Database::fetchOne("SELECT COUNT(*) AS c FROM pages WHERE status = 'published'")['c'];
            if ($cnt > 0) {
                self::generatePages(self::CACHE_DIR . '/pages.xml.gz');
            }
        }
    }

     

    

    private static function generateFile(string $name, string $outFile): bool
    {
        $types = Video::availableTypes();
        $multiType = count($types) >= 2;
        $defaultType = $types[0] ?? Video::DEFAULT_TYPE;
        $opts = self::getOptions();
        $maxUrls = $opts['max_urls'];

        $videoType = $defaultType;
        $entity = $name;
        if ($multiType) {
            foreach ($types as $type) {
                if ($type === $defaultType) continue;
                if (str_starts_with($name, $type . '-')) {
                    $videoType = $type;
                    $entity = substr($name, strlen($type) + 1);
                    break;
                }
            }
        }

        if (preg_match('/^videos-(\d+)$/', $entity, $m)) {
            return self::generateVideos($videoType, (int) $m[1], $outFile);
        }
        if ($entity === 'categories') {
            return self::generateEntities('categories', 'category_translations', 'category_id', '/category/', $videoType, $outFile);
        }
        if (preg_match('/^tags-(\d+)$/', $entity, $m)) {
            $page = (int) $m[1];
            return self::generateEntities('tags', 'tag_translations', 'tag_id', '/tag/', $videoType, $outFile, $maxUrls, ($page - 1) * $maxUrls);
        }
        if ($entity === 'performers') {
            return self::generateEntities('performers', 'performer_translations', 'performer_id', '/pornstar/', $videoType, $outFile);
        }
        if ($entity === 'channels') {
            return self::generateEntities('channels', 'channel_translations', 'channel_id', '/channel/', $videoType, $outFile);
        }
        if ($entity === 'albums') {
            return self::generateEntities('albums', 'album_translations', 'album_id', '/album/', $videoType, $outFile, 0, 0, "status = 'published'");
        }
        if ($entity === 'album-categories') {
            return self::generateEntities('album_categories', 'album_category_translations', 'category_id', '/album-category/', $videoType, $outFile, 0, 0, 'album_count > 0');
        }
        if (preg_match('/^album-tags-(\d+)$/', $entity, $m)) {
            $page = (int) $m[1];
            return self::generateEntities('album_tags', 'album_tag_translations', 'tag_id', '/album-tag/', $videoType, $outFile, $maxUrls, ($page - 1) * $maxUrls, 'album_count > 0');
        }
        if ($entity === 'album-performers') {
            return self::generateEntities('album_performers', 'album_performer_translations', 'performer_id', '/album-pornstar/', $videoType, $outFile, 0, 0, 'album_count > 0');
        }
        if ($entity === 'album-channels') {
            return self::generateEntities('album_channels', 'album_channel_translations', 'channel_id', '/album-channel/', $videoType, $outFile, 0, 0, 'album_count > 0');
        }
        if ($entity === 'pages') {
            return self::generatePages($outFile);
        }

        return false;
    }

     

    

    private static function generateVideos(string $videoType, int $page, string $outFile): bool
    {
        $siteUrl = self::siteUrl();
        $typePrefix = self::typePrefixFor($videoType);
        $opts = self::getOptions();
        $maxUrls = $opts['max_urls'];
        $includeVideoTags = $opts['video_tags'];
        $includeImageTags = $opts['image_tags'];
        $globalOffset = ($page - 1) * $maxUrls;

        if (!function_exists('gzopen')) { error_log('Sitemap: ext-zlib (gzopen) unavailable, skipping ' . $outFile); return false; }
        $gz = gzopen($outFile, 'wb9');
        if (!$gz) return false;

        $namespaces = ' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"';
        if ($includeImageTags) {
            $namespaces .= ' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"';
        }

        gzwrite($gz, '<?xml version="1.0" encoding="UTF-8"?>' . "\n");
        gzwrite($gz, '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' . $namespaces . '>' . "\n");

        $written = 0;
        $batchOffset = $globalOffset;

        while ($written < $maxUrls) {
            $batchSize = min(self::BATCH_SIZE, $maxUrls - $written);
            $videos = Database::fetchAll(
                "SELECT id, slug, title, description, thumbnail, video_folder, embed_code, duration, views, published_at, updated_at
                 FROM videos
                 WHERE status = 'published' AND video_type = ?
                 ORDER BY id
                 LIMIT ? OFFSET ?",
                [$videoType, $batchSize, $batchOffset]
            );

            if (empty($videos)) break;

             
            $tagMap = [];
            if ($includeVideoTags) {
                $ids = array_column($videos, 'id');
                $tagMap = self::batchVideoTags($ids);
            }

            foreach ($videos as $v) {
                $vid = (int) $v['id'];
                $pageUrl = $siteUrl . $typePrefix . '/video/' . $v['slug'];

                 
                $thumbUrl = '';
                if (!empty($v['video_folder'])) {
                    $folderThumb = VideoAsset::thumbUrlFromFolder($v['video_folder']);
                    if ($folderThumb !== '') {
                        $thumbUrl = $siteUrl . $folderThumb;
                    }
                }
                if ($thumbUrl === '') {
                    $thumbUrl = self::resolveThumbUrl($v['thumbnail'] ?? '', $siteUrl);
                }

                gzwrite($gz, "  <url>\n");
                gzwrite($gz, '    <loc>' . self::esc($pageUrl) . "</loc>\n");
                if (!empty($v['updated_at'])) {
                    gzwrite($gz, '    <lastmod>' . substr($v['updated_at'], 0, 10) . "</lastmod>\n");
                }

                gzwrite($gz, "    <video:video>\n");
                if ($thumbUrl !== '') {
                    gzwrite($gz, '      <video:thumbnail_loc>' . self::esc($thumbUrl) . "</video:thumbnail_loc>\n");
                }
                gzwrite($gz, '      <video:title>' . self::esc($v['title'] ?? '') . "</video:title>\n");
                $desc = $v['description'] ?? $v['title'] ?? '';
                if (mb_strlen($desc) > 2048) {
                    $desc = mb_substr($desc, 0, 2045) . '...';
                }
                gzwrite($gz, '      <video:description>' . self::esc($desc) . "</video:description>\n");

                 
                $embedPlayerUrl = $siteUrl . '/embed/' . $v['slug'];
                gzwrite($gz, '      <video:player_loc>' . self::esc($embedPlayerUrl) . "</video:player_loc>\n");

                 
                if (!empty($v['video_folder'])) {
                    $videoFileUrl = VideoAsset::videoUrl($v['video_folder']);
                    if ($videoFileUrl !== '') {
                        gzwrite($gz, '      <video:content_loc>' . self::esc($siteUrl . $videoFileUrl) . "</video:content_loc>\n");
                    }
                }

                if (!empty($v['duration']) && (int) $v['duration'] > 0) {
                    gzwrite($gz, '      <video:duration>' . (int) $v['duration'] . "</video:duration>\n");
                }
                if (!empty($v['views'])) {
                    gzwrite($gz, '      <video:view_count>' . (int) $v['views'] . "</video:view_count>\n");
                }
                if (!empty($v['published_at'])) {
                    gzwrite($gz, '      <video:publication_date>' . substr($v['published_at'], 0, 10) . "</video:publication_date>\n");
                }
                gzwrite($gz, "      <video:family_friendly>no</video:family_friendly>\n");

                if ($includeVideoTags) {
                    $tags = $tagMap[$vid] ?? [];
                    if (!empty($tags)) {
                        $tagXml = [];
                        $tc = 0;
                        foreach ($tags as $tagName) {
                            if (++$tc > 32) break;
                            $tagXml[] = '<video:tag>' . self::esc($tagName) . '</video:tag>';
                        }
                        gzwrite($gz, '      ' . implode(' ', $tagXml) . "\n");
                    }
                }

                gzwrite($gz, "    </video:video>\n");

                if ($includeImageTags && $thumbUrl !== '') {
                    gzwrite($gz, "    <image:image>\n");
                    gzwrite($gz, '      <image:loc>' . self::esc($thumbUrl) . "</image:loc>\n");
                    gzwrite($gz, "    </image:image>\n");
                }

                gzwrite($gz, "  </url>\n");
            }

            $written += count($videos);
            $batchOffset += count($videos);

            if (count($videos) < $batchSize) break;  
        }

        gzwrite($gz, '</urlset>');
        gzclose($gz);
        return true;
    }

     

    

    private static function generateEntities(
        string $table, string $transTable, string $fkCol, string $routePrefix,
        string $videoType, string $outFile,
        int $limit = 0, int $offset = 0, string $extraWhere = ''
    ): bool {
        $siteUrl = self::siteUrl();
        $typePrefix = self::typePrefixFor($videoType);
        $defaultLang = Translator::getDefaultCode();
        $langs = Translator::available();
        $opts = self::getOptions();
        $includeHreflang = $opts['hreflang'];

        $sql = "SELECT id, slug, created_at FROM {$table} WHERE video_type = ?" . ($extraWhere !== '' ? " AND {$extraWhere}" : '') . " ORDER BY id";
        $params = [$videoType];
        if ($limit > 0) {
            $sql .= ' LIMIT ? OFFSET ?';
            $params[] = $limit;
            $params[] = $offset;
        }
        $entities = Database::fetchAll($sql, $params);
        if (empty($entities)) return false;

         
        $translations = $includeHreflang ? self::batchTranslations($transTable, $fkCol, $entities) : [];

        if (!function_exists('gzopen')) { error_log('Sitemap: ext-zlib (gzopen) unavailable, skipping ' . $outFile); return false; }
        $gz = gzopen($outFile, 'wb9');
        if (!$gz) return false;

        gzwrite($gz, '<?xml version="1.0" encoding="UTF-8"?>' . "\n");
        $ns = $includeHreflang ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : '';
        gzwrite($gz, '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' . $ns . '>' . "\n");

        foreach ($entities as $e) {
            $id = (int) $e['id'];
            $baseSlug = $e['slug'];
            $lastmod = !empty($e['created_at']) ? substr($e['created_at'], 0, 10) : '';

            if ($includeHreflang) {
                $trans = $translations[$id] ?? [];

                $available = [];
                $available[$defaultLang] = $siteUrl . $typePrefix . $routePrefix . $baseSlug;
                foreach ($langs as $lang) {
                    if ($lang['code'] === $defaultLang) continue;
                    $tSlug = $trans[$lang['code']]['slug'] ?? null;
                    if ($tSlug !== null) {
                        $available[$lang['code']] = $siteUrl . '/' . $lang['code'] . $typePrefix . $routePrefix . $tSlug;
                    }
                }
                $available['x-default'] = $available[$defaultLang];

                foreach ($available as $lc => $loc) {
                    if ($lc === 'x-default') continue;
                    gzwrite($gz, "  <url>\n");
                    gzwrite($gz, '    <loc>' . self::esc($loc) . "</loc>\n");
                    if ($lastmod !== '') {
                        gzwrite($gz, '    <lastmod>' . $lastmod . "</lastmod>\n");
                    }
                    foreach ($available as $hl => $hu) {
                        gzwrite($gz, '    <xhtml:link rel="alternate" hreflang="' . self::esc($hl) . '" href="' . self::esc($hu) . "\"/>\n");
                    }
                    gzwrite($gz, "  </url>\n");
                }
            } else {
                 
                $loc = $siteUrl . $typePrefix . $routePrefix . $baseSlug;
                gzwrite($gz, "  <url>\n");
                gzwrite($gz, '    <loc>' . self::esc($loc) . "</loc>\n");
                if ($lastmod !== '') {
                    gzwrite($gz, '    <lastmod>' . $lastmod . "</lastmod>\n");
                }
                gzwrite($gz, "  </url>\n");
            }
        }

        gzwrite($gz, "</urlset>\n");
        gzclose($gz);
        return true;
    }

     

    private static function generatePages(string $outFile): bool
    {
        $siteUrl = self::siteUrl();
        $defaultLang = Translator::getDefaultCode();
        $langs = Translator::available();
        $types = Video::availableTypes();
        $multiType = count($types) >= 2;
        $defaultType = $types[0] ?? Video::DEFAULT_TYPE;
        $opts = self::getOptions();
        $includeHreflang = $opts['hreflang'];

        $pages = Database::fetchAll(
            "SELECT id, slug, updated_at FROM pages WHERE status = 'published' ORDER BY id"
        );

         
        $translations = [];
        if ($includeHreflang && !empty($pages)) {
            $ids = array_column($pages, 'id');
            $ph = implode(',', array_fill(0, count($ids), '?'));
            $rows = Database::fetchAll(
                "SELECT page_id, lang_code, slug FROM page_translations WHERE page_id IN ({$ph})",
                $ids
            );
            foreach ($rows as $row) {
                $translations[(int) $row['page_id']][$row['lang_code']] = $row;
            }
        }

        if (!function_exists('gzopen')) { error_log('Sitemap: ext-zlib (gzopen) unavailable, skipping ' . $outFile); return false; }
        $gz = gzopen($outFile, 'wb9');
        if (!$gz) return false;

        gzwrite($gz, '<?xml version="1.0" encoding="UTF-8"?>' . "\n");
        $ns = $includeHreflang ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : '';
        gzwrite($gz, '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' . $ns . '>' . "\n");

         
        foreach ($types as $type) {
            $tp = ($multiType && $type !== $defaultType) ? '/' . $type : '';
            $homeLoc = $siteUrl . $tp . '/';

            if ($includeHreflang) {
                $homeAlts = [];
                $homeAlts[$defaultLang] = $homeLoc;
                foreach ($langs as $lang) {
                    if ($lang['code'] === $defaultLang) continue;
                    $homeAlts[$lang['code']] = $siteUrl . '/' . $lang['code'] . $tp . '/';
                }
                $homeAlts['x-default'] = $homeAlts[$defaultLang];

                foreach ($homeAlts as $lc => $loc) {
                    if ($lc === 'x-default') continue;
                    gzwrite($gz, "  <url>\n");
                    gzwrite($gz, '    <loc>' . self::esc($loc) . "</loc>\n");
                    foreach ($homeAlts as $hl => $hu) {
                        gzwrite($gz, '    <xhtml:link rel="alternate" hreflang="' . self::esc($hl) . '" href="' . self::esc($hu) . "\"/>\n");
                    }
                    gzwrite($gz, "  </url>\n");
                }
            } else {
                gzwrite($gz, "  <url>\n");
                gzwrite($gz, '    <loc>' . self::esc($homeLoc) . "</loc>\n");
                gzwrite($gz, "  </url>\n");
            }
        }

         
        foreach ($pages as $page) {
            $id = (int) $page['id'];
            $baseSlug = $page['slug'];
            $lastmod = !empty($page['updated_at']) ? substr($page['updated_at'], 0, 10) : '';

            if ($includeHreflang) {
                $trans = $translations[$id] ?? [];
                $available = [];
                $available[$defaultLang] = $siteUrl . '/' . $baseSlug;
                foreach ($langs as $lang) {
                    if ($lang['code'] === $defaultLang) continue;
                    $tSlug = $trans[$lang['code']]['slug'] ?? null;
                    if ($tSlug !== null) {
                        $available[$lang['code']] = $siteUrl . '/' . $lang['code'] . '/' . $tSlug;
                    }
                }
                $available['x-default'] = $available[$defaultLang];

                foreach ($available as $lc => $loc) {
                    if ($lc === 'x-default') continue;
                    gzwrite($gz, "  <url>\n");
                    gzwrite($gz, '    <loc>' . self::esc($loc) . "</loc>\n");
                    if ($lastmod !== '') {
                        gzwrite($gz, '    <lastmod>' . $lastmod . "</lastmod>\n");
                    }
                    foreach ($available as $hl => $hu) {
                        gzwrite($gz, '    <xhtml:link rel="alternate" hreflang="' . self::esc($hl) . '" href="' . self::esc($hu) . "\"/>\n");
                    }
                    gzwrite($gz, "  </url>\n");
                }
            } else {
                gzwrite($gz, "  <url>\n");
                gzwrite($gz, '    <loc>' . self::esc($siteUrl . '/' . $baseSlug) . "</loc>\n");
                if ($lastmod !== '') {
                    gzwrite($gz, '    <lastmod>' . $lastmod . "</lastmod>\n");
                }
                gzwrite($gz, "  </url>\n");
            }
        }

        gzwrite($gz, "</urlset>\n");
        gzclose($gz);
        return true;
    }

     

    private static function shouldSkipEntity(string $entityType): bool
    {
         
        if (!Album::featureEnabled() && str_starts_with($entityType, 'album')) {
            return true;
        }
        return HookSystem::applyFilter('sitemap.skip_entity', false, $entityType);
    }

    private static function getOptions(): array
    {
        return HookSystem::applyFilter('sitemap.options', [
            'video_tags'  => true,
            'image_tags'  => true,
            'hreflang'    => true,
            'max_urls'    => self::URLS_PER_FILE,
        ]);
    }

     

    private static function siteUrl(): string
    {
        return SEO::siteUrl();
    }

    private static function typePrefixFor(string $videoType): string
    {
        $types = Video::availableTypes();
        if (count($types) < 2) return '';
        $defaultType = $types[0] ?? Video::DEFAULT_TYPE;
        return ($videoType !== $defaultType) ? '/' . Video::typeSlug($videoType) : '';
    }

    

    private static function resolveThumbUrl(string $thumb, string $siteUrl): string
    {
        if ($thumb === '') return '';
        if (str_starts_with($thumb, 'http://') || str_starts_with($thumb, 'https://')) return $thumb;
        return $siteUrl . '/' . ltrim($thumb, '/');
    }

    

    private static function esc(string $s): string
    {
        return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
    }

    

    private static function batchVideoTags(array $videoIds): array
    {
        if (empty($videoIds)) return [];
        $ph = implode(',', array_fill(0, count($videoIds), '?'));
        $rows = Database::fetchAll(
            "SELECT vt.video_id, t.name FROM video_tags vt JOIN tags t ON vt.tag_id = t.id WHERE vt.video_id IN ({$ph})",
            $videoIds
        );
        $map = [];
        foreach ($rows as $row) {
            $map[(int) $row['video_id']][] = $row['name'];
        }
        return $map;
    }

    

    private static function batchTranslations(string $table, string $fkColumn, array $entities): array
    {
        if (empty($entities)) return [];
        $ids = array_column($entities, 'id');
        if (empty($ids)) return [];

        $ph = implode(',', array_fill(0, count($ids), '?'));
        $rows = Database::fetchAll(
            "SELECT {$fkColumn}, lang_code, slug FROM {$table} WHERE {$fkColumn} IN ({$ph})",
            $ids
        );

        $map = [];
        foreach ($rows as $row) {
            $map[(int) $row[$fkColumn]][$row['lang_code']] = $row;
        }
        return $map;
    }

    

    private static function writeEntry(string $loc, string $lastmod): void
    {
        echo "  <sitemap>\n";
        echo '    <loc>' . htmlspecialchars($loc) . "</loc>\n";
        if ($lastmod !== '') {
            echo '    <lastmod>' . htmlspecialchars(substr($lastmod, 0, 10)) . "</lastmod>\n";
        }
        echo "  </sitemap>\n";
    }

    

    private static function ensureCacheDir(): void
    {
        if (!is_dir(self::CACHE_DIR)) {
            mkdir(self::CACHE_DIR, 0755, true);
        }
    }

    

    private static function serveGzip(string $file): void
    {
        header('Content-Type: application/xml; charset=UTF-8');
        header('Content-Encoding: gzip');
        header('Cache-Control: public, max-age=3600');
        header('X-Robots-Tag: noindex');
        $size = filesize($file);
        if ($size !== false) {
            header('Content-Length: ' . $size);
        }
        readfile($file);
    }
}
