# thinkrank/2.7.0/includes/api/class-sitemap-endpoint.php

ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console &amp; Local SEO, version 2.7.0. 1,351 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/2.7.0/code/includes/api/class-sitemap-endpoint.php
- Raw: https://pluginprobe.com/plugins/thinkrank/2.7.0/raw/includes/api/class-sitemap-endpoint.php
- Modified: 2026-09-08T06:51:58+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/thinkrank/2.7.0/code/includes/api/class-sitemap-endpoint.php#L10-L20`.

```php
<?php
/**
 * Sitemap API Endpoints Class
 *
 * REST API endpoints for XML sitemap management including generation,
 * validation, status monitoring, and search engine submission with
 * proper authentication and comprehensive error handling.
 *
 * @package ThinkRank
 * @subpackage API
 * @since 1.0.0
 */

declare(strict_types=1);

namespace ThinkRank\API;

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

use ThinkRank\SEO\Sitemap_Generator;
use ThinkRank\API\Traits\CSRF_Protection;
use ThinkRank\API\Traits\Context_Authorization;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

// Load CSRF Protection trait
require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php';

/**
 * Sitemap API Endpoints Class
 *
 * Provides REST API endpoints for sitemap operations including
 * XML generation, validation, status monitoring, and search engine
 * submission with proper authentication and validation.
 *
 * @since 1.0.0
 */
class Sitemap_Endpoint extends WP_REST_Controller {
    use CSRF_Protection;
    use Context_Authorization;

    /**
     * Sitemap Generator instance
     *
     * @since 1.0.0
     * @var Sitemap_Generator
     */
    private Sitemap_Generator $sitemap_generator;

    /**
     * API namespace
     *
     * @since 1.0.0
     * @var string
     */
    protected $namespace = 'thinkrank/v1';

    /**
     * API resource base
     *
     * @since 1.0.0
     * @var string
     */
    protected $rest_base = 'sitemap';

    /**
     * Constructor
     *
     * @since 1.0.0
     */
    public function __construct() {
        $this->sitemap_generator = new Sitemap_Generator();
    }

    /**
     * Register API routes
     *
     * @since 1.0.0
     */
    public function register_routes(): void {
        // Generate XML sitemap
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/generate',
            [
                [
                    'methods' => 'POST',
                    'callback' => [$this, 'generate_sitemap'],
                    'permission_callback' => [$this, 'check_manage_permissions'],
                    'args' => $this->get_generate_args()
                ]
            ]
        );

        // Validate sitemap (read-only operation, no CSRF needed)
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/validate',
            [
                [
                    'methods' => 'POST',
                    'callback' => [$this, 'validate_sitemap'],
                    'permission_callback' => [$this, 'check_read_permissions'],
                    'args' => $this->get_validate_args()
                ]
            ]
        );

        // Get sitemap status
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/status',
            [
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'get_sitemap_status'],
                    'permission_callback' => [$this, 'check_read_permissions']
                ]
            ]
        );

        // Submit sitemap to search engines
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/submit',
            [
                [
                    'methods' => 'POST',
                    'callback' => [$this, 'submit_sitemap'],
                    'permission_callback' => [$this, 'check_manage_permissions'],
                    'args' => $this->get_submit_args()
                ]
            ]
        );

        // Ping search engines (unified endpoint for manual ping button)
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/ping',
            [
                [
                    'methods' => 'POST',
                    'callback' => [$this, 'ping_search_engines'],
                    'permission_callback' => [$this, 'check_manage_permissions']
                ]
            ]
        );

        // Get sitemap statistics
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/stats',
            [
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'get_sitemap_stats'],
                    'permission_callback' => [$this, 'check_read_permissions']
                ]
            ]
        );

        // Sitemap settings management (following Site Identity pattern)
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/settings',
            [
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'get_sitemap_settings'],
                    'permission_callback' => [$this, 'check_read_permissions'],
                    'args' => $this->get_context_route_args()
                ],
                [
                    'methods' => 'POST',
                    'callback' => [$this, 'update_sitemap_settings'],
                    'permission_callback' => [$this, 'check_manage_permissions'],
                    'args' => $this->get_settings_args()
                ]
            ]
        );

        // Get custom post types
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/custom-post-types',
            [
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'get_custom_post_types'],
                    'permission_callback' => [$this, 'check_read_permissions']
                ]
            ]
        );

        // Get sitemap URLs for robots.txt integration
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/robots-urls',
            [
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'get_robots_sitemap_urls'],
                    'permission_callback' => [$this, 'check_read_permissions']
                ]
            ]
        );

        // Get WooCommerce status
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/woocommerce-status',
            [
                [
                    'methods' => 'GET',
                    'callback' => [$this, 'get_woocommerce_status'],
                    'permission_callback' => [$this, 'check_read_permissions']
                ]
            ]
        );

        // Cleanup old sitemap files
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base . '/cleanup',
            [
                [
                    'methods' => 'POST',
                    'callback' => [$this, 'cleanup_sitemap_files'],
                    'permission_callback' => [$this, 'check_manage_permissions'],
                    'args' => [
                        'sitemap_urls' => [
                            'required' => false,
                            'type' => 'array',
                            'description' => 'Optional array of specific sitemap URLs to clean up. If not provided, scans filesystem automatically.'
                        ]
                    ]
                ]
            ]
        );
    }

    /**
     * Record that a sitemap generation just ran.
     *
     * Persists the `last_generated` timestamp so the admin UI can distinguish
     * generated sitemaps (whose files now exist on disk) from ones that are
     * merely configured — the "View Generated Sitemaps" links stay disabled
     * until this is set.
     *
     * @return string ISO-8601 timestamp stored as the `last_generated` setting.
     */
    private function record_generation(): string {
        $timestamp = gmdate('c');
        $settings  = $this->sitemap_generator->get_settings('site');
        $settings['last_generated'] = $timestamp;
        $this->sitemap_generator->save_settings('site', null, $settings);

        // This generation wrote the same files the outstanding automatic rebuild
        // was queued to write, so clear its marker (and any recorded failure)
        // instead of leaving a request-time takeover to repeat the work.
        $this->sitemap_generator->mark_regeneration_complete();

        return $timestamp;
    }

    /**
     * Record that the published sitemap files are gone.
     *
     * The inverse of {@see record_generation()}: clears `last_generated` so the
     * admin's "View Generated Sitemaps" links go back to disabled instead of
     * pointing at files that have just been deleted.
     *
     * @since 1.31.0
     * @return void
     */
    private function clear_generation_record(): void {
        $settings = $this->sitemap_generator->get_settings('site');
        if (empty($settings['last_generated'])) {
            return;
        }

        $settings['last_generated'] = '';
        $this->sitemap_generator->save_settings('site', null, $settings);
    }

    /**
     * Persist a manual-generation auto-promotion into the stored settings.
     *
     * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the
     * segmented sitemap_urls for the current generation. On the automatic path
     * generate_and_save() saves that resolved state; the manual generate route
     * must do the same, or the next content-/settings-triggered regeneration
     * (which reads stored settings) reverts the site to a single flat file.
     *
     * Only the two mode-defining keys are merged, so this partial generate
     * payload never clobbers unrelated saved settings.
     *
     * @param array $options Options after maybe_promote_to_index().
     * @return void
     */
    private function persist_promoted_mode(array $options): void {
        $saved = $this->sitemap_generator->get_settings('site');

        // Record the mode that was actually written, in both directions, so the
        // stored settings and the files on disk cannot disagree. Persisting a
        // demotion used to be unsafe because an absent use_sitemap_index was
        // indistinguishable from an explicit "off", and treating it as off would
        // clobber a saved index whenever the toggle merely happened to be
        // missing. maybe_promote_to_index() now resolves an absent key from the
        // saved settings before this runs, so whatever arrives here is the
        // resolved decision rather than a gap in the payload.
        $mode = !empty($options['use_sitemap_index']);
        $urls = $options['sitemap_urls'] ?? ($saved['sitemap_urls'] ?? null);

        $mode_unchanged = $mode === !empty($saved['use_sitemap_index']);
        $urls_unchanged = $urls === ($saved['sitemap_urls'] ?? null);

        if ($mode_unchanged && $urls_unchanged) {
            return;
        }

        $saved['use_sitemap_index'] = $mode;
        if (isset($options['sitemap_urls'])) {
            $saved['sitemap_urls'] = $options['sitemap_urls'];
        }
        $this->sitemap_generator->save_settings('site', null, $saved);
    }

    /**
     * Write the sitemap files when the site has none yet.
     *
     * The sitemap is served as a static file in the web root, so a site whose
     * sitemap is enabled but never generated serves nothing at /sitemap.xml —
     * WordPress core then claims that URL and redirects to wp-sitemap.xml.
     * Turning the sitemap on therefore has to produce the file, which is what
     * the Setup Wizard's "Save & Continue" relies on for its "View Sitemap"
     * link. Only fills the gap: an existing file is left to the explicit
     * "Generate" action so saving settings stays cheap on large sites.
     *
     * @since 1.17.0
     * @param string   $context_type Settings context type.
     * @param int|null $context_id   Settings context id.
     * @return string Sitemap URL, or an empty string when nothing is published.
     */
    private function ensure_sitemap_file(string $context_type, ?int $context_id, bool &$generated_now = false): string {
        $generated_now = false;

        if ($context_type !== 'site') {
            return '';
        }

        $settings = $this->sitemap_generator->get_settings($context_type, $context_id);

        if (empty($settings['enabled'])) {
            return '';
        }

        $sitemap_url = $this->sitemap_generator->get_primary_sitemap_url($settings);

        if ($this->sitemap_generator->primary_sitemap_file_exists($settings)) {
            return $sitemap_url;
        }

        // Never fail the settings save over generation: the settings are already
        // persisted, and content changes or a manual Generate will retry.
        try {
            if (!$this->sitemap_generator->generate_and_save($settings)) {
                return '';
            }
            $generated_now = true;
        } catch (\Throwable $e) {
            return '';
        }

        return $sitemap_url;
    }

    /**
     * Generate XML sitemap
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function generate_sitemap(WP_REST_Request $request) {
        try {
            // Rate limiting: Max 3 generations per 5 minutes per user
            if (!$this->check_rate_limit()) {
                return new WP_Error(
                    'rate_limit_exceeded',
                    'Too many sitemap generation requests. Please wait before trying again.',
                    ['status' => 429]
                );
            }

            // Concurrent generation protection
            if (!$this->acquire_generation_lock()) {
                return new WP_Error(
                    'generation_in_progress',
                    'Sitemap generation is already in progress. Please wait.',
                    ['status' => 409]
                );
            }

            $options = $request->get_param('options') ?? [];
            if (!is_array($options)) {
                $options = [];
            }
            // sitemap_urls must be an array wherever it is counted/iterated below
            // (and in the generator); drop a wrong-typed value so a malformed
            // request yields normal output instead of an uncaught TypeError.
            if (isset($options['sitemap_urls']) && !is_array($options['sitemap_urls'])) {
                unset($options['sitemap_urls']);
            }

            // Resolve index-vs-single mode from the use_sitemap_index toggle
            // (synthesizing child sitemaps when the toggle is on but none are
            // configured, and auto-promoting an oversized single file), rather
            // than deciding purely by how many sitemap_urls happen to be present.
            $options = $this->sitemap_generator->maybe_promote_to_index($options);

            // Persist the resolved index-mode decision so a later content- or
            // settings-triggered regeneration (which reads stored settings)
            // doesn't revert a manual auto-promotion back to a single flat file.
            // generate_and_save() already does this on the automatic path; the
            // manual generate route must match it.
            $this->persist_promoted_mode($options);

            // Check if an index (multiple sitemaps) is configured
            if (!empty($options['use_sitemap_index']) || (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1)) {
                // Generate multiple sitemaps
                $results = $this->sitemap_generator->generate_multiple_sitemaps($options);

                if (!$results['success']) {
                    return new WP_Error(
                        'sitemap_generation_failed',
                        'Failed to generate sitemaps: ' . implode(', ', $results['errors']),
                        ['status' => 500]
                    );
                }

                return new WP_REST_Response([
                    'success' => true,
                    'message' => 'Multiple sitemaps generated successfully',
                    'data' => [
                        'sitemaps_generated' => $results['sitemaps_generated'],
                        'total_sitemaps' => count($results['sitemaps_generated']),
                        'url_count' => $results['total_urls'],
                        'last_generated' => $this->record_generation()
                    ]
                ]);
            } else {
                // Generate single sitemap (backward compatibility)
                $sitemap_xml = $this->sitemap_generator->generate_sitemap($options);

                // Save sitemap to file (optional)
                $save_to_file = $request->get_param('save_to_file') ?? true;
                $last_generated = '';
                if ($save_to_file) {
                    $filename = 'sitemap.xml';
                    if (!empty($options['sitemap_urls'][0]['url'])) {
                        $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH));
                    }
                    // A failed write has to surface here the way the index
                    // branch surfaces one. Discarding it let record_generation()
                    // advance last_generated and clear the pending marker and
                    // the recorded failure, so an unwritable site root — the
                    // exact case this endpoint reports health for — came back
                    // as a healthy "Generated successfully".
                    if (!$this->save_sitemap_file($sitemap_xml, $filename)) {
                        return new WP_Error(
                            'sitemap_generation_failed',
                            'Failed to save sitemap: ' . $filename,
                            ['status' => 500]
                        );
                    }

                    // Regenerate the standalone local business sitemap on the
                    // single-sitemap path too (parity with Rank Math).
                    $this->sitemap_generator->regenerate_local_sitemap($options);

                    // Only record generation when the files were actually
                    // written — a preview (save_to_file=false) must not enable
                    // the "View Generated Sitemaps" links.
                    $last_generated = $this->record_generation();
                }

                return new WP_REST_Response([
                    'success' => true,
                    'data' => [
                        'sitemap_xml' => $sitemap_xml,
                        'sitemap_url' => home_url('/sitemap.xml'),
                        'generated_at' => gmdate('c'),
                        'url_count' => $this->count_urls_in_xml($sitemap_xml),
                        'last_generated' => $last_generated
                    ],
                    'message' => 'Sitemap generated successfully'
                ]);
            }

        } catch (\Exception $e) {
            $this->release_generation_lock();
            return new WP_Error(
                'generation_failed',
                'Sitemap generation failed: ' . $e->getMessage(),
                ['status' => 500]
            );
        } finally {
            $this->release_generation_lock();
        }
    }

    /**
     * Validate sitemap
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function validate_sitemap(WP_REST_Request $request) {
        try {
            $sitemap_url = $request->get_param('sitemap_url') ?? home_url('/sitemap.xml');

            // Validate sitemap URL
            if (!filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
                return new WP_Error(
                    'invalid_url',
                    'Invalid sitemap URL provided',
                    ['status' => 400]
                );
            }

            // Block SSRF: this endpoint fetches the URL server-side, so reject
            // loopback/link-local/private hosts and non-http(s) schemes via
            // WordPress's own validator (same guard used in class-schema-endpoint).
            if (!wp_http_validate_url($sitemap_url)) {
                return new WP_Error(
                    'invalid_url',
                    'The sitemap URL is not allowed.',
                    ['status' => 400]
                );
            }

            // Perform validation
            $validation_result = $this->perform_sitemap_validation($sitemap_url);

            return new WP_REST_Response([
                'success' => true,
                'data' => $validation_result,
                'message' => 'Sitemap validation completed'
            ], 200);

        } catch (\Exception $e) {
            return new WP_Error(
                'validation_failed',
                'Sitemap validation failed: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Get sitemap status
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function get_sitemap_status(WP_REST_Request $request) {
        try {
            // Get sitemap output data from generator
            $status_data = $this->sitemap_generator->get_output_data('site', null);

            // Add additional status information
            $sitemap_file_path = ABSPATH . 'sitemap.xml';
            $status_data['file_exists'] = file_exists($sitemap_file_path);
            $status_data['file_size'] = $status_data['file_exists'] ? filesize($sitemap_file_path) : 0;
            $status_data['file_modified'] = $status_data['file_exists'] ? gmdate('c', filemtime($sitemap_file_path)) : null;

            return new WP_REST_Response([
                'success' => true,
                'data' => $status_data,
                'message' => 'Sitemap status retrieved successfully'
            ], 200);

        } catch (\Exception $e) {
            return new WP_Error(
                'status_failed',
                'Failed to get sitemap status: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Submit sitemap to search engines
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function submit_sitemap(WP_REST_Request $request) {
        // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
        // both now discover sitemaps via robots.txt on their own schedule. There
        // is nothing to submit, so this is a no-op kept only so existing clients
        // don't 404 (mirrors ping_search_engines()).
        return new WP_REST_Response([
            'success' => true,
            'data' => [],
            'message' => 'Search engines no longer accept sitemap submission; sitemaps are discovered automatically via robots.txt.',
        ], 200);
    }

    /**
     * Ping search engines about sitemap updates (unified method)
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function ping_search_engines(WP_REST_Request $request) {
        // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
        // both now rely on the sitemap being referenced from robots.txt and pulled
        // on their own schedule. There is nothing left to ping, so this endpoint is
        // a no-op kept only so existing clients don't 404.
        return new WP_REST_Response([
            'success' => true,
            'message' => 'Search engines no longer support sitemap ping; sitemaps are discovered automatically via robots.txt.',
            'engines' => [],
            'timestamp' => gmdate('c')
        ], 200);
    }

    /**
     * Get sitemap statistics
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function get_sitemap_stats(WP_REST_Request $request) {
        try {
            $settings = $this->sitemap_generator->get_settings('site');
            
            $stats = [
                'total_urls' => $this->sitemap_generator->count_sitemap_urls($settings),
                'post_count' => $settings['include_posts'] ? wp_count_posts('post')->publish : 0,
                'page_count' => $settings['include_pages'] ? wp_count_posts('page')->publish : 0,
                'category_count' => $settings['include_categories'] ? wp_count_terms('category') : 0,
                'tag_count' => $settings['include_tags'] ? wp_count_terms('post_tag') : 0,
                'last_generated' => $settings['last_generated'] ?? null,
                'sitemap_enabled' => $settings['enabled'] ?? true
            ];

            return new WP_REST_Response([
                'success' => true,
                'data' => $stats,
                'message' => 'Sitemap statistics retrieved successfully'
            ], 200);

        } catch (\Throwable $e) {
            return new WP_Error(
                'stats_failed',
                'Failed to get sitemap statistics: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Check read permissions
     *
     * @since 1.0.0
     *
     * @return bool Permission status
     */
    public function check_read_permissions(): bool {
        return current_user_can('edit_posts');
    }

    /**
     * Check manage permissions for the state-changing routes.
     *
     * Every route using this callback is a POST that writes something —
     * /generate, /submit, /ping, /settings, /cleanup — so it is nonce-gated as
     * well as capability-gated, matching Schema_Endpoint, Setup_Wizard_Endpoint
     * and Email_Report_Endpoint. The class already `use`d CSRF_Protection but
     * never called it, leaving this controller the odd one out.
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return bool|WP_Error Permission status
     */
    public function check_manage_permissions(WP_REST_Request $request) {
        if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling')) {
            return new WP_Error(
                'rest_forbidden',
                __('You do not have permission to manage sitemaps.', 'thinkrank'),
                ['status' => 403]
            );
        }

        if (!$this->verify_request_nonce($request)) {
            return new WP_Error(
                'rest_forbidden',
                __('Invalid security token. Please refresh the page and try again.', 'thinkrank'),
                ['status' => 403]
            );
        }

        return true;
    }

    /**
     * Save sitemap to file
     *
     * @since 1.0.0
     *
     * @param string $sitemap_xml Sitemap XML content
     * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
     * @return bool Success status
     */
    private function save_sitemap_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
        // Clean filename and ensure it ends with .xml
        $filename = sanitize_file_name($filename);
        if (!str_ends_with($filename, '.xml')) {
            $filename .= '.xml';
        }

        $sitemap_file_path = ABSPATH . $filename;

        // Use WordPress filesystem API
        global $wp_filesystem;
        if (empty($wp_filesystem)) {
            require_once ABSPATH . '/wp-admin/includes/file.php';
            WP_Filesystem();
        }

        return $wp_filesystem->put_contents($sitemap_file_path, $sitemap_xml, FS_CHMOD_FILE);
    }

    /**
     * Count URLs in sitemap XML content
     *
     * @since 1.0.0
     *
     * @param string $sitemap_xml Sitemap XML content
     * @return int URL count
     */
    private function count_urls_in_xml(string $sitemap_xml): int {
        return substr_count($sitemap_xml, '<url>');
    }

    /**
     * Perform sitemap validation
     *
     * @since 1.0.0
     *
     * @param string $sitemap_url Sitemap URL to validate
     * @return array Validation results
     */
    private function perform_sitemap_validation(string $sitemap_url): array {
        $validation_result = [
            'valid' => true,
            'errors' => [],
            'warnings' => [],
            'url_count' => 0,
            'file_size' => 0
        ];

        // Check if sitemap is accessible. wp_safe_remote_get() re-applies the
        // reject-unsafe-URLs / external-host filters (incl. on redirects) so an
        // internal host can't be reached even if it slipped past validation.
        $response = wp_safe_remote_get($sitemap_url, ['timeout' => 30]);

        if (is_wp_error($response)) {
            $validation_result['valid'] = false;
            $validation_result['errors'][] = 'Sitemap is not accessible: ' . $response->get_error_message();
            return $validation_result;
        }

        $status_code = wp_remote_retrieve_response_code($response);
        if ($status_code !== 200) {
            $validation_result['valid'] = false;
            $validation_result['errors'][] = "Sitemap returned HTTP status code: {$status_code}";
            return $validation_result;
        }

        $sitemap_content = wp_remote_retrieve_body($response);
        $validation_result['file_size'] = strlen($sitemap_content);
        $validation_result['url_count'] = $this->count_urls_in_xml($sitemap_content);

        // Basic XML validation
        libxml_use_internal_errors(true);
        $xml = simplexml_load_string($sitemap_content);

        if (false === $xml) {
            $validation_result['valid'] = false;
            $validation_result['errors'][] = 'Invalid XML format';

            foreach (libxml_get_errors() as $error) {
                $validation_result['errors'][] = trim($error->message);
            }
        }

        // Check file size (should be under 50MB)
        if ($validation_result['file_size'] > 50 * 1024 * 1024) {
            $validation_result['warnings'][] = 'Sitemap file size exceeds 50MB limit';
        }

        // Check URL count (should be under 50,000)
        if ($validation_result['url_count'] > 50000) {
            $validation_result['warnings'][] = 'Sitemap contains more than 50,000 URLs';
        }

        return $validation_result;
    }

    /**
     * Get arguments for generate endpoint
     *
     * @since 1.0.0
     *
     * @return array Arguments array
     */
    private function get_generate_args(): array {
        return [
            'options' => [
                'required' => false,
                'type' => 'object',
                'description' => 'Sitemap generation options'
            ],
            'save_to_file' => [
                'required' => false,
                'type' => 'boolean',
                'default' => true,
                'description' => 'Save sitemap to file'
            ]
        ];
    }

    /**
     * Get arguments for validate endpoint
     *
     * @since 1.0.0
     *
     * @return array Arguments array
     */
    private function get_validate_args(): array {
        return [
            'sitemap_url' => [
                'required' => false,
                'type' => 'string',
                'format' => 'uri',
                'default' => home_url('/sitemap.xml'),
                'description' => 'Sitemap URL to validate'
            ]
        ];
    }

    /**
     * Get arguments for submit endpoint
     *
     * @since 1.0.0
     *
     * @return array Arguments array
     */
    private function get_submit_args(): array {
        return [
            'search_engines' => [
                'required' => false,
                'type' => 'array',
                'items' => [
                    'type' => 'string',
                    'enum' => ['google', 'bing']
                ],
                'default' => ['google', 'bing'],
                'description' => 'Search engines to submit to'
            ],
            'sitemap_url' => [
                'required' => false,
                'type' => 'string',
                'format' => 'uri',
                'default' => home_url('/sitemap.xml'),
                'description' => 'Sitemap URL to submit'
            ]
        ];
    }

    /**
     * Get sitemap settings
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function get_sitemap_settings(WP_REST_Request $request) {
        try {
            // SECURITY: the settings are stored per context, so the object has
            // to be authorised before it is read (#385).
            $context = $this->resolve_request_context($request);
            if (is_wp_error($context)) {
                return $context;
            }
            [$context_type, $context_id] = $context;

            // Get settings from Sitemap_Generator
            $settings = $this->sitemap_generator->get_settings($context_type, $context_id);

            return new WP_REST_Response([
                'success' => true,
                'data' => [
                    'settings' => $settings,
                    'context_type' => $context_type,
                    'context_id' => $context_id,
                    // Kept out of `settings` on purpose: this is generator state,
                    // not something the settings POST round-trips.
                    'health' => $context_type === 'site'
                        ? $this->sitemap_generator->get_regeneration_health()
                        : null
                ],
                'message' => 'Sitemap settings retrieved successfully'
            ], 200);

        } catch (\Exception $e) {
            return new WP_Error(
                'settings_retrieval_failed',
                'Failed to retrieve sitemap settings: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Update sitemap settings
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function update_sitemap_settings(WP_REST_Request $request) {
        try {
            $settings = $request->get_param('settings') ?? [];

            // SECURITY: this write is keyed by the context, so the object has to
            // be authorised before anything is persisted (#385).
            $context = $this->resolve_request_context($request);
            if (is_wp_error($context)) {
                return $context;
            }
            [$context_type, $context_id] = $context;

            if (empty($settings)) {
                return new WP_Error(
                    'missing_settings',
                    'Settings data is required',
                    ['status' => 400]
                );
            }

            // Save settings using Sitemap_Generator
            $success = $this->sitemap_generator->save_settings($context_type, $context_id, $settings);

            if (!$success) {
                return new WP_Error(
                    'settings_save_failed',
                    'Failed to save sitemap settings',
                    ['status' => 500]
                );
            }

            $generated_now = false;
            $sitemap_url = $this->ensure_sitemap_file($context_type, $context_id, $generated_now);

            // Rebuild the served sitemap so inclusion-rule changes take effect
            // instead of waiting for a content edit (debounced against rapid
            // successive saves). Skip when ensure_sitemap_file() just built a
            // fresh file synchronously — otherwise we'd immediately schedule a
            // second full generation of the same content.
            if (!$generated_now) {
                $this->sitemap_generator->schedule_regeneration();
            }

            return new WP_REST_Response([
                'success' => true,
                'data' => [
                    'settings' => $settings,
                    'context_type' => $context_type,
                    'context_id' => $context_id,
                    'sitemap_url' => $sitemap_url
                ],
                'message' => 'Sitemap settings saved successfully'
            ], 200);

        } catch (\Exception $e) {
            return new WP_Error(
                'settings_update_failed',
                'Failed to update sitemap settings: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Get arguments for settings endpoints
     *
     * @since 1.0.0
     *
     * @return array Arguments array
     */
    private function get_settings_args(): array {
        return [
            'settings' => [
                'required' => true,
                'type' => 'object',
                'description' => 'Sitemap settings to save'
            ],
            'context_type' => [
                'required' => false,
                'type' => 'string',
                'default' => 'site',
                'description' => 'Context type for settings'
            ],
            'context_id' => [
                'required' => false,
                'type' => 'integer',
                'description' => 'Context ID for settings'
            ]
        ];
    }

    /**
     * Get custom post types for sitemap generation
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function get_custom_post_types(WP_REST_Request $request) {
        try {
            // Get all public custom post types (excluding built-in types)
            $post_types = get_post_types([
                'public' => true,
                '_builtin' => false
            ], 'objects');

            $custom_post_types = [];
            foreach ($post_types as $post_type) {
                // Skip if it's a WooCommerce product (handled separately)
                if ($post_type->name === 'product') {
                    continue;
                }

                $custom_post_types[] = [
                    'name' => $post_type->name,
                    'label' => $post_type->label,
                    'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
                    'public' => $post_type->public,
                    'has_archive' => $post_type->has_archive,
                    'count' => wp_count_posts($post_type->name)->publish ?? 0
                ];
            }

            return new WP_REST_Response([
                'success' => true,
                'data' => $custom_post_types,
                'message' => 'Custom post types retrieved successfully'
            ], 200);

        } catch (\Exception $e) {
            return new WP_Error(
                'custom_post_types_failed',
                'Failed to get custom post types: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Get WooCommerce status for sitemap generation
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function get_woocommerce_status(WP_REST_Request $request) {
        try {
            // Check if WooCommerce is active
            $is_woocommerce_active = class_exists('WooCommerce') && function_exists('WC');

            // Check if product post type exists
            $product_post_type_exists = post_type_exists('product');

            // Check if product category taxonomy exists
            $product_cat_taxonomy_exists = taxonomy_exists('product_cat');

            $status = [
                'is_active' => $is_woocommerce_active,
                'product_post_type_exists' => $product_post_type_exists,
                'product_cat_taxonomy_exists' => $product_cat_taxonomy_exists,
                'product_count' => $product_post_type_exists ? wp_count_posts('product')->publish ?? 0 : 0,
                'product_category_count' => $product_cat_taxonomy_exists ? wp_count_terms('product_cat') : 0
            ];

            return new WP_REST_Response([
                'success' => true,
                'data' => $status,
                'message' => 'WooCommerce status retrieved successfully'
            ], 200);

        } catch (\Exception $e) {
            return new WP_Error(
                'woocommerce_status_failed',
                'Failed to get WooCommerce status: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Clean up old sitemap files
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response|WP_Error Response object or error
     */
    public function cleanup_sitemap_files(WP_REST_Request $request) {
        try {
            $settings = $this->sitemap_generator->get_settings('site');

            // Delete only the files ThinkRank published. This used to glob
            // ABSPATH for 'sitemap*.xml' and '*sitemap*.xml' and delete anything
            // whose name contained "sitemap", which also swept up a physical
            // core wp-sitemap.xml and any other plugin's sitemap sitting in the
            // web root. delete_published_sitemaps() derives the name list from
            // our own stored sitemap_urls (honouring a custom url pattern) plus
            // the default names, and covers the -N pagination pages.
            $removed = $this->sitemap_generator->delete_published_sitemaps($settings);
            $cleaned_files = $removed['deleted'];
            $failed_files = $removed['failed'];

            // Cleanup on its own used to leave the site with no sitemap at all
            // and nothing scheduled to rebuild one: the regeneration that is
            // meant to follow lives in the admin bundle, so a bare REST/MCP call
            // — or a generate that then hit the rate limit or lost the
            // generation lock — published nothing and 404'd indefinitely. Queue
            // the rebuild here so the recovery does not depend on the caller.
            $regeneration_scheduled = false;
            if (!empty($settings['enabled']) && $cleaned_files) {
                $this->sitemap_generator->schedule_regeneration();
                $regeneration_scheduled = true;
            }

            // The files are gone, so stop reporting them as generated —
            // otherwise the admin keeps offering "View Generated Sitemaps"
            // links to files that no longer exist.
            if ($cleaned_files) {
                $this->clear_generation_record();
            }

            return new WP_REST_Response([
                'success' => true,
                'data' => [
                    'cleaned_files' => $cleaned_files,
                    'failed_files' => $failed_files,
                    'total_cleaned' => count($cleaned_files),
                    'regeneration_scheduled' => $regeneration_scheduled
                ],
                'message' => sprintf(
                    'Cleaned up %d sitemap file(s) successfully',
                    count($cleaned_files)
                )
            ], 200);

        } catch (\Exception $e) {
            return new WP_Error(
                'cleanup_failed',
                'Failed to clean up sitemap files: ' . $e->getMessage(),
                ['status' => 500]
            );
        }
    }

    /**
     * Get sitemap URLs for robots.txt integration
     *
     * Returns enabled sitemap URLs from sitemap settings for automatic
     * inclusion in robots.txt file. This eliminates the need for manual
     * sitemap URL configuration in robots.txt settings.
     *
     * @since 1.0.0
     *
     * @param WP_REST_Request $request Request object
     * @return WP_REST_Response Response object
     */
    public function get_robots_sitemap_urls(WP_REST_Request $request): WP_REST_Response {
        try {
            // Get sitemap settings
            $settings = $this->sitemap_generator->get_settings('site');

            // If sitemap is disabled, return empty array
            if (empty($settings['enabled'])) {
                return new WP_REST_Response([
                    'success' => true,
                    'data' => [
                        'sitemap_urls' => [],
                        'enabled' => false,
                        'message' => __('Sitemap generation is disabled', 'thinkrank')
                    ]
                ], 200);
            }

            // Extract enabled sitemap URLs
            $sitemap_urls = [];
            $site_url = home_url();

            if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
                foreach ($settings['sitemap_urls'] as $sitemap) {
                    if (!empty($sitemap['enabled']) && !empty($sitemap['url'])) {
                        $sitemap_urls[] = [
                            'url' => $sitemap['url'],
                            'full_url' => $site_url . $sitemap['url'],
                            'type' => $sitemap['type'] ?? 'general',
                            'type_label' => $this->get_sitemap_type_label($sitemap['type'] ?? 'general')
                        ];
                    }
                }
            }

            // Fallback to default sitemap if no URLs configured
            if (empty($sitemap_urls)) {
                $sitemap_urls[] = [
                    'url' => '/sitemap.xml',
                    'full_url' => $site_url . '/sitemap.xml',
                    'type' => 'general',
                    'type_label' => __('General', 'thinkrank')
                ];
            }

            return new WP_REST_Response([
                'success' => true,
                'data' => [
                    'sitemap_urls' => $sitemap_urls,
                    'enabled' => true,
                    'count' => count($sitemap_urls)
                ]
            ], 200);

        } catch (\Exception $e) {
            return new WP_REST_Response([
                'success' => false,
                'error' => 'Failed to retrieve sitemap URLs: ' . $e->getMessage()
            ], 500);
        }
    }

    /**
     * Get human-readable label for sitemap type
     *
     * @since 1.0.0
     *
     * @param string $type Sitemap type
     * @return string Human-readable label
     */
    private function get_sitemap_type_label(string $type): string {
        $labels = [
            'index' => __('Index', 'thinkrank'),
            'general' => __('General', 'thinkrank'),
            'posts' => __('Posts', 'thinkrank'),
            'pages' => __('Pages', 'thinkrank'),
            'categories' => __('Categories', 'thinkrank'),
            'tags' => __('Tags', 'thinkrank'),
            'products' => __('Products', 'thinkrank'),
            'wordpress' => __('WordPress Core', 'thinkrank'),
            'custom' => __('Custom', 'thinkrank')
        ];

        return $labels[$type] ?? ucfirst($type);
    }

    /**
     * Check rate limit for sitemap generation
     *
     * @since 1.0.0
     * @return bool True if within rate limit
     */
    private function check_rate_limit(): bool {
        $user_id = get_current_user_id();
        $rate_key = "thinkrank_sitemap_rate_{$user_id}";

        $requests = get_transient($rate_key) ?: 0;

        if ($requests >= 3) { // Max 3 requests per 5 minutes
            return false;
        }

        set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);
        return true;
    }

    /**
     * Acquire generation lock to prevent concurrent generation
     *
     * @since 1.0.0
     * @return bool True if lock acquired
     */
    private function acquire_generation_lock(): bool {
        $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT;

        if (get_transient($lock_key)) {
            return false; // Generation already in progress
        }

        set_transient($lock_key, time(), 5 * MINUTE_IN_SECONDS);
        return true;
    }

    /**
     * Release generation lock
     *
     * @since 1.0.0
     * @return void
     */
    private function release_generation_lock(): void {
        delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT);
    }
}

```
