<?php
/**
 * Tam Rank™ XML Sitemap Generator
 *
 * This class handles XML sitemap generation with robust output handling
 * to ensure compatibility with bots, crawlers, and API clients.
 *
 * Key features:
 * - Early request interception (before theme output)
 * - Clean output buffer handling
 * - Proper HTTP headers for XML
 * - Hard exit after sitemap output
 */

if (!defined('ABSPATH')) {
    exit;
}

class TamRank_Sitemap
{

    private $sitemap_settings;

    public function __construct()
    {
        $this->sitemap_settings = tam_rank_cached_option('tam_rank_sitemap_settings', array());

        // Disable WordPress core sitemap with highest priority - but only while our own
        // sitemap is switched on. With the master switch off, leaving /wp-sitemap.xml
        // alive is both more honest and less destructive than leaving the site with no
        // sitemap at all. Same behaviour as Yoast's Disable_Core_Sitemaps initializer.
        if (tam_rank_sitemap_is_enabled()) {
            add_filter('wp_sitemaps_enabled', '__return_false', 1);
        }

        // CRITICAL: Intercept sitemap requests as early as possible
        // Using 'init' with priority 0 catches requests before most plugins/themes
        add_action('init', array($this, 'intercept_sitemap_request'), 0);

        // Also hook into parse_request as a secondary check
        add_action('parse_request', array($this, 'intercept_sitemap_request'), 0);

        // Invalidate on any content change that can show up in a sitemap. Note
        // that 'publish_post' is the dynamic {$new_status}_{$post_type} hook, so
        // it only ever fires for post-type "post". Pages and custom post types
        // would keep serving a stale sitemap for the full cache TTL.
        add_action('transition_post_status', array($this, 'maybe_clear_sitemap_cache_on_transition'), 10, 3);
        add_action('before_delete_post', array($this, 'maybe_clear_sitemap_cache_on_delete'));
        add_action('edited_term', array($this, 'clear_sitemap_cache'));
        add_action('created_term', array($this, 'clear_sitemap_cache'));
        add_action('delete_term', array($this, 'clear_sitemap_cache'));
        add_action('wp_ajax_tam_rank_regenerate_sitemap', array($this, 'regenerate_sitemap_ajax'));

        // Clear cache when settings are updated (both sitemap and general — content type toggles live in both)
        add_action('update_option_tam_rank_sitemap_settings', array($this, 'clear_sitemap_cache'));
        add_action('update_option_tam_rank_sitemap_settings', array($this, 'maybe_flush_rewrites_on_switch'), 10, 2);
        add_action('update_option_tam_rank_general_settings', array($this, 'clear_sitemap_cache'));

        // Add media library checkbox
        add_filter('attachment_fields_to_edit', array($this, 'add_attachment_sitemap_field'), 10, 2);
        add_filter('attachment_fields_to_save', array($this, 'save_attachment_sitemap_field'), 10, 2);

        // Enqueue the attachment-field CSS/JS (moved out of inline <style>/<script> for
        // wp.org compliance).
        add_action('admin_enqueue_scripts', array($this, 'enqueue_attachment_field_assets'));
    }

    /**
     * Enqueue the "exclude from sitemap" attachment-field assets on media screens.
     *
     * @param string $hook Current admin page hook.
     */
    public function enqueue_attachment_field_assets($hook)
    {
        if (!in_array($hook, array('upload.php', 'post.php', 'post-new.php'), true)) {
            return;
        }
        wp_enqueue_style('tamrank-sitemap-attachment', TAM_RANK_PLUGIN_URL . 'assets/css/sitemap-attachment-field.css', array('tamrank-variables'), TAM_RANK_VERSION);
        wp_enqueue_script('tamrank-sitemap-attachment', TAM_RANK_PLUGIN_URL . 'assets/js/sitemap-attachment-field.js', array(), TAM_RANK_VERSION, true);
    }

    /**
     * Flush rewrite rules when the sitemap master switch flips.
     *
     * WordPress registers its own /wp-sitemap.xml rewrite rules on init, but only while
     * core sitemaps are enabled - and the generated rules live in the cached
     * rewrite_rules option. While our switch is on we disable core sitemaps, so those
     * rules are absent from the cache; switching our sitemap off would leave
     * /wp-sitemap.xml 404-ing until something else happened to flush. Flushing exactly
     * on the transition keeps that cost to one write, instead of every settings save.
     *
     * @param mixed $old_value Previous option value.
     * @param mixed $value     New option value.
     * @return void
     */
    public function maybe_flush_rewrites_on_switch($old_value, $value)
    {
        $was = !is_array($old_value) || !array_key_exists('sitemap_enabled', $old_value)
            ? true
            : (bool) $old_value['sitemap_enabled'];
        $now = !is_array($value) || !array_key_exists('sitemap_enabled', $value)
            ? true
            : (bool) $value['sitemap_enabled'];

        if ($was === $now) {
            return;
        }

        // The filter decision was made earlier in this request from the old value, so
        // re-apply it before regenerating the rules.
        if ($now) {
            add_filter('wp_sitemaps_enabled', '__return_false', 1);
        } else {
            remove_filter('wp_sitemaps_enabled', '__return_false', 1);
        }

        // Rebuild WP_Sitemaps' rewrite rules for the new state, then flush.
        if (function_exists('wp_sitemaps_get_server')) {
            wp_sitemaps_get_server();
        }
        flush_rewrite_rules(false);
    }

