<?php
/**
 * Knowledge Index for BoostBot
 *
 * Manages the knowledge base index with vector search.
 *
 * @package BoostBot
 * @since 1.0.0
 */

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Class BoostBot_Knowledge_Index
 *
 * Manages indexing and searching of site content.
 */
class BoostBot_Knowledge_Index {

    /**
     * Database table name.
     *
     * @var string
     */
    private $table_name;

    /**
     * Knowledge chunks table name (semantic chunks).
     *
     * @var string
     */
    private $chunks_table_name;

    /**
     * Knowledge RAPTOR nodes table name (hierarchical summaries).
     *
     * @var string
     */
    private $raptor_nodes_table_name;

    /**
     * Site scanner instance.
     *
     * @var BoostBot_Site_Scanner
     */
    private $scanner;

    /**
     * Content processor instance.
     *
     * @var BoostBot_Content_Processor
     */
    private $processor;

    /**
     * Constructor.
     */
    public function __construct() {
        global $wpdb;
        $this->table_name = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );
        $this->chunks_table_name = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_chunks' );
        $this->raptor_nodes_table_name = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_raptor_nodes' );
        $this->scanner    = new BoostBot_Site_Scanner();
        $this->processor  = new BoostBot_Content_Processor();
    }

    /**
     * Index a single content item.
     *
     * Enhanced in Week 24 to store raw_content, service_category,
     * and is_service_page for Global Context Awareness.
     *
     * @param int    $content_id   Content ID.
     * @param string $content_type Content type (post, page, product).
     * @return bool True on success.
     */
    public function index_content( int $content_id, string $content_type, array $options = array() ): bool {
        BoostBot_Logger::debug( 'Indexing content', array(
            'id'   => $content_id,
            'type' => $content_type,
        ) );

        $mode   = isset( $options['mode'] ) ? (string) $options['mode'] : 'basic';
        $job_id = isset( $options['job_id'] ) ? (string) $options['job_id'] : '';
        $extraction_level = isset( $options['extraction_level'] ) ? (int) $options['extraction_level'] : 5;
        $extraction_level = max( 1, min( 5, $extraction_level ) );

        // Week 24.5: deep_incremental fast-path (skip unchanged content completely).
        $existing = $this->get_by_content_id( $content_id, $content_type );
        if ( 'deep_incremental' === $mode && $existing ) {
            $canonical = '';
            if ( $this->processor && method_exists( $this->processor, 'get_canonical_text_for_indexing_level' ) ) {
                $canonical = (string) $this->processor->get_canonical_text_for_indexing_level( $content_id, $extraction_level );
            } elseif ( $this->processor && method_exists( $this->processor, 'get_canonical_text_for_indexing' ) ) {
                $canonical = (string) $this->processor->get_canonical_text_for_indexing( $content_id );
            }
            $canonical = trim( $canonical );
            $hash = ( '' !== $canonical ) ? hash( 'sha256', $canonical ) : '';

            $prev_hash = isset( $existing->content_hash ) ? (string) $existing->content_hash : '';
            $prev_mode = isset( $existing->indexed_mode ) ? (string) $existing->indexed_mode : '';
            $has_chunks = $this->has_chunks_for_knowledge_id( (int) $existing->id );

            if ( '' !== $hash && '' !== $prev_hash && $hash === $prev_hash && in_array( $prev_mode, array( 'deep', 'deep_incremental' ), true ) && $has_chunks ) {
                // Mark update time for observability, but avoid expensive work.
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->update(
                    $this->table_name,
                    array( 'updated_at' => current_time( 'mysql' ) ),
                    array( 'id' => (int) $existing->id )
                );
                BoostBot_Logger::debug( 'Deep incremental: content unchanged, skipping', array(
                    'id'   => $content_id,
                    'type' => $content_type,
                ) );
                return true;
            }
        }

        // Get content data
        $content = $this->scanner->get_content_for_indexing( $content_id, $content_type );

        if ( null === $content ) {
            BoostBot_Logger::warning( 'Content not found for indexing', array(
                'id'   => $content_id,
                'type' => $content_type,
            ) );
            return false;
        }

        // Process content using the enhanced processor (Week 24)
        // This now includes raw_content, is_service_page, service_category
        $processed = $this->processor->process_post( $content_id, array( 'extraction_level' => $extraction_level ) );

        // Fallback to simple processing if process_post fails
        if ( false === $processed ) {
            $processed = $this->processor->process_content( $content );
        }

        // =====================================================
        // MEMORY-EFFICIENT: compute hash, build data, then free
        // sources ($content, $processed) as early as possible.
        // =====================================================

        // Week 24.5: compute canonical hash/length for deep modes (deterministic, no LLM).
        $canonical = '';
        if ( $this->processor && method_exists( $this->processor, 'get_canonical_text_for_indexing_level' ) ) {
            $canonical = (string) $this->processor->get_canonical_text_for_indexing_level( $content_id, $extraction_level );
        } elseif ( $this->processor && method_exists( $this->processor, 'get_canonical_text_for_indexing' ) ) {
            $canonical = (string) $this->processor->get_canonical_text_for_indexing( $content_id );
        }
        if ( '' === trim( $canonical ) ) {
            $canonical = (string) ( $processed['full_content'] ?? '' );
        }
        $canonical = trim( (string) $canonical );
        $content_hash   = ( '' !== $canonical ) ? hash( 'sha256', $canonical ) : null;
        $content_length = ( '' !== $canonical ) ? (int) mb_strlen( $canonical ) : 0;
        unset( $canonical ); // Free hash source.

        // Cap stored content to 65KB to prevent bloating the DB and memory.
        $stored_full_content = mb_substr( (string) ( $processed['full_content'] ?? ( $content['content'] ?? '' ) ), 0, 65000 );
        $lang_sample = $content['title'] . ' ' . mb_substr( $stored_full_content, 0, 500 );

        // Prepare data for database (using Claude-generated summary and keywords for search)
        $data = array(
            'content_type'     => $content_type,
            'content_id'       => $content_id,
            'title'            => $content['title'],
            'summary'          => $processed['summary'] ?? '',
            'full_content'     => $stored_full_content,
            'raw_content'      => isset( $processed['raw_content'] ) ? mb_substr( (string) $processed['raw_content'], 0, 65000 ) : null,
            'content_hash'     => $content_hash,
            'content_length'   => $content_length,
            'indexed_mode'     => $mode,
            'indexed_version'  => 1,
            'keywords'         => wp_json_encode( $processed['keywords'] ?? array(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
            'service_category' => $processed['service_category'] ?? null,
            'is_service_page'  => ( $processed['is_service_page'] ?? false ) ? 1 : 0,
            'embedding_vector' => null,
            'url'              => urldecode( (string) ( $content['url'] ?? '' ) ),
            'language'         => $this->detect_language( $lang_sample ),
            'relevance_score'  => $this->calculate_relevance_score( $content ),
            'indexed_at'       => current_time( 'mysql' ),
            'updated_at'       => current_time( 'mysql' ),
        );
        unset( $stored_full_content, $lang_sample ); // Free intermediates.

        // Add technical specs and price info if available
        if ( ! empty( $processed['technical_specs'] ) ) {
            $data['technical_specs'] = wp_json_encode( $processed['technical_specs'] );
        }
        if ( ! empty( $processed['price_info'] ) ) {
            $data['price_info'] = wp_json_encode( $processed['price_info'] );
        }

        // Keep small metadata needed for logging, then free the scanner content.
        $log_title           = $content['title'];
        $log_is_service_page = $data['is_service_page'];
        $log_service_cat     = $data['service_category'] ?? null;
        unset( $content );

        // Upsert (update if exists, insert if not)
        $existing = $existing ?: $this->get_by_content_id( $content_id, $content_type );

        global $wpdb;

        if ( $existing ) {
            unset( $data['indexed_at'] ); // Keep original index date
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $result = $wpdb->update(
                $this->table_name,
                $data,
                array( 'id' => $existing->id )
            );
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $result = $wpdb->insert(
                $this->table_name,
                $data
            );
        }

        if ( false === $result ) {
            BoostBot_Logger::error( 'Failed to save index entry', array(
                'error' => $wpdb->last_error,
            ) );
            return false;
        }

        // Determine knowledge index row ID for chunk storage.
        $knowledge_id = $existing ? (int) $existing->id : (int) $wpdb->insert_id;

        // Free $data — it has been written to DB.
        unset( $data );

        // Invalidate service catalog cache (Week 24)
        if ( class_exists( 'BoostBot_Service_Catalog' ) ) {
            BoostBot_Service_Catalog::invalidate_cache();
        }

        // Optional: Deep semantic indexing (chunk embeddings).
        if ( in_array( $mode, array( 'deep', 'deep_incremental' ), true ) ) {
            // Safety: some Elementor pages can OOM during deep chunking/embedding. If `_elementor_data` is huge,
            // keep the basic index entry but skip deep semantic chunks for this item.
            try {
                if ( 'page' === $content_type ) {
                    $max_elementor_data_chars = (int) apply_filters( 'boostbot_elementor_data_max_chars', 300000 );
                    global $wpdb;
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $elementor_len = (int) $wpdb->get_var(
                        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                        $wpdb->prepare(
                            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                            "SELECT LENGTH(meta_value) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
                            (int) $content_id,
                            '_elementor_data'
                        )
                    );
                    if ( $elementor_len > $max_elementor_data_chars ) {
                        BoostBot_Logger::warning( 'Skipping deep semantic chunks for large Elementor page', array(
                            'content_id' => $content_id,
                            'data_len'   => $elementor_len,
                        ) );
                        return true;
                    }
                }
            } catch ( Exception $e ) {
                // ignore
            }

            $this->index_semantic_chunks(
                $knowledge_id,
                $content_type,
                $content_id,
                is_array( $processed ) ? $processed : array(),
                array(
                    'job_id' => $job_id,
                    'mode'   => $mode,
                )
            );

            // Week 29 (RAPTOR): build hierarchical summaries for this content (index-time only).
            // This is best-effort and must never block embeddings indexing.
            try {
                $enable_raptor = (bool) apply_filters( 'boostbot_enable_raptor_indexing', true, $content_type, $content_id );
                if ( $enable_raptor ) {
                    $this->index_raptor_tree(
                        $knowledge_id,
                        $content_type,
                        $content_id,
                        is_array( $processed ) ? $processed : array(),
                        array(
                            'job_id' => $job_id,
                            'mode'   => $mode,
                        )
                    );
                }
            } catch ( Exception $e ) {
                BoostBot_Logger::warning( 'RAPTOR indexing failed (best-effort)', array(
                    'content_type' => $content_type,
                    'content_id'   => $content_id,
                    'error'        => $e->getMessage(),
                ) );
            }
        }

        // Free $processed after chunking/RAPTOR is done.
        unset( $processed );

        BoostBot_Logger::info( 'Content indexed successfully', array(
            'id'              => $content_id,
            'type'            => $content_type,
            'title'           => $log_title,
            'is_service_page' => $log_is_service_page,
            'category'        => $log_service_cat,
        ) );

        return true;
    }

    /**
     * Index semantic chunks (embeddings) for a knowledge entry.
     *
     * Week 24.5/27 additions:
     * - batching
     * - indexing credits gating + idempotent consume (when backend configured)
     *
     * @since 1.8.0
     * @param int    $knowledge_id
     * @param string $content_type
     * @param int    $content_id
     * @param array  $processed
     * @param array  $options { job_id?:string, mode?:string }
     */
    private function index_semantic_chunks( int $knowledge_id, string $content_type, int $content_id, array $processed, array $options = array() ): void {
        if ( ! class_exists( 'BoostBot_Embedding_Generator' ) || ! class_exists( 'BoostBot_Text_Chunker' ) ) {
            return;
        }

        // Prefer full extracted content (Elementor-aware), fallback to raw_content/summary.
        $text = (string) ( $processed['full_content'] ?? '' );
        if ( '' === trim( $text ) ) {
            $text = (string) ( $processed['raw_content'] ?? '' );
        }
        if ( '' === trim( $text ) ) {
            $text = (string) ( $processed['summary'] ?? '' );
        }

        $text = trim( $text );
        if ( '' === $text ) {
            return;
        }

        // Extra safety for Elementor-heavy pages: even after extraction, some pages can still OOM in chunking/filters.
        // If `_elementor_data` is huge, embed only the summary to keep memory bounded.
        try {
            if ( 'page' === $content_type && ! empty( $processed['summary'] ) ) {
                $max_elementor_data_chars = (int) apply_filters( 'boostbot_elementor_data_max_chars', 300000 );
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $elementor_len = (int) $wpdb->get_var(
                    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                    $wpdb->prepare(
                        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                        "SELECT LENGTH(meta_value) FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
                        (int) $content_id,
                        '_elementor_data'
                    )
                );
                if ( $elementor_len > $max_elementor_data_chars ) {
                    BoostBot_Logger::warning( 'Deep indexing fallback to summary for large Elementor page', array(
                        'content_id'  => $content_id,
                        'data_len'    => $elementor_len,
                    ) );
                    $text = (string) $processed['summary'];
                }
            }
        } catch ( Exception $e ) {
            // ignore
        }

        // Guardrails: avoid OOM for pathological/very large content.
        // We cap the text length and chunk count to keep memory bounded and costs predictable.
        $max_chars_total  = (int) apply_filters( 'boostbot_deep_indexing_max_chars', 200000 ); // ~200k chars
        $max_total_chunks = (int) apply_filters( 'boostbot_deep_indexing_max_chunks', 1000 );  // hard stop

        $text_len = mb_strlen( $text );
        if ( $text_len > $max_chars_total ) {
            BoostBot_Logger::warning( 'Deep indexing text truncated for safety', array(
                'content_type' => $content_type,
                'content_id'   => $content_id,
                'orig_chars'   => $text_len,
                'cap_chars'    => $max_chars_total,
            ) );
            $text = mb_substr( $text, 0, $max_chars_total );
        }

        $chunks = BoostBot_Text_Chunker::chunk( $text, 2500, 250 );
        if ( empty( $chunks ) ) {
            return;
        }
        if ( count( $chunks ) > $max_total_chunks ) {
            BoostBot_Logger::warning( 'Deep indexing chunks capped for safety', array(
                'content_type' => $content_type,
                'content_id'   => $content_id,
                'orig_chunks'  => count( $chunks ),
                'cap_chunks'   => $max_total_chunks,
            ) );
            $chunks = array_slice( $chunks, 0, $max_total_chunks );
        }

        $embeddings = new BoostBot_Embedding_Generator();
        if ( ! $embeddings->is_available() ) {
            // Deep mode requires an embedding provider.
            return;
        }

        // Week 27: indexing credits gating (backend-owned).
        $job_id = isset( $options['job_id'] ) ? (string) $options['job_id'] : '';
        $batch_size = 25;
        $total_chunks = count( $chunks );

        $credits_client = null;
        $site_id = null;
        $backend_base_url = trim( (string) BoostBot_Settings::get( 'backend_base_url', '' ) );
        if ( ! empty( $backend_base_url ) && class_exists( 'BoostBot_Indexing_Credits_Backend_Client' ) ) {
            $credits_client = new BoostBot_Indexing_Credits_Backend_Client();
            $site_id = $credits_client->ensure_site_id();
        }

        // ── Credit enforcement gate ──
        // Must block BEFORE generating embeddings to avoid wasting OpenAI API costs.
        if ( $credits_client && ! is_wp_error( $site_id ) ) {
            // Happy path: backend is reachable, check the `allowed` flag.
            $status = $credits_client->get_status( (string) $site_id );
            if ( is_wp_error( $status ) ) {
                throw new Exception( 'Failed to fetch indexing credits status: ' . esc_html( $status->get_error_message() ) );
            }
            $allowed = isset( $status['allowed'] ) ? (bool) $status['allowed'] : false;
            if ( ! $allowed ) {
                throw new Exception( 'Insufficient BoostCoin for indexing. Please purchase BoostCoin to continue.' );
            }
        } elseif ( $credits_client && is_wp_error( $site_id ) ) {
            // Backend client exists but authentication failed (e.g., missing API key).
            // Block indexing and include the actual error for diagnosis.
            throw new Exception( 'Indexing blocked — backend auth failed: ' . esc_html( $site_id->get_error_message() ) );
        } elseif ( in_array( (string) ( $options['mode'] ?? 'deep' ), array( 'deep', 'deep_incremental' ), true ) ) {
            // Backend client couldn't be created (class missing or backend URL not set).
            throw new Exception( 'Indexing credits backend is not configured. Deep indexing requires BoostCoin.' );
        }

        global $wpdb;

        // Clear previous chunks for this knowledge_id (re-index).
        // NOTE: we do this only after credits preflight passes, so we don't delete usable old chunks when blocked.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->delete( $this->chunks_table_name, array( 'knowledge_id' => $knowledge_id ) );

        $provider_info = method_exists( $embeddings, 'get_provider_info' ) ? $embeddings->get_provider_info() : array();
        $provider      = is_array( $provider_info ) ? ( $provider_info['provider'] ?? null ) : null;
        $model         = is_array( $provider_info ) ? ( $provider_info['model'] ?? null ) : null;
        $dimension     = method_exists( $embeddings, 'get_dimension' ) ? $embeddings->get_dimension() : null;

        $now = current_time( 'mysql' );
        $batch_idx = 0;
        for ( $start = 0; $start < $total_chunks; $start += $batch_size ) {
            $batch = array_slice( $chunks, $start, $batch_size );
            if ( empty( $batch ) ) {
                continue;
            }

            $vectors = $embeddings->generate_batch( $batch );

            // Week 30: Token-based credit consumption (replaces chunk-based).
            // Consume credits idempotently per batch AFTER embeddings are generated.
            if ( $credits_client && ! is_wp_error( $site_id ) ) {
                $idem = 'idx:' . ( $job_id ?: (string) $knowledge_id ) . ':' . (string) $knowledge_id . ':' . (string) $batch_idx;

                // Calculate total characters for token estimation
                $batch_chars = 0;
                foreach ( $batch as $chunk_text ) {
                    $batch_chars += mb_strlen( $chunk_text );
                }

                // Estimate tokens: Hebrew/English average ~3-4 chars per token, use 3 for safety
                $estimated_input_tokens = (int) ceil( $batch_chars / 3 );

                // Use the actual provider/model from embeddings, fallback to defaults
                $consume_provider = $provider ?: 'openai';
                $consume_model    = $model ?: 'text-embedding-3-small';

                $consume = $credits_client->consume( (string) $site_id, array(
                    'job_id'           => $job_id ?: (string) $knowledge_id,
                    'input_tokens'     => $estimated_input_tokens,
                    'output_tokens'    => 0, // Embeddings have no output tokens
                    'provider'         => $consume_provider,
                    'model'            => $consume_model,
                    'idempotency_key'  => $idem,
                ) );
                if ( is_wp_error( $consume ) ) {
                    throw new Exception( 'Failed to consume indexing credits: ' . esc_html( $consume->get_error_message() ) );
                }
                if ( empty( $consume['allowed'] ) ) {
                    throw new Exception( 'Insufficient BoostCoin' );
                }

                // Week 30: Enhanced cost logging for profitability analysis.
                BoostBot_Logger::info( 'INDEXING_METERED_BATCH', array(
                    'knowledge_id'        => $knowledge_id,
                    'content_type'        => $content_type,
                    'content_id'          => $content_id,
                    'batch_idx'           => $batch_idx,
                    'chunks_in_batch'     => count( $batch ),
                    'chars_in_batch'      => $batch_chars,
                    'input_tokens_est'    => $estimated_input_tokens,
                    'provider'            => $consume_provider,
                    'model'               => $consume_model,
                    'idempotency_key'     => $idem,
                ) );
            }

            foreach ( $batch as $i => $chunk_text ) {
                $global_idx = $start + $i;
                $vec = $vectors[ $i ] ?? null;
                if ( empty( $vec ) || ! is_array( $vec ) ) {
                    continue;
                }

                $vec = array_map( function ( $v ) {
                    return round( (float) $v, 6 );
                }, $vec );
                $vec_json = wp_json_encode( array_values( $vec ) );
                if ( ! $vec_json ) {
                    continue;
                }

                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
                $wpdb->insert(
                    $this->chunks_table_name,
                    array(
                        'knowledge_id'        => $knowledge_id,
                        'content_type'        => $content_type,
                        'content_id'          => $content_id,
                        'chunk_index'         => (int) $global_idx,
                        'chunk_text'          => $chunk_text,
                        'embedding_vector'    => $vec_json,
                        'embedding_provider'  => $provider,
                        'embedding_model'     => $model,
                        'embedding_dimension' => $dimension,
                        'char_count'          => mb_strlen( $chunk_text ),
                        'created_at'          => $now,
                        'updated_at'          => $now,
                    )
                );
            }

            $batch_idx++;
        }

        // Week 30: Enhanced summary log for profitability analysis.
        $total_chars = 0;
        foreach ( $chunks as $chunk_text ) {
            $total_chars += mb_strlen( $chunk_text );
        }
        BoostBot_Logger::info( 'INDEXING_COMPLETED', array(
            'knowledge_id'      => $knowledge_id,
            'content_type'      => $content_type,
            'content_id'        => $content_id,
            'total_chunks'      => count( $chunks ),
            'total_chars'       => $total_chars,
            'credits_consumed'  => count( $chunks ),
            'batches_processed' => $batch_idx,
            'provider'          => $provider,
            'model'             => $model,
        ) );
    }

    /**
     * Search the knowledge index.
     *
     * Uses intelligent keyword-based search with Claude-generated keywords.
     *
     * @param string $query   Search query.
     * @param array  $options Search options.
     * @return array Search results.
     */
    public function search( string $query, array $options = array() ): array {
        $limit    = $options['limit'] ?? 5;
        $types    = $options['types'] ?? null;
        $language = $options['language'] ?? null;

        BoostBot_Logger::debug( 'Searching knowledge base', array(
            'query'    => mb_substr( $query, 0, 50 ),
            'limit'    => $limit,
            'types'    => $types,
            'language' => $language,
        ) );

        $results = $this->keyword_search( $query, $limit, $types, $language );

        if ( empty( $results ) && mb_strlen( trim( $query ) ) > 3 ) {
            global $wpdb;
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $indexed_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table_name} WHERE is_enabled = 1" );
            if ( $indexed_count > 0 ) {
                $terms = $this->extract_search_words( $query );
                BoostBot_Logger::warning( sprintf(
                    '[SEARCH_MISS] Site has %d indexed pages but search returned 0 results for terms: %s (query: %s)',
                    $indexed_count,
                    implode( ', ', $terms ),
                    mb_substr( $query, 0, 80 )
                ) );
            }
        }

        return $results;
    }

    /**
     * Semantic search over stored chunk embeddings (hybrid retrieval).
     *
     * Strategy:
     * - Prefilter candidate pages using existing keyword search (cheap)
     * - Score candidate chunks by cosine similarity to query embedding (accurate)
     * - Apply weighted field scoring based on term classification
     *
     * @since 1.7.0
     * @since 1.17.0 Added term_config for AND/OR logic.
     * @param string $query   User query.
     * @param array  $options Options: limit, prefilter_limit, max_chunks, term_config.
     * @return array Results (chunk-level).
     */
    public function semantic_search( string $query, array $options = array() ): array {
        if ( ! class_exists( 'BoostBot_Embedding_Generator' ) ) {
            return array();
        }

        $embeddings = new BoostBot_Embedding_Generator();
        if ( ! $embeddings->is_available() ) {
            return array();
        }

        $limit           = (int) ( $options['limit'] ?? 4 );
        $prefilter_limit = (int) ( $options['prefilter_limit'] ?? 40 );
        $max_chunks      = (int) ( $options['max_chunks'] ?? 400 );
        $types           = isset( $options['types'] ) && is_array( $options['types'] ) ? $options['types'] : null;
        $language        = isset( $options['language'] ) && is_string( $options['language'] ) ? $options['language'] : null;
        $extra_keywords  = isset( $options['extra_keywords'] ) && is_array( $options['extra_keywords'] ) ? $options['extra_keywords'] : array();
        $force_urls      = isset( $options['force_urls'] ) && is_array( $options['force_urls'] ) ? $options['force_urls'] : array();
        $force_chunk_ids = isset( $options['force_chunk_ids'] ) && is_array( $options['force_chunk_ids'] ) ? $options['force_chunk_ids'] : array();
        
        // Term classification from Gemini (for AND/OR logic and field weighting)
        $term_config = isset( $options['term_config'] ) && is_array( $options['term_config'] ) ? $options['term_config'] : array();

        // Week 27.9: guided prefilter — allow caller to supply extra keywords to steer candidate selection.
        $clean_kw = array();
        foreach ( $extra_keywords as $k ) {
            if ( is_string( $k ) ) {
                $s = trim( $k );
                if ( '' !== $s ) {
                    $clean_kw[] = $s;
                }
            }
        }
        $clean_kw = array_values( array_unique( $clean_kw ) );
        if ( count( $clean_kw ) > 30 ) {
            $clean_kw = array_slice( $clean_kw, 0, 30 );
        }

        // Week 27.9: widen support — allow caller to force-include candidate pages by URL (e.g., services pages).
        $force_url_clean = array();
        foreach ( $force_urls as $u ) {
            if ( is_string( $u ) ) {
                $u = trim( $u );
                if ( '' !== $u ) {
                    $force_url_clean[] = $u;
                }
            }
        }
        $force_url_clean = array_values( array_unique( $force_url_clean ) );
        if ( count( $force_url_clean ) > 25 ) {
            $force_url_clean = array_slice( $force_url_clean, 0, 25 );
        }

        // Prefilter candidate pages.
        $prefilter_query = $query;
        if ( ! empty( $clean_kw ) ) {
            // Append a bounded keyword tail to influence keyword_search scoring (cheap).
            $tail = implode( ' ', array_slice( $clean_kw, 0, 20 ) );
            if ( mb_strlen( $tail ) > 240 ) {
                $tail = mb_substr( $tail, 0, 240 );
            }
            $prefilter_query = trim( $prefilter_query . ' ' . $tail );
        }

        $candidates = $this->search( $prefilter_query, array(
            'limit'    => $prefilter_limit,
            'types'    => $types,
            'language' => $language,
        ) );
        if ( empty( $candidates ) ) {
            return array();
        }

        $candidate_ids = array();
        foreach ( $candidates as $c ) {
            if ( isset( $c['id'] ) ) {
                $candidate_ids[] = (int) $c['id'];
            }
        }

        // Force-include specific URLs (best-effort).
        if ( ! empty( $force_url_clean ) ) {
            global $wpdb;
            $placeholders = implode( ',', array_fill( 0, count( $force_url_clean ), '%s' ) );
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $sql_force = "SELECT id FROM {$this->table_name} WHERE is_enabled = 1 AND url IN ({$placeholders})";
            // 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_force = $wpdb->get_results( $wpdb->prepare( $sql_force, $force_url_clean ), ARRAY_A );
            foreach ( (array) $rows_force as $rf ) {
                if ( isset( $rf['id'] ) ) {
                    $candidate_ids[] = (int) $rf['id'];
                }
            }
        }

        $candidate_ids = array_values( array_unique( array_filter( $candidate_ids ) ) );
        if ( empty( $candidate_ids ) ) {
            return array();
        }

        // Optional: force-include specific chunk IDs (e.g., from RAPTOR node hints).
        $force_chunk_ids_clean = array();
        foreach ( $force_chunk_ids as $cid ) {
            $n = (int) $cid;
            if ( $n > 0 ) {
                $force_chunk_ids_clean[] = $n;
            }
        }
        $force_chunk_ids_clean = array_values( array_unique( $force_chunk_ids_clean ) );
        if ( count( $force_chunk_ids_clean ) > 200 ) {
            $force_chunk_ids_clean = array_slice( $force_chunk_ids_clean, 0, 200 );
        }

        // Generate query embedding.
        $q_vec = $embeddings->generate( $query );
        if ( empty( $q_vec ) || ! is_array( $q_vec ) ) {
            return array();
        }

        // Fetch candidate chunks.
        global $wpdb;
        $placeholders = implode( ',', array_fill( 0, count( $candidate_ids ), '%d' ) );
        $force_placeholders = ! empty( $force_chunk_ids_clean ) ? implode( ',', array_fill( 0, count( $force_chunk_ids_clean ), '%d' ) ) : '';

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = "SELECT id, knowledge_id, chunk_index, chunk_text, embedding_vector
                FROM {$this->chunks_table_name}
                WHERE (knowledge_id IN ({$placeholders})" .
                ( $force_placeholders ? " OR id IN ({$force_placeholders})" : '' ) .
                ")
                ORDER BY updated_at DESC
                LIMIT %d";

        $params = array_merge( $candidate_ids, $force_chunk_ids_clean, array( $max_chunks ) );
        // 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 ( empty( $rows ) ) {
            return array();
        }

        // Map knowledge_id -> candidate metadata (title/url/summary).
        $candidate_map = array();
        foreach ( $candidates as $c ) {
            $cid = (int) ( $c['id'] ?? 0 );
            if ( $cid ) {
                $candidate_map[ $cid ] = $c;
            }
        }

        // Hybrid retrieval: get BM25 scores for keyword matching (Phase 4 optimization)
        $use_hybrid = (bool) ( $options['use_hybrid'] ?? true );
        $bm25_weight = (float) ( $options['bm25_weight'] ?? 0.25 );
        $semantic_weight = (float) ( $options['semantic_weight'] ?? 0.50 );
        $field_weight = (float) ( $options['field_weight'] ?? 0.25 ); // New: weighted field scoring
        
        // Normalize weights to sum to 1
        $weight_sum = $bm25_weight + $semantic_weight + $field_weight;
        if ( $weight_sum > 0 ) {
            $bm25_weight = $bm25_weight / $weight_sum;
            $semantic_weight = $semantic_weight / $weight_sum;
            $field_weight = $field_weight / $weight_sum;
        }
        
        $bm25_scores = array();
        if ( $use_hybrid && method_exists( $this, 'bm25_search' ) ) {
            // Get chunk IDs from rows for filtering
            $row_chunk_ids = array();
            foreach ( $rows as $row ) {
                $row_chunk_ids[] = (int) $row['id'];
            }
            
            if ( ! empty( $row_chunk_ids ) ) {
                $bm25_scores = $this->bm25_search( $query, array(
                    'limit'     => count( $row_chunk_ids ),
                    'chunk_ids' => $row_chunk_ids,
                ) );
            }
        }
        
        // Extract query words for field scoring
        $query_words = $this->extract_search_words( $query );

        $scored = array();
        foreach ( $rows as $row ) {
            $vec = json_decode( (string) $row['embedding_vector'], true );
            if ( ! is_array( $vec ) || empty( $vec ) ) {
                continue;
            }

            $cosine_score = $this->cosine_similarity( $q_vec, $vec );
            if ( $cosine_score <= 0 ) {
                continue;
            }

            $chunk_id = (int) $row['id'];
            $kid = (int) $row['knowledge_id'];
            $meta = $candidate_map[ $kid ] ?? null;
            
            // Calculate weighted field score (title > summary > content priority)
            $chunk_data = array(
                'title'      => is_array( $meta ) ? ( $meta['title'] ?? '' ) : '',
                'summary'    => is_array( $meta ) ? ( $meta['summary'] ?? '' ) : '',
                'chunk_text' => (string) $row['chunk_text'],
            );
            $field_score = $this->calculate_chunk_field_score( $chunk_data, $query_words, $term_config );

            // Calculate BM25 score
            $bm25_score = $bm25_scores[ $chunk_id ] ?? 0;
            
            // Combined hybrid score: semantic + BM25 + field weighting
            $hybrid_score = ( $semantic_weight * $cosine_score ) 
                          + ( $bm25_weight * $bm25_score )
                          + ( $field_weight * $field_score );

            $scored[] = array(
                'knowledge_id'   => $kid,
                'chunk_id'       => $chunk_id,
                'chunk_index'    => (int) $row['chunk_index'],
                'chunk_text'     => (string) $row['chunk_text'],
                'score'          => $hybrid_score,
                'cosine_score'   => $cosine_score,
                'bm25_score'     => $bm25_score,
                'field_score'    => $field_score,
                'title'          => is_array( $meta ) ? ( $meta['title'] ?? '' ) : '',
                'summary'        => is_array( $meta ) ? ( $meta['summary'] ?? '' ) : '',
                'url'            => urldecode( is_array( $meta ) ? ( $meta['url'] ?? '' ) : '' ),
            );
        }

        if ( empty( $scored ) ) {
            return array();
        }

        usort( $scored, function( $a, $b ) {
            return $b['score'] <=> $a['score'];
        } );

        return array_slice( $scored, 0, max( 1, $limit ) );
    }

    /**
     * BM25-style keyword search for hybrid retrieval.
     *
     * Uses MySQL FULLTEXT search (if available) or falls back to LIKE-based scoring.
     * BM25 is a probabilistic ranking function that scores documents based on term frequency.
     *
     * @since 1.15.0
     * @param string $query   User query.
     * @param array  $options Options: limit, chunk_ids (optional filter).
     * @return array Array of [chunk_id => bm25_score].
     */
    public function bm25_search( string $query, array $options = array() ): array {
        global $wpdb;

        $query = trim( $query );
        if ( '' === $query ) {
            return array();
        }

        $limit     = (int) ( $options['limit'] ?? 100 );
        $chunk_ids = isset( $options['chunk_ids'] ) && is_array( $options['chunk_ids'] ) ? $options['chunk_ids'] : array();

        // Clean up chunk_ids filter
        $chunk_ids_clean = array();
        foreach ( $chunk_ids as $cid ) {
            $n = (int) $cid;
            if ( $n > 0 ) {
                $chunk_ids_clean[] = $n;
            }
        }

        // Check if FULLTEXT index exists on chunk_text column
        $has_fulltext = $this->has_fulltext_index();

        $scores = array();

        if ( $has_fulltext ) {
            // FULLTEXT search (fast, accurate)
            $scores = $this->bm25_fulltext( $query, $limit, $chunk_ids_clean );
        } else {
            // Fallback: LIKE-based scoring (slower but works everywhere)
            $scores = $this->bm25_fallback( $query, $limit, $chunk_ids_clean );
        }

        return $scores;
    }

    /**
     * Check if FULLTEXT index exists on the chunks table.
     *
     * @since 1.15.0
     * @return bool True if FULLTEXT index exists.
     */
    private function has_fulltext_index(): bool {
        global $wpdb;

        // Cache the result for this request
        static $has_index = null;
        if ( null !== $has_index ) {
            return $has_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().
        $result = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SHOW INDEX FROM {$this->chunks_table_name} WHERE Index_type = %s AND Column_name = %s",
                'FULLTEXT',
                'chunk_text'
            ),
            ARRAY_A
        );

        $has_index = ! empty( $result );
        return $has_index;
    }

    /**
     * BM25 search using MySQL FULLTEXT (natural language mode).
     *
     * @since 1.15.0
     * @param string $query      User query.
     * @param int    $limit      Maximum results.
     * @param array  $chunk_ids  Optional filter to specific chunk IDs.
     * @return array [chunk_id => score].
     */
    private function bm25_fulltext( string $query, int $limit, array $chunk_ids ): array {
        global $wpdb;

        // Build WHERE clause for chunk ID filter
        $chunk_filter = '';
        if ( ! empty( $chunk_ids ) ) {
            $placeholders = implode( ',', array_fill( 0, count( $chunk_ids ), '%d' ) );
            $chunk_filter = " AND id IN ({$placeholders})";
        }

        // FULLTEXT MATCH...AGAINST returns relevance score
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = "SELECT id, MATCH(chunk_text) AGAINST(%s IN NATURAL LANGUAGE MODE) AS relevance
                FROM {$this->chunks_table_name}
                WHERE MATCH(chunk_text) AGAINST(%s IN NATURAL LANGUAGE MODE) > 0
                {$chunk_filter}
                ORDER BY relevance DESC
                LIMIT %d";

        $params = array( $query, $query );
        if ( ! empty( $chunk_ids ) ) {
            $params = array_merge( $params, $chunk_ids );
        }
        $params[] = $limit;

        // 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 );

        $scores = array();
        if ( ! empty( $rows ) ) {
            // Normalize scores to 0-1 range
            $max_relevance = 0;
            foreach ( $rows as $row ) {
                $max_relevance = max( $max_relevance, (float) $row['relevance'] );
            }
            
            if ( $max_relevance > 0 ) {
                foreach ( $rows as $row ) {
                    $scores[ (int) $row['id'] ] = (float) $row['relevance'] / $max_relevance;
                }
            }
        }

        return $scores;
    }

    /**
     * Fallback BM25-like scoring using LIKE queries.
     *
     * Uses term frequency approximation based on keyword matches.
     *
     * @since 1.15.0
     * @param string $query      User query.
     * @param int    $limit      Maximum results.
     * @param array  $chunk_ids  Optional filter to specific chunk IDs.
     * @return array [chunk_id => score].
     */
    private function bm25_fallback( string $query, int $limit, array $chunk_ids ): array {
        global $wpdb;

        // Tokenize query into words
        $words = preg_split( '/\s+/', strtolower( $query ), -1, PREG_SPLIT_NO_EMPTY );
        $words = array_unique( $words );
        $words = array_filter( $words, fn( $w ) => mb_strlen( $w ) >= 2 );
        $words = array_slice( $words, 0, 20 ); // Limit tokens

        if ( empty( $words ) ) {
            return array();
        }

        // Build WHERE clause for chunk ID filter
        $chunk_filter = '';
        $filter_params = array();
        if ( ! empty( $chunk_ids ) ) {
            $placeholders = implode( ',', array_fill( 0, count( $chunk_ids ), '%d' ) );
            $chunk_filter = " AND id IN ({$placeholders})";
            $filter_params = $chunk_ids;
        }

        // Fetch chunks (limited)
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = "SELECT id, chunk_text FROM {$this->chunks_table_name} WHERE 1=1 {$chunk_filter} LIMIT %d";
        $params = array_merge( $filter_params, array( $limit * 5 ) ); // Over-fetch for scoring

        // 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 ( empty( $rows ) ) {
            return array();
        }

        // Score each chunk by keyword matches (simplified BM25)
        $scores = array();
        $total_docs = count( $rows );

        foreach ( $rows as $row ) {
            $text = strtolower( (string) $row['chunk_text'] );
            $text_len = mb_strlen( $text );
            if ( $text_len < 1 ) {
                continue;
            }

            $chunk_score = 0;
            foreach ( $words as $word ) {
                // Count occurrences (term frequency)
                $count = mb_substr_count( $text, $word );
                if ( $count > 0 ) {
                    // Simplified BM25 term frequency saturation: tf / (tf + 1)
                    // This prevents very frequent terms from dominating
                    $tf_component = $count / ( $count + 1.0 );
                    
                    // Length normalization (shorter docs get slight boost)
                    $len_norm = 1.0 / ( 1.0 + log10( max( 1, $text_len / 500.0 ) ) );
                    
                    $chunk_score += $tf_component * $len_norm;
                }
            }

            if ( $chunk_score > 0 ) {
                $scores[ (int) $row['id'] ] = $chunk_score;
            }
        }

        // Sort by score and limit
        arsort( $scores );
        $scores = array_slice( $scores, 0, $limit, true );

        // Normalize to 0-1
        $max_score = empty( $scores ) ? 1 : max( $scores );
        if ( $max_score > 0 ) {
            foreach ( $scores as $id => $score ) {
                $scores[ $id ] = $score / $max_score;
            }
        }

        return $scores;
    }

    /**
     * Best-effort RAPTOR hinting: return chunk IDs that belong to relevant summary nodes.
     *
     * This is a cheap keyword/LIKE search over the stored RAPTOR summaries. It is used to
     * *steer* semantic retrieval by force-including likely-relevant leaf chunks, without
     * adding any chat-time LLM calls.
     *
     * @since 1.12.0
     * @param string $query
     * @param array  $options { limit_nodes?:int, max_chunk_ids?:int }
     * @return int[] Chunk IDs to force-include.
     */
    public function get_raptor_chunk_hints( string $query, array $options = array() ): array {
        global $wpdb;

        $limit_nodes   = (int) ( $options['limit_nodes'] ?? 8 );
        $max_chunk_ids = (int) ( $options['max_chunk_ids'] ?? 60 );
        if ( $limit_nodes < 1 ) {
            $limit_nodes = 1;
        }
        if ( $limit_nodes > 30 ) {
            $limit_nodes = 30;
        }
        if ( $max_chunk_ids < 1 ) {
            $max_chunk_ids = 1;
        }
        if ( $max_chunk_ids > 200 ) {
            $max_chunk_ids = 200;
        }

        $q = trim( (string) $query );
        if ( '' === $q ) {
            return array();
        }

        // Extract a few non-trivial tokens for LIKE matching.
        $tokens = preg_split( '/\s+/u', $q );
        $want = array();
        if ( is_array( $tokens ) ) {
            foreach ( $tokens as $t ) {
                $t = trim( preg_replace( '/[^\p{L}\p{N}\-_]+/u', '', (string) $t ) );
                if ( mb_strlen( $t ) < 3 ) {
                    continue;
                }
                $want[] = $t;
                if ( count( $want ) >= 6 ) {
                    break;
                }
            }
        }
        if ( empty( $want ) ) {
            return array();
        }

        $where = array();
        $params = array();
        foreach ( $want as $t ) {
            $where[] = "summary_text LIKE %s";
            $params[] = '%' . $wpdb->esc_like( $t ) . '%';
        }

        // Prefer level-1 nodes because they carry direct source_chunk_ids.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = "SELECT source_chunk_ids
                FROM {$this->raptor_nodes_table_name}
                WHERE node_level = 1 AND (" . implode( ' OR ', $where ) . ")
                ORDER BY updated_at DESC
                LIMIT %d";
        $params[] = $limit_nodes;

        // 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 ( empty( $rows ) ) {
            return array();
        }

        $out = array();
        foreach ( $rows as $r ) {
            $raw = isset( $r['source_chunk_ids'] ) ? (string) $r['source_chunk_ids'] : '';
            if ( '' === trim( $raw ) ) {
                continue;
            }
            $ids = json_decode( $raw, true );
            if ( ! is_array( $ids ) ) {
                continue;
            }
            foreach ( $ids as $cid ) {
                $n = (int) $cid;
                if ( $n > 0 ) {
                    $out[] = $n;
                }
            }
        }

        $out = array_values( array_unique( $out ) );
        if ( count( $out ) > $max_chunk_ids ) {
            $out = array_slice( $out, 0, $max_chunk_ids );
        }
        return $out;
    }

    /**
     * Compute cosine similarity between two vectors.
     *
     * @since 1.7.0
     * @param array $a Vector A.
     * @param array $b Vector B.
     * @return float Similarity in [-1, 1].
     */
    private function cosine_similarity( array $a, array $b ): float {
        if ( count( $a ) !== count( $b ) || empty( $a ) ) {
            return 0.0;
        }

        $dot = 0.0;
        $na  = 0.0;
        $nb  = 0.0;

        $n = count( $a );
        for ( $i = 0; $i < $n; $i++ ) {
            $ai = (float) $a[ $i ];
            $bi = (float) $b[ $i ];
            $dot += $ai * $bi;
            $na  += $ai * $ai;
            $nb  += $bi * $bi;
        }

        if ( $na <= 0 || $nb <= 0 ) {
            return 0.0;
        }

        return $dot / ( sqrt( $na ) * sqrt( $nb ) );
    }

    /**
     * Intelligent keyword-based search.
     *
     * Uses Claude-generated keywords and summaries for better matching.
     * Calculates relevance score based on multiple factors.
     *
     * @param string     $query Search query.
     * @param int        $limit Maximum results.
     * @param array|null $types Content types filter.
     * @return array Results.
     */
    private function keyword_search( string $query, int $limit, ?array $types, ?string $language = null ): array {
        global $wpdb;

        $t_start = microtime( true );

        $query_words = $this->extract_search_words( $query );

        if ( empty( $query_words ) ) {
            return $this->simple_search( $query, $limit, $types );
        }

        $search_cols = 'id, title, summary, keywords, url, content_type, language, is_service_page, relevance_score';

        $where_parts = array( 'is_enabled = 1' );
        $params      = array();

        if ( $types ) {
            $placeholders  = implode( ',', array_fill( 0, count( $types ), '%s' ) );
            $where_parts[] = "content_type IN ({$placeholders})";
            $params        = array_merge( $params, $types );
        }

        if ( $language ) {
            $where_parts[] = 'language = %s';
            $params[]      = $language;
        }

        $fetch_limit = min( $limit * 5, 200 );
        $entries     = null;
        $strategy    = 'none';

        // Strategy 1: FULLTEXT (fast MySQL-side filtering + scoring)
        if ( $this->has_fulltext_index_knowledge() ) {
            $ft_search     = implode( ' ', $query_words );
            $ft_where      = $where_parts;
            $ft_where[]    = 'MATCH(title, summary, keywords) AGAINST(%s IN NATURAL LANGUAGE MODE) > 0';
            $ft_params     = array_merge( $params, array( $ft_search, $fetch_limit ) );

            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $sql = $wpdb->prepare(
                "SELECT {$search_cols} FROM {$this->table_name} WHERE " . implode( ' AND ', $ft_where ) . ' ORDER BY relevance_score DESC LIMIT %d',
                $ft_params
            );

            // 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().
            $entries  = $wpdb->get_results( $sql );
            $strategy = 'fulltext';
        }

        // Strategy 2: LIKE fallback (if FULLTEXT unavailable or returned nothing)
        if ( empty( $entries ) ) {
            $like_where  = $where_parts;
            $like_params = $params;

            $like_conditions = array();
            foreach ( $query_words as $word ) {
                $like_term         = '%' . $wpdb->esc_like( $word ) . '%';
                $like_conditions[] = '(title LIKE %s OR summary LIKE %s OR keywords LIKE %s)';
                $like_params[]     = $like_term;
                $like_params[]     = $like_term;
                $like_params[]     = $like_term;
            }
            $like_where[] = '(' . implode( ' OR ', $like_conditions ) . ')';
            $like_params[] = $fetch_limit;

            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $sql = $wpdb->prepare(
                "SELECT {$search_cols} FROM {$this->table_name} WHERE " . implode( ' AND ', $like_where ) . ' ORDER BY relevance_score DESC LIMIT %d',
                $like_params
            );

            // 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().
            $entries  = $wpdb->get_results( $sql );
            $strategy = empty( $entries ) ? 'like(empty)' : 'like';
        }

        if ( empty( $entries ) ) {
            return array();
        }

        // PHP-side scoring on the filtered lean result set
        $scored = array();
        foreach ( $entries as $entry ) {
            $score = $this->calculate_search_score( $entry, $query_words, $query );

            if ( $score > 0 ) {
                $scored[] = array(
                    'item'  => $entry,
                    'score' => $score,
                );
            }
        }

        usort( $scored, function ( $a, $b ) {
            return $b['score'] <=> $a['score'];
        } );

        $results = array();
        foreach ( array_slice( $scored, 0, $limit ) as $item ) {
            $results[] = array(
                'id'       => $item['item']->id,
                'type'     => $item['item']->content_type,
                'title'    => $item['item']->title,
                'summary'  => $item['item']->summary,
                'url'      => urldecode( (string) $item['item']->url ),
                'keywords' => json_decode( $item['item']->keywords, true ) ?: array(),
                'score'    => round( $item['score'], 3 ),
            );
        }

        $elapsed_ms = ( microtime( true ) - $t_start ) * 1000;
        BoostBot_Logger::info( sprintf(
            '[PERF] keyword_search: %.0fms strategy=%s candidates=%d results=%d query=%s',
            $elapsed_ms,
            $strategy,
            count( $entries ),
            count( $results ),
            mb_substr( $query, 0, 60 )
        ) );

        return $results;
    }

    /**
     * Check if FULLTEXT index exists on the knowledge_index table.
     *
     * Result is cached for the duration of the request.
     *
     * @return bool
     */
    private static $has_ft_index_knowledge = null;

    private function has_fulltext_index_knowledge(): bool {
        if ( self::$has_ft_index_knowledge !== null ) {
            return self::$has_ft_index_knowledge;
        }

        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $result = $wpdb->get_results(
            "SHOW INDEX FROM {$this->table_name} WHERE Index_type = 'FULLTEXT' AND Column_name = 'title'"
        );

        self::$has_ft_index_knowledge = ! empty( $result );
        return self::$has_ft_index_knowledge;
    }

    /**
     * Extract search words from query, expanding Hebrew-prefixed terms.
     *
     * @param string $query User query.
     * @return array Search words.
     */
    private function extract_search_words( string $query ): array {
        $stop_words = array(
            'של', 'את', 'על', 'עם', 'מה', 'איך', 'למה', 'מי', 'כי', 'גם',
            'או', 'אם', 'כל', 'יש', 'אין', 'הם', 'היא', 'הוא', 'זה', 'זו',
            'אני', 'אתה', 'את', 'הנה', 'כבר', 'עוד', 'רק', 'לא', 'כן',
            'the', 'a', 'an', 'is', 'are', 'what', 'how', 'why', 'who',
            'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
        );

        $words = preg_split( '/[\s,.\-!?]+/u', mb_strtolower( $query ) );
        $words = array_filter( $words, function( $word ) use ( $stop_words ) {
            return mb_strlen( $word ) > 1 && ! in_array( $word, $stop_words, true );
        } );
        $words = array_values( $words );

        $expanded = array();
        foreach ( $words as $word ) {
            $expanded[] = $word;
            $stripped = $this->strip_hebrew_prefix( $word );
            if ( $stripped !== $word ) {
                $expanded[] = $stripped;
            }
        }

        return array_values( array_unique( $expanded ) );
    }

    /**
     * Check if a word starts with a Hebrew character (U+0590–U+05FF).
     *
     * @param string $word Word to check.
     * @return bool True if the first character is Hebrew.
     */
    private function is_hebrew_word( string $word ): bool {
        return (bool) preg_match( '/^[\x{0590}-\x{05FF}]/u', $word );
    }

    /**
     * Strip common Hebrew prefixes from a word.
     *
     * Returns the stripped form if a prefix is found and the remainder
     * is at least 2 characters long; otherwise returns the original word.
     * Only applies to Hebrew words.
     *
     * @param string $word Word to strip.
     * @return string Stripped word or original.
     */
    private function strip_hebrew_prefix( string $word ): string {
        if ( ! $this->is_hebrew_word( $word ) ) {
            return $word;
        }

        // Two-character prefixes (longer match wins).
        $two_char = array( 'וב', 'ול', 'וה', 'ומ', 'שב', 'של', 'מה', 'בה', 'לה' );
        foreach ( $two_char as $prefix ) {
            $plen = mb_strlen( $prefix );
            if ( mb_strpos( $word, $prefix ) === 0 && mb_strlen( $word ) > $plen + 1 ) {
                return mb_substr( $word, $plen );
            }
        }

        // Single-character prefixes.
        $one_char = array( 'ב', 'ל', 'מ', 'ה', 'ו', 'כ', 'ש' );
        foreach ( $one_char as $prefix ) {
            if ( mb_strpos( $word, $prefix ) === 0 && mb_strlen( $word ) > 2 ) {
                return mb_substr( $word, mb_strlen( $prefix ) );
            }
        }

        return $word;
    }

    /**
     * Field weights for search scoring.
     * 
     * Title > Summary > Keywords > Full Content
     * 
     * @since 1.17.0
     * @var array
     */
    private static $field_weights = array(
        'title'        => 4.0,  // Highest priority - page title
        'summary'      => 3.0,  // High priority - page summary/description
        'keywords'     => 2.0,  // Medium priority - SEO/Claude keywords
        'full_content' => 1.0,  // Lowest priority - full page content
    );

    /**
     * Calculate search relevance score for an entry with weighted fields.
     *
     * Uses configurable field weights:
     * - Title: 4.0 (highest priority)
     * - Summary: 3.0
     * - Keywords: 2.0
     * - Full Content: 1.0 (lowest priority)
     *
     * @param object $entry       Database entry.
     * @param array  $query_words Search words.
     * @param string $full_query  Original query.
     * @param array  $term_config Optional term classification config.
     * @return float Score (0-1).
     */
    private function calculate_search_score( object $entry, array $query_words, string $full_query, array $term_config = array() ): float {
        $score = 0;
        $keywords = json_decode( $entry->keywords, true ) ?: array();
        
        // Extract term classification
        $required_terms    = isset( $term_config['required_terms'] ) ? array_map( 'mb_strtolower', $term_config['required_terms'] ) : array();
        $optional_keywords = isset( $term_config['optional_keywords'] ) ? array_map( 'mb_strtolower', $term_config['optional_keywords'] ) : array();
        $boost_terms       = isset( $term_config['boost_terms'] ) ? array_map( 'mb_strtolower', $term_config['boost_terms'] ) : array();
        
        // Lowercase all fields for comparison
        $title_lower    = mb_strtolower( $entry->title );
        $summary_lower  = mb_strtolower( $entry->summary ?? '' );
        $keywords_lower = array_map( 'mb_strtolower', $keywords );
        $full_content   = mb_strtolower( $entry->full_content ?? '' );
        
        // Combine all text for required term check
        $all_text = $title_lower . ' ' . $summary_lower . ' ' . implode( ' ', $keywords_lower ) . ' ' . $full_content;
        
        // === REQUIRED TERMS CHECK (AND logic) ===
        // If required_terms are specified, ALL must appear or score is severely penalized
        if ( ! empty( $required_terms ) ) {
            $missing_required = 0;
            foreach ( $required_terms as $term ) {
                if ( mb_strpos( $all_text, $term ) === false ) {
                    $missing_required++;
                }
            }
            // Penalize heavily if required terms are missing
            if ( $missing_required > 0 ) {
                // Apply penalty proportional to missing terms
                $penalty = $missing_required / count( $required_terms );
                // Don't return 0 immediately - let other signals contribute but with penalty
                $score -= 0.5 * $penalty;
            }
        }

        // Get field weights (can be overridden via filter)
        $weights = apply_filters( 'boostbot_search_field_weights', self::$field_weights );
        $max_possible_score = array_sum( $weights ) * count( $query_words );

        foreach ( $query_words as $word ) {
            $word_lower = mb_strtolower( $word );
            
            // Determine word importance multiplier based on term classification
            $importance = 1.0;
            if ( in_array( $word_lower, $required_terms, true ) ) {
                $importance = 2.0; // Required terms get double weight
            } elseif ( in_array( $word_lower, $optional_keywords, true ) ) {
                $importance = 1.5; // Optional keywords get 1.5x weight
            } elseif ( in_array( $word_lower, $boost_terms, true ) ) {
                $importance = 1.2; // Boost terms get 1.2x weight
            }
            
            // Title match (highest weight)
            if ( mb_strpos( $title_lower, $word_lower ) !== false ) {
                $score += $weights['title'] * $importance;
            }

            // Keyword match (medium-high weight - these are SEO/Claude-generated)
            $keyword_matched = false;
            foreach ( $keywords_lower as $keyword ) {
                if ( mb_strpos( $keyword, $word_lower ) !== false || mb_strpos( $word_lower, $keyword ) !== false ) {
                    $keyword_matched = true;
                    break;
                }
            }
            if ( $keyword_matched ) {
                $score += $weights['keywords'] * $importance;
            }

            // Summary match (high weight)
            if ( mb_strpos( $summary_lower, $word_lower ) !== false ) {
                $score += $weights['summary'] * $importance;
            }

            // Full content match (low weight)
            if ( mb_strpos( $full_content, $word_lower ) !== false ) {
                $score += $weights['full_content'] * $importance;
            }
        }

        // Exact phrase match bonus (in title = very high, in summary = high)
        $query_lower = mb_strtolower( $full_query );
        if ( mb_strpos( $title_lower, $query_lower ) !== false ) {
            $score += $weights['title'] * 1.5; // Bonus for exact phrase in title
        }
        if ( mb_strpos( $summary_lower, $query_lower ) !== false ) {
            $score += $weights['summary'] * 1.0; // Bonus for exact phrase in summary
        }

        // Factor in the base relevance score (from indexing)
        $score += $entry->relevance_score * 0.5;

        // Normalize to 0-1 range
        if ( $max_possible_score > 0 ) {
            $score = $score / $max_possible_score;
        }
        
        return max( 0.0, min( 1.0, $score ) );
    }
    
    /**
     * Calculate weighted field score for a chunk with parent metadata.
     *
     * Used in semantic search to boost chunks whose parent pages have good metadata matches.
     *
     * @since 1.17.0
     * @param array $chunk       Chunk data with title, summary from parent.
     * @param array $query_words Query words.
     * @param array $term_config Term classification config.
     * @return float Weighted field score (0-1).
     */
    private function calculate_chunk_field_score( array $chunk, array $query_words, array $term_config = array() ): float {
        $weights = apply_filters( 'boostbot_search_field_weights', self::$field_weights );
        
        $title_lower   = mb_strtolower( $chunk['title'] ?? '' );
        $summary_lower = mb_strtolower( $chunk['summary'] ?? '' );
        $chunk_text    = mb_strtolower( $chunk['chunk_text'] ?? '' );
        
        // Extract term classification
        $required_terms    = isset( $term_config['required_terms'] ) ? array_map( 'mb_strtolower', $term_config['required_terms'] ) : array();
        $optional_keywords = isset( $term_config['optional_keywords'] ) ? array_map( 'mb_strtolower', $term_config['optional_keywords'] ) : array();
        $boost_terms       = isset( $term_config['boost_terms'] ) ? array_map( 'mb_strtolower', $term_config['boost_terms'] ) : array();
        
        $score = 0;
        $max_score = 0;
        
        foreach ( $query_words as $word ) {
            $word_lower = mb_strtolower( $word );
            
            // Determine importance multiplier
            $importance = 1.0;
            if ( in_array( $word_lower, $required_terms, true ) ) {
                $importance = 2.0;
            } elseif ( in_array( $word_lower, $optional_keywords, true ) ) {
                $importance = 1.5;
            } elseif ( in_array( $word_lower, $boost_terms, true ) ) {
                $importance = 1.2;
            }
            
            $max_score += $weights['title'] * $importance;
            
            // Parent title match (highest priority)
            if ( mb_strpos( $title_lower, $word_lower ) !== false ) {
                $score += $weights['title'] * $importance;
            }
            
            // Parent summary match
            if ( mb_strpos( $summary_lower, $word_lower ) !== false ) {
                $score += $weights['summary'] * $importance;
            }
            
            // Chunk text match (lower weight)
            if ( mb_strpos( $chunk_text, $word_lower ) !== false ) {
                $score += $weights['full_content'] * $importance;
            }
        }
        
        // Normalize to 0-1
        return $max_score > 0 ? min( 1.0, $score / $max_score ) : 0.0;
    }

    /**
     * Fallback search for pages that may not have chunks.
     * 
     * Uses title, summary, and keywords from knowledge_index table.
     * This is the last resort when semantic_search doesn't find matches.
     *
     * @since 1.17.0
     * @param string $query       User query.
     * @param array  $options     Search options.
     * @return array Results in chunk-like format.
     */
    public function fallback_title_search( string $query, array $options = array() ): array {
        global $wpdb;
        
        $limit = (int) ( $options['limit'] ?? 5 );
        $required_terms = isset( $options['required_terms'] ) && is_array( $options['required_terms'] ) ? $options['required_terms'] : array();
        $optional_keywords = isset( $options['optional_keywords'] ) && is_array( $options['optional_keywords'] ) ? $options['optional_keywords'] : array();
        
        $query = trim( $query );
        if ( '' === $query && empty( $required_terms ) && empty( $optional_keywords ) ) {
            return array();
        }
        
        // Build search terms list
        $search_terms = array();
        
        // Add query words
        $query_words = $this->extract_search_words( $query );
        foreach ( $query_words as $word ) {
            if ( mb_strlen( $word ) >= 2 ) {
                $search_terms[] = $word;
            }
        }
        
        // Add required terms (high priority)
        foreach ( $required_terms as $term ) {
            $term = trim( $term );
            if ( '' !== $term && ! in_array( mb_strtolower( $term ), array_map( 'mb_strtolower', $search_terms ), true ) ) {
                array_unshift( $search_terms, $term ); // Add to front (higher priority)
            }
        }
        
        // Add optional keywords
        foreach ( $optional_keywords as $kw ) {
            $kw = trim( $kw );
            if ( '' !== $kw && ! in_array( mb_strtolower( $kw ), array_map( 'mb_strtolower', $search_terms ), true ) ) {
                $search_terms[] = $kw;
            }
        }
        
        if ( empty( $search_terms ) ) {
            return array();
        }
        
        // Limit terms to prevent huge queries
        $search_terms = array_slice( $search_terms, 0, 15 );
        
        // Build WHERE clause for title/summary/keywords matching
        $where_parts = array( 'is_enabled = 1' );
        $params = array();
        
        // Create OR conditions for each term
        $term_conditions = array();
        foreach ( $search_terms as $term ) {
            $like_term = '%' . $wpdb->esc_like( $term ) . '%';
            $term_conditions[] = "(title LIKE %s OR summary LIKE %s OR keywords LIKE %s OR full_content LIKE %s)";
            $params[] = $like_term;
            $params[] = $like_term;
            $params[] = $like_term;
            $params[] = $like_term;
        }
        
        if ( ! empty( $term_conditions ) ) {
            $where_parts[] = '(' . implode( ' OR ', $term_conditions ) . ')';
        }
        
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $where_clause = 'WHERE ' . implode( ' AND ', $where_parts );
        $params[] = $limit * 3; // Fetch more than needed for scoring
        
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = $wpdb->prepare(
            "SELECT id, title, summary, url, full_content, keywords, relevance_score
             FROM {$this->table_name}
             {$where_clause}
             ORDER BY relevance_score DESC
             LIMIT %d",
            $params
        );
        
        // 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( $sql, ARRAY_A );
        
        if ( empty( $rows ) ) {
            BoostBot_Logger::info( sprintf( '[Fallback Search] No results for terms: %s', implode( ', ', $search_terms ) ) );
            return array();
        }
        
        // Score each result based on how many terms match and where
        $weights = apply_filters( 'boostbot_search_field_weights', self::$field_weights );
        $scored_results = array();
        
        foreach ( $rows as $row ) {
            $score = 0;
            $title_lower = mb_strtolower( $row['title'] ?? '' );
            $summary_lower = mb_strtolower( $row['summary'] ?? '' );
            $content_lower = mb_strtolower( $row['full_content'] ?? '' );
            // Decode keywords JSON to readable text for matching (handles Unicode-escaped storage).
            $kw_decoded = json_decode( $row['keywords'] ?? '[]', true );
            $keywords_str = mb_strtolower( is_array( $kw_decoded ) ? implode( ' ', array_map( 'strval', $kw_decoded ) ) : '' );
            
            foreach ( $search_terms as $i => $term ) {
                $term_lower = mb_strtolower( $term );
                $is_required = in_array( $term, $required_terms, true );
                $importance = $is_required ? 3.0 : 1.0;
                
                // Title match (highest priority)
                if ( mb_strpos( $title_lower, $term_lower ) !== false ) {
                    $score += $weights['title'] * $importance;
                    
                    // Bonus for exact title match
                    if ( $title_lower === $term_lower ) {
                        $score += $weights['title'] * 2.0;
                    }
                }
                
                // Summary match
                if ( mb_strpos( $summary_lower, $term_lower ) !== false ) {
                    $score += $weights['summary'] * $importance;
                }
                
                // Keywords match
                if ( mb_strpos( $keywords_str, $term_lower ) !== false ) {
                    $score += $weights['keywords'] * $importance;
                }
                
                // Content match (lower weight)
                if ( mb_strpos( $content_lower, $term_lower ) !== false ) {
                    $score += $weights['full_content'] * $importance * 0.5;
                }
            }
            
            // Add base relevance score
            $score += ( (float) $row['relevance_score'] ) * 2.0;
            
            if ( $score > 0 ) {
                // Create chunk-like result format
                $scored_results[] = array(
                    'knowledge_id' => (int) $row['id'],
                    'chunk_id'     => 0, // No chunk
                    'chunk_index'  => 0,
                    'chunk_text'   => $row['summary'] ?: mb_substr( $row['full_content'] ?? '', 0, 500 ), // Use summary as chunk text
                    'score'        => $score / 20.0, // Normalize
                    'title'        => $row['title'],
                    'summary'      => $row['summary'],
                    'url'          => urldecode( (string) ( $row['url'] ?? '' ) ),
                    'source'       => 'fallback_title_search',
                );
            }
        }
        
        // Sort by score descending
        usort( $scored_results, function( $a, $b ) {
            return $b['score'] <=> $a['score'];
        } );
        
        $results = array_slice( $scored_results, 0, $limit );
        
        BoostBot_Logger::info( sprintf( '[Fallback Search] Found %d results for terms: %s (top: %s, score=%.2f)',
            count( $results ),
            implode( ', ', array_slice( $search_terms, 0, 5 ) ),
            ! empty( $results ) ? $results[0]['title'] : 'none',
            ! empty( $results ) ? $results[0]['score'] : 0
        ) );
        
        return $results;
    }

    /**
     * Simple LIKE-based search fallback.
     *
     * @param string     $query Search query.
     * @param int        $limit Maximum results.
     * @param array|null $types Content types filter.
     * @return array Results.
     */
    private function simple_search( string $query, int $limit, ?array $types ): array {
        global $wpdb;

        $search_term = '%' . $wpdb->esc_like( $query ) . '%';

        // Build WHERE clause (only enabled entries)
        $where_parts  = array(
            'is_enabled = 1',
            '(title LIKE %s OR summary LIKE %s OR keywords LIKE %s)',
        );
        $where_values = array( $search_term, $search_term, $search_term );

        if ( $types ) {
            $placeholders  = implode( ',', array_fill( 0, count( $types ), '%s' ) );
            $where_parts[] = "content_type IN ({$placeholders})";
            $where_values  = array_merge( $where_values, $types );
        }

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $where_clause = 'WHERE ' . implode( ' AND ', $where_parts );

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = $wpdb->prepare(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT id, title, summary, keywords, url, content_type, language, relevance_score FROM {$this->table_name} {$where_clause} ORDER BY relevance_score DESC LIMIT %d",
            array_merge( $where_values, array( $limit ) )
        );

        // 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().
        $entries = $wpdb->get_results( $sql );

        $results = array();
        foreach ( $entries as $entry ) {
            $results[] = array(
                'id'       => $entry->id,
                'type'     => $entry->content_type,
                'title'    => $entry->title,
                'summary'  => $entry->summary,
                'url'      => urldecode( (string) $entry->url ),
                'keywords' => json_decode( $entry->keywords, true ) ?: array(),
                'score'    => 0.5,
            );
        }

        return $results;
    }

    /**
     * Get index entry by content ID.
     *
     * @param int    $content_id   Content ID.
     * @param string $content_type Content type.
     * @return object|null Entry or null.
     */
    public function get_by_content_id( int $content_id, string $content_type ): ?object {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        return $wpdb->get_row(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT * FROM {$this->table_name} WHERE content_id = %d AND content_type = %s",
                $content_id,
                $content_type
            )
        );
    }

    /**
     * Delete index entry.
     *
     * @param int    $content_id   Content ID.
     * @param string $content_type Content type.
     * @return bool True on success.
     */
    public function delete_entry( int $content_id, string $content_type ): bool {
        global $wpdb;

        // Delete chunks first (if any).
        $existing = $this->get_by_content_id( $content_id, $content_type );
        if ( $existing && isset( $existing->id ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->delete( $this->chunks_table_name, array( 'knowledge_id' => (int) $existing->id ) );
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $result = $wpdb->delete(
            $this->table_name,
            array(
                'content_id'   => $content_id,
                'content_type' => $content_type,
            ),
            array( '%d', '%s' )
        );

        return false !== $result;
    }

    /**
     * Week 24.5: Cleanup stale/unpublished content for a given content type.
     *
     * IMPORTANT: $current_ids should be the full published set for that type
     * (NOT filtered by user selection), so we don't accidentally delete valid entries.
     *
     * @since 1.8.0
     * @param string $content_type
     * @param array  $current_ids
     * @return int Number of deleted knowledge_index rows.
     */
    public function cleanup_stale_entries( string $content_type, array $current_ids ): int {
        global $wpdb;

        $content_type = sanitize_text_field( $content_type );
        $current_ids  = array_values( array_unique( array_map( 'intval', $current_ids ) ) );

        // Fetch all indexed content_ids for this type.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT id, content_id FROM {$this->table_name} WHERE content_type = %s",
                $content_type
            ),
            ARRAY_A
        );

        if ( empty( $rows ) ) {
            return 0;
        }

        $current_set = array_flip( $current_ids );
        $deleted = 0;

        foreach ( $rows as $row ) {
            $kid = (int) ( $row['id'] ?? 0 );
            $cid = (int) ( $row['content_id'] ?? 0 );
            if ( ! $kid || ! $cid ) {
                continue;
            }
            if ( isset( $current_set[ $cid ] ) ) {
                continue;
            }

            // Delete chunks for this knowledge row.
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->delete( $this->chunks_table_name, array( 'knowledge_id' => $kid ) );

            // Delete the index row.
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->delete( $this->table_name, array( 'id' => $kid ) );
            $deleted++;
        }

        return $deleted;
    }

    /**
     * Check if a knowledge index row has semantic chunks.
     *
     * @since 1.8.0
     */
    private function has_chunks_for_knowledge_id( int $knowledge_id ): bool {
        global $wpdb;
        if ( $knowledge_id <= 0 ) {
            return false;
        }
        // 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().
        $count = (int) $wpdb->get_var(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT COUNT(*) FROM {$this->chunks_table_name} WHERE knowledge_id = %d",
                $knowledge_id
            )
        );
        return $count > 0;
    }

    /**
     * Reindex all content.
     *
     * @param array $content_types Content types to index.
     * @return int Number of items queued.
     */
    public function reindex_all( array $content_types = array( 'post', 'page', 'product' ) ): int {
        BoostBot_Logger::info( 'Starting full reindex', array(
            'types' => $content_types,
        ) );

        $scan_results = $this->scanner->scan_site();
        $queue        = array();

        if ( in_array( 'post', $content_types, true ) ) {
            foreach ( $scan_results['posts'] as $post ) {
                $queue[] = array(
                    'id'   => $post['id'],
                    'type' => 'post',
                );
            }
        }

        if ( in_array( 'page', $content_types, true ) ) {
            foreach ( $scan_results['pages'] as $page ) {
                $queue[] = array(
                    'id'   => $page['id'],
                    'type' => 'page',
                );
            }
        }

        if ( in_array( 'product', $content_types, true ) ) {
            foreach ( $scan_results['products'] as $product ) {
                $queue[] = array(
                    'id'   => $product['id'],
                    'type' => 'product',
                );
            }
        }

        // Store queue in option for background processing
        update_option( 'boostbot_index_queue', $queue );

        return count( $queue );
    }

    /**
     * Process next item in queue.
     *
     * @return bool True if item was processed, false if queue empty.
     */
    public function process_queue_item(): bool {
        $queue = get_option( 'boostbot_index_queue', array() );

        if ( empty( $queue ) ) {
            return false;
        }

        $item  = array_shift( $queue );
        update_option( 'boostbot_index_queue', $queue );

        if ( ! isset( $item['id'] ) || ! isset( $item['type'] ) ) {
            return $this->process_queue_item(); // Skip invalid item
        }

        $this->index_content( $item['id'], $item['type'] );

        // Update progress
        $status = get_option( 'boostbot_indexing_status', array() );
        if ( isset( $status['indexed'] ) ) {
            $status['indexed']++;
            update_option( 'boostbot_indexing_status', $status );
        }

        return true;
    }

    /**
     * Get index statistics.
     *
     * @return array Statistics.
     */
    public function get_stats(): array {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table_name}" );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $by_type = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT content_type, COUNT(*) as count FROM {$this->table_name} GROUP BY content_type",
            ARRAY_A
        );

        // Count entries with keywords (processed by Claude)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $with_keywords = (int) $wpdb->get_var(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT COUNT(*) FROM {$this->table_name} WHERE keywords IS NOT NULL AND keywords != '[]'"
        );

        $types = array();
        foreach ( $by_type as $row ) {
            $types[ $row['content_type'] ] = (int) $row['count'];
        }

        // Chunk and embedding health.
        $chunks_table = $this->chunks_table_name;

        // 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_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().
        $chunks_with_embeddings = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$chunks_table} WHERE embedding_vector IS NOT NULL AND embedding_vector != ''"
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $enabled_pages = (int) $wpdb->get_var(
            "SELECT COUNT(*) FROM {$this->table_name} WHERE is_enabled = 1"
        );

        $embeddings         = new BoostBot_Embedding_Generator();
        $embedding_available = $embeddings->is_available();

        $search_status = 'healthy';
        if ( 0 === $total_chunks && $enabled_pages > 0 ) {
            $search_status = 'degraded_no_chunks';
        } elseif ( 0 === $chunks_with_embeddings && $total_chunks > 0 ) {
            $search_status = 'degraded_no_embeddings';
        } elseif ( ! $embedding_available ) {
            $search_status = 'warning_no_embedding_provider';
        }

        return array(
            'total'                  => $total,
            'by_type'                => $types,
            'with_keywords'          => $with_keywords,
            'processed_rate'         => $total > 0 ? round( $with_keywords / $total * 100, 1 ) : 0,
            'enabled_pages'          => $enabled_pages,
            'total_chunks'           => $total_chunks,
            'chunks_with_embeddings' => $chunks_with_embeddings,
            'embedding_available'    => $embedding_available,
            'search_status'          => $search_status,
        );
    }

    /**
     * Clear entire index.
     *
     * @return bool True on success.
     */
    public function clear(): bool {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $result = $wpdb->query( "TRUNCATE TABLE {$this->table_name}" );

        // Also clear semantic chunks (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().
        $wpdb->query( "TRUNCATE TABLE {$this->chunks_table_name}" );

        BoostBot_Logger::info( 'Knowledge index cleared' );

        return false !== $result;
    }

    /**
     * Detect content language.
     *
     * @param string $text Text to analyze.
     * @return string Language code (he, en, etc.).
     */
    private function detect_language( string $text ): string {
        // Simple Hebrew detection
        if ( preg_match( '/[\x{0590}-\x{05FF}]/u', $text ) ) {
            return 'he';
        }

        return 'en';
    }

    /**
     * Calculate relevance score for content.
     *
     * @param array $content Content data.
     * @return float Score (0-1).
     */
    private function calculate_relevance_score( array $content ): float {
        $score = 0.5; // Base score

        // Boost for longer content
        $content_length = mb_strlen( $content['content'] ?? '' );
        if ( $content_length > 1000 ) {
            $score += 0.1;
        }
        if ( $content_length > 5000 ) {
            $score += 0.1;
        }

        // Boost for having categories
        if ( ! empty( $content['categories'] ) ) {
            $score += 0.1;
        }

        // Boost for having excerpt
        if ( ! empty( $content['excerpt'] ) ) {
            $score += 0.1;
        }

        // Boost for products
        if ( 'product' === ( $content['type'] ?? '' ) ) {
            $score += 0.1;
        }

        return min( 1.0, $score );
    }

    /**
     * Get content for context (used in chat).
     *
     * @param string $query    Search query.
     * @param int    $limit    Maximum items.
     * @param string $language Bot language code (default: site primary).
     * @return string Formatted context for AI.
     */
    public function get_context_for_chat( string $query, int $limit = 3, string $language = '' ): string {
        $results = $this->search( $query, array(
            'limit'     => $limit,
            'threshold' => 0.4,
        ) );

        if ( empty( $results ) ) {
            return '';
        }

        if ( '' === $language && class_exists( 'BoostBot_Language' ) ) {
            $language = BoostBot_Language::get_primary();
        }
        $is_he       = ( 'he' === $language || '' === $language );
        $header      = $is_he ? "מידע רלוונטי מהאתר:\n\n" : "Relevant information from the website:\n\n";
        $link_label  = $is_he ? 'קישור' : 'Link';

        $context = $header;
        $max_total_chars = 2000;
        $max_summary_chars = 400;

        foreach ( $results as $result ) {
            $title = isset( $result['title'] ) ? (string) $result['title'] : '';
            $summary = isset( $result['summary'] ) ? (string) $result['summary'] : '';
            $url = isset( $result['url'] ) ? urldecode( (string) $result['url'] ) : '';

            if ( mb_strlen( $summary ) > $max_summary_chars ) {
                $summary = mb_substr( $summary, 0, $max_summary_chars ) . '...';
            }

            $block = "📄 **{$title}**\n{$summary}\n{$link_label}: {$url}\n\n";

            if ( mb_strlen( $context ) + mb_strlen( $block ) > $max_total_chars ) {
                break;
            }

            $context .= $block;
        }

        return $context;
    }

    /**
     * Get raw content for a specific entry.
     *
     * Used for detailed queries that need full unprocessed content.
     *
     * @since 1.6.0 (Week 24)
     * @param int $index_id Knowledge index ID.
     * @return string|null Raw content or null.
     */
    public function get_raw_content( int $index_id ): ?string {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $raw_content = $wpdb->get_var(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT raw_content FROM {$this->table_name} WHERE id = %d",
                $index_id
            )
        );

        return $raw_content;
    }

    /**
     * Get all service pages.
     *
     * @since 1.6.0 (Week 24)
     * @param int $limit Maximum results.
     * @return array Service pages.
     */
    public function get_service_pages( int $limit = 50 ): array {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $results = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                "SELECT id, title, summary, url, service_category, price_info
                 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                 FROM {$this->table_name}
                 WHERE is_service_page = 1
                 ORDER BY relevance_score DESC
                 LIMIT %d",
                $limit
            ),
            ARRAY_A
        );

        return $results ?: array();
    }

    /**
     * Get entries by service category.
     *
     * @since 1.6.0 (Week 24)
     * @param string $category Category name.
     * @param int    $limit    Maximum results.
     * @return array Matching entries.
     */
    public function get_by_category( string $category, int $limit = 20 ): array {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $results = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                "SELECT id, title, summary, url, service_category, is_service_page
                 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                 FROM {$this->table_name}
                 WHERE service_category = %s
                 ORDER BY relevance_score DESC
                 LIMIT %d",
                $category,
                $limit
            ),
            ARRAY_A
        );

        return $results ?: array();
    }

    /**
     * Enhanced search that includes raw_content for detailed queries.
     *
     * Used when the query appears to need detailed/technical information.
     *
     * @since 1.6.0 (Week 24)
     * @param string $query   Search query.
     * @param array  $options Search options.
     * @return array Search results with optional raw_content.
     */
    public function search_with_details( string $query, array $options = array() ): array {
        // First, perform regular search
        $results = $this->search( $query, $options );

        // Check if query seems to need detailed information
        $needs_details = $this->query_needs_details( $query );

        if ( ! $needs_details || empty( $results ) ) {
            return $results;
        }

        // Enhance top results with raw_content
        $max_detailed = min( 2, count( $results ) ); // Limit to top 2

        for ( $i = 0; $i < $max_detailed; $i++ ) {
            $raw_content = $this->get_raw_content( $results[ $i ]['id'] );
            if ( $raw_content ) {
                // Limit raw content size for context
                $results[ $i ]['raw_content'] = mb_substr( $raw_content, 0, 5000 );
            }
        }

        return $results;
    }

    /**
     * Check if query seems to need detailed information.
     *
     * @since 1.6.0 (Week 24)
     * @param string $query Search query.
     * @return bool True if query needs details.
     */
    private function query_needs_details( string $query ): bool {
        $query_lower = mb_strtolower( $query );

        // Indicators that query needs detailed/technical information
        $detail_indicators = array(
            // Hebrew
            'מפרט', 'פרטים', 'מידע טכני', 'כמה', 'מה כולל', 'מה ה',
            'מחיר', 'תעריף', 'עלות', 'מה המחיר', 'כמה עולה',
            'גודל', 'משקל', 'מידות', 'קיבולת', 'זמן', 'תהליך',
            'איך עובד', 'מה השלבים', 'רשימה', 'פירוט',
            // English
            'specifications', 'specs', 'details', 'technical', 'how much',
            'price', 'cost', 'dimensions', 'capacity', 'process',
            'how does', 'what includes', 'list', 'breakdown',
        );

        foreach ( $detail_indicators as $indicator ) {
            if ( mb_strpos( $query_lower, $indicator ) !== false ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get RAG statistics including Week 24 enhancements.
     *
     * @since 1.6.0 (Week 24)
     * @return array Enhanced statistics.
     */
    public function get_rag_stats(): array {
        global $wpdb;

        $base_stats = $this->get_stats();

        // Count service pages
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $service_pages = (int) $wpdb->get_var(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT COUNT(*) FROM {$this->table_name} WHERE is_service_page = 1"
        );

        // Count entries with raw_content
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $with_raw_content = (int) $wpdb->get_var(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            "SELECT COUNT(*) FROM {$this->table_name} WHERE raw_content IS NOT NULL AND raw_content != ''"
        );

        // Get category distribution
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $categories = $wpdb->get_results(
            "SELECT service_category, COUNT(*) as count
             // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
             FROM {$this->table_name}
             WHERE service_category IS NOT NULL
             GROUP BY service_category",
            ARRAY_A
        );

        $category_dist = array();
        foreach ( $categories as $cat ) {
            if ( $cat['service_category'] ) {
                $category_dist[ $cat['service_category'] ] = (int) $cat['count'];
            }
        }

        return array_merge( $base_stats, array(
            'service_pages'    => $service_pages,
            'with_raw_content' => $with_raw_content,
            'categories'       => $category_dist,
            'rag_version'      => '2.0', // Week 24 enhanced
        ) );
    }

    /**
     * Build and store a RAPTOR-style hierarchical summary tree for a knowledge entry.
     *
     * Design goals:
     * - Index-time only (no chat-time extra LLM calls).
     * - Deterministic grouping (stable re-indexing) to keep costs predictable.
     * - Best-effort: failures must not break indexing.
     *
     * Implementation note:
     * - This is a lightweight RAPTOR approximation using sequential chunk grouping rather than embedding clustering.
     *   (We can upgrade to embedding-based clustering later without changing storage schema.)
     *
     * @since 1.12.0
     * @param int    $knowledge_id
     * @param string $content_type
     * @param int    $content_id
     * @param array  $processed
     * @param array  $options { job_id?:string, mode?:string }
     * @return void
     */
    private function index_raptor_tree( int $knowledge_id, string $content_type, int $content_id, array $processed, array $options = array() ): void {
        global $wpdb;

        // Require backend summarization (we want to use our own key via backend).
        if ( ! class_exists( 'BoostBot_Indexing_Backend_Client' ) ) {
            return;
        }
        $backend_base_url = trim( (string) BoostBot_Settings::get( 'backend_base_url', '' ) );
        if ( '' === $backend_base_url ) {
            return;
        }

        // Fetch leaf chunks for this knowledge_id.
        // 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().
        $leaf_rows = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                "SELECT id, chunk_index, chunk_text FROM {$this->chunks_table_name} WHERE knowledge_id = %d ORDER BY chunk_index ASC",
                $knowledge_id
            ),
            ARRAY_A
        );
        if ( empty( $leaf_rows ) || ! is_array( $leaf_rows ) ) {
            return;
        }

        // Clear previous RAPTOR nodes for this knowledge entry (re-index).
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->delete( $this->raptor_nodes_table_name, array( 'knowledge_id' => $knowledge_id ) );

        $title = isset( $processed['title'] ) ? (string) $processed['title'] : '';
        $url   = isset( $processed['url'] ) ? urldecode( (string) $processed['url'] ) : '';
        if ( '' === $title ) {
            // Best-effort: try to read from knowledge_index row.
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $title = (string) $wpdb->get_var( $wpdb->prepare( "SELECT title FROM {$this->table_name} WHERE id = %d", $knowledge_id ) );
        }
        if ( '' === $url ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $url = (string) $wpdb->get_var( $wpdb->prepare( "SELECT url FROM {$this->table_name} WHERE id = %d", $knowledge_id ) );
        }

        $client = new BoostBot_Indexing_Backend_Client();

        // Configurable grouping size; default 5 leaf chunks per node.
        $group_size = (int) apply_filters( 'boostbot_raptor_group_size', 5, $content_type, $content_id );
        if ( $group_size < 2 ) {
            $group_size = 2;
        }
        if ( $group_size > 12 ) {
            $group_size = 12;
        }

        // Level 1: summarize groups of leaf chunks.
        $level = 1;
        $prev_level_node_ids = array(); // node ids at previous level (for parent building)
        $prev_level_nodes    = array(); // array of arrays: {id, summary_text}

        $leaf_groups = array_chunk( $leaf_rows, $group_size );
        $node_index = 0;
        foreach ( $leaf_groups as $g ) {
            $chunk_ids = array();
            $parts = array();
            foreach ( $g as $row ) {
                $cid = isset( $row['id'] ) ? (int) $row['id'] : 0;
                $txt = isset( $row['chunk_text'] ) ? (string) $row['chunk_text'] : '';
                if ( $cid ) {
                    $chunk_ids[] = $cid;
                }
                $txt = trim( $txt );
                if ( '' !== $txt ) {
                    if ( mb_strlen( $txt ) > 1200 ) {
                        $txt = mb_substr( $txt, 0, 1200 ) . '...';
                    }
                    $parts[] = $txt;
                }
            }
            if ( empty( $parts ) ) {
                continue;
            }

            $is_he = ( class_exists( 'BoostBot_Language' ) && 'he' === BoostBot_Language::get_primary() ) || ! class_exists( 'BoostBot_Language' );
            $title_lbl    = $is_he ? 'כותרת' : 'Title';
            $sections_lbl = $is_he ? 'קטעי תוכן' : 'Content sections';
            $input = "{$title_lbl}: {$title}\n\n{$sections_lbl}:\n" . implode( "\n\n---\n\n", $parts );
            if ( mb_strlen( $input ) > 12000 ) {
                $input = mb_substr( $input, 0, 12000 ) . '...';
            }

            $resp = $client->summarize( array(
                'title'   => $title,
                'content' => $input,
                'type'    => $content_type,
            ) );
            if ( is_wp_error( $resp ) ) {
                // Fail-soft: store a naive summary (first ~400 chars).
                $sum = mb_substr( implode( "\n", $parts ), 0, 400 );
                $meta = array(
                    'provider' => 'none',
                    'error'    => $resp->get_error_message(),
                );
            } else {
                $sum = isset( $resp['summary'] ) ? (string) $resp['summary'] : '';
                if ( '' === trim( $sum ) ) {
                    $sum = mb_substr( implode( "\n", $parts ), 0, 400 );
                }
                $meta = array(
                    'provider' => 'backend',
                    'keywords' => isset( $resp['keywords'] ) ? $resp['keywords'] : null,
                    'intent'   => isset( $resp['intent'] ) ? $resp['intent'] : null,
                );
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $wpdb->insert(
                $this->raptor_nodes_table_name,
                array(
                    'knowledge_id'      => $knowledge_id,
                    'content_type'      => $content_type,
                    'content_id'        => $content_id,
                    'node_level'        => $level,
                    'node_index'        => $node_index,
                    'parent_node_id'    => null,
                    'title'             => $title,
                    'url'               => $url,
                    'summary_text'      => $sum,
                    'child_node_ids'    => null,
                    'source_chunk_ids'  => wp_json_encode( array_values( $chunk_ids ) ),
                    'meta_json'         => wp_json_encode( $meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
                    'created_at'        => current_time( 'mysql' ),
                    'updated_at'        => current_time( 'mysql' ),
                )
            );
            $nid = (int) $wpdb->insert_id;
            if ( $nid ) {
                $prev_level_node_ids[] = $nid;
                $prev_level_nodes[] = array( 'id' => $nid, 'summary_text' => $sum );
                $node_index++;
            }
        }

        // Upper levels: summarize groups of previous level nodes until a single root exists.
        while ( count( $prev_level_nodes ) > 1 ) {
            $level++;
            $groups = array_chunk( $prev_level_nodes, $group_size );
            $new_level_nodes = array();
            $node_index = 0;

            foreach ( $groups as $g ) {
                $child_ids = array();
                $parts = array();
                foreach ( $g as $node ) {
                    $cid = isset( $node['id'] ) ? (int) $node['id'] : 0;
                    $txt = isset( $node['summary_text'] ) ? (string) $node['summary_text'] : '';
                    if ( $cid ) {
                        $child_ids[] = $cid;
                    }
                    $txt = trim( $txt );
                    if ( '' !== $txt ) {
                        if ( mb_strlen( $txt ) > 900 ) {
                            $txt = mb_substr( $txt, 0, 900 ) . '...';
                        }
                        $parts[] = $txt;
                    }
                }
                if ( empty( $parts ) ) {
                    continue;
                }

                $is_he_upper = ( class_exists( 'BoostBot_Language' ) && 'he' === BoostBot_Language::get_primary() ) || ! class_exists( 'BoostBot_Language' );
                $title_lbl_u    = $is_he_upper ? 'כותרת' : 'Title';
                $summaries_lbl  = $is_he_upper ? "סיכומים מרמה {$level}-1" : "Summaries from level " . ( $level - 1 );
                $input = "{$title_lbl_u}: {$title}\n\n{$summaries_lbl}:\n" . implode( "\n\n---\n\n", $parts );
                if ( mb_strlen( $input ) > 12000 ) {
                    $input = mb_substr( $input, 0, 12000 ) . '...';
                }

                $resp = $client->summarize( array(
                    'title'   => $title,
                    'content' => $input,
                    'type'    => $content_type,
                ) );
                if ( is_wp_error( $resp ) ) {
                    $sum = mb_substr( implode( "\n", $parts ), 0, 400 );
                    $meta = array(
                        'provider' => 'none',
                        'error'    => $resp->get_error_message(),
                    );
                } else {
                    $sum = isset( $resp['summary'] ) ? (string) $resp['summary'] : '';
                    if ( '' === trim( $sum ) ) {
                        $sum = mb_substr( implode( "\n", $parts ), 0, 400 );
                    }
                    $meta = array(
                        'provider' => 'backend',
                        'keywords' => isset( $resp['keywords'] ) ? $resp['keywords'] : null,
                        'intent'   => isset( $resp['intent'] ) ? $resp['intent'] : null,
                    );
                }

                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
                $wpdb->insert(
                    $this->raptor_nodes_table_name,
                    array(
                        'knowledge_id'      => $knowledge_id,
                        'content_type'      => $content_type,
                        'content_id'        => $content_id,
                        'node_level'        => $level,
                        'node_index'        => $node_index,
                        'parent_node_id'    => null,
                        'title'             => $title,
                        'url'               => $url,
                        'summary_text'      => $sum,
                        'child_node_ids'    => wp_json_encode( array_values( $child_ids ) ),
                        'source_chunk_ids'  => null,
                        'meta_json'         => wp_json_encode( $meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
                        'created_at'        => current_time( 'mysql' ),
                        'updated_at'        => current_time( 'mysql' ),
                    )
                );
                $nid = (int) $wpdb->insert_id;
                if ( $nid ) {
                    // Link children to parent id (best-effort). Build the IN()
                    // list out of %d placeholders so every child id is bound
                    // through $wpdb->prepare() rather than concatenated.
                    if ( ! empty( $child_ids ) ) {
                        $int_child_ids = array_map( 'intval', $child_ids );
                        $in_holders    = implode( ', ', array_fill( 0, count( $int_child_ids ), '%d' ) );
                        $table         = preg_replace( '/[^A-Za-z0-9_]/', '', (string) $this->raptor_nodes_table_name );
                        // 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(
                                "UPDATE `{$table}` SET parent_node_id = %d WHERE id IN ({$in_holders})",
                                array_merge( array( $nid ), $int_child_ids )
                            )
                        );
                    }
                    $new_level_nodes[] = array( 'id' => $nid, 'summary_text' => $sum );
                    $node_index++;
                }
            }

            if ( empty( $new_level_nodes ) ) {
                break;
            }
            $prev_level_nodes = $new_level_nodes;
        }

        BoostBot_Logger::info( 'RAPTOR nodes indexed', array(
            'knowledge_id' => $knowledge_id,
            'content_type' => $content_type,
            'content_id'   => $content_id,
            'levels_built' => $level,
        ) );
    }

    /**
     * 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 );
    }
}

