<?php

use RankMath\Helper;
use RankMath\Paper\Paper;

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

// WEBSITE_LLMS_TXT_DIR, never LLMS_PLUGIN_DIR (LifterLMS collision): see docs/internals-class-llms-generator.md.
require_once WEBSITE_LLMS_TXT_DIR . 'includes/class-llms-content-cleaner.php';
require_once WEBSITE_LLMS_TXT_DIR . 'includes/class-llms-content-sanitizer.php';

class LLMS_Generator
{
    private $settings;
    private $content_cleaner;
    private $wp_filesystem;
    private $llms_path;
    private $write_log_path;
    private $llms_name;
    private $limit = 500;
    private $temp_llms_path;
    private $batch_size = 5;

    /**
     * The generation lock.
     */
    private $lock_name = 'llms_generation_lock';

    /** @var string Current lease token, empty when this instance holds no lock. */
    private $lock_token = '';

    /**
     * @var bool Whether this run's lease was stolen while it was running.
     */
    private $lock_lost = false;

    /**
     * @var bool Whether the shutdown handler is registered for this instance.
     */
    private $shutdown_registered = false;

    /**
     * @var bool Whether a generation is in flight whose end state is not yet decided.
     */
    private $generation_armed = false;

    /** @var string[] The destinations a run that does not finish has to remove. */
    private $shutdown_remove = array();

    /**
     * @var bool Whether this instance is inside update_llms_file() right now.
     */
    private $running = false;

    public function __construct()
    {
        // Every key this class reads has a default, applied to a stored option as well as to a missing one: see docs/internals-class-llms-generator.md.
        $llms_settings_defaults = array(
            'post_types' => array('page', 'documentation', 'post'),
            'post_name' => array(),
            'max_posts' => 100,
            'max_words' => 250,
            'include_meta' => false,
            'include_excerpts' => false,
            'include_taxonomies' => false,
            'update_frequency' => 'immediate',
            'need_check_option' => true,
            'noindex_header' => false,
            'gform_include' => false,
            'llms_allow_indexing' => false,
            'include_md_file' => false,
            'detailed_content' => false,
            'llms_txt_title' => '',
            'llms_txt_description' => '',
            'llms_after_txt_description' => '',
            'llms_end_file_description' => ''
        );

        $llms_stored_settings = get_option('llms_generator_settings', array());

        $this->settings = wp_parse_args(
            is_array($llms_stored_settings) ? $llms_stored_settings : array(),
            $llms_settings_defaults
        );

        if (!is_array($this->settings['post_types'])) {
            $this->settings['post_types'] = $llms_settings_defaults['post_types'];
        }

        $this->content_cleaner = new LLMS_Content_Cleaner();

        $this->init_filesystem();

        add_action('init', array($this, 'init_generator'), 20);

        add_action('wp_ajax_run_llms_txt_reset_file',  [$this, 'ajax_reset_gen_init']);
        add_action('wp_ajax_llms_gen_init',  [$this, 'ajax_gen_init']);
        add_action('wp_ajax_llms_gen_step',  [$this, 'ajax_gen_step']);
        add_action('wp_ajax_llms_update_file',  [$this, 'ajax_update_file']);

        add_action('save_post', array($this, 'handle_post_update'), 10, 3);
        add_action('deleted_post', array($this, 'handle_post_deletion'), 999, 2);
        add_action('wp_update_term', array($this, 'handle_term_update'));
        add_action('llms_scheduled_update', array($this, 'llms_scheduled_update'));
        add_action('schedule_updates', array($this, 'schedule_updates'));
        add_filter('get_llms_content', array($this, 'get_llms_content'));

        add_action('template_redirect', array($this, 'llms_txt_absent_response'), 11);
        add_action('init', array($this, 'llms_maybe_create_ai_sitemap_page'));
        add_action('llms_update_llms_file_cron', array($this, 'update_llms_file'));
        add_action('admin_post_run_manual_update_llms_file', array($this, 'run_manual_update_llms_file'));
        add_action('init', array($this, 'llms_create_txt_cache_table_if_not_exists'), 999);
        add_action('updates_all_posts', array($this, 'updates_all_posts'), 999);
        add_filter('get_llms_generator_settings', array($this, 'get_llms_generator_settings'));
        add_action('single_llms_generator_hook', array($this, 'single_llms_generator_hook'));
    }

    public function ajax_update_file(){
        if(!current_user_can('manage_options')) wp_send_json_error('denied');
        check_ajax_referer('llms_gen_nonce');
        $this->update_llms_file();
        wp_send_json_success();
    }

    public function clean_html_text( $html ) {
        $text = wp_strip_all_tags( $html );
        $text = preg_replace( '/\s{2,}/', ' ', $text );
        $text = preg_replace( '/^\s*$(\r\n|\n|\r)/m', '', $text );
        $text = trim( $text );
        return $text;
    }

    /**
     * Re-read one post's rendered page over HTTP and cache the text.
     *
     * @param int $post_id
     * @return void
     */
    public function single_llms_generator_hook( $post_id )
    {
        global $wpdb;
        $post_url = get_permalink( $post_id );

        if ( ! $post_url ) {
            return;
        }

        $parsed   = wp_parse_url( $post_url );
        $host     = $parsed['host'] ?? '';

        $response = wp_remote_get( $post_url, [
            'timeout' => 10,
            'sslverify' => false,
            'headers' => [
                'Host' => $host
            ]
        ]);

        if ( is_wp_error( $response ) ) {
            return;
        }

        $html = wp_remote_retrieve_body( $response );
        if ( empty( $html ) ) {
            return;
        }

        // Rendered markup, so TEXT: nothing here is expanded again. This write bypasses
        // handle_post_update() entirely, which is why it sanitises for itself.
        $text = $this->clean_html_text(
            LLMS_Content_Sanitizer::sanitize( $html, LLMS_Content_Sanitizer::PROFILE_TEXT )
        );

        $table = $wpdb->prefix . 'llms_txt_cache';
        $wpdb->update($table, [
            'content' => $text,
        ], [
            'post_id' => $post_id,
        ], [
            '%s',
        ], [
            '%d'
        ]);
    }

    public function run_manual_update_llms_file()
    {
        set_time_limit(0);
        if (!current_user_can('manage_options')) {
            wp_die('Permission denied');
        }

        check_admin_referer('generate_llms_txt_nonce');
        $this->update_llms_file();
        wp_safe_redirect(admin_url('tools.php?page=llms-file-manager'));
        exit;
    }

    public function get_llms_generator_settings( $settings = [] )
    {
        return $this->settings;
    }

    public function llms_create_txt_cache_table_if_not_exists()
    {
        LLMS_DB::maybe_upgrade();
    }

    public function llms_maybe_create_ai_sitemap_page()
    {
        if (!isset($this->settings['removed_ai_sitemap']))
        {
            $page = get_page_by_path('ai-sitemap');
            if ($page && $page->post_type === 'page')
            {
                wp_delete_post($page->ID, true);
                $this->settings['removed_ai_sitemap'] = true;
                update_option('llms_generator_settings', $this->settings);
            }
        }
    }

    public function llms_scheduled_update()
    {
        $this->init_generator(true);
    }

    /**
     * Take a WP_Filesystem transport, or nothing at all.
     *
     * @return void
     */
    private function init_filesystem()
    {
        global $wp_filesystem;

        $this->wp_filesystem = null;

        if (empty($wp_filesystem)) {
            require_once(ABSPATH . '/wp-admin/includes/file.php');
            if (!WP_Filesystem()) {
                return;
            }
        }

        if (empty($wp_filesystem) || !is_object($wp_filesystem)) {
            return;
        }

        if (isset($wp_filesystem->errors)
            && is_wp_error($wp_filesystem->errors)
            && $wp_filesystem->errors->has_errors()) {
            return;
        }

        $this->wp_filesystem = $wp_filesystem;
    }