    /**
     * Intercept sitemap requests early in WordPress lifecycle
     *
     * This runs on 'init' with priority 0 to catch requests before
     * any theme or plugin can output content.
     */
    public function intercept_sitemap_request()
    {
        // Master switch off: let /sitemap.xml and /sitemap-*.xml fall through to
        // WordPress. Checked before the static guard, because this method is hooked to
        // both init(0) and parse_request(0) and the guard would otherwise make the
        // first call decide for the second.
        if (!tam_rank_sitemap_is_enabled()) {
            return;
        }

        // Prevent running multiple times
        static $checked = false;
        if ($checked) {
            return;
        }
        $checked = true;

        // Get the request URI
        $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';

        // Remove query string for matching
        $request_path = strtok($request_uri, '?');

        // Check if this is a sitemap request
        if (preg_match('/\/sitemap\.xml$/i', $request_path)) {
            $this->serve_sitemap('main');
        } elseif (preg_match('/\/sitemap-([a-zA-Z0-9_-]+)\.xml$/i', $request_path, $matches)) {
            $this->serve_sitemap(sanitize_key($matches[1]));
        }
    }

    /**
     * Serve the sitemap - Central function for all sitemap output
     *
     * This function ensures:
     * 1. All output buffers are cleaned
     * 2. Proper HTTP headers are sent
     * 3. Pure XML output with no extra content
     * 4. WordPress execution stops after output
     *
     * @param string $type The sitemap type (main, posts, pages, etc.)
     */
    private function serve_sitemap($type)
    {
        // =====================================================
        // STEP 0: Disable query monitor and other debug tools
        // =====================================================
        if (!defined('QM_DISABLED')) {
            define('QM_DISABLED', true); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- third-party Query Monitor constant; name is defined by that plugin
        }
        remove_action('shutdown', 'wp_ob_end_flush_all', 1);

        // =====================================================
        // STEP 1: Clean ALL output buffers
        // This prevents any accidental output (whitespace, HTML, errors)
        // from appearing before the XML declaration
        // =====================================================
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        // Start fresh output buffer
        ob_start();

        // =====================================================
        // STEP 2: Set HTTP status code
        // =====================================================
        if (!headers_sent()) {
            // Set status code using multiple methods for compatibility
            if (function_exists('http_response_code')) {
                http_response_code(200);
            }
            header('HTTP/1.1 200 OK', true, 200);
            status_header(200);
        }

        // =====================================================
        // STEP 3: Set proper XML headers
        // =====================================================
        if (!headers_sent()) {
            // Remove any conflicting headers
            header_remove('Content-Type');
            header_remove('X-Pingback');
            header_remove('Link');

            // Set XML content type - critical for bots/crawlers
            header('Content-Type: application/xml; charset=UTF-8');

            // Tell search engines not to index the sitemap itself
            header('X-Robots-Tag: noindex, follow');

            // Caching headers for performance
            header('Cache-Control: public, max-age=3600');
            header('Pragma: public');

            // Prevent caching issues with proxies
            header('Vary: Accept-Encoding');
        }

        // =====================================================
        // STEP 4: Generate the XML content
        // =====================================================
        $xml_content = $this->generate_sitemap_content($type);

        // =====================================================
        // STEP 5: Ensure clean XML (no BOM, no leading whitespace)
        // =====================================================
        $xml_content = ltrim($xml_content);

        // Verify XML starts with declaration
        if (strpos($xml_content, '<?xml') !== 0) {
            // Something is wrong - prepend declaration
            $xml_content = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . $xml_content;
        }

        // =====================================================
        // STEP 6: Set Content-Length for better bot compatibility
        // =====================================================
        if (!headers_sent()) {
            header('Content-Length: ' . strlen($xml_content));
        }

        // =====================================================
        // STEP 7: Clear output buffer and output the XML
        // =====================================================
        // Discard any unexpected pre-output so it can't corrupt the XML document.
        ob_get_clean();

        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- complete XML sitemap document generated internally and sent with an XML content-type; HTML escaping would corrupt it.
        echo $xml_content;

        // =====================================================
        // STEP 8: Flush output to client immediately
        // =====================================================
        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        } else {
            if (ob_get_level() > 0) {
                ob_end_flush();
            }
            flush();
        }

