<?php
/**
 * Schemafy Sitemap Manager.
 *
 * Generates Schemafy-owned XML sitemaps and guards against duplicate sitemap
 * output from WordPress core and common SEO plugins.
 *
 * @package Schemafy
 */

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

class SCHEMAFY_Sitemap {

    const OPTION_NAME = 'schemafy_sitemap_settings';
    const INDEX_FILE = 'sitemap-index.xml';
    const DEFAULT_PER_PAGE = 1000;
    const MAX_PER_PAGE = 50000;
    const MIN_PER_PAGE = 100;
    const LOCK_OPTION = 'schemafy_sitemap_save_lock';
    const REWRITE_VERSION_OPTION = 'schemafy_sitemap_rewrite_version';

    private static $available_post_types_cache = null;
    private static $available_taxonomies_cache = null;
    private static $settings_cache = null;

    /**
     * Bootstrap hooks.
     */
    public function __construct() {
        add_action('init', array($this, 'register_rewrite_rules'), 1);
        add_filter('query_vars', array($this, 'register_query_vars'));
        add_action('template_redirect', array($this, 'handle_sitemap_request'), 0);
        add_filter('wp_sitemaps_enabled', array($this, 'disable_core_sitemaps'), PHP_INT_MAX);

        add_action('wp_ajax_schemafy_save_sitemap_settings', array($this, 'ajax_save_settings'));
        add_action('wp_ajax_schemafy_get_sitemap_status', array($this, 'ajax_get_status'));

        add_action('init', array($this, 'maybe_register_conflict_guards'), 20);
        add_action('wp_loaded', array($this, 'maybe_heal_rewrite_rules'));
    }

    /**
     * Self-heal: re-flush when our rules vanished from the stored rewrite_rules.
     *
     * Other plugins flush rewrite rules constantly; if that happens in a request
     * where Schemafy is inactive (or before our init hook ran), our rules drop
     * out and every sitemap URL starts to 404. Detect and repair once per request.
     * Guarded by a transient so a persistent failure cannot flush on every load.
     */
    public function maybe_heal_rewrite_rules() {
        if (!self::is_enabled() || wp_doing_ajax() || wp_doing_cron()) {
            return;
        }

        $rules = get_option('rewrite_rules');

        // Empty rules = plain permalinks; rewrites never apply there and the
        // path-based handler serves the sitemap directly. Nothing to heal.
        if (!is_array($rules) || empty($rules)) {
            return;
        }

        if (isset($rules['^sitemap-index\.xml$'])) {
            return;
        }

        if (get_transient('schemafy_sitemap_heal_lock')) {
            return;
        }
        set_transient('schemafy_sitemap_heal_lock', 1, 5 * MINUTE_IN_SECONDS);

        flush_rewrite_rules(false);
    }

    /**
     * Activation hook helper.
     */
    public static function activate() {
        self::add_rewrite_rules();
        flush_rewrite_rules(false);
        update_option(self::REWRITE_VERSION_OPTION, defined('SCHEMAFY_VERSION') ? SCHEMAFY_VERSION : '1', false);
    }

    /**
     * Deactivation hook helper.
     */
    public static function deactivate() {
        flush_rewrite_rules(false);
    }

    /**
     * Register public rewrite rules.
     */
    public function register_rewrite_rules() {
        self::add_rewrite_rules();
    }

    /**
     * Add rewrite rules for the sitemap endpoints.
     */
    private static function add_rewrite_rules() {
        add_rewrite_rule('^sitemap-index\.xml$', 'index.php?schemafy_sitemap=index', 'top');
        add_rewrite_rule('^(sitemap_index|sitemap|wp-sitemap|index-sitemap)\.xml$', 'index.php?schemafy_sitemap=legacy', 'top');
        add_rewrite_rule('^([a-zA-Z0-9_-]+)-sitemap\.xml$', 'index.php?schemafy_sitemap=collection&schemafy_sitemap_name=$matches[1]&schemafy_sitemap_page=1', 'top');
        add_rewrite_rule('^([a-zA-Z0-9_-]+)-sitemap-([0-9]+)\.xml$', 'index.php?schemafy_sitemap=collection&schemafy_sitemap_name=$matches[1]&schemafy_sitemap_page=$matches[2]', 'top');
    }

    /**
     * Register query vars used by sitemap rewrites.
     *
     * @param array $vars Query vars.
     * @return array
     */
    public function register_query_vars($vars) {
        $vars[] = 'schemafy_sitemap';
        $vars[] = 'schemafy_sitemap_name';
        $vars[] = 'schemafy_sitemap_page';
        return $vars;
    }

    /**
     * Disable WordPress core sitemaps when Schemafy owns the sitemap.
     *
     * @param bool $enabled Core sitemap enabled state.
     * @return bool
     */
    public function disable_core_sitemaps($enabled) {
        return self::is_enabled() ? false : $enabled;
    }

