<?php
/**
 * Rank Forge — XML Sitemap Generator
 *
 * Generates /sitemap_index.xml and per-post-type sub-sitemaps.
 * Respects noindex posts. Cached via transients. Pings Google on publish.
 *
 * FREE feature — available on all plans.
 *
 * @package RankForge
 * @since 1.2.0
 */

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

class RANKFORGE_Sitemap {
    private static $instance = null;
    public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; }

    private function __construct() {
        add_action('init', [$this, 'register_rewrites']);
        add_filter('query_vars', [$this, 'add_query_vars']);
        add_filter('redirect_canonical', [$this, 'prevent_canonical_redirect'], 10, 2);
        add_action('template_redirect', [$this, 'render_sitemap']);
        add_action('save_post', [$this, 'invalidate_cache'], 99);
        add_action('delete_post', [$this, 'invalidate_cache']);
    }

    /**
 * Keep WordPress core's redirect_canonical from 301-redirecting our
 * XML sitemap URLs to a trailing-slash variant (/sitemap.xml ->
 * /sitemap.xml/). On any site whose permalink structure uses a trailing
 * slash, redirect_canonical fires on template_redirect *before* our
 * render_sitemap and appends the slash — so crawlers and Google Search
 * Console get a 301 instead of the XML and report "couldn't fetch" /
 * "general HTTP error" for the index while child sitemaps that were
 * submitted directly still passed. A sitemap file must resolve with a 200
 * at its exact URL, so we bail out of canonicalisation for sitemap
 * requests (index, per-post-type, and video). The query var is already
 * populated by parse_request at this point for both the slashed and
 * unslashed forms, so this matches every sitemap request.
 */
    public function prevent_canonical_redirect($redirect_url, $requested_url) {
        if (get_query_var('rankforge_sitemap')) {
            return false;
        }
        return $redirect_url;
    }

    public function register_rewrites() {
        add_rewrite_rule('^sitemap_index\.xml$', 'index.php?rankforge_sitemap=index', 'top');
        add_rewrite_rule('^sitemap\.xml$', 'index.php?rankforge_sitemap=index', 'top');
        add_rewrite_rule('^([a-z0-9_-]+)-sitemap\.xml$', 'index.php?rankforge_sitemap=$matches[1]', 'top');
    }

    public function add_query_vars($vars) {
        $vars[] = 'rankforge_sitemap';
        return $vars;
    }

    public function render_sitemap() {
        $type = get_query_var('rankforge_sitemap');
        if (!$type) return;

        header('Content-Type: application/xml; charset=UTF-8');
        header('X-Robots-Tag: noindex');

        if ($type === 'index') {
            // Not HTML: full XML document sent with an explicit XML
            // Content-Type header above and exit below. esc_html/wp_kses
            // would mangle the XML declaration and tag structure; every
            // dynamic value (URLs, dates) is already esc_url/escaped
            // individually while the string is assembled in generate_index.
            echo $this->generate_index(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- non-HTML XML payload, see comment above
        } elseif ($type === 'video') {
            // Same rationale as generate_index above: raw XML payload,
            // per-field values already escaped when the string was built.
            echo $this->generate_video_sitemap(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- non-HTML XML payload, see comment above
        } else {
            // Same rationale as generate_index above: raw XML payload,
            // per-field values already escaped when the string was built.
            echo $this->generate_post_type_sitemap($type); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- non-HTML XML payload, see comment above
        }
        exit;
    }

    /**
 * Generate sitemap index.
 */
    private function generate_index() {
        $cached = get_transient('rankforge_sitemap_index');
        if ($cached) return $cached;

        // which post types ship in the sitemap is driven by the
        // "Show in search results" toggle next to each Title Template
        // (rankforge_posttype_robots[pt]['noindex']), not a separate
        // "Include Post Types" list in Sitemap settings. A single switch
        // drives both the robots noindex tag and sitemap inclusion.
        $enabled = $this->get_indexed_types();

        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url($this->stylesheet_url()) . '"?>' . "\n";
        $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

        global $wpdb;
        foreach ($enabled as $pt) {
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            $lastmod = $wpdb->get_var($wpdb->prepare(
                "SELECT MAX(post_modified_gmt) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish'",
                $pt
            ));
            if (!$lastmod) continue;

            $xml .= '<sitemap>' . "\n";
            $xml .= '  <loc>' . esc_url(home_url("/{$pt}-sitemap.xml")) . '</loc>' . "\n";
            $xml .= '  <lastmod>' . mysql2date('c', $lastmod) . '</lastmod>' . "\n";
            $xml .= '</sitemap>' . "\n";
        }

        $xml .= '</sitemapindex>';

        set_transient('rankforge_sitemap_index', $xml, HOUR_IN_SECONDS);
        return $xml;
    }

    /**
 * Generate post type sitemap.
 */
    private function generate_post_type_sitemap($post_type) {
        $cached = get_transient('rankforge_sitemap_' . $post_type);
        if ($cached) return $cached;

        $settings = get_option('rankforge_sitemap_settings', []);
        $max_urls = (int) ($settings['max_urls'] ?? 1000);

        global $wpdb;
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $posts = $wpdb->get_results($wpdb->prepare(
            "SELECT ID, post_modified_gmt FROM {$wpdb->posts}
             WHERE post_type = %s AND post_status = 'publish'
             ORDER BY post_modified_gmt DESC LIMIT %d",
            $post_type, $max_urls
        ));

        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url($this->stylesheet_url()) . '"?>' . "\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";

        $is_product_sitemap = ($post_type === 'product' && taxonomy_exists('product_visibility'));

        foreach ($posts as $post) {
            // Skip noindex posts
            if (RANKFORGE_Robots_Meta::is_noindex($post->ID)) continue;

            // skip WooCommerce products hidden from search results
            // (catalog_visibility 'hidden' or 'catalog' carries the
            // 'exclude-from-search' product_visibility term). Such products
            // must never appear in the XML sitemap we submit to Google.
            if ($is_product_sitemap && has_term('exclude-from-search', 'product_visibility', $post->ID)) {
                continue;
            }

            $url = get_permalink($post->ID);
            $xml .= '<url>' . "\n";
            $xml .= '  <loc>' . esc_url($url) . '</loc>' . "\n";
            $xml .= '  <lastmod>' . mysql2date('c', $post->post_modified_gmt) . '</lastmod>' . "\n";

            // Featured image
            $thumb_id = get_post_thumbnail_id($post->ID);
            if ($thumb_id) {
                $img_url = wp_get_attachment_url($thumb_id);
                if ($img_url) {
                    $xml .= '  <image:image>' . "\n";
                    $xml .= '    <image:loc>' . esc_url($img_url) . '</image:loc>' . "\n";
                    $xml .= '  </image:image>' . "\n";
                }
            }

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

        $xml .= '</urlset>';

        set_transient('rankforge_sitemap_' . $post_type, $xml, HOUR_IN_SECONDS);
        return $xml;
    }

    /**
 * Generate video sitemap (delegated to Video SEO if available).
 */
    private function generate_video_sitemap() {
        if (class_exists('RANKFORGE_Video_Seo')) {
            return RANKFORGE_Video_Seo::instance()->generate_video_sitemap_xml();
        }
        return '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>';
    }

    /**
 * Stylesheet URL for human-friendly sitemap rendering in browsers.
 * Filterable so themes can swap in a custom XSL if needed.
 */
    private function stylesheet_url(): string {
        $default = plugins_url('rankforge/assets/sitemap.xsl');
        return apply_filters('rankforge_sitemap_stylesheet_url', $default);
    }

    /**
 * Invalidate all sitemap caches.
 */
    public function invalidate_cache($post_id = 0) {
        delete_transient('rankforge_sitemap_index');
        delete_transient('rankforge_video_sitemap');

        $types = $this->get_default_types();
        foreach ($types as $pt) {
            delete_transient('rankforge_sitemap_' . $pt);
        }
    }

    /**
 * Post types to include in the sitemap. controlled by the
 * "Show in search results" toggle in Title Templates
 * (rankforge_posttype_robots[pt]['noindex']). A post type is INCLUDED
 * when its noindex flag is empty/false. Attachment excluded always.
 *
 * This replaces the previous `rankforge_sitemap_settings[enabled_types]`
 * list which duplicated the same decision and forced the user to
 * uncheck in two places.
 */
    private function get_indexed_types() {
        $all = $this->get_default_types();
        $robots = get_option('rankforge_posttype_robots', []);
        $out = [];
        foreach ($all as $pt) {
            $noindex = !empty($robots[$pt]['noindex']);
            if (!$noindex) $out[] = $pt;
        }
        return $out;
    }

    /**
 * Get default enabled post types.
 */
    private function get_default_types() {
        $types = get_post_types(['public' => true], 'names');
        unset($types['attachment']);
        return array_values($types);
    }
}
