<?php

declare(strict_types=1);



class SitemapController
{

    private const MAX_URLS_PER_FILE  = 50000;


    private const SOFT_URLS_PER_FILE = 45000;


    private const MAX_RAW_BYTES  = 52428800;


    private const SOFT_RAW_BYTES = 47185920;


    private const ENTITIES = [
        'categories'       => ['categories',        'category_translations',        'category_id',  '/category/',        '',                     'created_at'],
        'tags'             => ['tags',              'tag_translations',             'tag_id',       '/tag/',             '',                     'created_at'],
        'performers'       => ['performers',        'performer_translations',       'performer_id', '/pornstar/',        '',                     'updated_at'],
        'channels'         => ['channels',          'channel_translations',         'channel_id',   '/channel/',         '',                     'updated_at'],
        'albums'           => ['albums',            'album_translations',           'album_id',     '/album/',           "status = 'published'", 'updated_at'],
        'album-categories' => ['album_categories',  'album_category_translations',  'category_id',  '/album-category/',  'album_count > 0',      'created_at'],
        'album-tags'       => ['album_tags',        'album_tag_translations',       'tag_id',       '/album-tag/',       'album_count > 0',      'created_at'],
        'album-performers' => ['album_performers',  'album_performer_translations', 'performer_id', '/album-pornstar/',  'album_count > 0',      'created_at'],
        'album-channels'   => ['album_channels',    'album_channel_translations',   'channel_id',   '/album-channel/',   'album_count > 0',      'created_at'],
    ];


    private const HUB_ROUTES = [
        'categories' => '/categories',
        'tags'       => '/tags',
        'performers' => '/pornstars',
        'channels'   => '/channels',
    ];


    private const LIST_ROUTES = [
        'slug_new_videos'     => 'new_videos',
        'slug_top_rated'      => 'top_rated',
        'slug_most_viewed'    => 'most_viewed',
        'slug_most_favorited' => 'most_favorited',
    ];


    private const BATCH_SIZE = 500;

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

     
    private const MANIFEST_FILE = APP_ROOT . '/storage/sitemaps/manifest.json';

     
    private const CACHE_TTL = 3600;

     
    private const CRON_INTERVAL = 3600;

     
    private const GEN_BUDGET_SECONDS = 20;

     

    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
    {
        header('Content-Type: text/plain; charset=UTF-8');
        header('Cache-Control: public, max-age=86400');
        echo HookSystem::applyFilter('sitemap.robots_txt', self::defaultRobotsTxt());
        exit;
    }

    

    public static function defaultRobotsTxt(): string
    {
        $siteUrl = self::siteUrl();

        if (Setting::get('seo_noindex_entire_site', '0') === '1') {
            return "User-agent: *\n"
                . "Disallow: /\n";
        }

        $private = [
            '/admin/', '/install/', '/api/', '/cron/', '/storage/',
            '/login', '/logout', '/register', '/forgot-password', '/reset-password',
            '/profile', '/account', '/favorites', '/history',
            '/search',
        ];

        $rules = '';
        foreach ($private as $path) {
            $rules .= 'Disallow: ' . $path . "\n";
            if ($path !== '/admin/' && $path !== '/install/' && $path !== '/api/'
                && $path !== '/cron/' && $path !== '/storage/') {
                $rules .= 'Disallow: /*' . $path . "\n";
            }
        }
        $rules .= "Disallow: /*?sort=\n";

        $allow = "Allow: /themes/\n"
            . "Allow: /templates/admin/assets/\n"
            . "Allow: /uploads/\n"
            . "Allow: /embed/\n";

        $default = "User-agent: *\n"
            . $rules
            . $allow
            . "\n"
            . "User-agent: AhrefsBot\n"
            . "Crawl-delay: 10\n"
            . $rules
            . "\n"
            . "User-agent: SemrushBot\n"
            . "Crawl-delay: 10\n"
            . $rules
            . "\n"
            . "User-agent: MJ12bot\n"
            . "Crawl-delay: 10\n"
            . $rules
            . "\n"
            . "Sitemap: {$siteUrl}/sitemap.xml\n";

        return $default;
    }

    