        // =====================================================
        // STEP 9: CRITICAL - Stop all WordPress execution
        // This prevents any theme, plugin, or WordPress core from
        // outputting additional content after the sitemap
        // =====================================================
        die(); // Use die() instead of exit for better compatibility
    }

    /**
     * Generate sitemap content based on type
     *
     * @param string $type Sitemap type
     * @return string XML content
     */
    private function generate_sitemap_content($type)
    {
        $enabled_pt_slugs  = TamRank_Content_Type_Config::get_enabled_sitemap_slugs();
        $enabled_tax_slugs = TamRank_Content_Type_Config::get_enabled_sitemap_taxonomy_slugs();

        switch ($type) {
            case 'main':
                return $this->generate_sitemap_index();
            case 'posts':
                if (!in_array('post', $enabled_pt_slugs, true)) {
                    return $this->render_sitemap(array());
                }
                return $this->generate_posts_sitemap();
            case 'pages':
                if (!in_array('page', $enabled_pt_slugs, true)) {
                    return $this->render_sitemap(array());
                }
                return $this->generate_pages_sitemap();
            case 'categories':
                if (!in_array('category', $enabled_tax_slugs, true)) {
                    return $this->render_sitemap(array());
                }
                return $this->generate_taxonomy_sitemap('category');
            case 'tags':
                if (!in_array('post_tag', $enabled_tax_slugs, true)) {
                    return $this->render_sitemap(array());
                }
                return $this->generate_taxonomy_sitemap('post_tag');
            case 'images':
                return $this->generate_images_sitemap();
            default:
                // Check if it's a custom post type sitemap enabled in config
                if (
                    in_array($type, $enabled_pt_slugs, true) &&
                    !in_array($type, array('post', 'page'), true)
                ) {
                    $cache_key = 'tam_rank_sitemap_' . $type;
                    $cached    = get_transient($cache_key);
                    if ($cached !== false) {
                        return $cached;
                    }
                    $urls    = $this->get_post_type_urls($type);
                    $sitemap = $this->render_sitemap($urls);
                    set_transient($cache_key, $sitemap, 12 * HOUR_IN_SECONDS);
                    return $sitemap;
                }

                // Check if it's a custom taxonomy sitemap (tax-{taxonomy_name})
                if (strpos($type, 'tax-') === 0) {
                    $taxonomy = substr($type, 4);
                    if (taxonomy_exists($taxonomy) && in_array($taxonomy, $enabled_tax_slugs, true)) {
                        return $this->generate_taxonomy_sitemap($taxonomy);
                    }
                }
                return $this->generate_main_sitemap();
        }
    }

    /**
     * Generate sitemap index XML
     *
     * @return string XML content
     */
    private function generate_sitemap_index()
    {
        $sitemaps = array();

        // Use content type config for ALL post types (posts, pages, CPTs)
        $enabled_pt_slugs = TamRank_Content_Type_Config::get_enabled_sitemap_slugs();
        foreach ($enabled_pt_slugs as $slug) {
            // Map post type slug to sitemap slug
            $sitemap_slug = ($slug === 'post') ? 'posts' : (($slug === 'page') ? 'pages' : $slug);
            $sitemaps[] = array(
                'loc'     => home_url('/sitemap-' . $sitemap_slug . '.xml'),
                'lastmod' => $this->get_last_modified_date($slug),
            );
        }

        // Use content type config for ALL taxonomies
        $enabled_tax_slugs = TamRank_Content_Type_Config::get_enabled_sitemap_taxonomy_slugs();
        foreach ($enabled_tax_slugs as $tax_slug) {
            // Verify taxonomy still exists and is public
            $tax = get_taxonomy($tax_slug);
            if (!$tax || $tax->publicly_queryable === false) {
                continue;
            }
            // Use batch lastmod map to get the most recent date across all terms
            $tax_lastmod_map = $this->get_taxonomy_term_lastmods($tax_slug);
            // Skip empty taxonomies (no terms with published posts)
            if (empty($tax_lastmod_map)) {
                continue;
            }
            $tax_lastmod = max($tax_lastmod_map);

            $slug = $this->get_taxonomy_sitemap_slug($tax_slug);
            $sitemaps[] = array(
                'loc'     => home_url('/sitemap-' . $slug . '.xml'),
                'lastmod' => $tax_lastmod,
            );
        }

        // Add image sitemap
        $sitemaps[] = array(
            'loc' => home_url('/sitemap-images.xml'),
            'lastmod' => $this->get_last_modified_date('attachment')
        );

        // If we have multiple sub-sitemaps, output index
        if (count($sitemaps) > 1) {
            return $this->render_sitemap_index_xml($sitemaps);
        } else {
            // Otherwise output main sitemap with all content
            return $this->generate_main_sitemap();
        }
    }

    /**
     * Generate main sitemap with all content
     *
     * @return string XML content
     */
    private function generate_main_sitemap()
    {
        // Check cache first
        $cache_key = 'tam_rank_sitemap_main';
        $cached_sitemap = get_transient($cache_key);

        if ($cached_sitemap && !$this->is_cache_expired()) {
            return $cached_sitemap;
        }

        $urls = array();
        foreach ($this->get_content_groups() as $group) {
            $urls = array_merge($urls, $group['urls']);
        }

        $sitemap_xml = $this->render_sitemap($urls);

        // Cache for 12 hours
        set_transient($cache_key, $sitemap_xml, 12 * HOUR_IN_SECONDS);

        return $sitemap_xml;
    }

    /**
     * Every URL the sitemap publishes, grouped by the content it came from.
     *
     * This is the single collector behind BOTH sitemap renderings. The XML main
     * sitemap flattens the groups into one <urlset>; the HTML sitemap shortcode
     * renders one list per group. Because both read this method, the two can
     * never drift apart — in particular the noindex exclusions and the content
     * type config are applied here once, not twice.
     *
     * Groups come out in the same order the XML sitemap has always used:
     * homepage first, then every enabled post type, then every enabled taxonomy.
     *
     * @param bool $with_images Whether to collect the per-URL image list. Only
     *                          the XML sitemap needs it (it emits <image:image>
     *                          children); skipping it saves a content scan plus
     *                          an attachment_url_to_postid() query per image.
     *                          It never changes WHICH urls are returned.
     * @return array<int, array{type:string, slug:string, label:string, urls:array}>
     */
    public function get_content_groups(bool $with_images = true): array
    {
        $groups = array();

        // Homepage — the sitemap's own entry, not tied to a post type.
        $groups[] = array(
            'type'  => 'home',
            'slug'  => 'home',
            'label' => tamrank__('frontend_home', 'Home'),
            'urls'  => array(
                array(
                    'loc'        => home_url('/'),
                    'title'      => tamrank__('frontend_home', 'Home'),
                    'lastmod'    => $this->get_last_modified_date(),
                    'changefreq' => 'daily',
                    'priority'   => '1.0',
                ),
            ),
        );

        // All enabled post types from config
        $enabled_pt_slugs = TamRank_Content_Type_Config::get_enabled_sitemap_slugs();
        foreach ($enabled_pt_slugs as $pt_slug) {
            $prefix   = ($pt_slug === 'post') ? 'posts' : (($pt_slug === 'page') ? 'pages' : null);
            $pt_obj   = get_post_type_object($pt_slug);
            $groups[] = array(
                'type'  => 'post_type',
                'slug'  => $pt_slug,
                'label' => ($pt_obj && isset($pt_obj->labels->name)) ? $pt_obj->labels->name : $pt_slug,
                'urls'  => $this->get_post_type_urls($pt_slug, $prefix, $with_images),
            );
        }

        // All enabled taxonomies from config
        $enabled_tax_slugs = TamRank_Content_Type_Config::get_enabled_sitemap_taxonomy_slugs();
        foreach ($enabled_tax_slugs as $tax_slug) {
            $tax = get_taxonomy($tax_slug);
            if (!$tax || $tax->publicly_queryable === false) {
                continue;
            }
            $groups[] = array(
                'type'  => 'taxonomy',
                'slug'  => $tax_slug,
                'label' => isset($tax->labels->name) ? $tax->labels->name : $tax_slug,
                'urls'  => $this->get_taxonomy_urls($tax_slug),
            );
        }

        return $groups;
    }

    /**
     * Generate posts sitemap XML
     *
     * @return string XML content
     */
    private function generate_posts_sitemap()
    {
        $urls = $this->get_posts_urls();
        return $this->render_sitemap($urls);
    }

    /**
     * Generate pages sitemap XML
     *
     * @return string XML content
     */
    private function generate_pages_sitemap()
    {
        $urls = $this->get_pages_urls();
        return $this->render_sitemap($urls);
    }

    /**
     * Generate taxonomy sitemap XML
     *
     * @param string $taxonomy Taxonomy name
     * @return string XML content
     */
    private function generate_taxonomy_sitemap($taxonomy)
    {
        $urls = $this->get_taxonomy_urls($taxonomy);
        return $this->render_sitemap($urls);
    }

    /**
     * Generate images sitemap XML
     *
     * @return string XML content
     */
    private function generate_images_sitemap()
    {
        $urls = $this->get_images_urls();
        return $this->render_sitemap($urls);
    }

    /**
     * Get URLs for any post type — generic implementation
     *
     * Retrieves all published posts of the given type, skips noindex posts,
     * and returns URL entries with permalink, real lastmod, changefreq, priority,
     * and images.
     *
     * @param string      $post_type      Post type slug (e.g. 'post', 'page', 'product')
     * @param string|null $settings_prefix Optional prefix for reading settings
     *                                     (e.g. 'posts' reads 'posts_frequency' / 'posts_priority').
     *                                     When null, falls back to per-type defaults.
     * @param bool        $with_images     Collect the per-URL image list. Off for the
     *                                     HTML sitemap, which has no use for it and
     *                                     would otherwise pay a content scan plus an
     *                                     attachment lookup per image. Does not change
     *                                     which URLs are returned.
     * @return array URLs array
     */
    private function get_post_type_urls(string $post_type, ?string $settings_prefix = null, bool $with_images = true): array
    {
        $urls = array();

        // Default changefreq / priority per post type
        $type_defaults = array(
            'post' => array('changefreq' => 'weekly',  'priority' => '0.6'),
            'page' => array('changefreq' => 'monthly', 'priority' => '0.8'),
        );
        $default_changefreq = $type_defaults[$post_type]['changefreq'] ?? 'weekly';
        $default_priority   = $type_defaults[$post_type]['priority']   ?? '0.5';

        // Resolve changefreq / priority from settings when a prefix is provided
        if ($settings_prefix !== null) {
            $changefreq = $this->get_setting($settings_prefix . '_frequency', $default_changefreq);
            $priority   = $this->get_setting($settings_prefix . '_priority',  $default_priority);
        } else {
            $changefreq = $default_changefreq;
            $priority   = $default_priority;
        }

        $posts = get_posts(array(
            'numberposts' => -1,
            'post_status' => 'publish',
            'post_type'   => $post_type,
        ));

        foreach ($posts as $post) {
            // Skip if noindex is set
            $noindex = get_post_meta($post->ID, '_tam_rank_noindex', true);
            if ($noindex == '1') {
                continue;
            }

            $urls[] = array(
                'loc'        => get_permalink($post->ID),
                // Human-readable label for the HTML sitemap. The XML renderer
                // ignores unknown keys, so this costs the XML output nothing.
                'title'      => get_the_title($post->ID),
                'lastmod'    => get_post_modified_time('c', false, $post->ID, true),
                'changefreq' => $changefreq,
                'priority'   => $priority,
                'images'     => $with_images ? $this->get_post_images($post->ID) : array(),
            );
        }

        return $urls;
    }

    /**
     * Get posts URLs
     *
     * @return array URLs array
     */
    private function get_posts_urls()
    {
        return $this->get_post_type_urls('post', 'posts');
    }

    /**
     * Get pages URLs
     *
     * @return array URLs array
     */
    private function get_pages_urls()
    {
        return $this->get_post_type_urls('page', 'pages');
    }

    /**
     * Get images from a post
     *
     * @param int $post_id Post ID
     * @return array Images array
     */
    private function get_post_images($post_id)
    {
        $images = array();

        // Get featured image
        if (has_post_thumbnail($post_id)) {
            $thumbnail_id = get_post_thumbnail_id($post_id);

            // Check if this image should be excluded from sitemap
            if (!get_post_meta($thumbnail_id, '_tam_rank_exclude_from_sitemap', true)) {
                $image_url = get_the_post_thumbnail_url($post_id, 'full');
                if ($image_url) {
                    $images[] = array(
                        'loc' => $image_url,
                        'title' => get_the_title($thumbnail_id),
                        'caption' => wp_get_attachment_caption($thumbnail_id)
                    );
                }
            }
        }

        // Get images from post content
        $post_content = get_post_field('post_content', $post_id);
        preg_match_all('/<img[^>]+>/i', $post_content, $img_tags);

        foreach ($img_tags[0] as $img_tag) {
            // Extract src from img tag
            if (preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
                $image_url = $src_match[1];

                // Get attachment ID from URL
                $attachment_id = attachment_url_to_postid($image_url);

                // Check if this image should be excluded
                if ($attachment_id && get_post_meta($attachment_id, '_tam_rank_exclude_from_sitemap', true)) {
                    continue;
                }

                // Extract alt text
                $alt = '';
                if (preg_match('/alt=["\']([^"\']+)["\']/i', $img_tag, $alt_match)) {
                    $alt = $alt_match[1];
                }

                $images[] = array(
                    'loc' => $image_url,
                    'title' => $alt,
                    'caption' => ''
                );
            }
        }

        // Remove duplicates based on URL
        $unique_images = array();
        $seen_urls = array();

        foreach ($images as $image) {
            if (!in_array($image['loc'], $seen_urls)) {
                $unique_images[] = $image;
                $seen_urls[] = $image['loc'];
            }
        }

        return $unique_images;
    }

    /**
     * Get batch lastmod dates for all terms in a taxonomy
     *
     * Returns a map of term_taxonomy_id => ISO 8601 lastmod, based on the
     * most recently modified published post in each term. Terms with no
     * published posts are omitted from the map.
     *
     * @param string $taxonomy Taxonomy name
     * @return array Map of [ term_taxonomy_id (int) => ISO 8601 date string ]
     */
    private function get_taxonomy_term_lastmods(string $taxonomy): array
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query joining core posts/term tables (no API for batch term lastmods); front-end request-scoped read, result is cached in a transient by callers
        $results = $wpdb->get_results($wpdb->prepare(
            "SELECT tr.term_taxonomy_id,
                    MAX(p.post_modified_gmt) AS lastmod
             FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->term_relationships} tr ON p.ID = tr.object_id
             INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
             WHERE tt.taxonomy = %s
               AND p.post_status = 'publish'
             GROUP BY tr.term_taxonomy_id",
            $taxonomy
        ));

        $map = [];
        foreach ($results as $row) {
            $map[(int) $row->term_taxonomy_id] = gmdate('c', strtotime($row->lastmod));
        }

        return $map;
    }

    /**
     * Get URLs for any taxonomy
     *
     * Uses a batch SQL query for real lastmod dates (SMAP-01).
     * Skips terms where get_term_link() returns WP_Error (SMAP-02).
     * Skips terms with no published posts (no entry in lastmod map).
     *
     * @param string $taxonomy Taxonomy name
     * @return array URLs array
     */
    private function get_taxonomy_urls($taxonomy)
    {
        $urls = array();

        $terms = get_terms(array(
            'taxonomy'   => $taxonomy,
            'hide_empty' => true,
        ));

        if (is_wp_error($terms)) {
            return $urls;
        }

        // Batch-fetch real lastmod dates from published posts (SMAP-01)
        $lastmod_map = $this->get_taxonomy_term_lastmods($taxonomy);

        // Determine priority/frequency based on taxonomy type
        $priority = '0.4';
        $changefreq = 'weekly';
        if ($taxonomy === 'post_tag') {
            $priority = '0.3';
            $changefreq = 'monthly';
        }

        foreach ($terms as $term) {
            // Skip terms marked as noindex
            $noindex = get_term_meta($term->term_id, '_tam_rank_noindex', true);
            if ($noindex == '1') {
                continue;
            }

            // SMAP-01: Skip terms with no published posts (no real lastmod available)
            $lastmod = $lastmod_map[$term->term_taxonomy_id] ?? null;
            if ($lastmod === null) {
                continue;
            }

            // SMAP-02: Guard against WP_Error from get_term_link() — broken terms must
            // not corrupt XML output
            $link = get_term_link($term);
            if (is_wp_error($link)) {
                continue;
            }

            $urls[] = array(
                'loc'        => $link,
                // Human-readable label for the HTML sitemap; ignored by the XML renderer.
                'title'      => $term->name,
                'lastmod'    => $lastmod,
                'changefreq' => $changefreq,
                'priority'   => $priority,
            );
        }

        return $urls;
    }


    /**
     * Get the sitemap URL slug for a taxonomy
     *
     * @param string $taxonomy Taxonomy name
     * @return string Sitemap slug
     */
    private function get_taxonomy_sitemap_slug($taxonomy)
    {
        // Keep backwards-compatible slugs for categories and tags
        if ($taxonomy === 'category') {
            return 'categories';
        }
        if ($taxonomy === 'post_tag') {
            return 'tags';
        }
        // Custom taxonomies use tax-{name} prefix
        return 'tax-' . $taxonomy;
    }

    /**
     * Get images URLs
     *
     * @return array URLs array
     */
    private function get_images_urls()
    {
        $urls = array();

        $images = get_posts(array(
            'post_type' => 'attachment',
            'post_mime_type' => 'image',
            'post_status' => 'inherit',
            'numberposts' => -1
        ));

        foreach ($images as $image) {
            // Check if image is excluded from sitemap
            $exclude_from_sitemap = get_post_meta($image->ID, '_tam_rank_exclude_from_sitemap', true);
            if ($exclude_from_sitemap == '1') {
                continue;
            }

            $image_url = wp_get_attachment_url($image->ID);
            if (!$image_url) {
                continue;
            }

            $urls[] = array(
                'loc' => $image_url,
                'lastmod' => get_post_modified_time('c', false, $image->ID, true),
                'changefreq' => 'monthly',
                'priority' => '0.5',
                'images' => array(
                    array(
                        'loc' => $image_url,
                        'title' => get_the_title($image->ID),
                        'caption' => wp_get_attachment_caption($image->ID)
                    )
                )
            );
        }

        return $urls;
    }

    /**
     * Render sitemap XML
     *
     * @param array $urls URLs to include
     * @return string XML content
     */
    private function render_sitemap($urls)
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(TAM_RANK_PLUGIN_URL . 'assets/sitemap.xsl') . '"?>' . "\n";
        $xml .= '<!-- Generated by Tam Rank™ SEO Plugin -->' . "\n";
        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' . "\n";
        $xml .= '        xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";

        foreach ($urls as $url) {
            $xml .= "\t<url>\n";
            $xml .= "\t\t<loc>" . esc_url($url['loc']) . "</loc>\n";

            if (isset($url['lastmod']) && !empty($url['lastmod'])) {
                $xml .= "\t\t<lastmod>" . esc_html($url['lastmod']) . "</lastmod>\n";
            }

            if (isset($url['changefreq']) && !empty($url['changefreq'])) {
                $xml .= "\t\t<changefreq>" . esc_html($url['changefreq']) . "</changefreq>\n";
            }

            if (isset($url['priority']) && !empty($url['priority'])) {
                $xml .= "\t\t<priority>" . esc_html($url['priority']) . "</priority>\n";
            }

            // Add images if available
            if (isset($url['images']) && !empty($url['images'])) {
                foreach ($url['images'] as $image) {
                    $xml .= "\t\t<image:image>\n";
                    $xml .= "\t\t\t<image:loc>" . esc_url($image['loc']) . "</image:loc>\n";

                    if (!empty($image['title'])) {
                        $img_title = sanitize_text_field(wp_strip_all_tags($image['title']));
                        $xml .= "\t\t\t<image:title><![CDATA[" . str_replace(']]>', ']]]]><![CDATA[>', $img_title) . "]]></image:title>\n";
                    }

                    if (!empty($image['caption'])) {
                        $img_caption = sanitize_text_field(wp_strip_all_tags($image['caption']));
                        $xml .= "\t\t\t<image:caption><![CDATA[" . str_replace(']]>', ']]]]><![CDATA[>', $img_caption) . "]]></image:caption>\n";
                    }

                    $xml .= "\t\t</image:image>\n";
                }
            }

            $xml .= "\t</url>\n";
        }

        $xml .= '</urlset>';

        return $xml;
    }

    /**
     * Render sitemap index XML
     *
     * @param array $sitemaps Sitemaps to include
     * @return string XML content
     */
    private function render_sitemap_index_xml($sitemaps)
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(TAM_RANK_PLUGIN_URL . 'assets/sitemap.xsl') . '"?>' . "\n";
        $xml .= '<!-- Generated by Tam Rank™ SEO Plugin -->' . "\n";
        $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

        foreach ($sitemaps as $sitemap) {
            $xml .= "\t<sitemap>\n";
            $xml .= "\t\t<loc>" . esc_url($sitemap['loc']) . "</loc>\n";

            if (isset($sitemap['lastmod']) && !empty($sitemap['lastmod'])) {
                $xml .= "\t\t<lastmod>" . esc_html($sitemap['lastmod']) . "</lastmod>\n";
            }

            $xml .= "\t</sitemap>\n";
        }

        $xml .= '</sitemapindex>';

        return $xml;
    }

    /**
     * Get last modified date
     *
     * @param string|null $post_type Post type to check
     * @return string ISO 8601 date
     */
    private function get_last_modified_date($post_type = null)
    {
        global $wpdb;

        if ($post_type) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on core posts table for newest lastmod (no equivalent API); front-end request-scoped read, sitemap output is cached in a transient by callers
            $last_modified = $wpdb->get_var($wpdb->prepare(
                "SELECT post_modified_gmt FROM {$wpdb->posts}
                WHERE post_status = 'publish' AND post_type = %s
                ORDER BY post_modified_gmt DESC LIMIT 1",
                $post_type
            ));
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on core posts table for newest lastmod (no equivalent API); front-end request-scoped read, sitemap output is cached in a transient by callers
            $last_modified = $wpdb->get_var(
                "SELECT post_modified_gmt FROM {$wpdb->posts}
                WHERE post_status = 'publish'
                ORDER BY post_modified_gmt DESC LIMIT 1"
            );
        }

        return $last_modified ? gmdate('c', strtotime($last_modified)) : gmdate('c');
    }

    /**
     * Get setting value
     *
     * @param string $key Setting key
     * @param mixed $default Default value
     * @return mixed Setting value
     */
    private function get_setting($key, $default = null)
    {
        return isset($this->sitemap_settings[$key]) ? $this->sitemap_settings[$key] : $default;
    }

    /**
     * Check if cache is expired
     *
     * @return bool True if expired
     */
    private function is_cache_expired()
    {
        return false;
    }

    /**
     * Clear the sitemap cache when a post enters or leaves the sitemap.
     *
     * Fires for every post type, unlike the dynamic publish_{$post_type} hooks.
     * Only publish transitions matter: publishing adds a URL, unpublishing or
     * trashing removes one, and re-saving a published post can change its
     * lastmod. Anything that stays outside "publish" is invisible in the
     * sitemap, so clearing the cache for it would only throw away good cache.
     *
     * @param string  $new_status New post status.
     * @param string  $old_status Previous post status.
     * @param WP_Post $post       Post being transitioned.
     */
    public function maybe_clear_sitemap_cache_on_transition($new_status, $old_status, $post)
    {
        if ($new_status !== 'publish' && $old_status !== 'publish') {
            return;
        }
        if (!$this->post_can_be_in_sitemap($post)) {
            return;
        }
        $this->clear_sitemap_cache();
    }

    /**
     * Clear the sitemap cache when published content is deleted for good.
     *
     * wp_delete_post() with $force_delete fires no status transition, so
     * without this hook a deleted page or CPT item keeps being served from the
     * cached sitemap until the TTL expires.
     *
     * @param int $post_id Post about to be deleted.
     */
    public function maybe_clear_sitemap_cache_on_delete($post_id)
    {
        $post = get_post($post_id);
        if (!$post || $post->post_status !== 'publish') {
            return;
        }
        if (!$this->post_can_be_in_sitemap($post)) {
            return;
        }
        $this->clear_sitemap_cache();
    }

    /**
     * Whether a post is the kind of object that can appear in a sitemap.
     *
     * Filters out autosaves, revisions and non-public post types, so routine
     * editor traffic does not keep flushing the cache.
     *
     * @param mixed $post Post object to check.
     * @return bool
     */
    private function post_can_be_in_sitemap($post)
    {
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return false;
        }
        if (!$post instanceof WP_Post || wp_is_post_revision($post->ID)) {
            return false;
        }
        $post_type_object = get_post_type_object($post->post_type);
        return ($post_type_object && $post_type_object->public);
    }

    /**
     * Clear sitemap cache
     */
    public function clear_sitemap_cache()
    {
        delete_transient('tam_rank_sitemap_main');
        delete_transient('tam_rank_sitemap_posts');
        delete_transient('tam_rank_sitemap_pages');
        delete_transient('tam_rank_sitemap_categories');
        delete_transient('tam_rank_sitemap_tags');

        // Clear custom taxonomy caches
        $taxonomies = get_taxonomies(array('public' => true), 'names');
        unset($taxonomies['post_format'], $taxonomies['nav_menu'], $taxonomies['category'], $taxonomies['post_tag']);
        foreach ($taxonomies as $taxonomy) {
            delete_transient('tam_rank_sitemap_tax-' . $taxonomy);
        }

        // Clear custom post type sitemap caches
        $cpt_config = tamrank_get_content_type_config();
        foreach (array_keys($cpt_config['sitemap']['post_types']) as $slug) {
            if (in_array($slug, array('post', 'page'), true)) {
                continue;
            }
            delete_transient('tam_rank_sitemap_' . $slug);
        }

        // Invalidate every cached HTML-sitemap rendering. Those transients are
        // keyed per shortcode-attribute combination, so there is no fixed list
        // to delete — bumping the version number orphans them all at once and
        // lets them expire on their own TTL.
        if (class_exists('TamRank_HTML_Sitemap')) {
            TamRank_HTML_Sitemap::bump_cache_version();
        }

        // Flush opcache for sitemap file so config changes take effect immediately
        if (function_exists('opcache_invalidate')) {
            opcache_invalidate(__FILE__, true);
        }
    }

    /**
     * AJAX handler for regenerating sitemap
     */
    public function regenerate_sitemap_ajax()
    {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        // Clearing a cache for a sitemap that is not served has no meaning; say so
        // instead of reporting a successful regeneration.
        if (!tam_rank_sitemap_is_enabled()) {
            wp_send_json_error(array(
                'message' => tamrank__('sitemap_disabled_regenerate_error', 'The XML sitemap is switched off, so there is nothing to regenerate. Switch it on first.'),
                'code'    => 'sitemap_disabled',
            ));
        }

        $this->clear_sitemap_cache();

        wp_send_json_success('Sitemap cache cleared and will be regenerated on next request');
    }

    /**
     * Add checkbox field to attachment editor
     */
    public function add_attachment_sitemap_field($form_fields, $post)
    {
        $exclude_from_sitemap = get_post_meta($post->ID, '_tam_rank_exclude_from_sitemap', true);
        $is_checked = $exclude_from_sitemap === '1';
        $field_id = 'attachments-' . $post->ID . '-tam_rank_exclude_from_sitemap';

        $form_fields['tam_rank_exclude_from_sitemap'] = array(
            'label' => '',
            'input' => 'html',
            'html' => '
                <label for="' . esc_attr($field_id) . '" class="tam-sitemap-exclude-label' . ($is_checked ? ' checked' : '') . '">
                    <input type="checkbox"
                           name="attachments[' . $post->ID . '][tam_rank_exclude_from_sitemap]"
                           id="' . esc_attr($field_id) . '"
                           class="tam-sitemap-checkbox"
                           value="1" ' . checked($is_checked, true, false) . ' />
                    <div>
                        <span style="font-weight: 600; font-size: 14px; color: var(--tr-color-slate-800); display: block; line-height: 1.3; min-height: 20px; padding-top: 2px;">' . esc_html(tamrank__('exclude_xml_sitemap', 'Exclude from XML sitemap')) . '</span>
                        <span style="font-size: 13px; color: var(--tr-color-slate-500); margin-top: 2px; display: block; line-height: 1.4;">' . esc_html(tamrank__('exclude_xml_sitemap_desc', 'Search engines will not index this image via the sitemap')) . '</span>
                    </div>
                </label>
            '
        );

        return $form_fields;
    }

    /**
     * Save attachment sitemap field
     */
    public function save_attachment_sitemap_field($post, $attachment)
    {
        if (isset($attachment['tam_rank_exclude_from_sitemap'])) {
            update_post_meta($post['ID'], '_tam_rank_exclude_from_sitemap', '1');
        } else {
            delete_post_meta($post['ID'], '_tam_rank_exclude_from_sitemap');
        }

        // Clear sitemap cache when image settings change
        $this->clear_sitemap_cache();

        return $post;
    }
}

// Register query vars
add_filter('query_vars', function ($vars) {
    $vars[] = 'tam_rank_sitemap';
    return $vars;
});