    /**
     * Runtime guards for known sitemap generators.
     */
    private function register_conflict_guards() {
        // Turning off another plugin's sitemap is an override like any other, so
        // it needs the site owner's consent — same rule as every suppression in
        // SCHEMAFY_Schema_Injector. Off until switched on under Plugin
        // Compatibility, which leaves both sitemaps live by default.
        if (!SCHEMAFY_Schema_Injector::compat_enabled('disable_other_sitemaps')) {
            return;
        }

        add_filter('wpseo_sitemaps_enabled', '__return_false', PHP_INT_MAX);
        add_filter('rank_math/sitemap/sitemap_index_uri', array($this, 'rank_math_index_uri'), PHP_INT_MAX);
    }

    /**
     * Register conflict guards after plugins have had time to register content.
     */
    public function maybe_register_conflict_guards() {
        if (self::is_enabled()) {
            $this->register_conflict_guards();
        }
    }

    /**
     * Tell Rank Math integrations that Schemafy's index is the submitted sitemap.
     *
     * @return string
     */
    public function rank_math_index_uri() {
        return self::INDEX_FILE;
    }

    /**
     * Main request handler for XML sitemap endpoints and legacy redirects.
     *
     * Resolves the request from the RAW URL PATH first and only falls back to
     * rewrite query vars. This way the sitemap works even when rewrite rules
     * are stale (plugin updated without re-activation), cached by the host,
     * or never applied at all (sites using "plain" permalinks).
     */
    public function handle_sitemap_request() {
        $path = self::get_current_request_path();
        $query_var = (string) get_query_var('schemafy_sitemap');

        // Fast bail for every normal page view.
        if ($query_var === '' && !self::is_sitemap_like_path($path)) {
            return;
        }

        // Sitemap disabled: never hijack the URL. WordPress (or another
        // sitemap plugin) handles it and renders its own themed 404 if needed.
        if (!self::is_enabled()) {
            return;
        }

        $request = self::resolve_sitemap_request($path, $query_var);

        if ($request === null) {
            return;
        }

        if ($request['type'] === 'index') {
            $this->render_index(); // exits
        }

        if ($request['type'] === 'collection') {
            $collection = self::get_collection_by_slug($request['slug']);
            $page = max(1, (int) $request['page']);

            if (!$collection || $page > $collection['pages']) {
                // Unknown source or out-of-range page: send crawlers to the
                // index so they re-discover live URLs instead of hitting 404s.
                wp_safe_redirect(self::get_index_url(), 301);
                exit;
            }

            $this->render_collection($collection, $page); // exits
        }

        if ($request['type'] === 'legacy') {
            $settings = self::get_settings();
            if (!empty($settings['redirect_legacy'])) {
                wp_safe_redirect(self::get_index_url(), 301);
                exit;
            }

            // Legacy redirect intentionally off: hand back to WordPress so the
            // themed 404 page renders (never a blank white/grey response).
            $this->serve_not_found();
        }
    }

    /**
     * Resolve a sitemap request from the URL path, with query vars as fallback.
     *
     * @param string $path Request path relative to home.
     * @param string $query_var Value of the schemafy_sitemap query var.
     * @return array|null Array with type/slug/page, or null when not a sitemap request.
     */
    private static function resolve_sitemap_request($path, $query_var) {
        $path = trim((string) $path, '/');

        if ($path === self::INDEX_FILE) {
            return array('type' => 'index');
        }

        if (in_array($path, array('sitemap.xml', 'sitemap_index.xml', 'wp-sitemap.xml', 'index-sitemap.xml'), true)) {
            return array('type' => 'legacy');
        }

        if (preg_match('/^([a-z0-9_-]+)-sitemap(?:-([0-9]+))?\.xml$/i', $path, $matches)) {
            return array(
                'type' => 'collection',
                'slug' => sanitize_key($matches[1]),
                'page' => isset($matches[2]) && $matches[2] !== '' ? absint($matches[2]) : 1,
            );
        }

        // Fallback to rewrite query vars (unusual proxies where REQUEST_URI
        // does not expose the original path).
        if ($query_var === 'index') {
            return array('type' => 'index');
        }

        if ($query_var === 'legacy') {
            return array('type' => 'legacy');
        }

        if ($query_var === 'collection') {
            return array(
                'type' => 'collection',
                'slug' => sanitize_key((string) get_query_var('schemafy_sitemap_name')),
                'page' => max(1, absint(get_query_var('schemafy_sitemap_page'))),
            );
        }

        return null;
    }