    public function init_generator($force = false)
    {

        $siteurl = get_option('siteurl');
        if($siteurl) {
            $this->llms_name = wp_parse_url($siteurl)['host'];
        }

        if (isset($this->settings['update_frequency']) && $this->settings['update_frequency'] !== 'immediate') {
            do_action('schedule_updates');
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only flag check; nonce is verified by WP core's options.php handler before settings are persisted.
        if (isset($_POST['llms_generator_settings'], $_POST['llms_generator_settings']['update_frequency']) || $force) {
            wp_clear_scheduled_hook('llms_update_llms_file_cron');
            wp_schedule_single_event(time() + 30, 'llms_update_llms_file_cron');
        }
    }

    /**
     * Writes the content to a log file using WP_Filesystem.
     *
     * @param string $content Content for recording.
     */
    private function write_log($content)
    {
        if (!$this->wp_filesystem) {
            $this->init_filesystem();
        }

        if ($this->wp_filesystem) {
            if (!$this->write_log_path) {
                $upload_dir = wp_upload_dir();
                $this->write_log_path = $upload_dir['basedir'] . '/log.txt';
            }

            if ($this->wp_filesystem->exists($this->write_log_path)) {
                $current_content = $this->wp_filesystem->get_contents($this->write_log_path);
                $this->wp_filesystem->put_contents($this->write_log_path, $current_content . $content, FS_CHMOD_FILE);
            } else {
                $this->wp_filesystem->put_contents($this->write_log_path, $content, FS_CHMOD_FILE);
            }
        }
    }

    /**
     * Writes the content to an LLMS file using WP_Filesystem,
     * with an optimized approach for large files.
     *
     * @param string $content Content for recording.
     */
    private function write_file($content)
    {
        if (!$this->wp_filesystem) {
            $this->init_filesystem();
        }

        if ($this->wp_filesystem) {
            if (!$this->temp_llms_path) {
                $upload_dir = wp_upload_dir();
                $this->temp_llms_path = $upload_dir['basedir'] . '/' . $this->llms_name . '.temp.llms.txt';
            }

            if ($this->wp_filesystem->method == 'direct') {
                $file_handle = @fopen($this->temp_llms_path, 'a');
                if ($file_handle) {
                    @fwrite($file_handle, (string)$content);
                    @fclose($file_handle);
                    $this->wp_filesystem->chmod($this->temp_llms_path, FS_CHMOD_FILE, false);
                } else {
                    if ($this->wp_filesystem->exists($this->temp_llms_path)) {
                        $current_content = $this->wp_filesystem->get_contents($this->temp_llms_path);
                        $this->wp_filesystem->put_contents($this->temp_llms_path, $current_content . (string)$content, FS_CHMOD_FILE);
                    } else {
                        $this->wp_filesystem->put_contents($this->temp_llms_path, (string)$content, FS_CHMOD_FILE);
                    }
                }
            } else {
                if ($this->wp_filesystem->exists($this->temp_llms_path)) {
                    $current_content = $this->wp_filesystem->get_contents($this->temp_llms_path);
                    $this->wp_filesystem->put_contents($this->temp_llms_path, $current_content . (string)$content, FS_CHMOD_FILE);
                } else {
                    $this->wp_filesystem->put_contents($this->temp_llms_path, (string)$content, FS_CHMOD_FILE);
                }
            }
        }
    }

    public function get_llms_content($content)
    {
        if (!$this->wp_filesystem) {
            $this->init_filesystem();
        }

        $upload_dir = wp_upload_dir();
        $upload_path = $upload_dir['basedir'] . '/' . $this->llms_name . '.llms.txt';

        // Only the promoted copy is ever served.
        if ($this->wp_filesystem && $this->wp_filesystem->exists($upload_path)) {
            $content .= $this->wp_filesystem->get_contents($upload_path);
        }
        return $content;
    }

    /**
     * Answer /llms.txt with 404 when there is no document.
     *
     * @return void
     */
    public function llms_txt_absent_response()
    {
        if (!get_query_var('llms_txt')) {
            return;
        }

        if ('' !== (string) apply_filters('get_llms_content', '')) {
            return;
        }

        status_header(404);
        nocache_headers();
        header('Content-Type: text/plain; charset=utf-8');
        header('X-Robots-Tag: noindex');
        echo esc_html("llms.txt has not been generated on this site yet.\n");
        exit;
    }

    /**
     * Fill the cache table for every post that has no row yet.
     *
     * @return void
     */
    public function updates_all_posts()
    {
        global $wpdb;
        $table_cache = $wpdb->prefix . 'llms_txt_cache';
        foreach ($this->settings['post_types'] as $post_type) {
            if ($post_type === 'llms_txt') continue;

            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('Processing type: ' . $post_type);
            }

            // Keyset, not OFFSET: this query does not see the rows the previous pass
            // filled, so a rising offset skips exactly as many posts as it processed.
            // See docs/internals-class-llms-generator.md.
            $cursor = PHP_INT_MAX;
            do {
                $post_ids = $wpdb->get_col($wpdb->prepare(
                    "SELECT p.ID FROM {$wpdb->posts} p"
                    . " LEFT JOIN {$table_cache} cache ON p.ID = cache.post_id"
                    . " WHERE p.post_type = %s AND cache.post_id IS NULL AND p.ID < %d"
                    . " ORDER BY p.ID DESC LIMIT %d",
                    $post_type,
                    $cursor,
                    $this->limit
                ));

                foreach ($post_ids as $post_id) {
                    $post_id = (int) $post_id;
                    // Advance before the work, never after: a post that writes no row
                    // must not be able to hand the next pass the same batch forever.
                    $cursor = $post_id;

                    $post = get_post($post_id);
                    if (!$post) {
                        continue;
                    }

                    if(function_exists('wpml_object_id_filter')) {
                        $lang = apply_filters('wpml_element_language_code', null, [
                            'element_id' => $post->ID,
                            'element_type' => 'post_' . $post->post_type
                        ]);

                        if ($lang) {
                            do_action('wpml_switch_language', $lang);
                        }
                    }
                    $this->handle_post_update($post_id, $post, 'manual');
                    if(function_exists('wpml_object_id_filter')) {
                        wp_reset_postdata();
                    }
                    unset($post);
                }

                $this->refresh_lock();
            } while (!empty($post_ids));

            if(function_exists('wpml_object_id_filter')) {
                do_action('wpml_switch_language', apply_filters('wpml_default_language', null));
            }

            unset($post_ids);

            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('END processing type: ' . $post_type);
            }
        }
    }

    /**
     * Whether an anonymous, cookie-less visitor can read each of these posts.
     *
     * @param int[] $post_ids
     * @return array [ post_id => bool ]
     */
    private function publicly_readable(array $post_ids)
    {
        $out = array();
        foreach ($post_ids as $raw) {
            $id = absint($raw);
            if ($id) {
                $out[$id] = false;
            }
        }

        if (!$out) {
            return array();
        }

        if (!class_exists('LLMS_Access')) {
            return $out;
        }

        $verdict = LLMS_Access::filter_publicly_readable(array_keys($out));
        foreach ($out as $id => $unused) {
            $out[$id] = !empty($verdict[$id]);
        }

        return $out;
    }

    /**
     * Whether this run should hold back a section heading until something survives to go
     * under it.
     *
     * @return bool
     */
    private function defer_empty_headings()
    {
        if (!class_exists('LLMS_Access')) {
            return true;
        }

        return LLMS_Access::site_is_gated() || (bool) LLMS_Access::gated_post_types();
    }

    public function generate_content()
    {
        $this->updates_all_posts();

        // The render, not the fill: an anonymous visitor reads the site language.
        $switched = switch_to_locale(get_locale());
        try {
            $this->generate_site_info();
            $this->generate_overview();
            $this->generate_detailed_content();
        } finally {
            if ($switched) {
                restore_previous_locale();
            }
        }
    }

    private function generate_site_info()
    {

        $settings = apply_filters('get_llms_generator_settings', []);
        if(isset($settings['llms_txt_description']) && $settings['llms_txt_description']) {
            $meta_description = $settings['llms_txt_description'];
        } else {
            $meta_description = $this->get_site_meta_description();
        }
        $slug = 'ai-sitemap';
        $existing_page = get_page_by_path( $slug );
        // UTF-8 BOM, load bearing: see docs/internals-class-llms-generator.md.
        $output = "\xEF\xBB\xBF";
        if(is_a($existing_page,'WP_Post')) {
            $output .= "# Learn more:" . get_permalink($existing_page) . "\n\n";
        }
        $output .= "# " . $this->plain_text(isset($settings['llms_txt_title']) && $settings['llms_txt_title'] ? $settings['llms_txt_title'] : get_bloginfo('name')) . "\n\n";
        if ($meta_description) {
            $output .= "> " . $this->plain_text($meta_description) . "\n\n";
        }

        if (isset($settings['llms_after_txt_description']) && $settings['llms_after_txt_description']) {
            $output .= "> " . $this->plain_text($settings['llms_after_txt_description']) . "\n\n";
        }
        $this->write_file(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
        unset($output);
        unset($meta_description);
    }

    /**
     * The non-shortcode tail of the old remove_shortcodes(): forms, style, script,
     * invisible characters, entities, whitespace, tags.
     *
     * It no longer strips brackets. LLMS_Content_Sanitizer has already decided which
     * ones are gates, and a blanket strip here would delete the literals it restored.
     *
     * @param string $content
     * @return string
     */
    private function remove_shortcodes($content)
    {
        $settings = apply_filters('get_llms_generator_settings', []);
        $clean = (string) $content;

        if(!isset($settings['gform_include']) || !$settings['gform_include']) {
            $clean = preg_replace('/<form[^>]+id=("|\')gform_\d+("|\')[\s\S]*?<\/form>/i', '', $clean);

            $clean = preg_replace('/<div[^>]+class=("|\')[^"\']*gform_wrapper[^"\']*("|\')[\s\S]*?<\/div>/i', '', $clean);
        }

        $clean = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $clean);
        $clean = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $clean);

        $clean = preg_replace('/[\x{00A0}\x{200B}\x{200C}\x{200D}\x{FEFF}\x{202A}-\x{202E}\x{2060}]/u', ' ', $clean);

        $clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        $clean = preg_replace('/[ \t]+/', ' ', $clean);
        $clean = preg_replace('/\s{2,}/u', ' ', $clean);
        $clean = preg_replace('/[\r\n]+/', "\n", $clean);

        return trim(wp_strip_all_tags($clean));
    }

    private function generate_overview()
    {
        global $wpdb;
        if (defined('WP_CLI') && WP_CLI) {
            \WP_CLI::log('Start generate overview');
        }

        $defer_headings = $this->defer_empty_headings();

        $table_cache = $wpdb->prefix . 'llms_txt_cache';
        foreach ($this->settings['post_types'] as $post_type) {
            if ($post_type === 'llms_txt') continue;

            $heading = '';
            $post_type_obj = get_post_type_object($post_type);
            if (is_object($post_type_obj) && isset($post_type_obj->labels->name)) {

                $name = $this->plain_text($post_type_obj->labels->name);
                if(isset($this->settings['post_name'][$post_type_obj->labels->name]) && $this->settings['post_name'][$post_type_obj->labels->name]) {
                    $name = $this->plain_text($this->settings['post_name'][$post_type_obj->labels->name]);
                }

                $heading = "\n## {$name}\n\n";
            }

            if ($heading !== '' && !$defer_headings) {
                $this->write_file(mb_convert_encoding($heading, 'UTF-8', 'UTF-8'));
                $heading = '';
            }

            $offset = 0;
            $i = 0;
            $exit = false;
            $emitted = false;

            do {
                // The `post_id` tiebreaker in ORDER BY is load bearing: see docs/internals-class-llms-generator.md.
                $conditions = " WHERE `type` = %s AND `show`=1 AND `status`='publish' ";
                $params = [
                    $post_type,
                    $this->limit,
                    $offset
                ];

                $sql = "SELECT `post_id`, `overview` FROM $table_cache $conditions ORDER BY `published` DESC, `post_id` DESC LIMIT %d OFFSET %d";

                $posts = $wpdb->get_results($wpdb->prepare($sql, ...$params));
                if (defined('WP_CLI') && WP_CLI) {
                    \WP_CLI::log('Count: ' . count($posts));
                    \WP_CLI::log($wpdb->prepare($sql, ...$params));
                }
                if (!empty($posts)) {
                    $chunk_ids = array();
                    foreach ($posts as $data) {
                        $chunk_ids[] = $data->post_id;
                    }
                    $readable = $this->publicly_readable($chunk_ids);
                    unset($chunk_ids);

                    $output = '';
                    foreach ($posts as $data) {
                        if($i > $this->settings['max_posts']) {
                            $exit = true;
                            break;
                        }

                        if (empty($readable[(int) $data->post_id])) {
                            unset($data);
                            continue;
                        }

                        if($data->overview) {
                            $output .= $data->overview;
                            $i++;
                        }

                        unset($data);
                    }

                    if ($output !== '') {
                        if ($heading !== '') {
                            $this->write_file(mb_convert_encoding($heading, 'UTF-8', 'UTF-8'));
                            $heading = '';
                        }
                        $this->write_file(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
                        $emitted = true;
                    }
                    unset($output);
                    unset($readable);
                }

                $offset += $this->limit;

                $this->refresh_lock();

            } while (!empty($posts) && !$exit);

            if (!$defer_headings || $emitted) {
                $this->write_file(mb_convert_encoding("\n", 'UTF-8', 'UTF-8'));
            }

            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('End generate overview');
            }
        }
    }

    private function generate_detailed_content()
    {
        global $wpdb;

        if (defined('WP_CLI') && WP_CLI) {
            \WP_CLI::log('Start generate detailed content');
        }

        $defer_headings = $this->defer_empty_headings();
        $pending_banner  = '';
        $pending_heading = '';

        if(isset($this->settings['detailed_content']) && $this->settings['detailed_content'] || isset($this->settings['include_excerpts']) && $this->settings['include_excerpts'] || isset($this->settings['include_taxonomies']) && $this->settings['include_taxonomies'] || isset($this->settings['include_meta']) && $this->settings['include_meta']) {
            $output = "#\n" . "# Detailed Content\n\n";
            if ($defer_headings) {
                $pending_banner .= $output;
            } else {
                $this->write_file(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
            }
        }

        $table_cache = $wpdb->prefix . 'llms_txt_cache';

        foreach ($this->settings['post_types'] as $post_type) {
            if ($post_type === 'llms_txt') continue;

            $emitted = false;

            $pending_heading = '';

            if(isset($this->settings['detailed_content']) && $this->settings['detailed_content'] || isset($this->settings['include_excerpts']) && $this->settings['include_excerpts'] || isset($this->settings['include_taxonomies']) && $this->settings['include_taxonomies'] || isset($this->settings['include_meta']) && $this->settings['include_meta']) {
                $post_type_obj = get_post_type_object($post_type);
                if (is_object($post_type_obj) && isset($post_type_obj->labels->name)) {
                    $name = $post_type_obj->labels->name;
                    if(isset($this->settings['post_name'][$post_type_obj->labels->name]) && $this->settings['post_name'][$post_type_obj->labels->name]) {
                        $name = $this->settings['post_name'][$post_type_obj->labels->name];
                    }
                    $output = "\n## " . $name . "\n\n";
                    if ($defer_headings) {
                        $pending_heading = $output;
                    } else {
                        $this->write_file(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
                    }
                }
            }

            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('Generate detailed: ' . $post_type);
            }

            $offset = 0;
            $exit = false;
            $i = 0;

            do {
                $output = '';
                $conditions = " WHERE `type` = %s AND `show`=1 AND `status`='publish' ";
                $params = [
                    $post_type,
                    $this->limit,
                    $offset
                ];

                $posts = $wpdb->get_results($wpdb->prepare("SELECT * FROM $table_cache $conditions ORDER BY `published` DESC, `post_id` DESC LIMIT %d OFFSET %d", ...$params));
                if (!empty($posts)) {
                    $chunk_ids = array();
                    foreach ($posts as $data) {
                        $chunk_ids[] = $data->post_id;
                    }
                    $readable = $this->publicly_readable($chunk_ids);
                    unset($chunk_ids);

                    foreach ($posts as $data) {
                        if (!$data->content) continue;
                        if (empty($readable[(int) $data->post_id])) continue;
                        if ($i > $this->settings['max_posts']) {
                            $exit = true;
                            break;
                        }

                        if (isset($this->settings['include_meta']) && $this->settings['include_meta']) {
                            if ($data->meta) {
                                $output .= "> " . wp_trim_words($data->meta, $this->settings['max_words'] ?? 250, '...') . "\n\n";
                            }

                            $published = $this->normalize_date($data->published);
                            if ('' !== $published) {
                                $output .= "- Published: " . $published . "\n";
                            }

                            $modified = $this->normalize_date($data->modified);
                            if ('' !== $modified) {
                                $output .= "- Modified: " . $modified . "\n";
                            }
                            $output .= "- URL: " . $data->link . "\n";

                            if ($data->sku) {
                                $output .= '- SKU: ' . $this->plain_text($data->sku) . "\n";
                            }

                            if ($data->price) {
                                $output .= '- Price: ' . $data->price . "\n";
                            }
                        }

                        if (isset($this->settings['include_taxonomies']) && $this->settings['include_taxonomies']) {
                            $taxonomies = get_object_taxonomies($data->type, 'objects');
                            foreach ($taxonomies as $tax) {
                                $terms = get_the_terms($data->post_id, $tax->name);
                                if ($terms && !is_wp_error($terms)) {
                                    $term_names = wp_list_pluck($terms, 'name');
                                    $output .= "- " . $this->plain_text($tax->labels->name) . ": " . $this->plain_text(implode(', ', $term_names)) . "\n";
                                }
                            }
                        }

                        $content = '';
                        if (isset($this->settings['detailed_content']) && $this->settings['detailed_content']) {
                            $content = wp_trim_words($data->content, $this->settings['max_words'] ?? 250, '...');
                            $output .= "\n";
                        }


                        if (isset($this->settings['include_excerpts']) && $this->settings['include_excerpts'] && $data->excerpts) {
                            $output .= $data->excerpts . "\n\n";
                        }

                        if ($content) {
                            $output .= $content . "\n\n";
                        }

                        if(isset($this->settings['detailed_content']) && $this->settings['detailed_content'] || isset($this->settings['include_excerpts']) && $this->settings['include_excerpts'] || isset($this->settings['include_taxonomies']) && $this->settings['include_taxonomies'] || isset($this->settings['include_meta']) && $this->settings['include_meta']) {
                            $output .= "\n";
                        }
                        unset($data);

                        $i++;
                    }

                    unset($readable);
                }

                if(isset($this->settings['detailed_content']) && $this->settings['detailed_content'] || isset($this->settings['include_excerpts']) && $this->settings['include_excerpts'] || isset($this->settings['include_taxonomies']) && $this->settings['include_taxonomies'] || isset($this->settings['include_meta']) && $this->settings['include_meta']) {
                    if ($output !== '') {
                        if ($pending_banner !== '') {
                            $this->write_file(mb_convert_encoding($pending_banner, 'UTF-8', 'UTF-8'));
                            $pending_banner = '';
                        }
                        if ($pending_heading !== '') {
                            $this->write_file(mb_convert_encoding($pending_heading, 'UTF-8', 'UTF-8'));
                            $pending_heading = '';
                        }
                        $this->write_file(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
                        $emitted = true;
                    } elseif (!$defer_headings) {
                        $this->write_file(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
                    }
                }
                unset($output);

                $offset += $this->limit;

                $this->refresh_lock();

            } while (!empty($posts) && !$exit);

            if((isset($this->settings['detailed_content']) && $this->settings['detailed_content'] || isset($this->settings['include_excerpts']) && $this->settings['include_excerpts'] || isset($this->settings['include_taxonomies']) && $this->settings['include_taxonomies'] || isset($this->settings['include_meta']) && $this->settings['include_meta']) && (!$defer_headings || $emitted)) {
                $this->write_file(mb_convert_encoding("\n", 'UTF-8', 'UTF-8'));
            }

            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('End generate detailed content');
            }
        }

        $settings = apply_filters('get_llms_generator_settings', []);
        if (isset($settings['llms_end_file_description']) && $settings['llms_end_file_description']) {
            $this->write_file(mb_convert_encoding('> ' . $this->plain_text($settings['llms_end_file_description']) . "\n\n", 'UTF-8', 'UTF-8'));
        }
    }

    public function remove_emojis($text) {
        return preg_replace('/[\x{1F600}-\x{1F64F}'
            . '\x{1F300}-\x{1F5FF}'
            . '\x{1F680}-\x{1F6FF}'
            . '\x{1F1E0}-\x{1F1FF}'
            . '\x{2600}-\x{26FF}'
            . '\x{2700}-\x{27BF}'
            . '\x{FE00}-\x{FE0F}'
            . '\x{1F900}-\x{1F9FF}'
            . '\x{1F018}-\x{1F270}'
            . '\x{238C}-\x{2454}'
            . '\x{20D0}-\x{20FF}]/u', '', $text);
    }

    private function get_site_meta_description()
    {
        $description = get_bloginfo('description');
        if ($description) {
            return get_bloginfo('description');
        } else {
            $front_page_id = (int) get_option('page_on_front');
            $description = '';

            if ($front_page_id) {
                $readable = $this->publicly_readable(array($front_page_id));
                if (empty($readable[$front_page_id])) {
                    $front_page_id = 0;
                }
            }

            if ($front_page_id) {
                $description = get_the_excerpt($front_page_id);
                if (empty($description)) {
                    $description = get_post_field('post_content', $front_page_id);
                }
            }

            // the_content runs do_shortcode as whoever triggered the run, so this runs
            // as user 0 with the guard behind it, then is sanitised as TEXT, once.
            $expanded = LLMS_Content_Sanitizer::as_anonymous_guarded(function () use ($description) {
                return apply_filters('the_content', $description);
            });

            $description = LLMS_Content_Sanitizer::sanitize(
                str_replace(']]>', ']]&gt;', (string) $expanded),
                LLMS_Content_Sanitizer::PROFILE_TEXT
            );

            $description = $this->remove_shortcodes($description);
            $description = wp_trim_words(wp_strip_all_tags(preg_replace('/[\x{00A0}\x{200B}\x{200C}\x{200D}\x{FEFF}\x{202A}-\x{202E}\x{2060}]/u', ' ', html_entity_decode($description))), 30, '');
        }

        // Same shape as llms_generator_post_content, and the same trap: $description is
        // already sanitised here, so only what the filter added may be sanitised.
        return $this->sanitize_filtered_content(
            $description,
            apply_filters('llms_generator_get_site_meta_description', $description)
        );
    }

    /**
     * The SEO plugin description for one post, sanitised.
     *
     * The filter's value used to be returned verbatim into both the overview line and
     * the meta column, which is the fourth raw text path: see
     * docs/internals-content-sanitizer.md.
     *
     * @param WP_Post $post
     * @return string|false
     */
    private function get_post_meta_description( $post )
    {
        $meta_description = apply_filters('llms_generator_get_post_meta_description', false, $post);
        if(!$meta_description || !is_string($meta_description)) {
            return $meta_description;
        }

        // Already rendered by whichever SEO plugin produced it, so TEXT, not FULL.
        return LLMS_Content_Sanitizer::sanitize($meta_description, LLMS_Content_Sanitizer::PROFILE_TEXT);
    }

    /**
     * Return the WPML language codes marked as hidden (WPML → Languages →
     * Hide languages). Posts in these languages are excluded from llms.txt.
     *
     * @return array Array of hidden language codes (e.g. ['de', 'fr']).
     */
    private function get_wpml_hidden_languages()
    {
        $hidden = apply_filters('wpml_setting', null, 'hidden_languages');
        if (!is_array($hidden)) {
            $settings = get_option('icl_sitepress_settings');
            $hidden = (is_array($settings) && isset($settings['hidden_languages']) && is_array($settings['hidden_languages']))
                ? $settings['hidden_languages']
                : [];
        }
        return $hidden;
    }

    public function ajax_reset_gen_init() {
        if ( ! current_user_can('manage_options') ) wp_send_json_error('Permission denied');
        check_ajax_referer('llms_gen_nonce');

        global $wpdb;
        $table_cache = $wpdb->prefix . 'llms_txt_cache';

        if (!$this->wp_filesystem) {
            $this->init_filesystem();
        }

        $upload_dir = wp_upload_dir();
        $old_upload_path = $upload_dir['basedir'] . '/llms.txt';
        $new_upload_path = $upload_dir['basedir'] . '/' . $this->llms_name . '.llms.txt';
        $this->temp_llms_path = $upload_dir['basedir'] . '/' . $this->llms_name . '.temp.llms.txt';

        $reset_paths = array($old_upload_path, $new_upload_path, $this->temp_llms_path);

        if (!is_multisite()) {
            if (defined('FLYWHEEL_PLUGIN_DIR')) {
                $reset_paths[] = trailingslashit(dirname(ABSPATH)) . 'www/llms.txt';
            } else {
                $reset_paths[] = trailingslashit(ABSPATH) . 'llms.txt';
            }
        }

        $this->remove_generated_files($reset_paths);

        if (class_exists('LLMS_DB')) {
            LLMS_DB::note_document_absent();
        }

        $wpdb->query( "TRUNCATE TABLE {$table_cache}" );

        $ids = [];
        foreach ($this->settings['post_types'] as $post_type) {
            if ($post_type === 'llms_txt') continue;
            $sql = $wpdb->prepare("SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$table_cache} c ON p.ID=c.post_id WHERE p.post_type=%s AND c.post_id IS NULL", $post_type);
            $ids = array_merge($ids, array_map('intval', $wpdb->get_col($sql)));
        }
        $ids = array_values(array_unique($ids));

        $qid = 'llms_q_' . wp_generate_uuid4();
        set_transient($qid, [
            'ids'   => $ids,
            'done'  => 0,
            'total' => count($ids),
        ], HOUR_IN_SECONDS);

        wp_send_json_success(['queue_id'=>$qid,'total'=>count($ids)]);
    }

    public function ajax_gen_init() {
        if ( ! current_user_can('manage_options') ) wp_send_json_error('Permission denied');
        check_ajax_referer('llms_gen_nonce');

        global $wpdb;
        $table_cache = $wpdb->prefix . 'llms_txt_cache';

        $ids = [];
        foreach ($this->settings['post_types'] as $post_type) {
            if ($post_type === 'llms_txt') continue;
            $sql = $wpdb->prepare("SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$table_cache} c ON p.ID=c.post_id WHERE p.post_type=%s AND c.post_id IS NULL", $post_type);
            $ids = array_merge($ids, array_map('intval', $wpdb->get_col($sql)));
        }
        $ids = array_values(array_unique($ids));

        $qid = 'llms_q_' . wp_generate_uuid4();
        set_transient($qid, [
            'ids'   => $ids,
            'done'  => 0,
            'total' => count($ids),
        ], HOUR_IN_SECONDS);

        wp_send_json_success(['queue_id'=>$qid,'total'=>count($ids)]);
    }

    public function ajax_gen_step()
    {
        set_time_limit(0);
        if (!current_user_can('manage_options')) wp_send_json_error('Permission denied');
        check_ajax_referer('llms_gen_nonce');

        $qid = isset($_POST['queue_id']) ? sanitize_text_field(wp_unslash($_POST['queue_id'])) : '';
        if (!$qid) wp_send_json_error('Missing queue_id');

        $state = get_transient($qid);
        if (!$state) {
            wp_send_json_success([
                'done' => 0,
                'total' => 0
            ]);
        }

        $batch = array_splice($state['ids'], 0, $this->batch_size);

        foreach ($batch as $post_id) {
            $post = get_post($post_id);
            if ($post instanceof WP_Post) {
                $this->handle_post_update($post_id, $post, 'manual');
            }
            $state['done']++;
        }

        set_transient($qid, $state, HOUR_IN_SECONDS);
        if (empty($state['ids'])) {
            delete_transient($qid);
            $this->update_llms_file();
        }

        wp_send_json_success([
            'done' => $state['done'],
            'total' => $state['total']
        ]);
    }

    /**
     * Cache one post's row and schedule a regeneration.
     *
     * @param int $post_id
     * @param WP_Post $post
     * @param $update
     * @return void
     */
    public function handle_post_update($post_id, $post, $update)
    {
        global $wpdb, $product;
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        if (!in_array($post->post_type, $this->settings['post_types'])) {
            return;
        }

        // Storage hygiene, not the gate, and it must sit ABOVE the WPML switch below: see docs/internals-class-llms-generator.md.
        if ('publish' !== $post->post_status || '' !== (string) $post->post_password) {
            $this->write_blank_cache_row($post_id, $post);

            if ('auto-draft' !== $post->post_status
                && $this->settings['update_frequency'] === 'immediate'
                && $update !== 'manual') {
                wp_clear_scheduled_hook('llms_update_llms_file_cron');
                wp_schedule_single_event(time() + 30, 'llms_update_llms_file_cron');
            }

            return;
        }

        $llms_product_swapped = false;
        $llms_previous_product = null;
        if (function_exists('wc_get_product') && $post instanceof WP_Post) {
            $llms_previous_product = $product;
            $llms_product_swapped  = true;
            $product = wc_get_product( $post->ID );
        }

        $llms_restore_lang = null;
        if (function_exists('wpml_object_id_filter') && $post instanceof WP_Post) {
            $llms_post_lang = apply_filters('wpml_element_language_code', null, array(
                'element_id'   => $post->ID,
                'element_type' => 'post_' . $post->post_type,
            ));
            if ($llms_post_lang) {
                $llms_restore_lang = apply_filters('wpml_current_language', null);
                do_action('wpml_switch_language', $llms_post_lang);
            }
        }

        $table = $wpdb->prefix . 'llms_txt_cache';
        $price = '';
        $sku = '';

        $is_hidden_language = false;
        if(defined('ICL_LANGUAGE_CODE')) {
            $language_code = $wpdb->get_var("SELECT language_code FROM {$wpdb->prefix}icl_translations WHERE element_id=" . intval($post->ID) . " AND element_type LIKE '%_" . $post->post_type . "'");
            $permalink = apply_filters(
                'wpml_permalink',
                get_permalink($post->ID),
                $language_code
            );

            $hidden_languages = $this->get_wpml_hidden_languages();
            if ($language_code && in_array($language_code, $hidden_languages, true)) {
                $is_hidden_language = true;
            }
        } else {
            $permalink = get_permalink($post->ID);
        }

        $description = isset($this->settings['include_excerpts']) && $this->settings['include_excerpts'] ? $this->get_post_meta_description( $post ) : '';
        $markdown = '';
        $md_toggle = get_post_meta( $post->ID, '_llmstxt_page_md', true );

        $md_url = get_post_meta( $post->ID, '_md_url', true );
        if ( ! empty( $md_url ) ) {
            $markdown = " → [Markdown](" . esc_url_raw( $md_url ) . ")";
        }

        if (!$description) {
            if($this->settings['include_excerpts']) {
                // get_the_excerpt runs the_content when post_excerpt is empty, so the
                // source read happens as user 0 and the result is sanitised once.
                $fallback_source = LLMS_Content_Sanitizer::as_anonymous_guarded(function () use ($post) {
                    return apply_filters('get_the_excerpt', $post->post_excerpt, $post) ?: get_the_content(null, false, $post);
                });

                $fallback_content = LLMS_Content_Sanitizer::sanitize((string) $fallback_source, LLMS_Content_Sanitizer::PROFILE_FULL);
                $fallback_content = $this->remove_shortcodes($fallback_content);
                $fallback_content = $this->content_cleaner->clean($fallback_content);
                $description = wp_trim_words(wp_strip_all_tags($fallback_content), 20, '...');
            }

            $overview = sprintf("- [%s](%s)%s\n", $this->plain_text($post->post_title), esc_url_raw($permalink), $markdown . ($this->settings['include_excerpts'] && $description ? ': ' . preg_replace('/[\x{00A0}\x{200B}\x{200C}\x{200D}\x{FEFF}]/u', ' ', $this->plain_text($description)) : ''));
        } else {
            $overview = sprintf("- [%s](%s)%s\n", $this->plain_text($post->post_title), esc_url_raw($permalink), $markdown . ($this->settings['include_excerpts'] ? ': ' . preg_replace('/[\x{00A0}\x{200B}\x{200C}\x{200D}\x{FEFF}]/u', ' ', $this->plain_text($description)) : ''));
        }

        $show = 1;
        if ($is_hidden_language) {
            $show = 0;
        }
        if (isset($post->post_type) && $post->post_type === 'product') {
            $sku = get_post_meta($post->ID, '_sku', true);
            $price = get_post_meta($post->ID, '_price', true);
            $currency = get_option('woocommerce_currency');
            if (!empty($price)) {
                $price = number_format((float)$price, 2) . " " . $currency;
            }

            $terms           = get_the_terms( $post->ID, 'product_visibility' );
            $term_names      = is_array( $terms ) ? wp_list_pluck( $terms, 'name' ) : array();
            $exclude_search  = in_array( 'exclude-from-search', $term_names, true );
            $exclude_catalog = in_array( 'exclude-from-catalog', $term_names, true );

            if ( $exclude_search || $exclude_catalog ) {
                $show = 0;
            }

        }

        $clean_description = '';
        $meta_description = $this->get_post_meta_description( $post );
        if ($meta_description) {
            $clean_description = preg_replace('/[\x{00A0}\x{200B}\x{200C}\x{200D}\x{FEFF}]/u', ' ', $meta_description);
        }

        $use_yoast = class_exists('WPSEO_Meta');
        $use_rankmath = function_exists('rank_math') && isset(rank_math()->variables);
        if($use_yoast) {
            $robots_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
            $robots_nofollow = get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true);
            if($robots_noindex || $robots_nofollow) {
                $show = 0;
            }
        } else {
            if(defined('SEOPRESS_VERSION')) {
                $robots_noindex = get_post_meta($post_id, '_seopress_robots_index', true);
                $robots_nofollow = get_post_meta($post_id, '_seopress_robots_follow', true);
                if($robots_noindex || $robots_nofollow) {
                    $show = 0;
                }
            }
        }

        $title = $post->post_title;

        if ($use_rankmath) {
            rank_math()->variables->setup();
            $robots_noindex = get_post_meta($post_id, 'rank_math_robots', true);
            $rank_math_title = get_post_meta($post_id, 'rank_math_title', true);
            $title = Helper::replace_vars( $rank_math_title, $post );

            if(is_array($robots_noindex) && (in_array('nofollow', $robots_noindex) || in_array('noindex', $robots_noindex))) {
                $show = 0;
            }
        }

        $aioseo_enabled = $wpdb->get_var("SHOW TABLES LIKE '{$wpdb->prefix}aioseo_posts'") === "{$wpdb->prefix}aioseo_posts";
        if($aioseo_enabled) {
            $row = $wpdb->get_row("SELECT robots_noindex, robots_nofollow FROM {$wpdb->prefix}aioseo_posts WHERE post_id=" . intval($post_id));
            if(isset($row->robots_noindex) && $row->robots_noindex) {
                $show = 0;
            }

            if(isset($row->robots_nofollow) && $row->robots_nofollow) {
                $show = 0;
            }
        }

        if (defined('SLIM_SEO_VER')) {
            $slim_seo = get_post_meta($post_id, 'slim_seo', true);
            if (!empty($slim_seo['noindex'])) {
                $show = 0;
            }
        }

        // The author's own excerpt field, already plain text: TEXT, and once.
        $excerpts = $this->remove_shortcodes(
            LLMS_Content_Sanitizer::sanitize((string) $post->post_excerpt, LLMS_Content_Sanitizer::PROFILE_TEXT)
        );

        $custom_txt = wp_kses_post(get_post_meta($post->ID, '_llmstxt_custom_note', true));
        if($custom_txt) {
            // Owner authored, so its legitimate shortcodes still expand: FULL.
            $content = LLMS_Content_Sanitizer::sanitize($custom_txt, LLMS_Content_Sanitizer::PROFILE_FULL);
        } else {
            // The buffer stays: a shortcode that echoes instead of returning would
            // otherwise print into whatever request triggered the save.
            ob_start();
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is captured via ob_get_clean() for further processing, not sent to the browser.
            echo $this->content_cleaner->clean($this->remove_emojis($this->remove_shortcodes(
                LLMS_Content_Sanitizer::sanitize((string) get_the_content(null, false, $post), LLMS_Content_Sanitizer::PROFILE_FULL)
            )));
            $content = ob_get_clean();
        }

        $content = $this->sanitize_filtered_content(
            $content,
            apply_filters('llms_generator_post_content', $content, $post)
        );

        if ( $md_toggle ) {
            $show = 0;
        }

        $template = get_post_meta( $post->ID, '_wp_page_template', true );
        if ( $template && $template !== 'default' && !trim($content)) {

            $hook      = 'single_llms_generator_hook';
            $post_id   = $post->ID;
            $timestamp = wp_next_scheduled( $hook, [ $post_id ] );

            if ( ! $timestamp ) {
                wp_schedule_single_event( time() + 10, $hook, [ $post_id ] );
            }
        }

        $replace_data = [
            'post_id' => $post_id,
            'show' => $show,
            'status' => $post->post_status,
            'type' => $post->post_type,
            'title' => $title,
            'link' => $permalink,
            'sku' => $sku,
            'price' => $price,
            'meta' => $clean_description,
            'excerpts' => $excerpts,
            'overview' => $overview,
            'content' => $content,
            'published' => $this->cache_date($post, 'date'),
            'modified' => $this->cache_date($post, 'modified'),
        ];

        $replace_format = [
            '%d',
            '%d',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s'
        ];

        $replace_data = apply_filters('llms_handle_post_update_replace_data', $replace_data, $post);
        $replace_format = apply_filters('llms_handle_post_update_replace_format', $replace_format, $post);

        $wpdb->replace(
            $table,
            $replace_data,
            $replace_format
        );

        if ($this->settings['update_frequency'] === 'immediate' && $update !== 'manual') {
            wp_clear_scheduled_hook('llms_update_llms_file_cron');
            wp_schedule_single_event(time() + 30, 'llms_update_llms_file_cron');
        }

        // Put WPML back where we found it (see the switch at the top of this method).
        if ($llms_restore_lang) {
            do_action('wpml_switch_language', $llms_restore_lang);
        }

        if ($llms_product_swapped) {
            $product = $llms_previous_product;
        }
    }

    /**
     * Sanitise what a filter added to the content, and only what it added.
     *
     * The already sanitised part is spliced back untouched, because sanitising a string
     * twice destroys escaped shortcodes: see docs/internals-content-sanitizer.md.
     *
     * @param string $before Value handed to the filter.
     * @param mixed  $after  Value the filter returned.
     * @return string
     */
    private function sanitize_filtered_content($before, $after)
    {
        $after = is_string($after) ? $after : (string) $before;

        if ($after === $before) {
            return $before;
        }

        $at = ('' === $before) ? false : strpos($after, $before);

        if (false === $at) {
            return LLMS_Content_Sanitizer::sanitize($after, LLMS_Content_Sanitizer::PROFILE_FULL);
        }

        $head = LLMS_Content_Sanitizer::sanitize(substr($after, 0, $at), LLMS_Content_Sanitizer::PROFILE_FULL);
        $tail = LLMS_Content_Sanitizer::sanitize(substr($after, $at + strlen($before)), LLMS_Content_Sanitizer::PROFILE_FULL);

        return $head . $before . $tail;
    }

    /**
     * Write the cache row for a post an anonymous visitor cannot read, with every
     * content bearing column empty.
     *
     * @param int     $post_id
     * @param WP_Post $post
     * @return void
     */
    private function write_blank_cache_row($post_id, $post)
    {
        global $wpdb;

        $replace_data = [
            'post_id' => $post_id,
            'show' => 0,
            'status' => $post->post_status,
            'type' => $post->post_type,
            'title' => $post->post_title,
            'link' => (string) get_permalink($post->ID),
            'sku' => '',
            'price' => '',
            'meta' => '',
            'excerpts' => '',
            'overview' => '',
            'content' => '',
            'published' => $this->cache_date($post, 'date'),
            'modified' => $this->cache_date($post, 'modified'),
        ];

        $replace_format = [
            '%d',
            '%d',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s',
            '%s'
        ];

        $replace_data = apply_filters('llms_handle_post_update_replace_data', $replace_data, $post);
        $replace_format = apply_filters('llms_handle_post_update_replace_format', $replace_format, $post);

        $wpdb->replace(
            $wpdb->prefix . 'llms_txt_cache',
            $replace_data,
            $replace_format
        );
    }

    /**
     * A 'Y-m-d' date for the cache table, or null when the post has no usable one.
     *
     * @param WP_Post $post
     * @param string  $field 'date' or 'modified'.
     * @return string|null
     */
    private function cache_date($post, $field)
    {
        $date = ('modified' === $field)
            ? get_the_modified_date('Y-m-d', $post)
            : get_the_date('Y-m-d', $post);

        if (is_string($date) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && '0000-00-00' !== $date) {
            return $date;
        }

        $raw = ('modified' === $field)
            ? (isset($post->post_modified) ? $post->post_modified : '')
            : (isset($post->post_date) ? $post->post_date : '');

        $normalized = $this->normalize_date($raw);

        return '' === $normalized ? null : $normalized;
    }

    /**
     * A stored date as 'Y-m-d', or '' when the value cannot be read as a real date.
     *
     * @param string|null $value
     * @return string
     */
    private function normalize_date($value)
    {
        if (!is_string($value) || '' === $value || 0 === strpos($value, '0000-00-00')) {
            return '';
        }

        $stamp = strtotime($value);
        if (false === $stamp) {
            return '';
        }

        $formatted = gmdate('Y-m-d', $stamp);

        return ('-' === $formatted[0] || '0000-00-00' === $formatted) ? '' : $formatted;
    }

    /**
     * Text for the document: what esc_html() did without the HTML encoding, then
     * the entities WordPress stores (blogname, kses'd titles) decoded once, tags out.
     *
     * @param string $text
     * @return string
     */
    private function plain_text($text)
    {
        $text = wp_check_invalid_utf8((string) $text);
        $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        return wp_strip_all_tags($text);
    }

    public function handle_post_deletion($post_id, $post)
    {
        global $wpdb;
        if (!$post || $post->post_type === 'revision') {
            return;
        }

        $table = $wpdb->prefix . 'llms_txt_cache';
        $wpdb->delete($table, [
            'post_id' => $post_id
        ], [
            '%d'
        ]);

        if ('auto-draft' === $post->post_status) {
            return;
        }

        if ($this->settings['update_frequency'] === 'immediate') {
            wp_clear_scheduled_hook('llms_update_llms_file_cron');
            wp_schedule_single_event(time() + 30, 'llms_update_llms_file_cron');
        }
    }

    public function handle_term_update($term_id)
    {
        if ($this->settings['update_frequency'] === 'immediate') {
            wp_clear_scheduled_hook('llms_update_llms_file_cron');
            wp_schedule_single_event(time() + 30, 'llms_update_llms_file_cron');
        }
    }

    /**
     * Generate the document and put it in place, or leave nothing behind.
     *
     * @return void
     */
    public function update_llms_file()
    {
        if (defined('WP_CLI') && WP_CLI) {
            \WP_CLI::log('Start');
        }

        if ($this->running) {
            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('A generation is already running in this request, nothing to do');
            }
            return;
        }

        if (function_exists('set_time_limit')) {
            @set_time_limit(0);
        }

        if (!$this->wp_filesystem) {
            $this->init_filesystem();
        }

        if (!$this->wp_filesystem) {
            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('WP_Filesystem is unavailable, nothing was generated');
            }
            $this->rearm_owed_rebuild();
            return;
        }

        $token = '';
        if (class_exists('LLMS_Lock')) {
            $acquired = LLMS_Lock::acquire($this->lock_name);
            if (false === $acquired) {
                if (defined('WP_CLI') && WP_CLI) {
                    \WP_CLI::log('Another generation holds the lock, nothing to do');
                }
                $this->rearm_owed_rebuild();
                return;
            }
            $token = $acquired;
        }

        $this->running    = true;
        $this->lock_token = $token;
        $this->lock_lost  = false;

        $upload_dir = wp_upload_dir();
        $old_upload_path = $upload_dir['basedir'] . '/llms.txt';
        $new_upload_path = $upload_dir['basedir'] . '/' . $this->llms_name . '.llms.txt';
        $this->temp_llms_path = $upload_dir['basedir'] . '/' . $this->llms_name . '.temp.llms.txt';

        $file_path = '';
        if (defined('FLYWHEEL_PLUGIN_DIR')) {
            $file_path = trailingslashit(dirname(ABSPATH)) . 'www/' . 'llms.txt';
        } else {
            $file_path = trailingslashit(ABSPATH) . 'llms.txt';
        }

        $complete = false;

        $this->shutdown_remove = array($this->temp_llms_path, $new_upload_path, $old_upload_path);
        if (!is_multisite()) {
            $this->shutdown_remove[] = $file_path;
        }

        try {
            if ($this->wp_filesystem->exists($this->temp_llms_path)) {
                $this->wp_filesystem->delete($this->temp_llms_path);
            }

            $this->generation_armed = true;
            if (!$this->shutdown_registered) {
                $this->shutdown_registered = true;
                register_shutdown_function(array($this, 'on_generation_shutdown'));
            }

            $this->generate_content();

            $complete = $this->wp_filesystem->exists($this->temp_llms_path);

            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('End generate_content event');
            }

            if ($complete) {
                $complete = $this->promote_generated_file($new_upload_path, $file_path, $old_upload_path);
            }
        } catch (\Throwable $e) {
            $complete = false;
            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('Generation failed: ' . $e->getMessage());
            }
            if (defined('WP_DEBUG') && WP_DEBUG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnostic for a failed generation, WP_DEBUG only.
                error_log('website-llms-txt: generation failed, no file was promoted: ' . $e->getMessage());
            }
        }

        if (!$complete && !$this->lock_lost) {
            $this->remove_generated_files($this->shutdown_remove);
        }

        if ($complete) {
            // The stamp first, with nothing between it and the promote: see docs/internals-class-llms-generator.md.
            if (class_exists('LLMS_DB')) {
                LLMS_DB::note_document_promoted();
            }

            update_option('llms_last_generated', time(), false);
        } elseif (!$this->lock_lost && class_exists('LLMS_DB')) {
            LLMS_DB::note_document_absent();
        }

        // Disarmed here, before anything below runs third party code: see docs/internals-class-llms-generator.md.
        $this->generation_armed = false;

        $core = new LLMS_Core();
        $existing_post = $core->get_llms_post();

        $post_data = array(
            'post_title' => 'LLMS.txt',
            'post_content' => 'content',
            'post_status' => 'publish',
            'post_type' => 'llms_txt'
        );

        if ($existing_post) {
            $post_data['ID'] = $existing_post->ID;
            wp_update_post($post_data);
        } else {
            wp_insert_post($post_data);
        }

        if (defined('WP_CLI') && WP_CLI) {
            \WP_CLI::log('Clear cache');
        }

        if ('' !== $this->lock_token && class_exists('LLMS_Lock')) {
            LLMS_Lock::release_identity($this->lock_name, $this->lock_token);
            $this->lock_token = '';
        }

        $this->running = false;

        do_action('wpseo_cache_clear_sitemap');
        do_action('llms_clear_seo_caches_rank_math');
    }

    /**
     * Put the scheduled route back when a rebuild is still owed.
     *
     * @return void
     */
    private function rearm_owed_rebuild()
    {
        if (class_exists('LLMS_DB')) {
            LLMS_DB::ensure_rebuild_scheduled();
        }
    }

    /**
     * Remove the generated destinations, without being able to fail the request.
     *
     * @param string[] $paths
     * @return void
     */
    private function remove_generated_files(array $paths)
    {
        foreach ($paths as $path) {
            $path = (string) $path;
            if ('' === $path) {
                continue;
            }

            try {
                if ($this->wp_filesystem && $this->wp_filesystem->exists($path)) {
                    $this->wp_filesystem->delete($path);
                }
            } catch (\Throwable $e) {
                if (defined('WP_DEBUG') && WP_DEBUG) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnostic for a failed cleanup, WP_DEBUG only.
                    error_log('website-llms-txt: could not remove ' . $path . ' through WP_Filesystem: ' . $e->getMessage());
                }
            }

            if (file_exists($path)) {
                wp_delete_file($path);
            }
        }
    }

    /**
     * Settle a generation that ended without unwinding.
     *
     * @return void
     */
    public function on_generation_shutdown()
    {
        try {
            if ($this->generation_armed && !$this->lock_lost) {
                $this->generation_armed = false;
                $this->remove_generated_files($this->shutdown_remove);

                if (class_exists('LLMS_DB')) {
                    LLMS_DB::note_document_absent();
                }
            }

            $this->running = false;

            if ('' !== $this->lock_token && class_exists('LLMS_Lock')) {
                LLMS_Lock::release_identity($this->lock_name, $this->lock_token);
                $this->lock_token = '';
            }
        } catch (\Throwable $e) {
        }
    }

    /**
     * Put the finished document in place.
     *
     * @param string $new_upload_path Uploads copy, the file get_llms_content() serves.
     * @param string $file_path       Root llms.txt, served by the web server directly.
     * @param string $old_upload_path Pre-domain-name uploads copy, deleted if present.
     * @return bool True when the uploads copy is the output of this run.
     */
    private function promote_generated_file($new_upload_path, $file_path, $old_upload_path)
    {
        if ($this->wp_filesystem->exists($old_upload_path)) {
            $this->wp_filesystem->delete($old_upload_path);
        }

        if (!$this->wp_filesystem->move($this->temp_llms_path, $new_upload_path, true)) {
            return false;
        }

        if (is_multisite()) {
            return true;
        }

        $staging = trailingslashit(dirname($file_path)) . '.llms.txt.part';

        if ($this->wp_filesystem->exists($staging)) {
            $this->wp_filesystem->delete($staging);
        }

        $root_ok = $this->wp_filesystem->copy($new_upload_path, $staging, true)
            && $this->wp_filesystem->move($staging, $file_path, true);

        if (!$root_ok) {
            $this->remove_generated_files(array($staging, $file_path));

            if (defined('WP_CLI') && WP_CLI) {
                \WP_CLI::log('Could not write the root llms.txt, the uploads copy is the served document');
            }

            if (file_exists($file_path)) {
                $rescued = false;

                try {
                    $rescued = (bool) $this->wp_filesystem->copy($new_upload_path, $file_path, true);
                } catch (\Throwable $e) {
                    $rescued = false;
                }

                if (!$rescued) {
                    if (defined('WP_CLI') && WP_CLI) {
                        \WP_CLI::log('The previous root llms.txt could not be removed or overwritten and is still being served');
                    }
                    if (defined('WP_DEBUG') && WP_DEBUG) {
                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnostic for an unremovable stale artifact, WP_DEBUG only.
                        error_log('website-llms-txt: the previous ' . $file_path . ' could not be replaced, removed or overwritten and is still being served');
                    }
                }
            }
        }

        return true;
    }

    /**
     * Keep the generation lease alive during a long run.
     *
     * @return void
     */
    private function refresh_lock()
    {
        if ('' === $this->lock_token || !class_exists('LLMS_Lock')) {
            return;
        }

        $refreshed = LLMS_Lock::refresh($this->lock_name, $this->lock_token);

        if (false === $refreshed) {
            $this->lock_token = '';
            $this->lock_lost  = true;
            throw new \RuntimeException('llms.txt generation lease was lost, another run has taken over');
        }

        $this->lock_token = $refreshed;
    }

    public function schedule_updates()
    {
        if (!wp_next_scheduled('llms_scheduled_update')) {
            $interval = ($this->settings['update_frequency'] === 'daily') ? 'daily' : 'weekly';
            wp_schedule_event(time(), $interval, 'llms_scheduled_update');
        }
    }
}