<?php
/**
 * Index Endpoint for BoostBot REST API.
 *
 * @package BoostBot
 */

// Prevent direct access to the file.
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if (!defined('ABSPATH')) {
    exit;
}

/**
 * BoostBot_Index_Endpoint class.
 *
 * Handles knowledge base indexing operations (Admin only).
 *
 * @since 1.0.0
 */
class BoostBot_Index_Endpoint extends BoostBot_Base_Endpoint
{
    /**
     * The base path for this endpoint.
     *
     * @since 1.0.0
     * @access protected
     * @var string $rest_base
     */
    protected $rest_base = 'index';

    /**
     * Register the routes for this endpoint.
     *
     * @since 1.0.0
     */
    public function register()
    {
        // GET /wp-json/boostbot/v1/index/scan - Scan site (preview)
        $this->register_route(
            '/scan',
            'GET',
            [$this, 'scan_site'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/start - Start indexing
        $this->register_route(
            '/start',
            'POST',
            [$this, 'start_indexing'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/status - Get indexing status
        $this->register_route(
            '/status',
            'GET',
            [$this, 'get_status'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/stop - Stop indexing
        $this->register_route(
            '/stop',
            'POST',
            [$this, 'stop_indexing'],
            [$this, 'admin_access']
        );

        // DELETE /wp-json/boostbot/v1/index/clear - Clear index
        $this->register_route(
            '/clear',
            'DELETE',
            [$this, 'clear_index'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/search - Search index
        $this->register_route(
            '/search',
            'GET',
            [$this, 'search_index'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/process-next - Process next item (loopback endpoint)
        $this->register_route(
            '/process-next',
            'POST',
            [$this, 'process_next_item'],
            [$this, 'loopback_access']
        );

        // POST /wp-json/boostbot/v1/index/kick - Process next item (admin kick / watchdog)
        $this->register_route(
            '/kick',
            'POST',
            [$this, 'kick_indexing'],
            [$this, 'admin_access']
        );

        // ── Rule-based indexing (Elementor-style conditions) ──

        // GET /wp-json/boostbot/v1/index/rules - Get saved rules
        $this->register_route(
            '/rules',
            'GET',
            [$this, 'get_rules'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/rules - Save rules
        $this->register_route(
            '/rules',
            'POST',
            [$this, 'save_rules'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/scope-options - Get hierarchical scope dropdown data
        $this->register_route(
            '/scope-options',
            'GET',
            [$this, 'get_scope_options'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/terms - Get terms for a taxonomy (used in rule value selector)
        $this->register_route(
            '/terms',
            'GET',
            [$this, 'get_terms'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/posts - Get published posts for a post type (used in single-item rule selector)
        $this->register_route(
            '/posts',
            'GET',
            [$this, 'get_posts'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/save-and-index - Save rules + start indexing in one call
        $this->register_route(
            '/save-and-index',
            'POST',
            [$this, 'save_and_index'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/pause - Pause indexing (admin only)
        $this->register_route(
            '/pause',
            'POST',
            [$this, 'pause_indexing'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/resume - Resume indexing (admin only)
        $this->register_route(
            '/resume',
            'POST',
            [$this, 'resume_indexing'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/queue - List queue items (admin only)
        $this->register_route(
            '/queue',
            'GET',
            [$this, 'get_queue'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/retry/(?P<id>\d+) - Retry an item (admin only)
        $this->register_route(
            '/retry/(?P<id>\d+)',
            'POST',
            [$this, 'retry_item'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/retry-all-failed - Retry all failed items (admin only)
        $this->register_route(
            '/retry-all-failed',
            'POST',
            [$this, 'retry_all_failed'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/estimate - Estimate deep indexing chunks/credits (Week 24.5)
        $this->register_route(
            '/estimate',
            'GET',
            [$this, 'estimate_indexing'],
            [$this, 'admin_access']
        );

        // Week 30: Content visibility management

        // GET /wp-json/boostbot/v1/index/indexed-content - List all indexed content with is_enabled
        $this->register_route(
            '/indexed-content',
            'GET',
            [$this, 'get_indexed_content'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/indexed-content/(?P<id>\d+)/toggle - Toggle is_enabled
        $this->register_route(
            '/indexed-content/(?P<id>\d+)/toggle',
            'POST',
            [$this, 'toggle_content_enabled'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/indexed-content/bulk-toggle - Bulk toggle multiple entries
        $this->register_route(
            '/indexed-content/bulk-toggle',
            'POST',
            [$this, 'bulk_toggle_content_enabled'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/site-content - Browse ALL WordPress content with filters
        $this->register_route(
            '/site-content',
            'GET',
            [$this, 'get_site_content'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/taxonomies - Get all available taxonomies with terms
        $this->register_route(
            '/taxonomies',
            'GET',
            [$this, 'get_taxonomies'],
            [$this, 'admin_access']
        );

        // GET /wp-json/boostbot/v1/index/excluded-ids - Get excluded content IDs
        $this->register_route(
            '/excluded-ids',
            'GET',
            [$this, 'get_excluded_ids'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/excluded-ids - Set excluded content IDs
        $this->register_route(
            '/excluded-ids',
            'POST',
            [$this, 'set_excluded_ids'],
            [$this, 'admin_access']
        );

        // POST /wp-json/boostbot/v1/index/reindex-stale - Re-index stale content
        $this->register_route(
            '/reindex-stale',
            'POST',
            [$this, 'reindex_stale'],
            [$this, 'admin_access']
        );
    }

    /**
     * Estimate deep indexing work (items/chars/chunks).
     *
     * Week 24.5: used for UI "estimate before indexing" and Week 27 credits gating.
     *
     * Query params:
     * - index_mode: basic|deep|deep_incremental
     * - content_types[]: post|page|product
     *
     * @since 1.8.0
     */
    public function estimate_indexing( $request ) {
        $index_mode = sanitize_text_field( $request->get_param( 'index_mode' ) ?: 'deep' );
        if ( ! in_array( $index_mode, array( 'basic', 'deep', 'deep_incremental' ), true ) ) {
            $index_mode = 'deep';
        }

        $content_types = $request->get_param( 'content_types' ) ?: array( 'post', 'page' );
        if ( ! is_array( $content_types ) ) {
            $content_types = array( $content_types );
        }
        $content_types = array_map( 'sanitize_text_field', $content_types );
        $allowed_types = array( 'post', 'page', 'product' );
        $content_types = array_values( array_intersect( $content_types, $allowed_types ) );
        if ( empty( $content_types ) ) {
            $content_types = array( 'post', 'page' );
        }

        $scanner = new BoostBot_Site_Scanner();
        $results = $scanner->scan_site();

        $processor = new BoostBot_Content_Processor();

        // Helper: estimate chunk count for a given char length (chunk size 2500, overlap 250 => stride 2250).
        $estimate_chunks = function( int $chars ): int {
            $chunk_size = 2500;
            $stride     = 2250;
            if ( $chars <= 0 ) {
                return 0;
            }
            if ( $chars <= $chunk_size ) {
                return 1;
            }
            return 1 + (int) ceil( ( $chars - $chunk_size ) / $stride );
        };

        global $wpdb;
        $index_table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );

        $per_type = array();
        $items_total   = 0;
        $items_changed = 0;
        $chars_total   = 0;
        $chunks_total  = 0;

        foreach ( $content_types as $type ) {
            $list = array();
            if ( 'post' === $type ) {
                $list = $results['posts'] ?? array();
            } elseif ( 'page' === $type ) {
                $list = $results['pages'] ?? array();
            } elseif ( 'product' === $type ) {
                $list = $results['products'] ?? array();
            }

            $ids = array();
            foreach ( $list as $item ) {
                if ( isset( $item['id'] ) ) {
                    $ids[] = (int) $item['id'];
                }
            }
            $ids = array_values( array_unique( array_filter( $ids ) ) );

            // Map stored hashes for incremental mode (single query).
            $stored_hash_by_id = array();
            if ( 'deep_incremental' === $index_mode && ! empty( $ids ) ) {
                $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $sql = "SELECT content_id, content_hash FROM {$index_table} WHERE content_type = %s AND content_id IN ({$placeholders})";
                $params = array_merge( array( $type ), $ids );
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
                $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A );
                if ( is_array( $rows ) ) {
                    foreach ( $rows as $r ) {
                        $cid = (int) ( $r['content_id'] ?? 0 );
                        $h   = isset( $r['content_hash'] ) ? (string) $r['content_hash'] : '';
                        if ( $cid && '' !== $h ) {
                            $stored_hash_by_id[ $cid ] = $h;
                        }
                    }
                }
            }

            $t_items_total   = count( $ids );
            $t_items_changed = 0;
            $t_chars_total   = 0;
            $t_chunks_total  = 0;

            foreach ( $ids as $id ) {
                $text = $processor->get_canonical_text_for_indexing( (int) $id );
                $len  = mb_strlen( (string) $text );
                $hash = ( '' !== trim( (string) $text ) ) ? hash( 'sha256', (string) $text ) : '';

                if ( 'deep_incremental' === $index_mode ) {
                    $prev = $stored_hash_by_id[ (int) $id ] ?? '';
                    if ( '' !== $prev && '' !== $hash && $prev === $hash ) {
                        continue; // unchanged -> no credits needed
                    }
                    $t_items_changed++;
                }

                $t_chars_total  += (int) $len;
                $t_chunks_total += $estimate_chunks( (int) $len );
            }

            if ( 'deep' === $index_mode || 'basic' === $index_mode ) {
                // For full modes, changed == total (for reporting clarity).
                $t_items_changed = $t_items_total;
            }

            $per_type[ $type ] = array(
                'items_total'   => $t_items_total,
                'items_changed' => $t_items_changed,
                'chars_total'   => $t_chars_total,
                'chunks_total'  => $t_chunks_total,
            );

            $items_total   += $t_items_total;
            $items_changed += $t_items_changed;
            $chars_total   += $t_chars_total;
            $chunks_total  += $t_chunks_total;
        }

        // Token-based cost calculation (matches backend/src/services/cost.ts):
        // - text-embedding-3-small: $0.02 per 1M tokens
        // - MAX_API_COST_PER_COIN = $0.04
        // - ~3 chars per token (Hebrew/English average)
        // Formula: BoostCoin = (chars / 3) * ($0.02 / 1,000,000) / $0.04
        //        = chars / 6,000,000
        $estimated_tokens        = (int) ceil( $chars_total / 3 );
        $usd_cost                = ( $estimated_tokens * 0.02 ) / 1000000;
        $estimated_boost_credits = $usd_cost / 0.04;

        return $this->success( array(
            'index_mode' => $index_mode,
            'content_types' => $content_types,
            'items_total' => $items_total,
            'items_changed' => $items_changed,
            'chars_total' => $chars_total,
            'estimated_chunks' => $chunks_total,
            'estimated_tokens' => $estimated_tokens,
            'estimated_boost_credits' => round( $estimated_boost_credits, 4 ),
            'by_type' => $per_type,
        ) );
    }

    /**
     * Scan site and return content summary.
     *
     * @since 1.0.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function scan_site($request)
    {
        BoostBot_Logger::debug('Scanning site for indexing');

        $scanner = new BoostBot_Site_Scanner();
        $results = $scanner->scan_site();

        // Get counts
        $counts = $scanner->get_content_counts();

        // Get estimates
        $estimates = $scanner->estimate_indexing($counts);

        // Get current index stats
        $index = new BoostBot_Knowledge_Index();
        $index_stats = $index->get_stats();

        // Get embedding provider info
        $embeddings = new BoostBot_Embedding_Generator();
        $provider_info = $embeddings->get_provider_info();

        return $this->success([
            'site_type'       => $results['site_type'],
            'counts'          => $counts,
            'categories'      => count($results['categories']),
            'custom_post_types' => $results['custom_post_types'],
            'estimates'       => $estimates,
            'index_stats'     => $index_stats,
            'embedding_provider' => $provider_info,
            'content_preview' => [
                'posts'    => array_slice($results['posts'], 0, 5),
                'pages'    => array_slice($results['pages'], 0, 5),
                'products' => array_slice($results['products'], 0, 5),
            ],
        ]);
    }

    /**
     * Search the knowledge index.
     *
     * @since 1.0.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function search_index($request)
    {
        $query = sanitize_text_field($request->get_param('q') ?: $request->get_param('query'));
        
        if (empty($query)) {
            return $this->error(__('Search query required', 'boostbot'), 400);
        }

        $limit = absint($request->get_param('limit') ?: 5);
        $types = $request->get_param('types');

        if ($types && !is_array($types)) {
            $types = explode(',', $types);
            $types = array_map('sanitize_text_field', $types);
        }

        $language = sanitize_text_field( $request->get_param( 'language' ) ?: '' );
        if ( empty( $language ) && class_exists( 'BoostBot_Language' ) ) {
            $language = BoostBot_Language::get_primary();
        }

        $index = new BoostBot_Knowledge_Index();
        $results = $index->search($query, [
            'limit'    => min($limit, 20),
            'types'    => $types,
            'language' => ( $language !== 'all' && $language !== '' ) ? $language : null,
        ]);

        return $this->success([
            'query'   => $query,
            'count'   => count($results),
            'results' => $results,
        ]);
    }

    /**
     * Start knowledge base indexing.
     *
     * @since 1.0.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response|WP_Error The response or error.
     */
    public function start_indexing($request)
    {
        BoostBot_Logger::debug('Starting indexing');

        // Check license for Pro feature
        if (class_exists('BoostBot_License_Manager')) {
            $license_manager = new BoostBot_License_Manager();
            if (!$license_manager->is_feature_enabled('knowledge_base')) {
                return $this->error(
                    __('Knowledge base indexing requires Pro subscription', 'boostbot'),
                    403
                );
            }
        }

        // Get content types to index
        $content_types = $request->get_param('content_types') ?: ['post', 'page'];
        if (!is_array($content_types)) {
            $content_types = [$content_types];
        }

        // Sanitize content types
        $allowed_types = ['post', 'page', 'product', 'custom'];
        $content_types = array_intersect($content_types, $allowed_types);

        if (empty($content_types)) {
            return $this->error(__('No valid content types specified', 'boostbot'), 400);
        }

        // Check if already indexing
        if (BoostBot_Index_Job::is_running()) {
            return $this->error(__('Indexing already in progress', 'boostbot'), 409);
        }

        // Indexing mode: basic (default) vs deep (semantic chunk embeddings)
        $index_mode = sanitize_text_field( $request->get_param( 'index_mode' ) ?: 'basic' );
        if ( ! in_array( $index_mode, array( 'basic', 'deep', 'deep_incremental' ), true ) ) {
            $index_mode = 'basic';
        }

        // Enforce unified BoostCoin gating before creating any indexing job.
        $credits_check = $this->check_boost_credits_available();
        if ( is_wp_error( $credits_check ) ) {
            return $this->error( $credits_check->get_error_message(), 402 );
        }

        // Create job
        $job_id = BoostBot_Index_Job::create([
            'content_types' => $content_types,
            'selected_ids'  => $request->get_param('selected_ids'),
            'index_mode'    => $index_mode,
        ]);

        // Dispatch background indexer
        $dispatched = BoostBot_Background_Indexer::dispatch($job_id);

        if (!$dispatched) {
            return $this->error(__('Failed to start indexing', 'boostbot'), 500);
        }

        $job = BoostBot_Index_Job::get($job_id);

        BoostBot_Logger::info('Indexing started', [
            'job_id'        => $job_id,
            'content_types' => $content_types,
            'total_items'   => $job->total,
        ]);

        return $this->success([
            'message'       => __('Indexing started', 'boostbot'),
            'job_id'        => $job_id,
            'content_types' => $content_types,
            'total_items'   => $job->total,
            'index_mode'    => $index_mode,
        ]);
    }

    /**
     * Get indexing status.
     *
     * @since 1.0.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function get_status($request)
    {
        BoostBot_Logger::debug('Getting indexing status');

        // Get active job status
        $job = BoostBot_Index_Job::get();
        $job_status = null;

        if ($job) {
            $job_status = [
                'id'         => $job->id,
                'status'     => $job->status,
                'progress'   => BoostBot_Index_Job::get_progress($job->id),
                'processed'  => $job->processed,
                'total'      => $job->total,
                'errors'     => BoostBot_Index_Job::get_error_count( $job ),
                'started_at' => $job->started_at,
            ];
        }

        $queue_stats = BoostBot_Index_Job::get_queue_stats();

        // Get indexed content count from database
        global $wpdb;
        $table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );

        // Check if table exists
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $table_exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table));

        $index_stats = [
            'total_indexed' => 0,
            'with_specs'    => 0,
            'with_prices'   => 0,
            'by_type'       => [],
        ];

        if ($table_exists) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
            $index_stats['total_indexed'] = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");

            // Count items with technical specs
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
            $index_stats['with_specs'] = (int) $wpdb->get_var(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT COUNT(*) FROM {$table} WHERE technical_specs IS NOT NULL AND technical_specs != '' AND technical_specs != '[]' AND technical_specs != '{}'"
            );

            // Count items with price info
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
            $index_stats['with_prices'] = (int) $wpdb->get_var(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT COUNT(*) FROM {$table} WHERE price_info IS NOT NULL AND price_info != '' AND price_info != 'null'"
            );

            // Count by content type
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
            $types = $wpdb->get_results(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT content_type, COUNT(*) as count FROM {$table} GROUP BY content_type",
                ARRAY_A
            );
            foreach ($types as $type) {
                $index_stats['by_type'][$type['content_type']] = (int) $type['count'];
            }
        }

        // Get last full index date
        $last_index = get_option('boostbot_last_full_index', null);
        $paused     = (bool) get_option( 'boostbot_index_paused', false );

        // Search health: chunk and embedding counts.
        $search_health = array(
            'total_chunks'           => 0,
            'chunks_with_embeddings' => 0,
            'embedding_available'    => false,
            'search_status'          => 'unknown',
        );

        $chunks_table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_chunks' );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $chunks_table_exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $chunks_table ) );

        if ( $chunks_table_exists ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
            $search_health['total_chunks'] = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$chunks_table}" );

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
            $search_health['chunks_with_embeddings'] = (int) $wpdb->get_var(
                "SELECT COUNT(*) FROM {$chunks_table} WHERE embedding_vector IS NOT NULL AND embedding_vector != ''"
            );
        }

        if ( class_exists( 'BoostBot_Embedding_Generator' ) ) {
            $eg = new BoostBot_Embedding_Generator();
            $search_health['embedding_available'] = $eg->is_available();
        }

        $enabled = $index_stats['total_indexed'];
        if ( 0 === $search_health['total_chunks'] && $enabled > 0 ) {
            $search_health['search_status'] = 'degraded_no_chunks';
        } elseif ( 0 === $search_health['chunks_with_embeddings'] && $search_health['total_chunks'] > 0 ) {
            $search_health['search_status'] = 'degraded_no_embeddings';
        } elseif ( ! $search_health['embedding_available'] ) {
            $search_health['search_status'] = 'warning_no_embedding_provider';
        } else {
            $search_health['search_status'] = 'healthy';
        }

        // Build response for frontend
        return $this->success([
            'job'             => $job_status,
            'queue_stats'     => $queue_stats,
            'index_stats'     => $index_stats,
            'last_full_index' => $last_index,
            'is_indexing'     => BoostBot_Index_Job::is_running(),
            'is_running'      => BoostBot_Index_Job::is_running(),
            'paused'          => $paused,
            'progress'        => $job_status ? $job_status['progress'] : 0,
            // Flattened stats for easier frontend access
            'total_indexed'   => $index_stats['total_indexed'],
            'with_specs'      => $index_stats['with_specs'],
            'with_prices'     => $index_stats['with_prices'],
            'last_indexed'    => $last_index,
            'search_health'   => $search_health,
        ]);
    }

    // =========================================================================
    // Rule-Based Indexing (Elementor-style display conditions)
    // =========================================================================

    /**
     * GET /index/rules — Return saved indexing rules.
     *
     * @since 1.14.2
     */
    public function get_rules( $request ) {
        $language = BoostBot_Language::with_fallback( $request->get_param( 'language' ) );

        return $this->success( array(
            'rules' => BoostBot_Index_Rules::get_rules( $language ),
        ) );
    }

    /**
     * POST /index/rules — Persist the rule set.
     *
     * @since 1.14.2
     */
    public function save_rules( $request ) {
        $body     = $request->get_json_params();
        $rules    = isset( $body['rules'] ) && is_array( $body['rules'] ) ? $body['rules'] : array();
        $language = BoostBot_Language::with_fallback( $body['language'] ?? '' );

        BoostBot_Index_Rules::save_rules( $rules, $language );

        return $this->success( array(
            'message' => __( 'Rules saved', 'boostbot' ),
            'rules'   => BoostBot_Index_Rules::get_rules( $language ),
        ) );
    }

    /**
     * GET /index/scope-options — Hierarchical dropdown data for the rule builder.
     *
     * @since 1.14.2
     */
    public function get_scope_options( $request ) {
        return $this->success( BoostBot_Index_Rules::get_scope_options() );
    }

    /**
     * GET /index/terms?taxonomy=category — Terms for secondary dropdown.
     *
     * @since 1.14.2
     */
    public function get_terms( $request ) {
        $taxonomy = sanitize_key( $request->get_param( 'taxonomy' ) ?: '' );
        if ( '' === $taxonomy ) {
            return $this->error( 'taxonomy parameter required', 400 );
        }
        return $this->success( BoostBot_Index_Rules::get_terms_for_taxonomy( $taxonomy ) );
    }

    /**
     * GET /index/posts?post_type=page&search=about&limit=20
     * Return published posts for one post type (single-item picker in rules UI).
     *
     * @since 1.14.2
     */
    public function get_posts( $request ) {
        $post_type = sanitize_key( (string) ( $request->get_param( 'post_type' ) ?: '' ) );
        $search    = sanitize_text_field( (string) ( $request->get_param( 'search' ) ?: '' ) );
        $limit     = (int) ( $request->get_param( 'limit' ) ?: 20 );

        if ( '' === $post_type ) {
            return $this->error( 'post_type parameter required', 400 );
        }

        return $this->success(
            BoostBot_Index_Rules::get_posts_for_post_type( $post_type, $search, $limit )
        );
    }

    /**
     * POST /index/save-and-index — Save rules + resolve + start indexing.
     *
     * Always uses deep_incremental mode. New (never-indexed) items are
     * naturally processed by the incremental flow since they have no hash.
     *
     * @since 1.14.2
     */
    public function save_and_index( $request ) {
        // 1. Save rules.
        $body     = $request->get_json_params();
        $rules    = isset( $body['rules'] ) && is_array( $body['rules'] ) ? $body['rules'] : array();
        $language = BoostBot_Language::with_fallback( $body['language'] ?? '' );
        BoostBot_Index_Rules::save_rules( $rules, $language );

        // 2. Resolve rules to post IDs.
        $resolved_ids = BoostBot_Index_Rules::resolve_to_post_ids( $rules );

        if ( empty( $resolved_ids ) ) {
            return $this->error( __( 'No content matched the rules', 'boostbot' ), 400 );
        }

        // 3. Check if already indexing.
        if ( BoostBot_Index_Job::is_running() ) {
            return $this->error( __( 'Indexing already in progress', 'boostbot' ), 409 );
        }

        // 4. Determine content types from resolved IDs.
        $content_types = array();
        foreach ( $resolved_ids as $pid ) {
            $pt = get_post_type( (int) $pid );
            if ( $pt ) {
                $content_types[ $pt ] = true;
            }
        }
        $content_types = array_keys( $content_types );

        // Enforce unified BoostCoin gating before creating any indexing job.
        $credits_check = $this->check_boost_credits_available();
        if ( is_wp_error( $credits_check ) ) {
            return $this->error( $credits_check->get_error_message(), 402 );
        }

        // 5. Create job with deep_incremental.
        $job_id = BoostBot_Index_Job::create( array(
            'content_types' => $content_types,
            'selected_ids'  => $resolved_ids,
            'index_mode'    => 'deep_incremental',
        ) );

        // 6. Dispatch.
        $dispatched = BoostBot_Background_Indexer::dispatch( $job_id );

        if ( ! $dispatched ) {
            return $this->error( __( 'Failed to start indexing', 'boostbot' ), 500 );
        }

        $job = BoostBot_Index_Job::get( $job_id );

        BoostBot_Logger::info( 'Rule-based indexing started', array(
            'job_id'        => $job_id,
            'content_types' => $content_types,
            'resolved_ids'  => count( $resolved_ids ),
            'total_items'   => $job->total ?? 0,
        ) );

        return $this->success( array(
            'message'       => __( 'Indexing started', 'boostbot' ),
            'job_id'        => $job_id,
            'content_types' => $content_types,
            'total_items'   => $job->total ?? 0,
            'resolved_ids'  => count( $resolved_ids ),
        ) );
    }

    /**
     * POST /index/reindex-stale — Find content where the WP post was
     * modified more recently than the index entry and start deep re-indexing.
     *
     * @since 1.0.17
     */
    public function reindex_stale( $request ) {
        $body       = $request->get_json_params();
        $days       = isset( $body['days'] ) ? absint( $body['days'] ) : absint( BoostBot_Settings::get( 'stale_index_days', 7 ) );
        $days       = max( 1, min( 365, $days ) );

        if ( BoostBot_Index_Job::is_running() ) {
            return $this->error( __( 'Indexing already in progress', 'boostbot' ), 409 );
        }

        // Resolve which post IDs are covered by current rules.
        $rule_ids = BoostBot_Index_Rules::resolve_to_post_ids();
        if ( empty( $rule_ids ) ) {
            return $this->success( array( 'message' => __( 'No index rules configured', 'boostbot' ), 'stale_count' => 0 ) );
        }

        // Find stale items: post_modified > index.updated_at by at least $days days.
        global $wpdb;
        $index_table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );
        $cutoff      = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
        $id_placeholders = implode( ',', array_fill( 0, count( $rule_ids ), '%d' ) );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $stale_ids = $wpdb->get_col(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                "SELECT p.ID
                 FROM {$wpdb->posts} p
                 LEFT JOIN {$index_table} ki ON ki.content_id = p.ID AND ki.content_type = p.post_type
                 WHERE p.ID IN ({$id_placeholders})
                   AND p.post_status = 'publish'
                   AND (
                       ki.id IS NULL
                       OR ki.updated_at < %s
                       OR ki.full_content IS NULL
                       OR ki.full_content = ''
                   )",
                ...array_merge( $rule_ids, array( $cutoff ) )
            )
        );

        if ( empty( $stale_ids ) ) {
            return $this->success( array( 'message' => __( 'All content is up to date', 'boostbot' ), 'stale_count' => 0 ) );
        }

        $stale_ids = array_map( 'intval', $stale_ids );

        $credits_check = $this->check_boost_credits_available();
        if ( is_wp_error( $credits_check ) ) {
            return $this->error( $credits_check->get_error_message(), 402 );
        }

        $content_types = array();
        foreach ( $stale_ids as $pid ) {
            $pt = get_post_type( $pid );
            if ( $pt ) {
                $content_types[ $pt ] = true;
            }
        }

        $job_id = BoostBot_Index_Job::create( array(
            'content_types' => array_keys( $content_types ),
            'selected_ids'  => $stale_ids,
            'index_mode'    => 'deep_incremental',
        ) );

        $dispatched = BoostBot_Background_Indexer::dispatch( $job_id );
        if ( ! $dispatched ) {
            return $this->error( __( 'Failed to start indexing', 'boostbot' ), 500 );
        }

        BoostBot_Logger::info( 'Stale re-indexing started', array(
            'job_id'      => $job_id,
            'stale_count' => count( $stale_ids ),
            'days'        => $days,
        ) );

        return $this->success( array(
            /* translators: %d: number of stale items to re-index */
            'message'     => sprintf( __( 'Re-indexing %d stale items', 'boostbot' ), count( $stale_ids ) ),
            'job_id'      => $job_id,
            'stale_count' => count( $stale_ids ),
        ) );
    }

    /**
     * Pause indexing.
     *
     * @since 1.13.0
     */
    public function pause_indexing( $request ) {
        update_option( 'boostbot_index_paused', true );
        return $this->success( array( 'message' => __( 'Indexing paused', 'boostbot' ) ) );
    }

    /**
     * Resume indexing.
     *
     * @since 1.13.0
     */
    public function resume_indexing( $request ) {
        delete_option( 'boostbot_index_paused' );
        // Kick the worker.
        if ( class_exists( 'BoostBot_Background_Indexer' ) ) {
            BoostBot_Background_Indexer::process_batch();
        }
        return $this->success( array( 'message' => __( 'Indexing resumed', 'boostbot' ) ) );
    }

    /**
     * Get queue items (paged).
     *
     * @since 1.13.0
     */
    public function get_queue( $request ) {
        $page     = (int) ( $request->get_param( 'page' ) ?: 1 );
        $per_page = (int) ( $request->get_param( 'per_page' ) ?: 50 );
        $status   = $request->get_param( 'status' );
        $status   = is_string( $status ) ? sanitize_text_field( $status ) : null;

        $queue = new BoostBot_Index_Queue();
        $data  = $queue->get_queue( $page, $per_page, $status );

        return $this->success( array(
            'page'       => $page,
            'per_page'   => $per_page,
            'status'     => $status,
            'total'      => (int) ( $data['total'] ?? 0 ),
            'items'      => $data['items'] ?? array(),
            'queue_stats'=> $queue->get_stats(),
        ) );
    }

    /**
     * Retry a specific queue item.
     *
     * @since 1.13.0
     */
    public function retry_item( $request ) {
        $id = (int) $request->get_param( 'id' );
        $queue = new BoostBot_Index_Queue();
        $ok = $queue->retry( $id );
        if ( ! $ok ) {
            return $this->error( __( 'Failed to retry item', 'boostbot' ), 400 );
        }

        // Kick the worker.
        BoostBot_Background_Indexer::process_batch();

        return $this->success( array( 'message' => __( 'Item queued for retry', 'boostbot' ), 'id' => $id ) );
    }

    /**
     * Retry all failed items.
     *
     * @since 1.13.0
     */
    public function retry_all_failed( $request ) {
        global $wpdb;
        $table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_index_queue' );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $updated = $wpdb->query( "UPDATE {$table} SET status='pending', attempts=0, last_error=NULL, started_at=NULL, completed_at=NULL WHERE status='failed'" );

        // Kick the worker.
        BoostBot_Background_Indexer::process_batch();

        return $this->success( array(
            'message' => __( 'Failed items queued for retry', 'boostbot' ),
            'updated' => (int) $updated,
        ) );
    }

    /**
     * Stop indexing.
     *
     * @since 1.0.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function stop_indexing($request)
    {
        BoostBot_Logger::debug('Stopping indexing');

        // Stop background indexer
        BoostBot_Background_Indexer::stop();

        BoostBot_Logger::info('Indexing stopped');

        return $this->success([
            'message' => __('Indexing stopped', 'boostbot'),
        ]);
    }

    /**
     * Clear the entire index.
     *
     * @since 1.0.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response|WP_Error The response or error.
     */
    public function clear_index($request)
    {
        BoostBot_Logger::debug('Clearing index');

        // Require confirmation
        $confirm = (bool) $request->get_param('confirm');
        if (!$confirm) {
            return $this->error(
                __('Please confirm by setting confirm=true', 'boostbot'),
                400
            );
        }

        global $wpdb;
        $table        = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );
        $chunks_table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_chunks' );
        $raptor_table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_raptor_nodes' );
        $queue_table  = self::sanitize_identifier( $wpdb->prefix . 'boostbot_index_queue' );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $wpdb->query( "TRUNCATE TABLE {$chunks_table}" );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $wpdb->query( "TRUNCATE TABLE {$raptor_table}" );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $wpdb->query( "TRUNCATE TABLE {$table}" );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $wpdb->query( "TRUNCATE TABLE {$queue_table}" );

        // Reset site profile stats
        $site_profile_table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_site_profile' );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $site_profile_table,
            [
                'total_posts_indexed'    => 0,
                'total_pages_indexed'    => 0,
                'total_products_indexed' => 0,
                'last_full_index'        => null,
            ],
            ['id' => 1]
        );

        // Clear indexing status
        delete_option('boostbot_indexing_status');
        delete_transient('boostbot_indexing_in_progress');

        BoostBot_Logger::info('Index cleared');

        return $this->success([
            'message' => __('Index cleared successfully', 'boostbot'),
        ]);
    }

    /**
     * Count items to index.
     *
     * @since 1.0.0
     * @param array $content_types Content types to count.
     * @return int Total count.
     */
    private function count_items_to_index($content_types)
    {
        $total = 0;

        foreach ($content_types as $type) {
            if ($type === 'product' && !class_exists('WooCommerce')) {
                continue;
            }

            $count = wp_count_posts($type);
            if (isset($count->publish)) {
                $total += $count->publish;
            }
        }

        return $total;
    }

    /**
     * Check unified BoostCoin availability for indexing operations.
     *
     * @return true|WP_Error
     */
    private function check_boost_credits_available() {
        if ( ! class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            return new WP_Error(
                'boostbot_credits_client_missing',
                __( 'BoostCoin backend client is not available.', 'boostbot' )
            );
        }

        $client = new BoostBot_Credits_Backend_Client();
        $site_id = $client->ensure_site_id();
        if ( is_wp_error( $site_id ) ) {
            return $site_id;
        }

        $status = $client->get_status( (string) $site_id );
        if ( is_wp_error( $status ) ) {
            return new WP_Error(
                'boostbot_credits_status_failed',
                __( 'Failed to verify BoostCoin before indexing.', 'boostbot' ) . ' ' . $status->get_error_message()
            );
        }

        $allowed = isset( $status['allowed'] ) ? (bool) $status['allowed'] : true;
        $boost_remaining = isset( $status['boost_credits']['remaining'] ) ? (float) $status['boost_credits']['remaining'] : 0.0;

        if ( ! $allowed || $boost_remaining <= 0.0 ) {
            return new WP_Error(
                'boostbot_insufficient_credits',
                __( 'Insufficient BoostCoin. Please purchase credits before indexing.', 'boostbot' )
            );
        }

        return true;
    }

    /**
     * Permission check for loopback requests.
     *
     * Validates the loopback token from HTTP header.
     *
     * @since 1.8.0
     * @param WP_REST_Request $request The REST request object.
     * @return bool|WP_Error True if authorized, WP_Error if not.
     */
    public function loopback_access( $request ) {
        $token = $request->get_header( 'X-BoostBot-Loopback' );

        if ( empty( $token ) ) {
            return new WP_Error( 'unauthorized', 'Missing loopback token', array( 'status' => 401 ) );
        }

        // Validate token - must match the current loopback token set by the indexer.
        $expected = (string) get_option( 'boostbot_index_loopback_token', '' );
        if ( '' === $expected || ! hash_equals( $expected, $token ) ) {
            return new WP_Error( 'unauthorized', 'Invalid loopback token', array( 'status' => 401 ) );
        }

        return true;
    }

    /**
     * Process next item in the queue (loopback endpoint).
     *
     * This endpoint is called via HTTP loopback to ensure memory isolation.
     * Each call processes exactly one item in a fresh PHP process.
     *
     * @since 1.8.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function process_next_item( $request ) {
        // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running indexing operation needs higher limits
        @ini_set( 'memory_limit', '512M' );
        // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- indexing may exceed default 30s
        @set_time_limit( 120 );

        // Process one batch (will be 1 item for deep mode).
        BoostBot_Background_Indexer::process_batch();

        return $this->success( array( 'processed' => true ) );
    }

    /**
     * Admin "kick": process exactly one batch/item.
     *
     * Some hosting setups don't run wp-cron reliably without traffic and may block loopback calls.
     * The admin UI polls status; this endpoint provides a safe way to advance indexing while the admin page is open.
     *
     * @since 1.13.1
     * @param WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function kick_indexing( $request ) {
        // Don't inflate memory limit — process_batch() has its own safety gate.
        // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- indexing may exceed default 30s
        @set_time_limit( 120 );

        BoostBot_Background_Indexer::process_batch();

        $job        = BoostBot_Index_Job::get();
        $queue_stats = BoostBot_Index_Job::get_queue_stats();

        return $this->success( array(
            'processed'   => true,
            'job'         => $job,
            'queue_stats' => $queue_stats,
        ) );
    }

    /**
     * Get all indexed content with is_enabled status.
     *
     * Week 30: Content visibility management.
     *
     * Query params:
     * - page: Page number (default 1)
     * - per_page: Items per page (default 50, max 200)
     * - content_type: Filter by type (post, page, product)
     * - search: Search by title
     * - enabled: Filter by is_enabled (0 or 1)
     *
     * @since 1.14.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function get_indexed_content( $request ) {
        global $wpdb;
        $table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );

        $page     = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
        $per_page = min( 200, max( 1, (int) $request->get_param( 'per_page' ) ?: 50 ) );
        $offset   = ( $page - 1 ) * $per_page;

        $content_type = sanitize_text_field( $request->get_param( 'content_type' ) ?: '' );
        $search       = sanitize_text_field( $request->get_param( 'search' ) ?: '' );
        $enabled      = $request->get_param( 'enabled' );
        $taxonomy     = sanitize_text_field( $request->get_param( 'taxonomy' ) ?: '' );  // e.g. "category:5"
        $date_from    = sanitize_text_field( $request->get_param( 'date_from' ) ?: '' );
        $date_to      = sanitize_text_field( $request->get_param( 'date_to' ) ?: '' );
        $sort_by      = sanitize_text_field( $request->get_param( 'sort_by' ) ?: 'updated_at' );
        $sort_order   = strtoupper( sanitize_text_field( $request->get_param( 'sort_order' ) ?: 'DESC' ) );
        if ( ! in_array( $sort_order, array( 'ASC', 'DESC' ), true ) ) {
            $sort_order = 'DESC';
        }
        $valid_sort_cols = array( 'updated_at', 'indexed_at', 'title', 'content_type' );
        if ( ! in_array( $sort_by, $valid_sort_cols, true ) ) {
            $sort_by = 'updated_at';
        }

        // Build WHERE clause
        $where_parts  = array( '1=1' );
        $where_values = array();

        if ( '' !== $content_type && in_array( $content_type, array( 'post', 'page', 'product' ), true ) ) {
            $where_parts[]  = 'ki.content_type = %s';
            $where_values[] = $content_type;
        }

        if ( '' !== $search ) {
            $like           = '%' . $wpdb->esc_like( $search ) . '%';
            $where_parts[]  = 'ki.title LIKE %s';
            $where_values[] = $like;
        }

        if ( '' !== $enabled && null !== $enabled ) {
            $where_parts[]  = 'ki.is_enabled = %d';
            $where_values[] = (int) $enabled;
        }

        $language = sanitize_text_field( $request->get_param( 'language' ) ?: '' );
        if ( '' !== $language && 'all' !== $language ) {
            $where_parts[]  = 'ki.language = %s';
            $where_values[] = $language;
        }

        // Date filters (based on WP post dates via JOIN)
        $needs_post_join = ( '' !== $date_from || '' !== $date_to || '' !== $taxonomy );
        if ( '' !== $date_from ) {
            $where_parts[]  = 'p.post_date >= %s';
            $where_values[] = $date_from . ' 00:00:00';
        }
        if ( '' !== $date_to ) {
            $where_parts[]  = 'p.post_date <= %s';
            $where_values[] = $date_to . ' 23:59:59';
        }

        // Taxonomy filter: "taxonomy_name:term_id" e.g. "category:5" or "product_cat:12"
        $tax_join = '';
        if ( '' !== $taxonomy && strpos( $taxonomy, ':' ) !== false ) {
            list( $tax_name, $term_id ) = explode( ':', $taxonomy, 2 );
            $tax_name = sanitize_key( $tax_name );
            $term_id  = (int) $term_id;
            if ( $tax_name && $term_id > 0 ) {
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $tax_join = $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    " INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = ki.content_id
                      INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
                        AND tt.taxonomy = %s AND tt.term_id = %d",
                    $tax_name,
                    $term_id
                );
            }
        }

        $post_join = '';
        if ( $needs_post_join ) {
            $post_join = " LEFT JOIN {$wpdb->posts} p ON p.ID = ki.content_id"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        }

        $where_clause = implode( ' AND ', $where_parts );

        // Get total count
        $count_sql = "SELECT COUNT(DISTINCT ki.id) FROM {$table} ki {$post_join} {$tax_join} WHERE {$where_clause}"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        if ( ! empty( $where_values ) ) {
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $count_sql = $wpdb->prepare( $count_sql, $where_values );
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $total = (int) $wpdb->get_var( $count_sql );

        // Get items
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/join/where from internal constants
        $select_sql = "SELECT DISTINCT ki.id, ki.content_type, ki.content_id, ki.title, ki.url, ki.indexed_at, ki.updated_at, ki.is_enabled
                       FROM {$table} ki {$post_join} {$tax_join}
                       WHERE {$where_clause}
                       ORDER BY ki.{$sort_by} {$sort_order}
                       LIMIT %d OFFSET %d";

        $params = array_merge( $where_values, array( $per_page, $offset ) );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $rows = $wpdb->get_results( $wpdb->prepare( $select_sql, $params ), ARRAY_A );

        $items = array();
        foreach ( (array) $rows as $row ) {
            $cid = (int) $row['content_id'];
            $taxonomies = $this->get_post_taxonomy_labels( $cid );

            $items[] = array(
                'id'           => (int) $row['id'],
                'content_type' => $row['content_type'],
                'content_id'   => $cid,
                'title'        => $row['title'],
                'url'          => $row['url'],
                'indexed_at'   => $row['indexed_at'],
                'updated_at'   => $row['updated_at'],
                'is_enabled'   => (bool) $row['is_enabled'],
                'taxonomies'   => $taxonomies,
                'date_created'  => get_post_field( 'post_date', $cid ) ?: null,
                'date_modified' => get_post_field( 'post_modified', $cid ) ?: null,
            );
        }

        return $this->success( array(
            'items'     => $items,
            'total'     => $total,
            'page'      => $page,
            'per_page'  => $per_page,
            'pages'     => (int) ceil( $total / $per_page ),
        ) );
    }

    /**
     * Toggle is_enabled for a single indexed entry.
     *
     * Week 30: Content visibility management.
     *
     * Body params:
     * - enabled: (bool) New enabled status
     *
     * @since 1.14.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function toggle_content_enabled( $request ) {
        global $wpdb;
        $table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );

        $id = (int) $request->get_param( 'id' );
        if ( $id <= 0 ) {
            return $this->error( 'מזהה לא תקין', 400 );
        }

        // Check if entry exists
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $entry = $wpdb->get_row( $wpdb->prepare(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT id, title, is_enabled FROM {$table} WHERE id = %d",
            $id
        ) );

        if ( ! $entry ) {
            return $this->error( 'תוכן לא נמצא', 404 );
        }

        // Get new enabled value from request body (or toggle if not specified)
        $body = $request->get_json_params();
        if ( isset( $body['enabled'] ) ) {
            $new_enabled = (bool) $body['enabled'] ? 1 : 0;
        } else {
            $new_enabled = $entry->is_enabled ? 0 : 1;
        }

        // Update
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->update(
            $table,
            array( 'is_enabled' => $new_enabled ),
            array( 'id' => $id ),
            array( '%d' ),
            array( '%d' )
        );

        return $this->success( array(
            'id'         => $id,
            'title'      => $entry->title,
            'is_enabled' => (bool) $new_enabled,
        ) );
    }

    /**
     * Bulk toggle is_enabled for multiple entries.
     *
     * Week 30: Content visibility management.
     *
     * Body params:
     * - ids: (array) Array of entry IDs
     * - enabled: (bool) New enabled status for all
     *
     * @since 1.14.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response The response.
     */
    public function bulk_toggle_content_enabled( $request ) {
        global $wpdb;
        $table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );

        $body = $request->get_json_params();
        $ids  = isset( $body['ids'] ) && is_array( $body['ids'] ) ? $body['ids'] : array();
        $ids  = array_map( 'intval', $ids );
        $ids  = array_values( array_filter( $ids, function( $id ) { return $id > 0; } ) );

        if ( empty( $ids ) ) {
            return $this->error( 'לא סופקו מזהים', 400 );
        }

        if ( ! isset( $body['enabled'] ) ) {
            return $this->error( 'יש לציין enabled', 400 );
        }

        $new_enabled = (bool) $body['enabled'] ? 1 : 0;

        // Bulk update
        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $wpdb->query( $wpdb->prepare(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "UPDATE {$table} SET is_enabled = %d WHERE id IN ({$placeholders})",
            array_merge( array( $new_enabled ), $ids )
        ) );

        return $this->success( array(
            'updated_count' => count( $ids ),
            'is_enabled'    => (bool) $new_enabled,
        ) );
    }

    // =========================================================================
    // Site Content Browser + Taxonomy + Exclusion List
    // =========================================================================

    /**
     * Helper: Get taxonomy labels for a post ID.
     *
     * @param int $post_id The WordPress post ID.
     * @return array Array of { taxonomy, term_id, name } objects.
     */
    private function get_post_taxonomy_labels( int $post_id ): array {
        $labels     = array();
        $taxonomies = get_object_taxonomies( get_post_type( $post_id ), 'objects' );
        foreach ( $taxonomies as $tax ) {
            if ( ! $tax->public ) {
                continue;
            }
            $terms = wp_get_object_terms( $post_id, $tax->name, array( 'fields' => 'all' ) );
            if ( is_wp_error( $terms ) ) {
                continue;
            }
            foreach ( $terms as $term ) {
                $labels[] = array(
                    'taxonomy' => $tax->name,
                    'tax_label' => $tax->label,
                    'term_id'  => $term->term_id,
                    'name'     => $term->name,
                );
            }
        }
        return $labels;
    }

    /**
     * Browse all WordPress content with rich filters.
     *
     * Query params:
     * - page, per_page, content_type (post|page|product|'' for all)
     * - search (title search)
     * - taxonomy: "taxonomy_name:term_id" e.g. "category:5"
     * - date_from, date_to: YYYY-MM-DD
     * - date_type: created|modified (default: modified)
     * - sort_by: title|date|modified (default: modified)
     * - sort_order: ASC|DESC (default: DESC)
     *
     * @param WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function get_site_content( $request ) {
        $page       = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
        $per_page   = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ?: 30 ) );
        $ctype      = sanitize_text_field( $request->get_param( 'content_type' ) ?: '' );
        $search     = sanitize_text_field( $request->get_param( 'search' ) ?: '' );
        $taxonomy   = sanitize_text_field( $request->get_param( 'taxonomy' ) ?: '' );
        $date_from  = sanitize_text_field( $request->get_param( 'date_from' ) ?: '' );
        $date_to    = sanitize_text_field( $request->get_param( 'date_to' ) ?: '' );
        $date_type  = sanitize_text_field( $request->get_param( 'date_type' ) ?: 'modified' );
        $sort_by    = sanitize_text_field( $request->get_param( 'sort_by' ) ?: 'modified' );
        $sort_order = strtoupper( sanitize_text_field( $request->get_param( 'sort_order' ) ?: 'DESC' ) );
        // Filter: "excluded" = only excluded items, "included" = only non-excluded, "" = all.
        $excl_filter = sanitize_text_field( $request->get_param( 'excluded' ) ?: '' );
        if ( ! in_array( $sort_order, array( 'ASC', 'DESC' ), true ) ) {
            $sort_order = 'DESC';
        }

        // Determine which post types to query
        $post_types = array();
        if ( '' === $ctype ) {
            $post_types = array( 'post', 'page' );
            if ( class_exists( 'WooCommerce' ) ) {
                $post_types[] = 'product';
            }
        } else {
            $post_types = array( $ctype );
        }

        // Get excluded IDs early (needed for query filtering).
        $excluded_ids = $this->load_excluded_ids();

        // Build WP_Query args
        $args = array(
            'post_type'      => $post_types,
            'post_status'    => 'publish',
            'posts_per_page' => $per_page,
            'paged'          => $page,
        );

        // Filter by exclusion status.
        if ( 'excluded' === $excl_filter && ! empty( $excluded_ids ) ) {
            $args['post__in'] = $excluded_ids;
        } elseif ( 'excluded' === $excl_filter && empty( $excluded_ids ) ) {
            // No excluded items — return empty.
            return $this->success( array( 'items' => array(), 'total' => 0, 'page' => $page, 'per_page' => $per_page, 'pages' => 0 ) );
        } elseif ( 'included' === $excl_filter && ! empty( $excluded_ids ) ) {
            // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- bounded exclusion list from admin settings
            $args['post__not_in'] = $excluded_ids;
        }

        // Search
        if ( '' !== $search ) {
            $args['s'] = $search;
        }

        // Sorting
        switch ( $sort_by ) {
            case 'title':
                $args['orderby'] = 'title';
                break;
            case 'date':
                $args['orderby'] = 'date';
                break;
            case 'modified':
            default:
                $args['orderby'] = 'modified';
                break;
        }
        $args['order'] = $sort_order;

        // Date filter
        $date_col = ( 'created' === $date_type ) ? 'post_date' : 'post_modified';
        if ( '' !== $date_from || '' !== $date_to ) {
            $args['date_query'] = array();
            if ( '' !== $date_from ) {
                $args['date_query'][] = array(
                    'after'     => $date_from,
                    'inclusive' => true,
                    'column'    => $date_col,
                );
            }
            if ( '' !== $date_to ) {
                $args['date_query'][] = array(
                    'before'    => $date_to,
                    'inclusive' => true,
                    'column'    => $date_col,
                );
            }
        }

        // Taxonomy filter: "taxonomy_name:term_id"
        if ( '' !== $taxonomy && strpos( $taxonomy, ':' ) !== false ) {
            list( $tax_name, $term_id ) = explode( ':', $taxonomy, 2 );
            $tax_name = sanitize_key( $tax_name );
            $term_id  = (int) $term_id;
            if ( $tax_name && $term_id > 0 ) {
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- standard WP taxonomy filtering
                $args['tax_query'] = array(
                    array(
                        'taxonomy' => $tax_name,
                        'field'    => 'term_id',
                        'terms'    => $term_id,
                    ),
                );
            }
        }

        $query = new \WP_Query( $args );

        // Get indexed IDs for status display.
        global $wpdb;
        $index_table = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
        $indexed_rows = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT content_id, is_enabled FROM {$index_table}",
            ARRAY_A
        );
        $indexed_map = array();
        foreach ( (array) $indexed_rows as $r ) {
            $indexed_map[ (int) $r['content_id'] ] = (bool) $r['is_enabled'];
        }

        $items = array();
        foreach ( $query->posts as $post ) {
            $pid = (int) $post->ID;
            $taxonomies = $this->get_post_taxonomy_labels( $pid );

            $items[] = array(
                'id'             => $pid,
                'title'          => get_the_title( $pid ),
                'url'            => urldecode( get_permalink( $pid ) ),
                'content_type'   => $post->post_type,
                'date_created'   => $post->post_date,
                'date_modified'  => $post->post_modified,
                'taxonomies'     => $taxonomies,
                'is_excluded'    => in_array( $pid, $excluded_ids, true ),
                'is_indexed'     => isset( $indexed_map[ $pid ] ),
                'index_enabled'  => $indexed_map[ $pid ] ?? null,
            );
        }

        return $this->success( array(
            'items'     => $items,
            'total'     => (int) $query->found_posts,
            'page'      => $page,
            'per_page'  => $per_page,
            'pages'     => (int) $query->max_num_pages,
        ) );
    }

    /**
     * Get all available public taxonomies with their terms.
     *
     * @param WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function get_taxonomies( $request ) {
        $post_types = array( 'post', 'page' );
        if ( class_exists( 'WooCommerce' ) ) {
            $post_types[] = 'product';
        }

        $result = array();
        $seen   = array();

        foreach ( $post_types as $pt ) {
            $taxonomies = get_object_taxonomies( $pt, 'objects' );
            foreach ( $taxonomies as $tax ) {
                if ( ! $tax->public || isset( $seen[ $tax->name ] ) ) {
                    continue;
                }
                $seen[ $tax->name ] = true;

                $terms = get_terms( array(
                    'taxonomy'   => $tax->name,
                    'hide_empty' => false,
                    'orderby'    => 'name',
                ) );

                if ( is_wp_error( $terms ) ) {
                    continue;
                }

                $term_items = array();
                foreach ( $terms as $term ) {
                    $term_items[] = array(
                        'id'    => $term->term_id,
                        'name'  => $term->name,
                        'slug'  => $term->slug,
                        'count' => $term->count,
                    );
                }

                $result[] = array(
                    'taxonomy'     => $tax->name,
                    'label'        => $tax->label,
                    'post_types'   => (array) $tax->object_type,
                    'hierarchical' => $tax->hierarchical,
                    'terms'        => $term_items,
                );
            }
        }

        return $this->success( $result );
    }

    /**
     * Get the list of content IDs excluded from indexing.
     *
     * @param WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function get_excluded_ids( $request ) {
        return $this->success( array(
            'excluded_ids' => $this->load_excluded_ids(),
        ) );
    }

    /**
     * Set the list of content IDs excluded from indexing.
     *
     * Body params:
     * - action: "add" | "remove" | "set"
     * - ids: array of content IDs
     *
     * @param WP_REST_Request $request
     * @return WP_REST_Response
     */
    public function set_excluded_ids( $request ) {
        $body   = $request->get_json_params();
        $action = sanitize_text_field( $body['action'] ?? 'set' );
        $ids    = isset( $body['ids'] ) && is_array( $body['ids'] ) ? array_map( 'intval', $body['ids'] ) : array();
        $ids    = array_values( array_filter( $ids, function( $id ) { return $id > 0; } ) );

        $current = $this->load_excluded_ids();

        switch ( $action ) {
            case 'add':
                $current = array_values( array_unique( array_merge( $current, $ids ) ) );
                break;
            case 'remove':
                $current = array_values( array_diff( $current, $ids ) );
                break;
            case 'set':
            default:
                $current = array_values( array_unique( $ids ) );
                break;
        }

        update_option( 'boostbot_index_excluded_ids', wp_json_encode( $current ), false );

        return $this->success( array(
            'excluded_ids' => $current,
            'count'        => count( $current ),
        ) );
    }

    /**
     * Load excluded IDs from wp_options.
     *
     * @return int[]
     */
    private function load_excluded_ids(): array {
        $raw = get_option( 'boostbot_index_excluded_ids', '[]' );
        $arr = json_decode( (string) $raw, true );
        if ( ! is_array( $arr ) ) {
            return array();
        }
        return array_values( array_map( 'intval', $arr ) );
    }

    /**
     * Restrict a SQL identifier to [A-Za-z0-9_] characters.
     *
     * DDL cannot use $wpdb->prepare() for identifiers, so any identifier
     * that ends up interpolated into a query first passes through this
     * allow-list.
     *
     * @param string $identifier Raw identifier.
     * @return string Identifier with disallowed characters removed.
     */
    private static function sanitize_identifier( $identifier ) {
        return preg_replace( '/[^A-Za-z0-9_]/', '', (string) $identifier );
    }
}