    /**
     * AJAX: Save settings from the Sitemap tab.
     */
    public function ajax_save_settings() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Unauthorized', 'schemafy'), 403);
        }

        if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'schemafy_nonce')) {
            wp_send_json_error(__('Invalid nonce', 'schemafy'), 403);
        }

        $incoming_revision = isset($_POST['revision']) ? absint($_POST['revision']) : 0;
        $current_settings = self::get_settings();
        $current_revision = isset($current_settings['revision']) ? absint($current_settings['revision']) : 0;

        if ($incoming_revision > 0 && $current_revision > $incoming_revision) {
            wp_send_json_success(array(
                'message' => __('A newer sitemap change has already been saved.', 'schemafy'),
                'settings' => $current_settings,
                'status' => self::get_safe_admin_data(),
                'stale' => true,
            ));
        }

        $lock_token = self::acquire_save_lock();
        if (!$lock_token) {
            wp_send_json_error(array(
                'code' => 'busy',
                'message' => __('Schemafy is already saving sitemap settings. Please retry in a moment.', 'schemafy'),
                'status' => self::get_safe_admin_data(),
            ), 423);
        }

        $response = null;
        $error = null;
        $error_status = 400;

        try {
            self::$settings_cache = null;
            $current_settings = self::get_settings();
            $current_revision = isset($current_settings['revision']) ? absint($current_settings['revision']) : 0;
            $previous_enabled = !empty($current_settings['enabled']);

            if ($incoming_revision > 0 && $current_revision > $incoming_revision) {
                $response = array(
                    'message' => __('A newer sitemap change has already been saved.', 'schemafy'),
                    'settings' => $current_settings,
                    'status' => self::get_safe_admin_data(),
                    'stale' => true,
                );
            } else {
                $available_post_types = array_keys(self::get_available_post_types());
                $available_taxonomies = array_keys(self::get_available_taxonomies());

                $post_types = isset($_POST['post_types']) && is_array($_POST['post_types'])
                    ? array_map('sanitize_key', wp_unslash($_POST['post_types']))
                    : array();
                $taxonomies = isset($_POST['taxonomies']) && is_array($_POST['taxonomies'])
                    ? array_map('sanitize_key', wp_unslash($_POST['taxonomies']))
                    : array();

                $post_types = array_values(array_intersect(array_unique($post_types), $available_post_types));
                $taxonomies = array_values(array_intersect(array_unique($taxonomies), $available_taxonomies));

                $enabled = !empty($_POST['enabled']) ? 1 : 0;
                if ($enabled && empty($post_types) && empty($taxonomies)) {
                    $error = array(
                        'code' => 'empty_sources',
                        'message' => __('Select at least one content source before enabling the sitemap.', 'schemafy'),
                        'status' => self::get_safe_admin_data(),
                    );
                } elseif ($enabled && self::has_blocking_filesystem_conflict()) {
                    $error = array(
                        'code' => 'blocking_file',
                        // translators: %s is a value inserted at runtime.
                        'message' => sprintf(__('Physical file detected: %s', 'schemafy'), self::INDEX_FILE),
                        'status' => self::get_safe_admin_data(),
                    );
                } else {
                    $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : self::DEFAULT_PER_PAGE;
                    $per_page = max(self::MIN_PER_PAGE, min(self::MAX_PER_PAGE, $per_page));

                    $settings = array(
                        'enabled' => $enabled,
                        'per_page' => $per_page,
                        'post_types' => $post_types,
                        'taxonomies' => $taxonomies,
                        'include_lastmod' => !empty($_POST['include_lastmod']) ? 1 : 0,
                        'redirect_legacy' => !empty($_POST['redirect_legacy']) ? 1 : 0,
                        // Server-generated monotonic revision. Client clocks must never
                        // produce revision values: a browser with a slow clock would send
                        // a revision below the stored one, get flagged stale, and the
                        // enable toggle would silently revert.
                        'revision' => $current_revision + 1,
                    );

                    update_option(self::OPTION_NAME, $settings, false);
                    self::$settings_cache = null;
                    self::maybe_flush_rewrite_rules();

                    $actions = array();
                    if ($enabled && !$previous_enabled) {
                        // Force a flush on the disabled→enabled transition so the XML
                        // endpoints answer immediately, even if a version-gated flush
                        // already ran earlier for an unrelated save.
                        flush_rewrite_rules(false);
                        update_option(self::REWRITE_VERSION_OPTION, defined('SCHEMAFY_VERSION') ? SCHEMAFY_VERSION : '1', false);
                        $actions = $this->deactivate_known_sitemap_modules();
                    }

                    $response = array(
                        'message' => $enabled
                            ? __('Schemafy sitemap is active.', 'schemafy')
                            : __('Schemafy sitemap is disabled.', 'schemafy'),
                        'settings' => self::get_settings(),
                        'status' => self::get_safe_admin_data(),
                        'actions' => $actions,
                    );
                }
            }
        } catch (\Throwable $exception) {
            schemafy_log('[Schemafy Sitemap] Save failed: ' . $exception->getMessage());
            $error = array(
                'code' => 'save_failed',
                'message' => __('Schemafy could not save sitemap settings. Please try again.', 'schemafy'),
                'status' => self::get_safe_admin_data(),
                'retry' => true,
            );
            $error_status = 500;
        } finally {
            self::release_save_lock($lock_token);
        }

        if ($error !== null) {
            wp_send_json_error($error, $error_status);
        }

        wp_send_json_success($response);
    }

    /**
     * AJAX: Return current sitemap status.
     */
    public function ajax_get_status() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error(__('Unauthorized', 'schemafy'), 403);
        }

        if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'schemafy_nonce')) {
            wp_send_json_error(__('Invalid nonce', 'schemafy'), 403);
        }

        wp_send_json_success(self::get_safe_admin_data());
    }

    /**
     * Try non-destructive/module-level deactivation where a plugin provides an API.
     *
     * @return array
     */
    private function deactivate_known_sitemap_modules() {
        $actions = array();
        $rank_math_helper = 'RankMath\\Helper';

        if (class_exists($rank_math_helper) && method_exists($rank_math_helper, 'deactivate_module')) {
            try {
                if (method_exists($rank_math_helper, 'is_module_active') && !\RankMath\Helper::is_module_active('sitemap')) {
                    return $actions;
                }

                \RankMath\Helper::deactivate_module('sitemap');
                $actions[] = __('Rank Math sitemap module deactivated.', 'schemafy');
            } catch (\Throwable $e) {
                schemafy_log('[Schemafy Sitemap] Could not deactivate Rank Math sitemap module: ' . $e->getMessage());
            }
        }

        return $actions;
    }

    /**
     * Render sitemap index XML.
     */
    private function render_index() {
        $collections = self::get_active_collections();

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

        foreach ($collections as $collection) {
            for ($page = 1; $page <= $collection['pages']; $page++) {
                $url = self::get_collection_url($collection['slug'], $page);
                echo "\t<sitemap>\n";
                echo "\t\t<loc>" . esc_xml($url) . "</loc>\n";
                if (!empty($collection['lastmod']) && self::include_lastmod()) {
                    echo "\t\t<lastmod>" . esc_xml($collection['lastmod']) . "</lastmod>\n";
                }
                echo "\t</sitemap>\n";
            }
        }

        echo '</sitemapindex>';
        exit;
    }

    /**
     * Render an individual post type or taxonomy sitemap.
     *
     * Callers must resolve and validate the collection first
     * (see handle_sitemap_request).
     *
     * @param array $collection Resolved collection from get_collection_by_slug().
     * @param int   $page Page number (already validated against pages).
     */
    private function render_collection($collection, $page) {
        $entries = $collection['kind'] === 'post_type'
            ? $this->get_post_type_entries($collection['name'], $page)
            : $this->get_taxonomy_entries($collection['name'], $page);

        $this->send_xml_headers();
        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $this->print_stylesheet_directive();
        echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

        foreach ($entries as $entry) {
            echo "\t<url>\n";
            echo "\t\t<loc>" . esc_xml($entry['loc']) . "</loc>\n";
            if (!empty($entry['lastmod']) && self::include_lastmod()) {
                echo "\t\t<lastmod>" . esc_xml($entry['lastmod']) . "</lastmod>\n";
            }
            echo "\t</url>\n";
        }

        echo '</urlset>';
        exit;
    }

    /**
     * Get entries for a post type sitemap page.
     *
     * @param string $post_type Post type.
     * @param int    $page Page number.
     * @return array
     */
    private function get_post_type_entries($post_type, $page) {
        $settings = self::get_settings();
        $query = new WP_Query(array(
            'post_type' => $post_type,
            'post_status' => 'publish',
            'posts_per_page' => $settings['per_page'],
            'paged' => $page,
            'orderby' => 'modified',
            'order' => 'DESC',
            'fields' => 'ids',
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
            'has_password' => false,
        ));

        $entries = array();
        foreach ($query->posts as $post_id) {
            if (!$this->should_include_post($post_id, $post_type)) {
                continue;
            }

            $entries[] = array(
                'loc' => get_permalink($post_id),
                'lastmod' => self::format_post_modified($post_id),
            );
        }

        wp_reset_postdata();
        return $entries;
    }

    /**
     * Get entries for a taxonomy sitemap page.
     *
     * @param string $taxonomy Taxonomy.
     * @param int    $page Page number.
     * @return array
     */
    private function get_taxonomy_entries($taxonomy, $page) {
        $settings = self::get_settings();
        $offset = ($page - 1) * $settings['per_page'];
        $terms = get_terms(array(
            'taxonomy' => $taxonomy,
            'hide_empty' => true,
            'number' => $settings['per_page'],
            'offset' => $offset,
            'fields' => 'ids',
        ));

        if (is_wp_error($terms) || empty($terms)) {
            return array();
        }

        $entries = array();
        foreach ($terms as $term_id) {
            $url = get_term_link((int) $term_id, $taxonomy);
            if (is_wp_error($url)) {
                continue;
            }

            $entries[] = array(
                'loc' => $url,
                'lastmod' => self::get_term_lastmod((int) $term_id, $taxonomy),
            );
        }

        return $entries;
    }

    /**
     * Decide whether a published post should appear in the sitemap.
     *
     * @param int    $post_id Post ID.
     * @param string $post_type Post type.
     * @return bool
     */
    private function should_include_post($post_id, $post_type) {
        if (post_password_required($post_id)) {
            return false;
        }

        if (get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true) === '1') {
            return false;
        }

        $rank_math_robots = get_post_meta($post_id, 'rank_math_robots', true);
        if (is_array($rank_math_robots) && in_array('noindex', $rank_math_robots, true)) {
            return false;
        }
        if (is_string($rank_math_robots) && stripos($rank_math_robots, 'noindex') !== false) {
            return false;
        }

        if (get_post_meta($post_id, '_seopress_robots_index', true) === 'yes') {
            return false;
        }

        if (get_post_meta($post_id, '_genesis_noindex', true)) {
            return false;
        }

        return (bool) apply_filters('schemafy_sitemap_include_post', true, $post_id, $post_type);
    }

    /**
     * Mark the request as 404 and hand control back to WordPress.
     *
     * Never exits with an empty body — exiting blank produces the "grey page"
     * browsers show for zero-length responses. Returning lets the template
     * loader render the theme's 404 page normally.
     */
    private function serve_not_found() {
        global $wp_query;

        if ($wp_query) {
            $wp_query->set_404();
        }
        status_header(404);
        nocache_headers();
    }

    /**
     * Send XML sitemap headers.
     */
    private function send_xml_headers() {
        // Discard anything already buffered (theme output, BOM from another
        // plugin, PHP notices). Stray bytes before <?xml make browsers fail
        // to parse the document and render a blank page.
        // The loop condition guarantees a buffer exists, so ob_end_clean()
        // cannot warn and needs no error suppression.
        while (ob_get_level() > 0) {
            if (!ob_end_clean()) {
                break; // non-removable buffer — stop instead of looping forever
            }
        }

        status_header(200);
        nocache_headers();
        header('Content-Type: application/xml; charset=UTF-8');
        header('X-Robots-Tag: noindex, follow', true);
    }

    /**
     * Print the xml-stylesheet directive only when the XSL file is readable.
     *
     * Browsers (Chrome especially) render a blank page when the referenced
     * XSL fails to load; plain XML without a stylesheet always displays.
     */
    private function print_stylesheet_directive() {
        $xsl_path = trailingslashit(SCHEMAFY_PLUGIN_PATH) . 'assets/xml/sitemap.xsl';

        if (is_readable($xsl_path)) {
            echo '<?xml-stylesheet type="text/xsl" href="' . esc_url(self::get_xsl_url()) . '"?>' . "\n";
        }
    }

    /**
     * Whether the sitemap is enabled.
     *
     * @return bool
     */
    public static function is_enabled() {
        $settings = get_option(self::OPTION_NAME, array());
        return is_array($settings) && !empty($settings['enabled']);
    }

    /**
     * Whether lastmod should be printed.
     *
     * @return bool
     */
    private static function include_lastmod() {
        $settings = self::get_settings();
        return !empty($settings['include_lastmod']);
    }

    /**
     * Get sanitized settings.
     *
     * @return array
     */
    public static function get_settings() {
        if (self::$settings_cache !== null) {
            return self::$settings_cache;
        }

        $defaults = self::get_default_settings();
        $saved = get_option(self::OPTION_NAME, null);

        if (!is_array($saved)) {
            self::$settings_cache = $defaults;
            return self::$settings_cache;
        }

        $settings = wp_parse_args($saved, $defaults);
        $settings['enabled'] = !empty($settings['enabled']) ? 1 : 0;
        $settings['include_lastmod'] = !empty($settings['include_lastmod']) ? 1 : 0;
        $settings['redirect_legacy'] = !empty($settings['redirect_legacy']) ? 1 : 0;
        $settings['per_page'] = max(self::MIN_PER_PAGE, min(self::MAX_PER_PAGE, absint($settings['per_page'])));
        $settings['revision'] = isset($settings['revision']) ? absint($settings['revision']) : 0;

        $available_post_types = array_keys(self::get_available_post_types());
        $available_taxonomies = array_keys(self::get_available_taxonomies());

        if (array_key_exists('post_types', $saved) && is_array($saved['post_types'])) {
            $settings['post_types'] = array_values(array_intersect(array_map('sanitize_key', $saved['post_types']), $available_post_types));
        } else {
            $settings['post_types'] = $defaults['post_types'];
        }

        if (array_key_exists('taxonomies', $saved) && is_array($saved['taxonomies'])) {
            $settings['taxonomies'] = array_values(array_intersect(array_map('sanitize_key', $saved['taxonomies']), $available_taxonomies));
        } else {
            $settings['taxonomies'] = $defaults['taxonomies'];
        }

        self::$settings_cache = $settings;
        return self::$settings_cache;
    }

    /**
     * Default settings.
     *
     * @return array
     */
    public static function get_default_settings() {
        return array(
            'enabled' => 0,
            'per_page' => self::DEFAULT_PER_PAGE,
            'post_types' => array_keys(self::get_available_post_types()),
            'taxonomies' => array_keys(self::get_available_taxonomies()),
            'include_lastmod' => 1,
            'redirect_legacy' => 1,
            'revision' => 0,
        );
    }

    /**
     * Admin data for the Sitemap tab.
     *
     * @return array
     */
    public static function get_admin_data() {
        $settings = self::get_settings();
        $active_collections = self::get_active_collections();
        $total_urls = 0;
        $total_sitemaps = 0;

        foreach ($active_collections as $collection) {
            $total_urls += (int) $collection['count'];
            $total_sitemaps += (int) $collection['pages'];
        }

        return array(
            'settings' => $settings,
            'enabled' => !empty($settings['enabled']),
            'index_url' => self::get_index_url(),
            'xsl_url' => self::get_xsl_url(),
            'post_types' => self::get_available_post_types(),
            'taxonomies' => self::get_available_taxonomies(),
            'active_collections' => $active_collections,
            'totals' => array(
                'urls' => $total_urls,
                'sitemaps' => $total_sitemaps,
                'sources' => count($active_collections),
            ),
            'conflicts' => self::get_conflict_plugins(),
            'filesystem' => self::get_filesystem_warnings(),
            'search_visibility' => (bool) get_option('blog_public'),
        );
    }

    /**
     * Return admin status without allowing secondary status errors to break AJAX responses.
     *
     * @return array
     */
    private static function get_safe_admin_data() {
        try {
            return self::get_admin_data();
        } catch (\Throwable $exception) {
            schemafy_log('[Schemafy Sitemap] Status build failed: ' . $exception->getMessage());

            try {
                $settings = self::get_settings();
            } catch (\Throwable $settings_exception) {
                schemafy_log('[Schemafy Sitemap] Settings fallback failed: ' . $settings_exception->getMessage());
                $settings = array(
                    'enabled' => 0,
                    'per_page' => self::DEFAULT_PER_PAGE,
                    'post_types' => array(),
                    'taxonomies' => array(),
                    'include_lastmod' => 1,
                    'redirect_legacy' => 1,
                    'revision' => 0,
                );
            }

            return array(
                'settings' => $settings,
                'enabled' => !empty($settings['enabled']),
                'index_url' => self::get_index_url(),
                'xsl_url' => self::get_xsl_url(),
                'post_types' => array(),
                'taxonomies' => array(),
                'active_collections' => array(),
                'totals' => array(
                    'urls' => 0,
                    'sitemaps' => 0,
                    'sources' => 0,
                ),
                'conflicts' => array(),
                'filesystem' => self::get_filesystem_warnings(),
                'search_visibility' => (bool) get_option('blog_public'),
            );
        }
    }

    /**
     * Available public post types.
     *
     * @return array
     */
    public static function get_available_post_types() {
        if (self::$available_post_types_cache !== null) {
            return self::$available_post_types_cache;
        }

        $objects = get_post_types(array('public' => true), 'objects');
        $excluded = array('attachment');
        $items = array();

        foreach ($objects as $name => $object) {
            if (in_array($name, $excluded, true)) {
                continue;
            }

            if (function_exists('is_post_type_viewable') && !is_post_type_viewable($object)) {
                continue;
            }

            $items[$name] = array(
                'slug' => $name,
                'label' => $object->labels->name,
                'count' => self::count_public_posts($name),
                'lastmod' => self::get_post_type_lastmod($name),
                'url' => self::get_collection_url($name),
            );
        }

        self::$available_post_types_cache = $items;
        return self::$available_post_types_cache;
    }

    /**
     * Available public taxonomies.
     *
     * @return array
     */
    public static function get_available_taxonomies() {
        if (self::$available_taxonomies_cache !== null) {
            return self::$available_taxonomies_cache;
        }

        $objects = get_taxonomies(array('public' => true), 'objects');
        $excluded = array('nav_menu', 'link_category', 'post_format');
        $items = array();

        foreach ($objects as $name => $object) {
            if (in_array($name, $excluded, true)) {
                continue;
            }

            if (function_exists('is_taxonomy_viewable') && !is_taxonomy_viewable($object)) {
                continue;
            }

            $items[$name] = array(
                'slug' => $name,
                'label' => $object->labels->name,
                'count' => self::count_terms($name),
                'lastmod' => '',
                'url' => self::get_collection_url($name),
            );
        }

        self::$available_taxonomies_cache = $items;
        return self::$available_taxonomies_cache;
    }

    /**
     * Active collections selected by the user.
     *
     * @return array
     */
    public static function get_active_collections() {
        $settings = self::get_settings();
        $collections = array();
        $post_types = self::get_available_post_types();
        $taxonomies = self::get_available_taxonomies();

        foreach ($settings['post_types'] as $post_type) {
            if (empty($post_types[$post_type]) || $post_types[$post_type]['count'] < 1) {
                continue;
            }

            $collections[] = self::format_collection('post_type', $post_type, $post_types[$post_type]);
        }

        foreach ($settings['taxonomies'] as $taxonomy) {
            if (empty($taxonomies[$taxonomy]) || $taxonomies[$taxonomy]['count'] < 1) {
                continue;
            }

            $collections[] = self::format_collection('taxonomy', $taxonomy, $taxonomies[$taxonomy]);
        }

        return $collections;
    }

    /**
     * Get one active collection by sitemap slug.
     *
     * @param string $slug Collection slug.
     * @return array|null
     */
    public static function get_collection_by_slug($slug) {
        $slug = sanitize_key($slug);

        foreach (self::get_active_collections() as $collection) {
            if ($collection['slug'] === $slug) {
                return $collection;
            }
        }

        return null;
    }

    /**
     * Format a collection row.
     *
     * @param string $kind Collection kind.
     * @param string $name Object name.
     * @param array  $item Source item.
     * @return array
     */
    private static function format_collection($kind, $name, $item) {
        $settings = self::get_settings();
        $count = (int) $item['count'];
        $pages = max(1, (int) ceil($count / max(1, $settings['per_page'])));

        return array(
            'kind' => $kind,
            'name' => $name,
            'slug' => $name,
            'label' => $item['label'],
            'count' => $count,
            'pages' => $pages,
            'lastmod' => $item['lastmod'],
            'url' => self::get_collection_url($name),
        );
    }

    /**
     * Count published posts for a post type.
     *
     * @param string $post_type Post type.
     * @return int
     */
    private static function count_public_posts($post_type) {
        $counts = wp_count_posts($post_type);
        return isset($counts->publish) ? (int) $counts->publish : 0;
    }

    /**
     * Count public terms.
     *
     * @param string $taxonomy Taxonomy.
     * @return int
     */
    private static function count_terms($taxonomy) {
        // WP 5.6+ signature: taxonomy goes inside the args array (the old
        // two-argument form is deprecated).
        $count = wp_count_terms(array(
            'taxonomy'   => $taxonomy,
            'hide_empty' => true,
        ));

        return is_wp_error($count) ? 0 : (int) $count;
    }

    /**
     * Latest modified time for a post type.
     *
     * @param string $post_type Post type.
     * @return string
     */
    private static function get_post_type_lastmod($post_type) {
        $query = new WP_Query(array(
            'post_type' => $post_type,
            'post_status' => 'publish',
            'posts_per_page' => 1,
            'orderby' => 'modified',
            'order' => 'DESC',
            'fields' => 'ids',
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
            'has_password' => false,
        ));

        if (empty($query->posts[0])) {
            return '';
        }

        return self::format_post_modified((int) $query->posts[0]);
    }

    /**
     * Latest modified time for content attached to a term.
     *
     * @param int    $term_id Term ID.
     * @param string $taxonomy Taxonomy.
     * @return string
     */
    private static function get_term_lastmod($term_id, $taxonomy) {
        $post_types = array_keys(self::get_available_post_types());
        if (empty($post_types)) {
            return '';
        }

        $query = new WP_Query(array(
            'post_type' => $post_types,
            'post_status' => 'publish',
            'posts_per_page' => 1,
            'orderby' => 'modified',
            'order' => 'DESC',
            'fields' => 'ids',
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
            'has_password' => false,
            'tax_query' => array(
                array(
                    'taxonomy' => $taxonomy,
                    'field' => 'term_id',
                    'terms' => $term_id,
                ),
            ),
        ));

        if (empty($query->posts[0])) {
            return '';
        }

        return self::format_post_modified((int) $query->posts[0]);
    }

    /**
     * Format post modified date as W3C datetime.
     *
     * @param int $post_id Post ID.
     * @return string
     */
    private static function format_post_modified($post_id) {
        $modified_gmt = get_post_field('post_modified_gmt', $post_id);
        if (empty($modified_gmt) || $modified_gmt === '0000-00-00 00:00:00') {
            $modified_gmt = get_post_field('post_date_gmt', $post_id);
        }

        return mysql2date('c', $modified_gmt, false);
    }

    /**
     * Get sitemap index URL.
     *
     * @return string
     */
    public static function get_index_url() {
        return home_url('/' . self::INDEX_FILE);
    }

    /**
     * Get collection sitemap URL.
     *
     * @param string $slug Collection slug.
     * @param int    $page Page number.
     * @return string
     */
    public static function get_collection_url($slug, $page = 1) {
        $slug = sanitize_key($slug);
        $file = $page > 1 ? "{$slug}-sitemap-{$page}.xml" : "{$slug}-sitemap.xml";
        return home_url('/' . $file);
    }

    /**
     * XSL stylesheet URL.
     *
     * @return string
     */
    public static function get_xsl_url() {
        return SCHEMAFY_PLUGIN_URL . 'assets/xml/sitemap.xsl';
    }

    /**
     * Detect plugins that can produce duplicate sitemaps.
     *
     * @return array
     */
    public static function get_conflict_plugins() {
        $plugins = array(
            array(
                'slug' => 'wordpress-core',
                'name' => 'WordPress Core',
                'active' => function_exists('wp_sitemaps_get_server'),
                'mode' => __('Disabled with wp_sitemaps_enabled filter', 'schemafy'),
            ),
            array(
                'slug' => 'yoast',
                'name' => 'Yoast SEO',
                'active' => defined('WPSEO_VERSION') || class_exists('WPSEO_Options'),
                'mode' => __('Legacy sitemap URLs redirect to Schemafy', 'schemafy'),
            ),
            array(
                'slug' => 'rank-math',
                'name' => 'Rank Math',
                'active' => defined('RANK_MATH_VERSION') || class_exists('RankMath'),
                'mode' => __('Sitemap module is deactivated when possible; URLs redirect to Schemafy', 'schemafy'),
            ),
            array(
                'slug' => 'aioseo',
                'name' => 'All in One SEO',
                'active' => function_exists('aioseo'),
                'mode' => __('Virtual sitemap URLs redirect to Schemafy', 'schemafy'),
            ),
            array(
                'slug' => 'seopress',
                'name' => 'SEOPress',
                'active' => defined('SEOPRESS_VERSION') || function_exists('seopress_get_service'),
                'mode' => __('XML sitemap URLs redirect to Schemafy', 'schemafy'),
            ),
            array(
                'slug' => 'the-seo-framework',
                'name' => 'The SEO Framework',
                'active' => function_exists('the_seo_framework'),
                'mode' => __('XML sitemap URLs redirect to Schemafy', 'schemafy'),
            ),
        );

        return $plugins;
    }

    /**
     * Detect physical files that WordPress cannot override.
     *
     * @return array
     */
    public static function get_filesystem_warnings() {
        $warnings = array();
        $files = array('sitemap.xml', 'sitemap_index.xml', self::INDEX_FILE);

        foreach ($files as $file) {
            $path = trailingslashit(ABSPATH) . $file;
            if (file_exists($path) && is_file($path)) {
                // translators: %s is a value inserted at runtime.
                $warnings[] = sprintf(__('Physical file detected: %s', 'schemafy'), $file);
            }
        }

        return $warnings;
    }

    /**
     * Whether a physical file would prevent Schemafy from serving its own index URL.
     *
     * @return bool
     */
    private static function has_blocking_filesystem_conflict() {
        $path = trailingslashit(ABSPATH) . self::INDEX_FILE;

        return file_exists($path) && is_file($path);
    }

    /**
     * Cheap check to avoid sitemap collection work on normal frontend requests.
     *
     * @param string $path Request path relative to home.
     * @return bool
     */
    private static function is_sitemap_like_path($path) {
        $path = trim((string) $path, '/');

        if ($path === '' || substr($path, -4) !== '.xml') {
            return false;
        }

        if ($path === self::INDEX_FILE) {
            return true;
        }

        if (in_array($path, array('sitemap.xml', 'sitemap_index.xml', 'wp-sitemap.xml', 'index-sitemap.xml'), true)) {
            return true;
        }

        return (bool) preg_match('/^[a-z0-9_-]+-sitemap(?:-[0-9]+)?\.xml$/i', $path);
    }

    /**
     * Acquire a short write lock so concurrent autosaves cannot race each other.
     *
     * @return string|false
     */
    private static function acquire_save_lock() {
        $token = wp_generate_uuid4();
        $lock = get_option(self::LOCK_OPTION);

        if (is_array($lock) && !empty($lock['created']) && (time() - absint($lock['created'])) > 15) {
            delete_option(self::LOCK_OPTION);
        }

        $created = add_option(self::LOCK_OPTION, array(
            'token' => $token,
            'created' => time(),
        ), '', 'no');

        return $created ? $token : false;
    }

    /**
     * Release the write lock only if this request owns it.
     *
     * @param string $token Lock token.
     */
    private static function release_save_lock($token) {
        $lock = get_option(self::LOCK_OPTION);

        if (is_array($lock) && isset($lock['token']) && hash_equals((string) $lock['token'], (string) $token)) {
            delete_option(self::LOCK_OPTION);
        }
    }

    /**
     * Flush rewrite rules only once per plugin version instead of on every autosave.
     */
    private static function maybe_flush_rewrite_rules() {
        $version = defined('SCHEMAFY_VERSION') ? SCHEMAFY_VERSION : '1';

        if (get_option(self::REWRITE_VERSION_OPTION) !== $version) {
            flush_rewrite_rules(false);
            update_option(self::REWRITE_VERSION_OPTION, $version, false);
        }
    }

    /**
     * Current request path relative to home URL.
     *
     * @return string
     */
    private static function get_current_request_path() {
        $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
        $path = (string) wp_parse_url($request_uri, PHP_URL_PATH);
        $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);

        $path = rawurldecode($path);
        $home_path = trim(rawurldecode($home_path), '/');

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

        return trim($path, '/');
    }
}
