<?php

namespace WPSN\Modules\Sitemap;

use WPSN\Core\AbstractModule;
use WPSN\Core\Http\HttpClient;
use WPSN\SEO\MetaKeys;
use WPSN\Domain\SeoDocumentContext;
use WPSN\Core\Queue\QueueInterface;
use WPSN\Modules\IndexNow\IndexNowJobs;
use WPSN\Modules\IndexNow\IndexNowLogRepository;
use WPSN\Modules\LocalSEO\LocalSEOModule;

defined('ABSPATH') || exit;

/**
 * Stage 6: XML sitemaps.
 *
 * Endpoints:
 * - /sitemap_index.xml (alias: /sitemap.xml)
 * - /{type}-sitemap.xml
 * - /{type}-sitemap2.xml, /{type}-sitemap3.xml, ...
 *
 * Where {type} is a post type slug (post, page, ...) or taxonomy slug prefixed with "tax-".
 */
final class SitemapModule extends AbstractModule
{
    private const QV_SITEMAP = 'wpsn_sitemap';
    private const QV_PAGE    = 'wpsn_sitemap_page';

    // Human-readable HTML sitemap endpoint.
    private const QV_HTML_SITEMAP = 'wpsn_html_sitemap';
    private const QV_HTML_TYPE    = 'wpsn_html_type';
    private const QV_HTML_TAX     = 'wpsn_html_tax';
    private const QV_HTML_PAGE    = 'wpsn_html_page';

    /**
     * Fallback detection when rewrite rules are missing/not flushed.
     *
     * Some hosts/plugins break pretty rewrites for *.xml endpoints.
     * We still want /sitemap.xml to work when WordPress loads normally.
     */
    private const URI_RE_INDEX = '#^/sitemap(?:_index)?\\.xml$#i';
    private const URI_RE_TYPE1 = '#^/([a-z0-9_-]+)-sitemap\\.xml$#i';
    private const URI_RE_TYPEP = '#^/([a-z0-9_-]+)-sitemap([0-9]+)\\.xml$#i';

    // Fallback for /html-sitemap/ when rewrite rules are missing.
    private const URI_RE_HTML  = '#^/html-sitemap/?$#i';

    public function id(): string
    {
        return 'sitemap';
    }

    public function name(): string
    {
        return __('Sitemaps', 'wp-seo-next');
    }

    public function description(): string
    {
        return __('XML sitemap index + per-type sitemaps.', 'wp-seo-next');
    }

    public function defaultEnabled(): bool
    {
        return true;
    }

    public function register(): void
    {
        add_action('init', [$this, 'registerRewriteRules']);
        // Register post meta used by the optional Video sitemap.
        add_action('init', [$this, 'registerVideoMetaKeys']);
        add_filter('query_vars', [$this, 'addQueryVars']);
        add_action('template_redirect', [$this, 'handleRequest'], 0);

        // Human-readable sitemap (shortcode) for users who want an HTML version.
        add_shortcode('wpsn_html_sitemap', [$this, 'shortcodeHtmlSitemap']);

        // Cache busting: keep sitemaps fresh on content changes.
        add_action('save_post', [$this, 'maybeBustCacheOnSavePost'], 100, 2);
        // SEO overrides updates (noindex/canonical) should also refresh sitemaps.
        add_action('seo/meta/updated', [$this, 'bustCache'], 100, 1);
        add_action('deleted_post', [$this, 'bustCache'], 100, 1);
        add_action('transition_post_status', [$this, 'bustCache'], 100, 3);
        add_action('created_term', [$this, 'bustCache'], 100, 3);
        add_action('edited_term', [$this, 'bustCache'], 100, 3);
        add_action('delete_term', [$this, 'bustCache'], 100, 4);

        // Optional: ping search engines when sitemaps change (uses queue).
        add_action('wpsn/sitemap/bust', [$this, 'maybeQueueSitemapPings'], 10, 2);
        add_filter('wpsn_queue_handle_' . IndexNowJobs::JOB_PING_SITEMAP, [$this, 'handleSitemapPing'], 10, 3);

    }

    public function maybeBustCacheOnSavePost(int $postId, \WP_Post $post): void
    {
        if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
            return;
        }

        // Only published/public content affects sitemaps.
        if ($post->post_status !== 'publish') {
            // Still bust; publishing/unpublishing happens through transition hooks.
            // But a manual update could have changed lastmod, so keep it simple.
        }