    public static function index(): void
    {
        $siteUrl = self::siteUrl();

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

        $files = self::manifestFiles();
        if ($files === []) {
            self::ensureCacheDir();
            self::generateAll(0);
            $files = self::manifestFiles();
        }

        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        echo '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
        foreach ($files as $f) {
            echo "  <sitemap>\n";
            echo '    <loc>' . self::esc($siteUrl . '/sitemap-' . $f['name'] . '.xml') . "</loc>\n";
            if (!empty($f['lastmod'])) {
                echo '    <lastmod>' . self::esc((string) $f['lastmod']) . "</lastmod>\n";
            }
            echo "  </sitemap>\n";
        }
        echo '</sitemapindex>';
        exit;
    }

    

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

        if (!preg_match('/^[a-z0-9\-]+$/', $name)) {
            self::notFound();
        }

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

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

        $entry = self::manifestEntry($name);
        if ($entry !== null) {
            self::runTask($entry['task'], $entry['type']);
        } elseif (!file_exists($cacheFile)) {
            self::generateAll(0);
        }

        if (file_exists($cacheFile)) {
            self::serveSitemap($cacheFile);
        }
        self::notFound();
    }

     

    

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

     

    

    public static function cronRegenerate(): void
    {
        $dirty = Setting::getDirect('sitemaps_dirty', '1') === '1';
        $resuming = Setting::getDirect('sitemaps_cursor', '0') !== '0';

        if (!$dirty && !$resuming) {
            if (is_dir(self::CACHE_DIR) && file_exists(self::MANIFEST_FILE)) {
                return;
            }
        }

        self::generateAll(self::GEN_BUDGET_SECONDS);
    }

    

    public static function generateAll(int $budgetSeconds = 0): void
    {
        self::ensureCacheDir();

        $tasks = self::taskList();
        $cursor = (int) Setting::getDirect('sitemaps_cursor', '0');
        if ($cursor < 0 || $cursor >= count($tasks)) {
            $cursor = 0;
        }
        if ($cursor === 0) {
            Setting::set('sitemaps_partial', json_encode([]));
        }

        $acc = json_decode((string) Setting::getDirect('sitemaps_partial', '[]'), true);
        if (!is_array($acc)) {
            $acc = [];
        }

        $started = time();
        $written = [];
        foreach ($acc as $row) {
            if (is_array($row) && isset($row['name'])) {
                $written[] = $row;
            }
        }

        for ($i = $cursor; $i < count($tasks); $i++) {
            [$task, $type] = $tasks[$i];
            foreach (self::runTask($task, $type) as $file) {
                $written[] = $file;
            }

            if ($budgetSeconds > 0 && (time() - $started) >= $budgetSeconds && $i + 1 < count($tasks)) {
                Setting::set('sitemaps_cursor', (string) ($i + 1));
                Setting::set('sitemaps_partial', json_encode($written));
                return;
            }
        }

        self::writeManifest($written);
        self::pruneStale($written);
        Setting::set('sitemaps_cursor', '0');
        Setting::set('sitemaps_partial', json_encode([]));
        Setting::set('sitemaps_dirty', '0');
    }

     

    

    private static function taskList(): array
    {
        $types = Video::availableTypes();
        $tasks = [];

        foreach ($types as $type) {
            if (!self::shouldSkipEntity('videos')) {
                $tasks[] = ['videos', $type];
            }
            foreach (self::ENTITIES as $key => $def) {
                if (self::shouldSkipEntity($key)) continue;
                $tasks[] = [$key, $type];
            }
        }
        $tasks[] = ['hubs', ''];
        if (!self::shouldSkipEntity('pages')) {
            $tasks[] = ['pages', ''];
        }

        return $tasks;
    }

    

    private static function runTask(string $task, string $type): array
    {
        if ($task === 'videos')  return self::generateVideos($type);
        if ($task === 'hubs')    return self::generateHubs();
        if ($task === 'pages')   return self::generatePages();
        if (isset(self::ENTITIES[$task])) return self::generateEntities($task, $type);
        return [];
    }

     

    

    private static function shardInit(string $base, string $ns, int $maxUrls = 0): array
    {
        return [
            'base'  => $base,
            'ns'    => $ns,
            'maxUrls' => $maxUrls > 0 ? min($maxUrls, self::SOFT_URLS_PER_FILE) : self::SOFT_URLS_PER_FILE,
            'seq'   => 0,
            'gz'    => null,
            'urls'  => 0,
            'bytes' => 0,
            'lastmod' => '',
            'files' => [],
            'task'  => '',
            'type'  => '',
        ];
    }

    

    private static function shardOpen(array &$st): bool
    {
        $st['seq']++;
        $st['gz'] = gzopen(self::CACHE_DIR . '/' . self::shardName($st) . '.xml.gz.tmp', 'wb9');
        if (!$st['gz']) {
            return false;
        }
        $head = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
            . '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' . $st['ns'] . '>' . "\n";
        gzwrite($st['gz'], $head);
        $st['urls']    = 0;
        $st['bytes']   = strlen($head) + 10;
        $st['lastmod'] = '';
        return true;
    }

    

    private static function shardName(array $st): string
    {
        return $st['base'] . '-' . $st['seq'];
    }

    

    private static function shardAdd(array &$st, string $block, int $urls, string $lastmod): void
    {
        $size = strlen($block);

        if ($st['gz'] !== null
            && ($st['urls'] + $urls > $st['maxUrls']
                || $st['bytes'] + $size > self::SOFT_RAW_BYTES)) {
            self::shardFinish($st);
        }
        if ($st['gz'] === null && !self::shardOpen($st)) {
            return;
        }

        gzwrite($st['gz'], $block);
        $st['urls']  += $urls;
        $st['bytes'] += $size;
        if ($lastmod !== '' && $lastmod > $st['lastmod']) {
            $st['lastmod'] = $lastmod;
        }
    }

    

    private static function shardFinish(array &$st): void
    {
        if ($st['gz'] === null) {
            return;
        }
        gzwrite($st['gz'], '</urlset>' . "\n");
        gzclose($st['gz']);
        $st['gz'] = null;
        $st['files'][] = [
            'name'    => self::shardName($st),
            'urls'    => $st['urls'],
            'bytes'   => $st['bytes'],
            'lastmod' => $st['lastmod'],
            'task'    => $st['task'],
            'type'    => $st['type'],
        ];
    }

    

    private static function shardCommit(array &$st): array
    {
        self::shardFinish($st);

        $single = count($st['files']) === 1;
        $out = [];
        foreach ($st['files'] as $f) {
            $final = $single ? $st['base'] : $f['name'];
            @rename(
                self::CACHE_DIR . '/' . $f['name'] . '.xml.gz.tmp',
                self::CACHE_DIR . '/' . $final . '.xml.gz'
            );
            $f['name'] = $final;
            $out[] = $f;
        }

        for ($n = count($st['files']) + 1; ; $n++) {
            $stale = self::CACHE_DIR . '/' . $st['base'] . '-' . $n . '.xml.gz';
            if (file_exists($stale)) { @unlink($stale); } else { break; }
        }
        if (!$single) {
            $plain = self::CACHE_DIR . '/' . $st['base'] . '.xml.gz';
            if (file_exists($plain)) { @unlink($plain); }
        }

        return $out;
    }

     

    

    private static function generateVideos(string $videoType): array
    {
        $siteUrl    = self::siteUrl();
        $typePrefix = self::typePrefixFor($videoType);
        $opts       = self::getOptions();
        $defaultLang = Translator::getDefaultCode();
        $langs       = self::langCodes();

        if (!function_exists('gzopen')) {
            error_log('Sitemap: ext-zlib (gzopen) unavailable, skipping videos');
            return [];
        }

        $ns = ' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"';
        if ($opts['image_tags']) {
            $ns .= ' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"';
        }
        if ($opts['hreflang']) {
            $ns .= ' xmlns:xhtml="http://www.w3.org/1999/xhtml"';
        }

        $st = self::shardInit(self::typeKey($videoType) . 'videos', $ns, $opts['max_urls']);
        $st['task'] = 'videos';
        $st['type'] = $videoType;

        $offset = 0;
        while (true) {
            $videos = Database::fetchAll(
                "SELECT id, slug, title, description, thumbnail, video_folder, storage_id,
                        duration, views, published_at, updated_at
                 FROM videos
                 WHERE status = 'published' AND video_type = ?
                 ORDER BY id
                 LIMIT ? OFFSET ?",
                [$videoType, self::BATCH_SIZE, $offset]
            );
            if (empty($videos)) break;

            $ids     = array_column($videos, 'id');
            $tagMap  = $opts['video_tags'] ? self::batchVideoTags($ids) : [];
            $transMap = $opts['hreflang'] ? self::batchVideoTranslations($ids) : [];

            foreach ($videos as $v) {
                $vid   = (int) $v['id'];
                $trans = $transMap[$vid] ?? [];
                $sid   = self::storageIdOf($v);
                $signed = self::storageIsSigned($sid);

                $thumbUrl = '';
                if (!$signed && !empty($v['video_folder'])) {
                    $thumbUrl = self::absUrl(VideoAsset::thumbUrlFromFolder((string) $v['video_folder'], false, $sid), $siteUrl);
                }
                if ($thumbUrl === '') {
                    $thumbUrl = self::absUrl((string) ($v['thumbnail'] ?? ''), $siteUrl);
                }

                $contentUrl = '';
                if (!$signed && !empty($v['video_folder'])) {
                    $contentUrl = self::absUrl(VideoAsset::videoUrl((string) $v['video_folder'], $sid), $siteUrl);
                }

                $available = [$defaultLang => $siteUrl . $typePrefix . '/video/' . $v['slug']];
                if ($opts['hreflang']) {
                    foreach ($langs as $code) {
                        if ($code === $defaultLang) continue;
                        $tSlug = self::transSlug($trans, $code);
                        if ($tSlug !== null) {
                            $available[$code] = $siteUrl . '/' . $code . $typePrefix . '/video/' . $tSlug;
                        }
                    }
                }

                $lastmod = self::dateOnly((string) ($v['updated_at'] ?? ''));
                $alts    = $opts['hreflang'] ? self::alternateBlock($available, $defaultLang) : '';

                $block = '';
                $count = 0;
                foreach ($available as $lc => $loc) {
                    $t     = $trans[$lc] ?? [];
                    $title = ($lc !== $defaultLang && !empty($t['title'])) ? $t['title'] : (string) ($v['title'] ?? '');
                    $desc  = ($lc !== $defaultLang && !empty($t['description']))
                        ? $t['description']
                        : (string) ($v['description'] ?? $v['title'] ?? '');
                    $block .= self::videoUrlBlock(
                        $loc, $v, $title, $desc, $thumbUrl, $contentUrl, $siteUrl,
                        $tagMap[$vid] ?? [], $opts['image_tags'], $alts, $lastmod
                    );
                    $count++;
                }
                self::shardAdd($st, $block, $count, $lastmod);
            }

            $offset += count($videos);
            if (count($videos) < self::BATCH_SIZE) break;
        }

        return self::shardCommit($st);
    }

    

    private static function videoUrlBlock(
        string $loc, array $v, string $title, string $desc,
        string $thumbUrl, string $contentUrl, string $siteUrl,
        array $tags, bool $includeImageTags, string $alts, string $lastmod
    ): string {
        $out = "  <url>\n";
        $out .= '    <loc>' . self::esc($loc) . "</loc>\n";
        if ($lastmod !== '') {
            $out .= '    <lastmod>' . $lastmod . "</lastmod>\n";
        }
        $out .= $alts;

        $out .= "    <video:video>\n";
        if ($thumbUrl !== '') {
            $out .= '      <video:thumbnail_loc>' . self::esc($thumbUrl) . "</video:thumbnail_loc>\n";
        }
        $out .= '      <video:title>' . self::esc(self::clip($title, 100)) . "</video:title>\n";
        $out .= '      <video:description>' . self::esc(self::clip($desc !== '' ? $desc : $title, 2048)) . "</video:description>\n";
        $out .= '      <video:player_loc allow_embed="yes">' . self::esc($siteUrl . '/embed/' . $v['slug']) . "</video:player_loc>\n";
        if ($contentUrl !== '') {
            $out .= '      <video:content_loc>' . self::esc($contentUrl) . "</video:content_loc>\n";
        }
        if (!empty($v['duration']) && (int) $v['duration'] > 0) {
            $out .= '      <video:duration>' . min(28800, (int) $v['duration']) . "</video:duration>\n";
        }
        if (!empty($v['views'])) {
            $out .= '      <video:view_count>' . (int) $v['views'] . "</video:view_count>\n";
        }
        $pub = self::dateOnly((string) ($v['published_at'] ?? ''));
        if ($pub !== '') {
            $out .= '      <video:publication_date>' . $pub . "</video:publication_date>\n";
        }
        $out .= "      <video:family_friendly>no</video:family_friendly>\n";

        $tc = 0;
        foreach ($tags as $tagName) {
            if (++$tc > 32) break;
            $out .= '      <video:tag>' . self::esc((string) $tagName) . "</video:tag>\n";
        }

        $out .= "    </video:video>\n";

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

        $out .= "  </url>\n";
        return $out;
    }

     

    

    private static function generateEntities(string $key, string $videoType): array
    {
        [$table, $transTable, $fkCol, $routePrefix, $extraWhere] = self::ENTITIES[$key];

        $thinFilter = SeoManager::taxonomySitemapCondition($key);
        if ($thinFilter !== '') {
            $extraWhere = $extraWhere !== '' ? $extraWhere . ' AND ' . $thinFilter : $thinFilter;
        }

        $siteUrl     = self::siteUrl();
        $typePrefix  = self::typePrefixFor($videoType);
        $defaultLang = Translator::getDefaultCode();
        $langs       = self::langCodes();
        $opts        = self::getOptions();

        if (!function_exists('gzopen')) {
            error_log('Sitemap: ext-zlib (gzopen) unavailable, skipping ' . $key);
            return [];
        }

        $lastmodCol = self::ENTITIES[$key][5];
        $ns = $opts['hreflang'] ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : '';
        $st = self::shardInit(self::typeKey($videoType) . $key, $ns, $opts['max_urls']);
        $st['task'] = $key;
        $st['type'] = $videoType;

        $offset = 0;
        while (true) {
            $sql = "SELECT id, slug, {$lastmodCol} AS lastmod_at FROM {$table} WHERE video_type = ?"
                . ($extraWhere !== '' ? " AND {$extraWhere}" : '')
                . ' ORDER BY id LIMIT ? OFFSET ?';
            $rows = Database::fetchAll($sql, [$videoType, self::BATCH_SIZE, $offset]);
            if (empty($rows)) break;

            $translations = $opts['hreflang'] ? self::batchTranslations($transTable, $fkCol, $rows) : [];

            foreach ($rows as $e) {
                $id      = (int) $e['id'];
                $lastmod = self::dateOnly((string) ($e['lastmod_at'] ?? ''));

                $available = [$defaultLang => $siteUrl . $typePrefix . $routePrefix . $e['slug']];
                if ($opts['hreflang']) {
                    $trans = $translations[$id] ?? [];
                    foreach ($langs as $code) {
                        if ($code === $defaultLang) continue;
                        $tSlug = self::transSlug($trans, $code);
                        if ($tSlug !== null) {
                            $available[$code] = $siteUrl . '/' . $code . $typePrefix . $routePrefix . $tSlug;
                        }
                    }
                }

                self::shardAdd($st, self::plainUrlBlock($available, $defaultLang, $lastmod, $opts['hreflang']), count($available), $lastmod);
            }

            $offset += count($rows);
            if (count($rows) < self::BATCH_SIZE) break;
        }

        return self::shardCommit($st);
    }

     

    

    private static function generateHubs(): array
    {
        $siteUrl     = self::siteUrl();
        $defaultLang = Translator::getDefaultCode();
        $langs       = self::langCodes();
        $opts        = self::getOptions();
        $types       = Video::availableTypes();
        $albumsOn    = !self::shouldSkipHub('albums');

        if (!function_exists('gzopen')) {
            return [];
        }

        $ns = $opts['hreflang'] ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : '';
        $st = self::shardInit('hubs', $ns, $opts['max_urls']);
        $st['task'] = 'hubs';
        $st['type'] = '';

        $freshest = self::dateOnly((string) (Database::fetchOne(
            "SELECT MAX(updated_at) AS m FROM videos WHERE status = 'published'"
        )['m'] ?? ''));

        foreach ($types as $type) {
            $tp = self::typePrefixFor($type);

            if (!self::shouldSkipHub('pages')) {
                $home = [$defaultLang => $siteUrl . ($tp !== '' ? $tp : '/')];
                foreach ($langs as $code) {
                    if ($code === $defaultLang) continue;
                    $home[$code] = $siteUrl . '/' . $code . $tp;
                }
                self::shardAdd($st, self::plainUrlBlock($home, $defaultLang, $freshest, $opts['hreflang']), count($home), $freshest);
            }

            $hubs = self::HUB_ROUTES;
            if ($albumsOn) {
                $hubs['albums'] = '/albums';
            }
            foreach ($hubs as $entityType => $path) {
                if (self::shouldSkipHub($entityType)) continue;
                $set = [$defaultLang => $siteUrl . $tp . $path];
                foreach ($langs as $code) {
                    if ($code === $defaultLang) continue;
                    $set[$code] = $siteUrl . '/' . $code . $tp . $path;
                }
                self::shardAdd($st, self::plainUrlBlock($set, $defaultLang, $freshest, $opts['hreflang']), count($set), $freshest);
            }

            if (self::shouldSkipHub('videos')) continue;

            foreach (self::LIST_ROUTES as $slugKey => $labelKey) {
                $set = [$defaultLang => $siteUrl . $tp . '/videos/' . self::routeSlug($defaultLang, $slugKey, $labelKey)];
                foreach ($langs as $code) {
                    if ($code === $defaultLang) continue;
                    $set[$code] = $siteUrl . '/' . $code . $tp . '/videos/' . self::routeSlug($code, $slugKey, $labelKey);
                }
                self::shardAdd($st, self::plainUrlBlock($set, $defaultLang, $freshest, $opts['hreflang']), count($set), $freshest);
            }
        }

        return self::shardCommit($st);
    }

     

    

    private static function generatePages(): array
    {
        $siteUrl     = self::siteUrl();
        $defaultLang = Translator::getDefaultCode();
        $langs       = self::langCodes();
        $opts        = self::getOptions();

        if (!function_exists('gzopen')) {
            return [];
        }

        $ns = $opts['hreflang'] ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : '';
        $st = self::shardInit('pages', $ns, $opts['max_urls']);
        $st['task'] = 'pages';
        $st['type'] = '';

        $offset = 0;
        while (true) {
            $pages = Database::fetchAll(
                "SELECT id, slug, updated_at FROM pages WHERE status = 'published' ORDER BY id LIMIT ? OFFSET ?",
                [self::BATCH_SIZE, $offset]
            );
            if (empty($pages)) break;

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

            foreach ($pages as $page) {
                $id      = (int) $page['id'];
                $lastmod = self::dateOnly((string) ($page['updated_at'] ?? ''));

                $available = [$defaultLang => $siteUrl . '/' . $page['slug']];
                if ($opts['hreflang']) {
                    $trans = $translations[$id] ?? [];
                    foreach ($langs as $code) {
                        if ($code === $defaultLang) continue;
                        $tSlug = self::transSlug($trans, $code);
                        if ($tSlug !== null) {
                            $available[$code] = $siteUrl . '/' . $code . '/' . $tSlug;
                        }
                    }
                }

                self::shardAdd($st, self::plainUrlBlock($available, $defaultLang, $lastmod, $opts['hreflang']), count($available), $lastmod);
            }

            $offset += count($pages);
            if (count($pages) < self::BATCH_SIZE) break;
        }

        return self::shardCommit($st);
    }

     

    

    private static function plainUrlBlock(array $available, string $defaultLang, string $lastmod, bool $hreflang): string
    {
        $alts = $hreflang ? self::alternateBlock($available, $defaultLang) : '';
        $out  = '';
        foreach ($available as $loc) {
            $out .= "  <url>\n";
            $out .= '    <loc>' . self::esc($loc) . "</loc>\n";
            if ($lastmod !== '') {
                $out .= '    <lastmod>' . $lastmod . "</lastmod>\n";
            }
            $out .= $alts;
            $out .= "  </url>\n";
        }
        return $out;
    }

    

    private static function alternateBlock(array $available, string $defaultLang): string
    {
        if (count($available) < 2) {
            return '';
        }
        $out = '';
        foreach ($available as $code => $href) {
            $out .= '    <xhtml:link rel="alternate" hreflang="' . self::esc($code) . '" href="' . self::esc($href) . "\"/>\n";
        }
        if (isset($available[$defaultLang])) {
            $out .= '    <xhtml:link rel="alternate" hreflang="x-default" href="' . self::esc($available[$defaultLang]) . "\"/>\n";
        }
        return $out;
    }

     

    

    private static function transSlug(array $trans, string $lang): ?string
    {
        $slug = $trans[$lang]['slug'] ?? null;
        if (!is_string($slug)) {
            return null;
        }
        $slug = trim($slug);
        return $slug !== '' ? $slug : null;
    }

    

    private static function routeSlug(string $lang, string $slugKey, string $labelKey): string
    {
        static $memo = [];
        $k = $lang . '|' . $slugKey;
        if (isset($memo[$k])) {
            return $memo[$k];
        }

        $prev = Translator::locale();
        if ($lang !== $prev) {
            Translator::setLocale($lang);
        }
        $slug = Translator::slug($slugKey, $labelKey);
        if ($lang !== $prev) {
            Translator::setLocale($prev);
        }

        return $memo[$k] = ($slug !== '' ? $slug : $labelKey);
    }

    

    private static function langCodes(): array
    {
        static $codes = null;
        if ($codes !== null) {
            return $codes;
        }
        $codes = [];
        foreach (Translator::available() as $lang) {
            if (!empty($lang['code'])) {
                $codes[] = (string) $lang['code'];
            }
        }
        return $codes;
    }

    

    private static function storageIdOf(array $row): ?int
    {
        $sid = isset($row['storage_id']) ? (int) $row['storage_id'] : 0;
        return $sid > 0 ? $sid : null;
    }

    

    private static function storageIsSigned(?int $storageId): bool
    {
        if ($storageId === null) {
            return false;
        }
        static $memo = [];
        if (isset($memo[$storageId])) {
            return $memo[$storageId];
        }
        try {
            $s = MediaStorage::get($storageId);
        } catch (\Throwable $e) {
            return $memo[$storageId] = false;
        }
        return $memo[$storageId] = !empty($s['secure_urls']);
    }

    

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

    

    private static function dateOnly(string $value): string
    {
        if ($value === '' || str_starts_with($value, '0000')) {
            return '';
        }
        return substr($value, 0, 10);
    }

    

    private static function clip(string $s, int $max): string
    {
        $s = trim(preg_replace('/\s+/u', ' ', strip_tags($s)) ?? '');
        if (mb_strlen($s) <= $max) {
            return $s;
        }
        return rtrim(mb_substr($s, 0, $max - 1)) . '…';
    }

     

    

    private static function writeManifest(array $files): void
    {
        $payload = json_encode([
            'generated' => time(),
            'files'     => array_values($files),
        ]);
        if ($payload === false) {
            return;
        }
        $tmp = self::MANIFEST_FILE . '.tmp';
        if (file_put_contents($tmp, $payload) !== false) {
            @rename($tmp, self::MANIFEST_FILE);
        }
    }

    

    private static function manifest(): array
    {
        static $memo = null;
        if ($memo !== null) {
            return $memo;
        }
        $raw = @file_get_contents(self::MANIFEST_FILE);
        if ($raw === false) {
            return $memo = [];
        }
        $data = json_decode($raw, true);
        return $memo = (is_array($data) && isset($data['files']) && is_array($data['files'])) ? $data['files'] : [];
    }

    

    private static function manifestFiles(): array
    {
        $out = [];
        foreach (self::manifest() as $f) {
            if (!empty($f['name']) && (int) ($f['urls'] ?? 0) > 0) {
                $out[] = $f;
            }
        }
        return $out;
    }

    

    private static function manifestEntry(string $name): ?array
    {
        foreach (self::manifest() as $f) {
            if (($f['name'] ?? '') === $name) {
                return [
                    'task' => (string) ($f['task'] ?? ''),
                    'type' => (string) ($f['type'] ?? ''),
                ];
            }
        }
        return null;
    }

    

    private static function pruneStale(array $files): void
    {
        $keep = [];
        foreach ($files as $f) {
            $keep[$f['name'] . '.xml.gz'] = true;
        }
        foreach (glob(self::CACHE_DIR . '/*.xml.gz') ?: [] as $path) {
            if (!isset($keep[basename($path)])) {
                @unlink($path);
            }
        }
        foreach (glob(self::CACHE_DIR . '/*.xml.gz.tmp') ?: [] as $path) {
            @unlink($path);
        }
    }

     

    

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

    

    private static function shouldSkipHub(string $entityType): bool
    {
        if (!Album::featureEnabled() && str_starts_with($entityType, 'album')) {
            return true;
        }
        if (Setting::get('seo_noindex_entire_site', '0') === '1') {
            return true;
        }
        if ($entityType === 'albums' && Setting::get('seo_noindex_albums', '0') === '1') {
            return true;
        }
        return HookSystem::applyFilter('sitemap.skip_entity', false, $entityType);
    }

    

    private static function isNoindexed(string $entityType): bool
    {
        if (Setting::get('seo_noindex_entire_site', '0') === '1') {
            return true;
        }
        $map = [
            'categories'       => 'seo_noindex_categories',
            'tags'             => 'seo_noindex_tags',
            'performers'       => 'seo_noindex_performers',
            'channels'         => 'seo_noindex_channels',
            'albums'           => 'seo_noindex_albums',
            'album-categories' => 'seo_noindex_albums',
            'album-tags'       => 'seo_noindex_albums',
            'album-performers' => 'seo_noindex_albums',
            'album-channels'   => 'seo_noindex_albums',
        ];
        $key = $map[$entityType] ?? '';
        return $key !== '' && Setting::get($key, '0') === '1';
    }

    

    private static function getOptions(): array
    {
        $opts = HookSystem::applyFilter('sitemap.options', [
            'video_tags' => true,
            'image_tags' => true,
            'hreflang'   => true,
            'max_urls'   => self::SOFT_URLS_PER_FILE,
        ]);
        $max = isset($opts['max_urls']) ? (int) $opts['max_urls'] : 0;
        if ($max < 1) {
            $max = self::SOFT_URLS_PER_FILE;
        }
        return [
            'video_tags' => !empty($opts['video_tags']),
            'image_tags' => !empty($opts['image_tags']),
            'hreflang'   => !empty($opts['hreflang']),
            'max_urls'   => min($max, self::SOFT_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 typeKey(string $videoType): string
    {
        $types = Video::availableTypes();
        if (count($types) < 2) return '';
        $defaultType = $types[0] ?? Video::DEFAULT_TYPE;
        return ($videoType !== $defaultType) ? $videoType . '-' : '';
    }

    

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

    

    private static function batchVideoTranslations(array $videoIds): array
    {
        if (empty($videoIds)) return [];
        $ph = implode(',', array_fill(0, count($videoIds), '?'));
        $rows = Database::fetchAll(
            "SELECT video_id, lang_code, slug, title, description FROM video_translations WHERE video_id IN ({$ph})",
            $videoIds
        );
        $map = [];
        foreach ($rows as $row) {
            $map[(int) $row['video_id']][$row['lang_code']] = $row;
        }
        return $map;
    }

    

    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 ensureCacheDir(): void
    {
        if (!is_dir(self::CACHE_DIR)) {
            mkdir(self::CACHE_DIR, 0755, true);
        }
    }

    

    private static function notFound(): never
    {
        http_response_code(404);
        header('Content-Type: text/html');
        echo '<h1>Sitemap not found</h1>';
        exit;
    }

    

    private static function serveSitemap(string $file): never
    {
        header('Content-Type: application/xml; charset=UTF-8');
        header('Cache-Control: public, max-age=3600');
        header('X-Robots-Tag: noindex');

        $accept = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '';
        if (stripos($accept, 'gzip') !== false) {
            header('Content-Encoding: gzip');
            $size = filesize($file);
            if ($size !== false) {
                header('Content-Length: ' . $size);
            }
            readfile($file);
        } else {
            readgzfile($file);
        }
        exit;
    }
}