        $this->bustCache();
    }

    /**
     * @param mixed ...$args
     */
    public function bustCache(...$args): void
    {
        // A simple version/timestamp that gets included into cache keys.
        $token = (string) time();
        update_option('wpsn_sitemap_cache_bust', $token, false);

        /**
         * Fires when the sitemap cache is busted (content/term/meta changed).
         * Useful for other modules (e.g. IndexNow) to ping/submit sitemap URLs.
         *
         * @param string $token Cache bust token (timestamp string).
         * @param array<int,mixed> $args Original hook arguments (if any).
         */
        do_action('wpsn/sitemap/bust', $token, $args);
    }


    /**
     * Queue lightweight sitemap ping requests to search engines.
     *
     * This is intentionally implemented in the Sitemap module so it can work even when
     * the IndexNow module is disabled (pings are configured under IndexNow settings).
     *
     * @param string $token Cache bust token.
     * @param array<int,mixed> $args Original hook args.
     */
    public function maybeQueueSitemapPings(string $token = '', array $args = []): void
    {
        if (!(bool) $this->settings->get('indexnow.sitemap_pings.enabled', false)) {
            return;
        }

        // Respect privacy / robots settings: do not send any external requests in local-only mode,
        // and avoid pinging when the site discourages indexing.
        if ((bool) $this->settings->get('privacy.local_only', false)) {
            return;
        }
        if ((string) get_option('blog_public') === '0') {
            return;
        }

        $sp = $this->settings->get('indexnow.sitemap_pings', []);
        $sp = is_array($sp) ? $sp : [];

        $prov = isset($sp['providers']) && is_array($sp['providers']) ? $sp['providers'] : [];
        $provList = [];
        foreach (['bing', 'google'] as $p) {
            if (!empty($prov[$p])) {
                $provList[] = $p;
            }
        }
        if ($provList === []) {
            $provList = ['bing'];
        }

        $sitemapUrls = [];
        if (!empty($sp['include_index'])) {
            $sitemapUrls[] = home_url('/sitemap_index.xml');
        }
        if (!empty($sp['include_news']) && (bool) $this->settings->get('sitemap.news.enabled', false)) {
            $sitemapUrls[] = home_url('/news-sitemap.xml');
        }
        if (!empty($sp['include_video']) && (bool) $this->settings->get('sitemap.video.enabled', false)) {
            $sitemapUrls[] = home_url('/video-sitemap.xml');
        }

        /**
         * Filter the list of sitemap URLs that will be pinged.
         *
         * @since 0.66.1
         *
         * @param array<int,string> $sitemapUrls
         * @param array<int,string> $providers
         * @param string $token
         */
        $sitemapUrls = apply_filters('wpsn/sitemap/ping_urls', $sitemapUrls, $provList, $token);
        $sitemapUrls = is_array($sitemapUrls) ? $sitemapUrls : [];

        if ($sitemapUrls === []) {
            return;
        }

        $dedupe = (int) $this->settings->get('indexnow.dedupe_window', 300);
        $dedupe = max(0, $dedupe);

        /** @var QueueInterface $queue */
        $queue = $this->container->get('queue');

        foreach ($sitemapUrls as $sUrl) {
            $sUrl = is_string($sUrl) ? trim($sUrl) : '';
            if ($sUrl === '' || (function_exists('wp_http_validate_url') && !wp_http_validate_url($sUrl))) {
                continue;
            }

            foreach ($provList as $provider) {
                $provider = sanitize_key((string) $provider);
                if ($provider === '') {
                    continue;
                }

                $key = 'wpsn_sitemap_ping_' . md5($provider . '|' . $sUrl);
                if ($dedupe > 0 && get_transient($key)) {
                    continue;
                }
                if ($dedupe > 0) {
                    set_transient($key, 1, $dedupe);
                }

                $queue->enqueue(IndexNowJobs::JOB_PING_SITEMAP, [
                    'provider' => $provider,
                    'sitemap_url' => $sUrl,
                    'reason' => 'sitemap_bust',
                    'token' => $token,
                ]);
            }
        }
    }

    /**
     * Queue handler for sitemap pings.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $jobRow
     * @return array<string,mixed>|null
     */
    public function handleSitemapPing($unused, array $payload, array $jobRow)
    {
        $provider = isset($payload['provider']) ? sanitize_key((string) $payload['provider']) : '';
        $sitemapUrl = isset($payload['sitemap_url']) ? trim((string) $payload['sitemap_url']) : '';

        if ($provider === '' || $sitemapUrl === '') {
            return ['status' => 'failed', 'error' => 'Missing provider or sitemap_url'];
        }

        if ((bool) $this->settings->get('privacy.local_only', false) || (string) get_option('blog_public') === '0') {
            // Do nothing in local-only / discouraged indexing mode.
            $reason = (bool) $this->settings->get('privacy.local_only', false)
                ? 'Local-only mode'
                : 'Indexing discouraged';
            $this->logSitemapPing('skipped', 0, '', $sitemapUrl, $reason);
            return ['status' => 'done'];
        }

        if (function_exists('wp_http_validate_url') && !wp_http_validate_url($sitemapUrl)) {
            return ['status' => 'failed', 'error' => 'Invalid sitemap_url'];
        }

        $pingUrl = $this->buildSitemapPingUrl($provider, $sitemapUrl);
        if ($pingUrl === '') {
            return ['status' => 'failed', 'error' => 'Unknown provider'];
        }

        $resp = HttpClient::get($pingUrl, [
            'timeout' => 10,
            'redirection' => 2,
            'headers' => [
                'Accept' => 'text/plain, */*;q=0.8',
                'X-WPSN-Service' => 'sitemap_ping',
            ],
            'user-agent' => 'WPSN/' . (defined('WPSN_VERSION') ? WPSN_VERSION : 'dev') . '; ' . home_url('/'),
        ], [
            'service' => 'sitemap_ping',
            'context' => 'ping',
            'retries' => 1,
            'retry_statuses' => [429, 500, 502, 503, 504],
        ]);

        if (is_wp_error($resp)) {
            // Retry with backoff on transient network errors.
            $attempts = isset($jobRow['attempts']) ? (int) $jobRow['attempts'] : 1;
            $attempts = max(1, min(8, $attempts));

            $base = (int) $this->settings->get('indexnow.base_backoff', 30);
            $base = max(5, min(3600, $base));

            $max = (int) $this->settings->get('indexnow.max_backoff', 600);
            $max = max($base, min(7200, $max));

            $retryIn = (int) ($base * pow(2, $attempts - 1));
            $retryIn = max($base, min($max, $retryIn));

            $this->logSitemapPing('retry', 0, $pingUrl, $sitemapUrl, 'Network error: ' . $resp->get_error_message());

            return [
                'status' => 'retry',
                'retry_in' => $retryIn,
                'error' => 'Network error: ' . $resp->get_error_message(),
            ];
        }

        $code = (int) wp_remote_retrieve_response_code($resp);

        // Soft backoff for rate limits.
        if ($code === 429 || $code === 503) {
            $attempts = isset($jobRow['attempts']) ? (int) $jobRow['attempts'] : 1;
            $attempts = max(1, min(8, $attempts));

            $base = (int) $this->settings->get('indexnow.base_backoff', 30);
            $base = max(5, min(3600, $base));

            $max = (int) $this->settings->get('indexnow.max_backoff', 600);
            $max = max($base, min(7200, $max));

            $retryIn = (int) ($base * pow(2, $attempts - 1));
            $retryIn = max($base, min($max, $retryIn));

            $this->logSitemapPing('retry', $code, $pingUrl, $sitemapUrl, 'Rate limited');

            return [
                'status' => 'retry',
                'retry_in' => $retryIn,
                'error' => 'Rate limited (HTTP ' . $code . ')',
            ];
        }

        if ($code < 200 || $code >= 300) {
            $this->logSitemapPing('failed', $code, $pingUrl, $sitemapUrl, 'Ping failed');

            return [
                'status' => 'failed',
                'error' => 'Ping failed (HTTP ' . $code . ')',
            ];
        }

        return ['status' => 'done'];
    }

    private function buildSitemapPingUrl(string $provider, string $sitemapUrl): string
    {
        $provider = sanitize_key($provider);
        $sitemapUrl = trim($sitemapUrl);

        if ($provider === '' || $sitemapUrl === '') {
            return '';
        }

        $encoded = rawurlencode($sitemapUrl);

        // Provider endpoints are intentionally simple; can be customized via filter below.
        $url = '';
        if ($provider === 'google') {
            $url = 'https://www.google.com/ping?sitemap=' . $encoded;
        } elseif ($provider === 'bing') {
            $url = 'https://www.bing.com/ping?sitemap=' . $encoded;
        }

        /**
         * Filter ping URL for a sitemap provider.
         *
         * @since 0.66.1
         *
         * @param string $url
         * @param string $provider
         * @param string $sitemapUrl
         */
        $url = (string) apply_filters('wpsn/sitemap/ping_url', $url, $provider, $sitemapUrl);

        $url = trim($url);
        return $url !== '' ? esc_url_raw($url) : '';
    }


    /**
     * Best-effort logging into the IndexNow log table (if it exists).
     *
     * The log table is installed by the IndexNow module; when the module is disabled, we skip logging.
     *
     * @param string $status ok|retry|failed
     */
    private function logSitemapPing(string $status, int $responseCode, string $endpoint, string $sitemapUrl, string $error = ''): void
    {
        static $hasTable = null;

        // Cache the table check per request.
        if ($hasTable === null) {
            global $wpdb;
            $table = $wpdb->prefix . 'wpsn_indexnow_log';
            $found = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table));
            $hasTable = is_string($found) && $found === $table;
        }

        if (!$hasTable) {
            return;
        }

        try {
            $log = new IndexNowLogRepository();
            $log->insert([
                'status' => $status,
                'response_code' => $responseCode,
                'url_count' => 1,
                'endpoint' => $endpoint,
                'payload' => wp_json_encode(['sitemap_url' => $sitemapUrl]),
                'error' => $error,
            ]);
        } catch (\Throwable $e) {
            // Ignore logging errors.
        }
    }


    private function cacheBustToken(): string
    {
        $v = get_option('wpsn_sitemap_cache_bust', '1');
        $v = is_string($v) ? $v : '1';
        return $v !== '' ? $v : '1';
    }

    public function registerRewriteRules(): void
    {
        // Index.
        add_rewrite_rule('^sitemap\.xml$', 'index.php?' . self::QV_SITEMAP . '=index', 'top');
        add_rewrite_rule('^sitemap_index\.xml$', 'index.php?' . self::QV_SITEMAP . '=index', 'top');

        // Sub-sitemaps.
        add_rewrite_rule('^([a-z0-9_-]+)-sitemap\.xml$', 'index.php?' . self::QV_SITEMAP . '=$matches[1]&' . self::QV_PAGE . '=1', 'top');
        add_rewrite_rule('^([a-z0-9_-]+)-sitemap([0-9]+)\.xml$', 'index.php?' . self::QV_SITEMAP . '=$matches[1]&' . self::QV_PAGE . '=$matches[2]', 'top');

        // Human-readable HTML sitemap.
        // NOTE: added at "bottom" to avoid hijacking an existing page with the same slug.
        add_rewrite_rule('^html-sitemap/?$', 'index.php?' . self::QV_HTML_SITEMAP . '=1', 'bottom');
    }

    /**
     * @param array<int, string> $vars
     * @return array<int, string>
     */
    public function addQueryVars(array $vars): array
    {
        $vars[] = self::QV_SITEMAP;
        $vars[] = self::QV_PAGE;

        $vars[] = self::QV_HTML_SITEMAP;
        $vars[] = self::QV_HTML_TYPE;
        $vars[] = self::QV_HTML_TAX;
        $vars[] = self::QV_HTML_PAGE;
        return $vars;
    }

    public function registerVideoMetaKeys(): void
    {
        // Register meta for all public post types (except attachments).
        $postTypes = get_post_types(['public' => true], 'names');
        foreach ($postTypes as $pt) {
            if (!is_string($pt) || $pt === 'attachment') {
                continue;
            }

            register_post_meta($pt, MetaKeys::VIDEO_URL, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => [self::class, 'sanitizeVideoUrl'],
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId, ...$rest): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);

            register_post_meta($pt, MetaKeys::VIDEO_TITLE, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => 'sanitize_text_field',
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId, ...$rest): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);

            register_post_meta($pt, MetaKeys::VIDEO_DESCRIPTION, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => 'sanitize_textarea_field',
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId, ...$rest): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);

            register_post_meta($pt, MetaKeys::VIDEO_THUMBNAIL, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => [self::class, 'sanitizeVideoUrl'],
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId, ...$rest): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);

            register_post_meta($pt, MetaKeys::VIDEO_DURATION, [
                'type'              => 'integer',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => static function ($value): int {
                    $v = (int) $value;
                    if ($v < 0) {
                        $v = 0;
                    }
                    // spec suggests <= 28800 (8 hours), but we keep permissive.
                    return $v;
                },
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId, ...$rest): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);
        }
    }

    /**
     * Accept absolute URLs and site-relative paths.
     *
     * @param mixed $value
     */
    public static function sanitizeVideoUrl($value): string
    {
        $raw = trim((string) $value);
        if ($raw === '') {
            return '';
        }

        // Convert scheme-relative and site-relative URLs.
        if (strpos($raw, '//') === 0) {
            $raw = set_url_scheme($raw);
        } elseif (strpos($raw, '/') === 0) {
            $raw = (string) home_url($raw);
        }

        return esc_url_raw($raw);
    }

    public function handleRequest(): void
    {
        // HTML sitemap (human-readable).
        $html = (string) get_query_var(self::QV_HTML_SITEMAP);
        if ($html === '') {
            $html = $this->detectHtmlFromRequestUri();
        }
        if ($html !== '') {
            // Only respond to GET/HEAD.
            $method = isset($_SERVER['REQUEST_METHOD']) ? (string) $_SERVER['REQUEST_METHOD'] : 'GET';
            $method = strtoupper($method);
            if ($method !== 'GET' && $method !== 'HEAD') {
                return;
            }

            // Prevent WP canonical redirects for our endpoints.
            remove_action('template_redirect', 'redirect_canonical');

            $doc = $this->buildHtmlSitemapDocument();
            $this->sendHtml($doc);
            exit;
        }

        $which = (string) get_query_var(self::QV_SITEMAP);

        $page = (int) get_query_var(self::QV_PAGE);
        if ($page < 1) {
            $page = 1;
        }

        // Fallback: detect from request URI when rewrite rules are missing.
        if ($which === '') {
            [$which, $page] = $this->detectFromRequestUri();
        }

        if ($which === '') {
            return;
        }

        // Only respond to GET/HEAD.
        $method = isset($_SERVER['REQUEST_METHOD']) ? (string) $_SERVER['REQUEST_METHOD'] : 'GET';
        $method = strtoupper($method);
        if ($method !== 'GET' && $method !== 'HEAD') {
            return;
        }

        // Prevent WP canonical redirects for our XML endpoints.
        remove_action('template_redirect', 'redirect_canonical');

        if ($which === 'index') {
            $xml = $this->buildIndexXml();
            $this->sendXml($xml);
            exit;
        }

        $xml = $this->buildTypeXml($which, $page);
        if ($xml === null) {
            $this->send404();
            exit;
        }

        $this->sendXml($xml);
        exit;
    }

    /**
     * Try to infer sitemap route from REQUEST_URI.
     *
     * @return array{0:string,1:int} [which, page]
     */
    private function detectFromRequestUri(): array
    {
        $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
        $uri = trim($uri);
        if ($uri === '') {
            return ['', 1];
        }

        // Strip query string.
        $path = $uri;
        $qPos = strpos($path, '?');
        if ($qPos !== false) {
            $path = substr($path, 0, $qPos);
        }

        // Normalize to leading slash.
        $path = '/' . ltrim($path, '/');

        // If WP is installed in a subdirectory, strip it.
        $basePath = '';
        if (function_exists('wp_parse_url')) {
            $bp = wp_parse_url(home_url('/'), PHP_URL_PATH);
            $basePath = is_string($bp) ? $bp : '';
        } else {
            $bp = parse_url(home_url('/'), PHP_URL_PATH);
            $basePath = is_string($bp) ? $bp : '';
        }

        $basePath = '/' . trim($basePath, '/');
        if ($basePath !== '/' && $basePath !== '') {
            // Example: basePath = /blog
            if (strpos($path, $basePath . '/') === 0) {
                $path = substr($path, strlen($basePath));
                if ($path === '') {
                    $path = '/';
                }
            }
        }

        if (preg_match(self::URI_RE_INDEX, $path)) {
            return ['index', 1];
        }
        if (preg_match(self::URI_RE_TYPEP, $path, $m)) {
            $which = isset($m[1]) ? sanitize_key((string) $m[1]) : '';
            $page = isset($m[2]) ? (int) $m[2] : 1;
            if ($page < 1) {
                $page = 1;
            }
            return [$which, $page];
        }
        if (preg_match(self::URI_RE_TYPE1, $path, $m)) {
            $which = isset($m[1]) ? sanitize_key((string) $m[1]) : '';
            return [$which, 1];
        }

        return ['', 1];
    }

    /**
     * Detect HTML sitemap request from REQUEST_URI when rewrite rules are missing.
     *
     * We only do this on 404 requests to avoid hijacking a real page with the same slug.
     */
    private function detectHtmlFromRequestUri(): string
    {
        // Only handle when WP did not resolve a real page/post.
        if (function_exists('is_404') && !is_404()) {
            return '';
        }

        $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
        $uri = trim($uri);
        if ($uri === '') {
            return '';
        }

        // Strip query string.
        $path = $uri;
        $qPos = strpos($path, '?');
        if ($qPos !== false) {
            $path = substr($path, 0, $qPos);
        }

        // Normalize to leading slash.
        $path = '/' . ltrim($path, '/');

        // If WP is installed in a subdirectory, strip it.
        $basePath = '';
        if (function_exists('wp_parse_url')) {
            $bp = wp_parse_url(home_url('/'), PHP_URL_PATH);
            $basePath = is_string($bp) ? $bp : '';
        } else {
            $bp = parse_url(home_url('/'), PHP_URL_PATH);
            $basePath = is_string($bp) ? $bp : '';
        }

        $basePath = '/' . trim($basePath, '/');
        if ($basePath !== '/' && $basePath !== '') {
            if (strpos($path, $basePath . '/') === 0) {
                $path = substr($path, strlen($basePath));
                if ($path === '') {
                    $path = '/';
                }
            }
        }

        return preg_match(self::URI_RE_HTML, $path) ? '1' : '';
    }

    /**
     * Shortcode: [wpsn_html_sitemap]
     *
     * Optional attributes:
     * - type="post" (post type slug)
     * - tax="category" (taxonomy slug)
     * - page="1"
     *
     * If no attributes provided, renders an index view.
     *
     * @param array<string,mixed> $atts
     */
    public function shortcodeHtmlSitemap($atts = []): string
    {
        $atts = is_array($atts) ? $atts : [];
        $type = isset($atts['type']) ? sanitize_key((string) $atts['type']) : '';
        $tax  = isset($atts['tax']) ? sanitize_key((string) $atts['tax']) : '';
        $page = isset($atts['page']) ? (int) $atts['page'] : 1;
        if ($page < 1) {
            $page = 1;
        }

        return $this->buildHtmlSitemapContent($type, $tax, $page, false);
    }

    private function sendHtml(string $html): void
    {
        status_header(200);
        nocache_headers();
        header('Content-Type: text/html; charset=UTF-8', true);
        echo $html;
    }

    private function buildHtmlSitemapDocument(): string
    {
        $type = sanitize_key((string) get_query_var(self::QV_HTML_TYPE));
        $tax  = sanitize_key((string) get_query_var(self::QV_HTML_TAX));

        $page = (int) get_query_var(self::QV_HTML_PAGE);
        if ($page < 1) {
            $page = 1;
        }

        $content = $this->buildHtmlSitemapContent($type, $tax, $page, true);

        // Title.
        $title = __('HTML Sitemap', 'wp-seo-next');
        if ($type !== '') {
            $pto = get_post_type_object($type);
            $label = $pto && isset($pto->labels->name) ? (string) $pto->labels->name : $type;
            $title = sprintf(
                /* translators: %s is a post type label */
                __('HTML Sitemap: %s', 'wp-seo-next'),
                $label
            );
        } elseif ($tax !== '') {
            $txo = get_taxonomy($tax);
            $label = $txo && isset($txo->labels->name) ? (string) $txo->labels->name : $tax;
            $title = sprintf(
                /* translators: %s is a taxonomy label */
                __('HTML Sitemap: %s', 'wp-seo-next'),
                $label
            );
        }

        // Canonical URL for this view.
        $url = home_url('/html-sitemap/');
        $args = [];
        if ($type !== '') {
            $args['wpsn_html_type'] = $type;
        }
        if ($tax !== '') {
            $args['wpsn_html_tax'] = $tax;
        }
        if ($page > 1) {
            $args['wpsn_html_page'] = $page;
        }
        if ($args !== []) {
            $url = add_query_arg($args, $url);
        }

        $siteName = (string) get_bloginfo('name');

        $css = 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:1100px;margin:24px auto;padding:0 16px;line-height:1.5;}'
            . 'h1{font-size:24px;margin:0 0 14px 0;}'
            . 'h2{font-size:18px;margin:22px 0 10px 0;}'
            . 'a{text-decoration:none;}'
            . 'a:hover{text-decoration:underline;}'
            . 'ul{margin:10px 0 0 20px;}'
            . '.wpsn-nav{margin:10px 0 18px 0;padding:10px 12px;border:1px solid #dcdcde;border-radius:6px;background:#fff;}'
            . '.wpsn-nav a{margin-right:10px;}'
            . '.wpsn-meta{color:#646970;font-size:13px;}'
            . '.wpsn-pager{margin-top:12px;}'
            . '.wpsn-pager a{margin-right:10px;}'
            . 'code{background:#f6f7f7;padding:2px 4px;border-radius:4px;}';

        return "<!doctype html>\n"
            . "<html lang=\"" . esc_attr(str_replace('_', '-', (string) get_locale())) . "\">\n"
            . "<head>\n"
            . "<meta charset=\"utf-8\">\n"
            . "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
            . "<title>" . esc_html($title) . "</title>\n"
            . "<link rel=\"canonical\" href=\"" . esc_url($url) . "\">\n"
            . "<style>" . $css . "</style>\n"
            . "</head>\n"
            . "<body>\n"
            . "<header>\n"
            . "<div class=\"wpsn-meta\">" . esc_html($siteName) . "</div>\n"
            . "<h1>" . esc_html($title) . "</h1>\n"
            . "</header>\n"
            . $content
            . "</body>\n"
            . "</html>";
    }

    /**
     * @param string $type Post type slug.
     * @param string $tax Taxonomy slug.
     * @param int $page 1-based page.
     * @param bool $publicEndpoint Whether this is served as a standalone endpoint.
     */
    private function buildHtmlSitemapContent(string $type, string $tax, int $page, bool $publicEndpoint): string
    {
        $page = max(1, $page);

        $bust = (string) get_option('wpsn_sitemap_cache_bust', '');
        $key = 'wpsn_html_sm_' . substr(md5($bust . '|' . $type . '|' . $tax . '|' . $page . '|' . ($publicEndpoint ? '1' : '0')), 0, 20);
        $cached = get_transient($key);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $baseUrl = home_url('/html-sitemap/');

        $nav = '<div class="wpsn-nav">';
        $nav .= '<a href="' . esc_url($baseUrl) . '">' . esc_html__('Index', 'wp-seo-next') . '</a>';
        $nav .= '<a href="' . esc_url(home_url('/sitemap_index.xml')) . '">' . esc_html__('XML Sitemap', 'wp-seo-next') . '</a>';
        $nav .= '</div>';

        $out = $nav;

        if ($type === '' && $tax === '') {
            $out .= $this->renderHtmlSitemapIndex($baseUrl);
            set_transient($key, $out, DAY_IN_SECONDS);
            return $out;
        }

        if ($type !== '') {
            $out .= $this->renderHtmlSitemapPostType($baseUrl, $type, $page);
            set_transient($key, $out, DAY_IN_SECONDS);
            return $out;
        }

        if ($tax !== '') {
            $out .= $this->renderHtmlSitemapTaxonomy($baseUrl, $tax, $page);
            set_transient($key, $out, DAY_IN_SECONDS);
            return $out;
        }

        $out .= '<p><em>' . esc_html__('Nothing to show.', 'wp-seo-next') . '</em></p>';
        set_transient($key, $out, HOUR_IN_SECONDS);
        return $out;
    }

    private function htmlSitemapPerPage(): int
    {
        $default = min(200, $this->urlsPerSitemap());
        $per = (int) apply_filters('wpsn/html_sitemap/per_page', $default);
        if ($per < 20) {
            $per = 20;
        }
        if ($per > 1000) {
            $per = 1000;
        }
        return $per;
    }

    private function renderHtmlSitemapIndex(string $baseUrl): string
    {
        $out = '';

        $out .= '<p class="wpsn-meta">' . wp_kses_post(sprintf(
            /* translators: %s is the shortcode */
            __('Shortcode: <code>%s</code>', 'wp-seo-next'),
            '[wpsn_html_sitemap]'
        )) . '</p>';

        $pts = $this->enabledPostTypes();
        $txs = $this->enabledTaxonomies();

        if ($pts !== []) {
            $out .= '<h2>' . esc_html__('Post types', 'wp-seo-next') . '</h2>';
            $out .= '<ul>';
            foreach ($pts as $pt) {
                $pto = get_post_type_object($pt);
                $label = $pto && isset($pto->labels->name) ? (string) $pto->labels->name : $pt;
                $countObj = wp_count_posts($pt);
                $count = is_object($countObj) && isset($countObj->publish) ? (int) $countObj->publish : 0;

                $url = add_query_arg(['wpsn_html_type' => $pt], $baseUrl);
                $out .= '<li><a href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
                $out .= ' <span class="wpsn-meta">(' . esc_html((string) $count) . ')</span></li>';
            }
            $out .= '</ul>';
        }

        if ($txs !== []) {
            $out .= '<h2>' . esc_html__('Taxonomies', 'wp-seo-next') . '</h2>';
            $out .= '<ul>';
            foreach ($txs as $tx) {
                $txo = get_taxonomy($tx);
                $label = $txo && isset($txo->labels->name) ? (string) $txo->labels->name : $tx;
                $count = function_exists('wp_count_terms') ? (int) wp_count_terms($tx, ['hide_empty' => false]) : 0;
                $url = add_query_arg(['wpsn_html_tax' => $tx], $baseUrl);
                $out .= '<li><a href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
                if ($count > 0) {
                    $out .= ' <span class="wpsn-meta">(' . esc_html((string) $count) . ')</span>';
                }
                $out .= '</li>';
            }
            $out .= '</ul>';
        }

        if ($pts === [] && $txs === []) {
            $out .= '<p><em>' . esc_html__('No public post types/taxonomies are enabled for sitemaps.', 'wp-seo-next') . '</em></p>';
        }

        return $out;
    }

    private function renderHtmlSitemapPostType(string $baseUrl, string $type, int $page): string
    {
        $enabled = $this->enabledPostTypes();
        if (!in_array($type, $enabled, true)) {
            return '<p><strong>' . esc_html__('Unknown or disabled post type.', 'wp-seo-next') . '</strong></p>';
        }

        $per = $this->htmlSitemapPerPage();
        $page = max(1, $page);

        $q = new \WP_Query([
            'post_type' => $type,
            'post_status' => 'publish',
            'posts_per_page' => $per,
            'paged' => $page,
            'orderby' => 'title',
            'order' => 'ASC',
            'fields' => 'ids',
            'ignore_sticky_posts' => true,
            'no_found_rows' => false,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        $ids = is_array($q->posts) ? $q->posts : [];

        $pto = get_post_type_object($type);
        $label = $pto && isset($pto->labels->name) ? (string) $pto->labels->name : $type;

        $out = '<h2>' . esc_html($label) . '</h2>';

        if (empty($ids)) {
            return $out . '<p><em>' . esc_html__('No published items found.', 'wp-seo-next') . '</em></p>';
        }

        $out .= '<ul>';
        foreach ($ids as $id) {
            $id = (int) $id;
            if ($id < 1) {
                continue;
            }
            $url = get_permalink($id);
            if (!is_string($url) || $url === '') {
                continue;
            }
            $t = (string) get_the_title($id);
            if ($t === '') {
                $t = sprintf(
                    /* translators: %d is post id */
                    __('(no title) #%d', 'wp-seo-next'),
                    $id
                );
            }
            $out .= '<li><a href="' . esc_url($url) . '">' . esc_html($t) . '</a></li>';
        }
        $out .= '</ul>';

        $max = (int) ($q->max_num_pages ?? 1);
        if ($max < 1) {
            $max = 1;
        }

        if ($max > 1) {
            $out .= '<div class="wpsn-pager">';
            $args = ['wpsn_html_type' => $type];
            if ($page > 1) {
                $out .= '<a href="' . esc_url(add_query_arg($args + ['wpsn_html_page' => $page - 1], $baseUrl)) . '">&larr; ' . esc_html__('Prev', 'wp-seo-next') . '</a>';
            }
            if ($page < $max) {
                $out .= '<a href="' . esc_url(add_query_arg($args + ['wpsn_html_page' => $page + 1], $baseUrl)) . '">' . esc_html__('Next', 'wp-seo-next') . ' &rarr;</a>';
            }
            $out .= '<span class="wpsn-meta">' . esc_html(sprintf(
                /* translators: 1: current page, 2: max page */
                __('Page %1$d of %2$d', 'wp-seo-next'),
                $page,
                $max
            )) . '</span>';
            $out .= '</div>';
        }

        return $out;
    }

    private function renderHtmlSitemapTaxonomy(string $baseUrl, string $tax, int $page): string
    {
        $enabled = $this->enabledTaxonomies();
        if (!in_array($tax, $enabled, true)) {
            return '<p><strong>' . esc_html__('Unknown or disabled taxonomy.', 'wp-seo-next') . '</strong></p>';
        }

        $txo = get_taxonomy($tax);
        $label = $txo && isset($txo->labels->name) ? (string) $txo->labels->name : $tax;

        $per = $this->htmlSitemapPerPage();
        $page = max(1, $page);
        $offset = ($page - 1) * $per;

        $terms = get_terms([
            'taxonomy' => $tax,
            'hide_empty' => false,
            'orderby' => 'name',
            'order' => 'ASC',
            'number' => $per,
            'offset' => $offset,
        ]);

        if (is_wp_error($terms)) {
            return '<h2>' . esc_html($label) . '</h2><p><em>' . esc_html($terms->get_error_message()) . '</em></p>';
        }

        $terms = is_array($terms) ? $terms : [];

        $out = '<h2>' . esc_html($label) . '</h2>';

        if (empty($terms)) {
            return $out . '<p><em>' . esc_html__('No terms found.', 'wp-seo-next') . '</em></p>';
        }

        $out .= '<ul>';
        foreach ($terms as $term) {
            if (!is_object($term) || !isset($term->term_id)) {
                continue;
            }
            $url = get_term_link($term);
            if (is_wp_error($url)) {
                continue;
            }
            $name = isset($term->name) ? (string) $term->name : '';
            if ($name === '') {
                $name = sprintf(
                    /* translators: %d is term id */
                    __('(no name) #%d', 'wp-seo-next'),
                    (int) $term->term_id
                );
            }
            $out .= '<li><a href="' . esc_url((string) $url) . '">' . esc_html($name) . '</a></li>';
        }
        $out .= '</ul>';

        $total = function_exists('wp_count_terms') ? (int) wp_count_terms($tax, ['hide_empty' => false]) : 0;
        $max = $total > 0 ? (int) ceil($total / $per) : 1;
        if ($max < 1) {
            $max = 1;
        }

        if ($max > 1) {
            $out .= '<div class="wpsn-pager">';
            $args = ['wpsn_html_tax' => $tax];
            if ($page > 1) {
                $out .= '<a href="' . esc_url(add_query_arg($args + ['wpsn_html_page' => $page - 1], $baseUrl)) . '">&larr; ' . esc_html__('Prev', 'wp-seo-next') . '</a>';
            }
            if ($page < $max) {
                $out .= '<a href="' . esc_url(add_query_arg($args + ['wpsn_html_page' => $page + 1], $baseUrl)) . '">' . esc_html__('Next', 'wp-seo-next') . ' &rarr;</a>';
            }
            $out .= '<span class="wpsn-meta">' . esc_html(sprintf(
                /* translators: 1: current page, 2: max page */
                __('Page %1$d of %2$d', 'wp-seo-next'),
                $page,
                $max
            )) . '</span>';
            $out .= '</div>';
        }

        return $out;
    }

    private function sendXml(string $xml): void
    {
        status_header(200);
        nocache_headers();
        header('Content-Type: application/xml; charset=UTF-8', true);
        echo $xml;
    }

    private function send404(): void
    {
        status_header(404);
        nocache_headers();
        header('Content-Type: text/plain; charset=UTF-8', true);
        echo "Not Found";
    }

    private function urlsPerSitemap(): int
    {
        $per = (int) $this->settings->get('sitemap.urls_per_sitemap', 1000);
        if ($per < 100) {
            $per = 100;
        }
        if ($per > 50000) {
            $per = 50000;
        }
        return $per;
    }

    /**
     * @return array<int, string>
     */
    private function enabledPostTypes(): array
    {
        $all = get_post_types(['public' => true], 'names');
        $all = array_values(array_filter($all, static fn ($pt): bool => is_string($pt) && $pt !== 'attachment'));

        // If Locations special sitemap is enabled, avoid duplicating the Locations CPT
        // inside the generic post-type sitemaps.
        if ($this->locationsSitemapEnabled()) {
            $all = array_values(array_filter($all, static fn ($pt): bool => (string) $pt !== LocalSEOModule::CPT_LOCATION));
        }

        /** @var array<string, bool> $map */
        $map = $this->settings->get('sitemap.post_types', []);
        if (!is_array($map) || $map === []) {
            return $all;
        }

        $enabled = [];
        foreach ($all as $pt) {
            if (!empty($map[$pt])) {
                $enabled[] = $pt;
            }
        }

        return $enabled;
    }

    /**
     * @return array<int, string>
     */
    private function enabledTaxonomies(): array
    {
        $includeTax = (bool) $this->settings->get('sitemap.include_taxonomies', true);
        if (!$includeTax) {
            return [];
        }

        $all = get_taxonomies(['public' => true], 'names');
        $all = array_values(array_filter($all, static fn ($tx): bool => is_string($tx)));

        /** @var array<string, bool> $map */
        $map = $this->settings->get('sitemap.taxonomies', []);
        if (!is_array($map) || $map === []) {
            return $all;
        }

        $enabled = [];
        foreach ($all as $tx) {
            if (!empty($map[$tx])) {
                $enabled[] = $tx;
            }
        }

        return $enabled;
    }

    private function newsSitemapEnabled(): bool
    {
        return (bool) $this->settings->get('sitemap.news.enabled', false);
    }

    private function videoSitemapEnabled(): bool
    {
        return (bool) $this->settings->get('sitemap.video.enabled', false);
    }

    private function locationsSitemapEnabled(): bool
    {
        if (!$this->settings->moduleEnabled('local_seo', false)) {
            return false;
        }
        if (!post_type_exists(LocalSEOModule::CPT_LOCATION)) {
            return false;
        }
        return (bool) $this->settings->get('local.locations_sitemap.enabled', true);
    }

    /**
     * Avoid URL conflicts with reserved slugs used by optional special sitemaps.
     */
    private function postTypeSitemapSlug(string $postType): string
    {
        $pt = (string) $postType;
        if ($pt === 'news' && $this->newsSitemapEnabled()) {
            return 'pt-news';
        }
        if ($pt === 'video' && $this->videoSitemapEnabled()) {
            return 'pt-video';
        }
        if ($pt === 'locations' && $this->locationsSitemapEnabled()) {
            return 'pt-locations';
        }
        return $pt;
    }

    /**
     * Reverse map for post type sitemap slugs when special sitemaps are enabled.
     */
    private function postTypeFromMappedSlug(string $slug): ?string
    {
        if ($slug === 'pt-news') {
            return 'news';
        }
        if ($slug === 'pt-video') {
            return 'video';
        }
        if ($slug === 'pt-locations') {
            return 'locations';
        }
        return null;
    }

    /**
     * @return array<int, string>
     */
    private function enabledNewsPostTypes(): array
    {
        /** @var array<string, bool> $map */
        $map = $this->settings->get('sitemap.news.post_types', ['post' => true]);
        if (!is_array($map) || $map === []) {
            return ['post'];
        }

        $out = [];
        foreach ($map as $pt => $enabled) {
            $pt = sanitize_key((string) $pt);
            if ($pt === '' || empty($enabled)) {
                continue;
            }
            if ($pt === 'attachment') {
                continue;
            }
            if (!post_type_exists($pt)) {
                continue;
            }
            $obj = get_post_type_object($pt);
            if (!$obj || empty($obj->public)) {
                continue;
            }
            $out[] = $pt;
        }

        if ($out === []) {
            $out[] = 'post';
        }
        return array_values(array_unique($out));
    }

    /**
     * @return array<int, string>
     */
    private function enabledVideoPostTypes(): array
    {
        /** @var array<string, bool> $map */
        $map = $this->settings->get('sitemap.video.post_types', []);
        if (!is_array($map) || $map === []) {
            return $this->enabledPostTypes();
        }

        $all = $this->enabledPostTypes();
        $enabled = [];
        foreach ($all as $pt) {
            if (!empty($map[$pt])) {
                $enabled[] = $pt;
            }
        }

        return $enabled;
    }

    private function sitemapIndexUrl(): string
    {
        return home_url('/sitemap_index.xml');
    }

    private function sitemapUrlForType(string $typeSlug, int $page = 1): string
    {
        if ($page <= 1) {
            return home_url('/' . $typeSlug . '-sitemap.xml');
        }
        return home_url('/' . $typeSlug . '-sitemap' . $page . '.xml');
    }

    private function buildIndexXml(): string
    {
        // Short cache to avoid expensive counts on big sites.
        $cacheKey = 'wpsn_sitemap_index_' . md5(serialize([
            $this->settings->get('sitemap', []),
            $this->settings->get('robots.contexts.singular.index', true),
            $this->settings->get('robots.contexts.term.index', true),
            $this->cacheBustToken(),
        ]));

        $cached = get_transient($cacheKey);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $per = $this->urlsPerSitemap();

        $items = [];

        // Post types.
        foreach ($this->enabledPostTypes() as $pt) {
            $pages = $this->countPostTypePages($pt, $per);
            if ($pages < 1) {
                continue;
            }

            $lastmod = $this->lastmodForPostType($pt);

            // Avoid conflicts with special sitemap slugs.
            $slug = $this->postTypeSitemapSlug($pt);

            for ($p = 1; $p <= $pages; $p++) {
                $items[] = [
                    'loc'     => $this->sitemapUrlForType($slug, $p),
                    'lastmod' => $lastmod,
                ];
            }
        }

        // Taxonomies.
        foreach ($this->enabledTaxonomies() as $tax) {
            $slug = 'tax-' . $tax;
            $pages = $this->countTaxonomyPages($tax, $per);
            if ($pages < 1) {
                continue;
            }

            for ($p = 1; $p <= $pages; $p++) {
                $items[] = [
                    'loc'     => $this->sitemapUrlForType($slug, $p),
                    'lastmod' => '',
                ];
            }
        }

        // Optional special sitemaps.
        if ($this->newsSitemapEnabled()) {
            $items[] = [
                'loc'     => $this->sitemapUrlForType('news', 1),
                'lastmod' => gmdate('c'),
            ];
        }

        if ($this->videoSitemapEnabled()) {
            $pages = $this->countVideoPages($per);
            if ($pages > 0) {
                $lastmod = $this->lastmodForVideo();
                for ($p = 1; $p <= $pages; $p++) {
                    $items[] = [
                        'loc'     => $this->sitemapUrlForType('video', $p),
                        'lastmod' => $lastmod,
                    ];
                }
            }
        }

        if ($this->locationsSitemapEnabled()) {
            $pages = $this->countPostTypePages(LocalSEOModule::CPT_LOCATION, $per);
            if ($pages > 0) {
                $lastmod = $this->lastmodForPostType(LocalSEOModule::CPT_LOCATION);
                for ($p = 1; $p <= $pages; $p++) {
                    $items[] = [
                        'loc'     => $this->sitemapUrlForType('locations', $p),
                        'lastmod' => $lastmod,
                    ];
                }
            }
        }

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

        foreach ($items as $it) {
            $xml .= "  <sitemap>\n";
            $xml .= '    <loc>' . esc_html(esc_url($it['loc'])) . "</loc>\n";
            if (!empty($it['lastmod'])) {
                $xml .= '    <lastmod>' . esc_html((string) $it['lastmod']) . "</lastmod>\n";
            }
            $xml .= "  </sitemap>\n";
        }

        $xml .= "</sitemapindex>\n";

        set_transient($cacheKey, $xml, 10 * MINUTE_IN_SECONDS);

        return $xml;
    }

    private function buildTypeXml(string $typeSlug, int $page): ?string
    {
        $per = $this->urlsPerSitemap();

        // Special sitemaps.
        if ($typeSlug === 'news' && $this->newsSitemapEnabled()) {
            if ($page !== 1) {
                return null;
            }
            return $this->buildNewsSitemapXml();
        }

        if ($typeSlug === 'video' && $this->videoSitemapEnabled()) {
            return $this->buildVideoSitemapXml($page, $per);
        }

        if ($typeSlug === 'locations' && $this->locationsSitemapEnabled()) {
            return $this->buildPostTypeSitemapXml(LocalSEOModule::CPT_LOCATION, $page, $per);
        }

        // Mapped post type slugs (when special sitemaps reserve common slugs).
        $mapped = $this->postTypeFromMappedSlug($typeSlug);
        if (is_string($mapped) && $mapped !== '' && $this->isPostTypeEnabled($mapped)) {
            return $this->buildPostTypeSitemapXml($mapped, $page, $per);
        }

        // Post type sitemap.
        if ($this->isPostTypeEnabled($typeSlug)) {
            return $this->buildPostTypeSitemapXml($typeSlug, $page, $per);
        }

        // Taxonomy sitemap.
        if (strpos($typeSlug, 'tax-') === 0) {
            $tax = substr($typeSlug, 4);
            if ($tax !== '' && taxonomy_exists($tax) && in_array($tax, $this->enabledTaxonomies(), true)) {
                return $this->buildTaxonomySitemapXml($tax, $page, $per);
            }
        }

        return null;
    }

    private function isPostTypeEnabled(string $postType): bool
    {
        if (!post_type_exists($postType)) {
            return false;
        }
        if ($postType === 'attachment') {
            return false;
        }

        $enabled = $this->enabledPostTypes();
        return in_array($postType, $enabled, true);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function metaQueryIndexableForSingular(): array
    {
        $globalIndex = (bool) $this->settings->get('robots.contexts.singular.index', true);

        if ($globalIndex) {
            // Default is index => exclude explicit noindex.
            return [
                'relation' => 'OR',
                [
                    'key'     => MetaKeys::ROBOTS_INDEX,
                    'compare' => 'NOT EXISTS',
                ],
                [
                    'key'     => MetaKeys::ROBOTS_INDEX,
                    'value'   => 'noindex',
                    'compare' => '!=',
                ],
            ];
        }

        // Default is noindex => include only explicit index.
        return [
            'relation' => 'AND',
            [
                'key'     => MetaKeys::ROBOTS_INDEX,
                'value'   => 'index',
                'compare' => '=',
            ],
        ];
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function metaQueryIndexableForTerm(): array
    {
        $globalIndex = (bool) $this->settings->get('robots.contexts.term.index', true);

        if ($globalIndex) {
            return [
                'relation' => 'OR',
                [
                    'key'     => MetaKeys::ROBOTS_INDEX,
                    'compare' => 'NOT EXISTS',
                ],
                [
                    'key'     => MetaKeys::ROBOTS_INDEX,
                    'value'   => 'noindex',
                    'compare' => '!=',
                ],
            ];
        }

        return [
            'relation' => 'AND',
            [
                'key'     => MetaKeys::ROBOTS_INDEX,
                'value'   => 'index',
                'compare' => '=',
            ],
        ];
    }

    private function countPostTypePages(string $postType, int $per): int
    {
        $q = new \WP_Query([
            'post_type'           => $postType,
            'post_status'         => 'publish',
            'posts_per_page'      => 1,
            'paged'               => 1,
            'fields'              => 'ids',
            'no_found_rows'       => false,
            'ignore_sticky_posts' => true,
            'meta_query'          => $this->metaQueryIndexableForSingular(),
        ]);

        $total = (int) ($q->found_posts ?? 0);
        if ($total < 1) {
            return 0;
        }

        return (int) ceil($total / max(1, $per));
    }

    private function lastmodForPostType(string $postType): string
    {
        $q = new \WP_Query([
            'post_type'           => $postType,
            'post_status'         => 'publish',
            'posts_per_page'      => 1,
            'orderby'             => 'modified',
            'order'               => 'DESC',
            'fields'              => 'ids',
            'no_found_rows'       => true,
            'ignore_sticky_posts' => true,
            'meta_query'          => $this->metaQueryIndexableForSingular(),
        ]);

        if (empty($q->posts) || !is_array($q->posts)) {
            return '';
        }

        $id = (int) $q->posts[0];
        if ($id < 1) {
            return '';
        }

        $t = get_post_modified_time('c', true, $id);
        return is_string($t) ? $t : '';
    }

    private function buildPostTypeSitemapXml(string $postType, int $page, int $per): string
    {
        $imgEnabled = (bool) $this->settings->get('sitemap.images.enabled', false);
        $imgIncludeFeatured = (bool) $this->settings->get('sitemap.images.include_featured', true);
        $imgIncludeContent  = (bool) $this->settings->get('sitemap.images.include_content', true);
        $imgMaxPerUrl       = (int) $this->settings->get('sitemap.images.max_per_url', 5);
        if ($imgMaxPerUrl < 1) {
            $imgMaxPerUrl = 1;
        }
        if ($imgMaxPerUrl > 100) {
            $imgMaxPerUrl = 100;
        }

        $hreflangEnabled = (bool) $this->settings->get('hreflang.enabled', false);
        $includeXDefault = (bool) $this->settings->get('hreflang.include_x_default', false);
        $xDefaultUrl = (string) $this->settings->get('hreflang.x_default_url', '');

        $cacheKey = 'wpsn_sitemap_' . md5(serialize([
            'pt' => $postType,
            'p'  => $page,
            'per' => $per,
            $this->settings->get('robots.contexts.singular.index', true),
            // Image sitemap settings should affect cache.
            $imgEnabled,
            $imgIncludeFeatured,
            $imgIncludeContent,
            $imgMaxPerUrl,
            'hreflang' => $hreflangEnabled,
            'xdefault' => $includeXDefault,
            'xdefault_url' => $xDefaultUrl,
            $this->cacheBustToken(),
        ]));
        $cached = get_transient($cacheKey);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $q = new \WP_Query([
            'post_type'           => $postType,
            'post_status'         => 'publish',
            'posts_per_page'      => $per,
            'paged'               => $page,
            'orderby'             => 'ID',
            'order'               => 'ASC',
            'fields'              => 'ids',
            'no_found_rows'       => true,
            'ignore_sticky_posts' => true,
            'meta_query'          => $this->metaQueryIndexableForSingular(),
        ]);

        $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
        $namespaces = ['xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'];
        if ($imgEnabled) {
            $namespaces[] = 'xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"';
        }
        if ($hreflangEnabled) {
            $namespaces[] = 'xmlns:xhtml="http://www.w3.org/1999/xhtml"';
        }
        $xml .= '<urlset ' . implode(' ', $namespaces) . '>' . "\n";

        if (!empty($q->posts) && is_array($q->posts)) {
            foreach ($q->posts as $id) {
                $id = (int) $id;
                if ($id < 1) {
                    continue;
                }

                $loc = get_permalink($id);
                if (!is_string($loc) || $loc === '') {
                    continue;
                }

                $lastmod = get_post_modified_time('c', true, $id);
                if (!is_string($lastmod)) {
                    $lastmod = '';
                }

                $xml .= "  <url>\n";
                $xml .= '    <loc>' . esc_html(esc_url($loc)) . "</loc>\n";
                if ($lastmod !== '') {
                    $xml .= '    <lastmod>' . esc_html($lastmod) . "</lastmod>\n";
                }

                if ($hreflangEnabled) {
                    $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_SINGULAR, $id, $postType, $loc, 1);
                    foreach ($this->resolveSitemapHreflangLinks($ctx) as $alternate) {
                        $xml .= '    <xhtml:link rel="alternate" hreflang="' . esc_attr((string) $alternate['hreflang']) . '" href="' . esc_html(esc_url((string) $alternate['href'])) . '" />' . "\n";
                    }
                }

                if ($imgEnabled) {
                    $imgs = $this->getImageUrlsForPost($id, $imgIncludeFeatured, $imgIncludeContent, $imgMaxPerUrl);
                    foreach ($imgs as $imgUrl) {
                        $xml .= "    <image:image>\n";
                        $xml .= '      <image:loc>' . esc_html(esc_url($imgUrl)) . "</image:loc>\n";
                        $xml .= "    </image:image>\n";
                    }
                }
                $xml .= "  </url>\n";
            }
        }

        $xml .= "</urlset>\n";

        set_transient($cacheKey, $xml, 10 * MINUTE_IN_SECONDS);

        return $xml;
    }



    /**
     * @return array<int, string>
     */
    private function getImageUrlsForPost(int $postId, bool $includeFeatured, bool $includeContent, int $limit): array
    {
        $urls = [];

        if ($includeFeatured) {
            $thumbId = (int) get_post_thumbnail_id($postId);
            if ($thumbId > 0) {
                $u = wp_get_attachment_url($thumbId);
                if (is_string($u) && $u !== '') {
                    $urls[] = $u;
                }
            }
        }

        if ($includeContent) {
            $content = get_post_field('post_content', $postId);
            $content = is_string($content) ? $content : '';
            if ($content !== '') {
                // Extract src attributes from <img> tags.
                if (preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $content, $m)) {
                    $candidates = $m[1] ?? [];
                    if (is_array($candidates)) {
                        foreach ($candidates as $raw) {
                            $raw = html_entity_decode((string) $raw, ENT_QUOTES);
                            $raw = trim($raw);
                            if ($raw === '') {
                                continue;
                            }

                            // Convert scheme-relative and site-relative URLs.
                            if (strpos($raw, '//') === 0) {
                                $raw = set_url_scheme($raw);
                            } elseif (strpos($raw, '/') === 0) {
                                $raw = (string) home_url($raw);
                            }

                            $u = esc_url_raw($raw);
                            if ($u === '') {
                                continue;
                            }

                            // Keep only same-host images (avoid external CDN embeds unless they are on-site).
                            $h1 = wp_parse_url(home_url('/'), PHP_URL_HOST);
                            $h2 = wp_parse_url($u, PHP_URL_HOST);
                            if (is_string($h1) && is_string($h2) && $h1 !== '' && $h2 !== '' && strtolower($h1) !== strtolower($h2)) {
                                continue;
                            }

                            $urls[] = $u;
                            if (count($urls) >= $limit) {
                                break;
                            }
                        }
                    }
                }
            }
        }

        // Unique + limit.
        $urls = array_values(array_unique(array_filter(array_map('strval', $urls))));
        if (count($urls) > $limit) {
            $urls = array_slice($urls, 0, $limit);
        }

        return $urls;
    }

    private function countTaxonomyPages(string $taxonomy, int $per): int
    {
        $q = new \WP_Term_Query([
            'taxonomy'   => $taxonomy,
            'hide_empty' => true,
            'number'     => 1,
            'offset'     => 0,
            'fields'     => 'ids',
            'meta_query' => $this->metaQueryIndexableForTerm(),
        ]);

        $total = (int) ($q->found_terms ?? 0);
        if ($total < 1) {
            return 0;
        }

        return (int) ceil($total / max(1, $per));
    }

    private function buildTaxonomySitemapXml(string $taxonomy, int $page, int $per): string
    {
        $hreflangEnabled = (bool) $this->settings->get('hreflang.enabled', false);
        $includeXDefault = (bool) $this->settings->get('hreflang.include_x_default', false);
        $xDefaultUrl = (string) $this->settings->get('hreflang.x_default_url', '');

        $cacheKey = 'wpsn_sitemap_' . md5(serialize([
            'tax' => $taxonomy,
            'p'   => $page,
            'per' => $per,
            $this->settings->get('robots.contexts.term.index', true),
            'hreflang' => $hreflangEnabled,
            'xdefault' => $includeXDefault,
            'xdefault_url' => $xDefaultUrl,
            $this->cacheBustToken(),
        ]));
        $cached = get_transient($cacheKey);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $offset = ($page - 1) * $per;
        if ($offset < 0) {
            $offset = 0;
        }

        $terms = get_terms([
            'taxonomy'   => $taxonomy,
            'hide_empty' => true,
            'number'     => $per,
            'offset'     => $offset,
            'orderby'    => 'id',
            'order'      => 'ASC',
            'meta_query' => $this->metaQueryIndexableForTerm(),
        ]);

        $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
        $namespaces = ['xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'];
        if ($hreflangEnabled) {
            $namespaces[] = 'xmlns:xhtml="http://www.w3.org/1999/xhtml"';
        }
        $xml .= '<urlset ' . implode(' ', $namespaces) . '>' . "\n";

        if (!is_wp_error($terms) && is_array($terms)) {
            foreach ($terms as $term) {
                if (!($term instanceof \WP_Term)) {
                    continue;
                }

                $loc = get_term_link($term);
                if (is_wp_error($loc) || !is_string($loc) || $loc === '') {
                    continue;
                }

                $xml .= "  <url>\n";
                $xml .= '    <loc>' . esc_html(esc_url($loc)) . "</loc>\n";
                if ($hreflangEnabled) {
                    $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_TERM, (int) $term->term_id, (string) $taxonomy, $loc, 1);
                    foreach ($this->resolveSitemapHreflangLinks($ctx) as $alternate) {
                        $xml .= '    <xhtml:link rel="alternate" hreflang="' . esc_attr((string) $alternate['hreflang']) . '" href="' . esc_html(esc_url((string) $alternate['href'])) . '" />' . "\n";
                    }
                }
                $xml .= "  </url>\n";
            }
        }

        $xml .= "</urlset>\n";

        set_transient($cacheKey, $xml, 10 * MINUTE_IN_SECONDS);

        return $xml;
    }



    /**
     * @return array<int,array{hreflang:string,href:string}>
     */
    private function resolveSitemapHreflangLinks(SeoDocumentContext $ctx): array
    {
        /** @var \WPSN\SEO\HreflangResolver $resolver */
        $resolver = $this->container->get('hreflang_resolver');

        return $resolver->resolve($ctx);
    }

    private function buildNewsSitemapXml(): string
    {
        $settings = $this->settings->get('sitemap.news', []);
        $cacheKey = 'wpsn_sitemap_' . md5(serialize([
            'news' => $settings,
            $this->settings->get('robots.contexts.singular.index', true),
            $this->cacheBustToken(),
        ]));

        $cached = get_transient($cacheKey);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $pubName = (string) $this->settings->get('sitemap.news.publication_name', '');
        $pubName = trim($pubName);
        if ($pubName === '') {
            $pubName = (string) get_bloginfo('name');
        }

        $lang = (string) $this->settings->get('sitemap.news.language', '');
        $lang = strtolower(trim($lang));
        if ($lang === '') {
            $lang = strtolower((string) substr((string) get_locale(), 0, 2));
        }
        if ($lang === '') {
            $lang = 'en';
        }
        $lang = preg_replace('/[^a-z\-]/', '', $lang);
        if (!is_string($lang) || $lang === '') {
            $lang = 'en';
        }
        if (strlen($lang) > 10) {
            $lang = substr($lang, 0, 10);
        }

        $genres = (string) $this->settings->get('sitemap.news.genres', '');
        $genres = trim($genres);
        $includeKeywords = (bool) $this->settings->get('sitemap.news.include_keywords', true);

        $postTypes = $this->enabledNewsPostTypes();
        $afterGmt = gmdate('Y-m-d H:i:s', time() - 48 * HOUR_IN_SECONDS);

        $q = new \WP_Query([
            'post_type'           => $postTypes,
            'post_status'         => 'publish',
            'posts_per_page'      => 1000,
            'orderby'             => 'date',
            'order'               => 'DESC',
            'fields'              => 'ids',
            'ignore_sticky_posts' => true,
            'no_found_rows'       => true,
            'date_query'          => [
                [
                    'after'     => $afterGmt,
                    'column'    => 'post_date_gmt',
                    'inclusive' => true,
                ],
            ],
            'meta_query' => $this->metaQueryIndexableForSingular(),
        ]);

        $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">' . "\n";

        $ids = isset($q->posts) && is_array($q->posts) ? $q->posts : [];
        foreach ($ids as $postId) {
            $postId = (int) $postId;
            if ($postId < 1) {
                continue;
            }

            $loc = get_permalink($postId);
            if (!is_string($loc) || $loc === '') {
                continue;
            }

            $title = (string) get_the_title($postId);
            $title = wp_strip_all_tags($title);
            $title = trim($title);
            if ($title === '') {
                continue;
            }

            $pubDate = get_post_time('c', true, $postId);
            if (!is_string($pubDate) || $pubDate === '') {
                $pubDate = gmdate('c');
            }

            $keywords = '';
            if ($includeKeywords) {
                $kw = [];

                $cats = wp_get_post_terms($postId, 'category', ['fields' => 'names']);
                if (!is_wp_error($cats) && is_array($cats)) {
                    foreach ($cats as $n) {
                        $n = trim(wp_strip_all_tags((string) $n));
                        if ($n !== '') {
                            $kw[] = $n;
                        }
                    }
                }

                $tags = wp_get_post_terms($postId, 'post_tag', ['fields' => 'names']);
                if (!is_wp_error($tags) && is_array($tags)) {
                    foreach ($tags as $n) {
                        $n = trim(wp_strip_all_tags((string) $n));
                        if ($n !== '') {
                            $kw[] = $n;
                        }
                    }
                }

                $kw = array_values(array_unique($kw));
                if (count($kw) > 10) {
                    $kw = array_slice($kw, 0, 10);
                }
                if ($kw !== []) {
                    $keywords = implode(', ', $kw);
                }
            }

            $xml .= "  <url>\n";
            $xml .= '    <loc>' . esc_html(esc_url($loc)) . "</loc>\n";
            $xml .= "    <news:news>\n";
            $xml .= "      <news:publication>\n";
            $xml .= '        <news:name>' . esc_html($pubName) . "</news:name>\n";
            $xml .= '        <news:language>' . esc_html($lang) . "</news:language>\n";
            $xml .= "      </news:publication>\n";
            $xml .= '      <news:publication_date>' . esc_html($pubDate) . "</news:publication_date>\n";
            $xml .= '      <news:title>' . esc_html($title) . "</news:title>\n";
            if ($genres !== '') {
                $xml .= '      <news:genres>' . esc_html($genres) . "</news:genres>\n";
            }
            if ($keywords !== '') {
                $xml .= '      <news:keywords>' . esc_html($keywords) . "</news:keywords>\n";
            }
            $xml .= "    </news:news>\n";
            $xml .= "  </url>\n";
        }

        $xml .= "</urlset>\n";

        // News changes often, cache shorter.
        set_transient($cacheKey, $xml, 5 * MINUTE_IN_SECONDS);

        return $xml;
    }

    private function countVideoPages(int $per): int
    {
        $per = max(1, $per);

        $postTypes = $this->enabledVideoPostTypes();
        if ($postTypes === []) {
            return 0;
        }

        $q = new \WP_Query([
            'post_type'           => $postTypes,
            'post_status'         => 'publish',
            'posts_per_page'      => 1,
            'orderby'             => 'ID',
            'order'               => 'ASC',
            'fields'              => 'ids',
            'ignore_sticky_posts' => true,
            'meta_query' => [
                'relation' => 'AND',
                $this->metaQueryIndexableForSingular(),
                [
                    'key'     => MetaKeys::VIDEO_URL,
                    'value'   => '',
                    'compare' => '!=',
                ],
            ],
        ]);

        $total = (int) ($q->found_posts ?? 0);
        if ($total < 1) {
            return 0;
        }

        return (int) ceil($total / $per);
    }

    private function lastmodForVideo(): string
    {
        $postTypes = $this->enabledVideoPostTypes();
        if ($postTypes === []) {
            return gmdate('c');
        }

        $q = new \WP_Query([
            'post_type'           => $postTypes,
            'post_status'         => 'publish',
            'posts_per_page'      => 1,
            'orderby'             => 'modified',
            'order'               => 'DESC',
            'fields'              => 'ids',
            'ignore_sticky_posts' => true,
            'no_found_rows'       => true,
            'meta_query' => [
                'relation' => 'AND',
                $this->metaQueryIndexableForSingular(),
                [
                    'key'     => MetaKeys::VIDEO_URL,
                    'value'   => '',
                    'compare' => '!=',
                ],
            ],
        ]);

        $ids = isset($q->posts) && is_array($q->posts) ? $q->posts : [];
        $id = isset($ids[0]) ? (int) $ids[0] : 0;
        if ($id < 1) {
            return gmdate('c');
        }

        $ts = get_post_modified_time('U', true, $id);
        $ts = is_numeric($ts) ? (int) $ts : 0;
        if ($ts < 1) {
            return gmdate('c');
        }
        return gmdate('c', $ts);
    }

    private function buildVideoSitemapXml(int $page, int $per): string
    {
        if ($page < 1) {
            $page = 1;
        }
        $per = max(1, $per);

        $settings = $this->settings->get('sitemap.video', []);
        $cacheKey = 'wpsn_sitemap_' . md5(serialize([
            'video' => $settings,
            'p' => $page,
            'per' => $per,
            $this->settings->get('robots.contexts.singular.index', true),
            $this->cacheBustToken(),
        ]));

        $cached = get_transient($cacheKey);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $mode = (string) $this->settings->get('sitemap.video.mode', 'meta');
        $includeFeatured = (bool) $this->settings->get('sitemap.video.include_featured_thumbnail', true);

        $postTypes = $this->enabledVideoPostTypes();
        if ($postTypes === []) {
            $postTypes = $this->enabledPostTypes();
        }

        $q = new \WP_Query([
            'post_type'           => $postTypes,
            'post_status'         => 'publish',
            'posts_per_page'      => $per,
            'paged'               => $page,
            'orderby'             => 'ID',
            'order'               => 'ASC',
            'fields'              => 'ids',
            'ignore_sticky_posts' => true,
            'no_found_rows'       => true,
            'meta_query' => [
                'relation' => 'AND',
                $this->metaQueryIndexableForSingular(),
                [
                    'key'     => MetaKeys::VIDEO_URL,
                    'value'   => '',
                    'compare' => '!=',
                ],
            ],
        ]);

        $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">' . "\n";

        $ids = isset($q->posts) && is_array($q->posts) ? $q->posts : [];
        foreach ($ids as $postId) {
            $postId = (int) $postId;
            if ($postId < 1) {
                continue;
            }

            $loc = get_permalink($postId);
            if (!is_string($loc) || $loc === '') {
                continue;
            }

            $videoUrl = (string) get_post_meta($postId, MetaKeys::VIDEO_URL, true);
            $videoUrl = trim($videoUrl);
            if ($videoUrl === '') {
                continue;
            }

            $videoUrl = self::sanitizeVideoUrl($videoUrl);
            if ($videoUrl === '') {
                continue;
            }

            $thumb = (string) get_post_meta($postId, MetaKeys::VIDEO_THUMBNAIL, true);
            $thumb = trim($thumb);
            $thumb = $thumb !== '' ? self::sanitizeVideoUrl($thumb) : '';

            $ytId = '';
            if ($mode === 'youtube_auto') {
                $ytId = $this->extractYouTubeIdFromUrl($videoUrl);
                if ($ytId !== '' && $thumb === '') {
                    $thumb = 'https://img.youtube.com/vi/' . rawurlencode($ytId) . '/hqdefault.jpg';
                }
            }

            if ($thumb === '' && $includeFeatured) {
                $fi = get_the_post_thumbnail_url($postId, 'full');
                if (is_string($fi) && $fi !== '') {
                    $thumb = esc_url_raw($fi);
                }
            }

            if ($thumb === '') {
                continue; // required
            }

            $title = (string) get_post_meta($postId, MetaKeys::VIDEO_TITLE, true);
            $title = trim(wp_strip_all_tags($title));
            if ($title === '') {
                $title = trim(wp_strip_all_tags((string) get_the_title($postId)));
            }
            if ($title === '') {
                continue;
            }
            if (strlen($title) > 100) {
                $title = substr($title, 0, 100);
            }

            $desc = (string) get_post_meta($postId, MetaKeys::VIDEO_DESCRIPTION, true);
            $desc = trim(wp_strip_all_tags($desc));
            if ($desc === '') {
                $desc = trim(wp_strip_all_tags((string) get_the_excerpt($postId)));
            }
            if ($desc === '') {
                $content = (string) get_post_field('post_content', $postId);
                $content = wp_strip_all_tags($content);
                $content = trim($content);
                if ($content !== '') {
                    $desc = wp_trim_words($content, 40, '');
                }
            }
            $desc = trim($desc);
            if ($desc === '') {
                continue;
            }
            if (strlen($desc) > 2048) {
                $desc = substr($desc, 0, 2048);
            }

            $duration = (int) get_post_meta($postId, MetaKeys::VIDEO_DURATION, true);
            if ($duration < 0) {
                $duration = 0;
            }

            $pubDate = get_post_time('c', true, $postId);
            if (!is_string($pubDate) || $pubDate === '') {
                $pubDate = gmdate('c');
            }

            // Decide content_loc vs player_loc.
            $videoTag = '';
            $parsed = wp_parse_url($videoUrl);
            $host = is_array($parsed) && isset($parsed['host']) ? strtolower((string) $parsed['host']) : '';
            $path = is_array($parsed) && isset($parsed['path']) ? strtolower((string) $parsed['path']) : '';
            $isFile = (bool) preg_match('/\.(mp4|m4v|mov|webm|ogv)(\?.*)?$/i', $path);

            if ($ytId !== '') {
                $player = 'https://www.youtube.com/embed/' . rawurlencode($ytId);
                $videoTag = '      <video:player_loc allow_embed="yes">' . esc_html(esc_url($player)) . "</video:player_loc>\n";
            } elseif ($isFile) {
                $videoTag = '      <video:content_loc>' . esc_html(esc_url($videoUrl)) . "</video:content_loc>\n";
            } else {
                // Generic player URL.
                $videoTag = '      <video:player_loc allow_embed="yes">' . esc_html(esc_url($videoUrl)) . "</video:player_loc>\n";
            }

            $xml .= "  <url>\n";
            $xml .= '    <loc>' . esc_html(esc_url($loc)) . "</loc>\n";
            $xml .= "    <video:video>\n";
            $xml .= '      <video:thumbnail_loc>' . esc_html(esc_url($thumb)) . "</video:thumbnail_loc>\n";
            $xml .= '      <video:title>' . esc_html($title) . "</video:title>\n";
            $xml .= '      <video:description>' . esc_html($desc) . "</video:description>\n";
            $xml .= $videoTag;
            $xml .= '      <video:publication_date>' . esc_html($pubDate) . "</video:publication_date>\n";
            if ($duration > 0) {
                $xml .= '      <video:duration>' . esc_html((string) $duration) . "</video:duration>\n";
            }
            $xml .= "    </video:video>\n";
            $xml .= "  </url>\n";
        }

        $xml .= "</urlset>\n";

        set_transient($cacheKey, $xml, 10 * MINUTE_IN_SECONDS);

        return $xml;
    }

    private function extractYouTubeIdFromUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }
        $p = wp_parse_url($url);
        if (!is_array($p)) {
            return '';
        }

        $host = isset($p['host']) ? strtolower((string) $p['host']) : '';
        $path = isset($p['path']) ? (string) $p['path'] : '';
        $query = isset($p['query']) ? (string) $p['query'] : '';

        if ($host === 'youtu.be') {
            $id = trim($path, '/');
            return preg_match('/^[A-Za-z0-9_-]{6,}$/', $id) ? $id : '';
        }

        if ($host === 'youtube.com' || $host === 'www.youtube.com' || $host === 'youtube-nocookie.com' || $host === 'www.youtube-nocookie.com') {
            if (strpos($path, '/embed/') === 0) {
                $id = substr($path, strlen('/embed/'));
                $id = trim($id, '/');
                return preg_match('/^[A-Za-z0-9_-]{6,}$/', $id) ? $id : '';
            }
            if ($query !== '') {
                parse_str($query, $qs);
                if (isset($qs['v'])) {
                    $id = (string) $qs['v'];
                    $id = trim($id);
                    return preg_match('/^[A-Za-z0-9_-]{6,}$/', $id) ? $id : '';
                }
            }
        }

        return '';
    }
}
