<?php

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

class Niwa_AI_Memory_Searcher {
    const TOOL_RESULT_MAX_JSON_BYTES = 4096;
    const TOOL_TITLE_MAX_BYTES       = 240;
    const TOOL_URL_MAX_BYTES         = 800;
    const TOOL_BRAND_MAX_BYTES       = 160;
    const TOOL_CATEGORIES_MAX_BYTES  = 400;
    const TOOL_TAGS_MAX_BYTES        = 240;
    const TOOL_ATTRIBUTES_MAX_BYTES  = 1200;
    const TOOL_EVIDENCE_MAX_BYTES    = 900;
    const DEFAULT_SEMANTIC_MIN_SCORE = 0.35;
    const QUERY_EMBEDDING_CACHE_LIMIT = 64;

    /** @var Niwa_AI_Content_Store_Interface|null */
    private $content_store;
    private $embedding_client;
    private $autonomy_workspace = null;
    private $agent_memory_manager = null;
	private $latency_tracker = null;
	private $trace_request_id = '';

    /**
     * Cached query embedding — avoids duplicate API calls within a single search().
     *
     * @var array|null
     */
    private $cached_query_vec = null;
    private $cached_query_key = '';
	private $query_embedding_cache = array();
    private $exact_title_map = null;
	private $last_search_meta = array();
	private $last_semantic_rows = array();

    public function __construct( Niwa_AI_Content_Store_Interface $content_store, $embedding_client ) {
        $this->content_store    = $content_store;
        $this->embedding_client = $embedding_client;
        $this->agent_memory_manager = new Niwa_AI_Agent_Memory_Manager( $embedding_client );
    }

    public function set_admin_autonomy_workspace( $workspace ) {
        $this->autonomy_workspace = $workspace;
        if ( $this->agent_memory_manager && method_exists( $this->agent_memory_manager, 'set_admin_autonomy_workspace' ) ) {
            $this->agent_memory_manager->set_admin_autonomy_workspace( $workspace );
        }
    }

	public function set_latency_tracker( $latency_tracker ) {
		$this->latency_tracker = $latency_tracker;
	}

    public function set_trace_request_id( $request_id ) {
		$this->trace_request_id = substr( preg_replace( '/[^a-zA-Z0-9_\-:.]/', '', trim( (string) $request_id ) ), 0, 96 );
	}

	/** Return semantic-completeness evidence for the latest search request. */
	public function get_last_search_meta( $query = '' ) {
		if ( '' === trim( (string) $query ) ) {
			return $this->last_search_meta;
		}
		$key = $this->normalize_query_cache_key( $query );
		return isset( $this->last_search_meta[ $key ] ) ? $this->last_search_meta[ $key ] : array();
	}

	/** Return the already-computed semantic product lane for routing evidence. */
	public function get_last_semantic_rows( $query = '' ) {
		$key = $this->normalize_query_cache_key( $query );
		return '' !== $key && isset( $this->last_semantic_rows[ $key ] )
			? $this->last_semantic_rows[ $key ]
			: array();
	}

    /**
     * Search several query variants with one remote embedding request.
     *
     * The lexical pipeline and result ordering remain authoritative for every
     * query. Query-vector acquisition is batched, and variants that still need
     * semantic expansion share one complete catalog scan.
     *
     * @param array $queries Query variants in preferred order.
     * @param int   $limit   Maximum rows per query.
     * @param array $filters Search filters.
     * @return array<string,array> Rows keyed by the normalized query string.
     */
    public function search_many( $queries, $limit = 10, $filters = array() ) {
		$this->last_search_meta = array();
        if ( ! $this->content_store ) {
            return array();
        }

        $unique_queries = array();
        $seen_queries   = array();
        foreach ( (array) $queries as $query ) {
            $query = trim( (string) $query );
            $seen_key = $this->normalize_query_cache_key( $query );
            if ( '' !== $query && ! isset( $seen_queries[ $seen_key ] ) ) {
                $seen_queries[ $seen_key ] = true;
                $unique_queries[]          = $query;
            }
        }

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

        $limit = max( 1, min( 20, intval( $limit ) ) );

        $vectors          = array();
        $exact_rows       = array();
        $pending_queries  = array();
        $pending_indexes  = array();
        foreach ( $unique_queries as $index => $query ) {
            $exact = $this->resolve_exact_product_rows( $query, $filters, $limit );
            if ( ! empty( $exact ) ) {
                $exact_rows[ $index ] = $exact;
                continue;
            }

            $cache_key = $this->normalize_query_cache_key( $query );
            if ( '' !== $cache_key && isset( $this->query_embedding_cache[ $cache_key ] ) ) {
                $vectors[ $index ] = $this->query_embedding_cache[ $cache_key ];
                continue;
            }

            $pending_indexes[] = $index;
            $pending_queries[] = $query;
        }

        if ( ! empty( $pending_queries ) && $this->embedding_client && method_exists( $this->embedding_client, 'embed_texts' ) ) {
            $stage_started = $this->trace_start();
            $batch_vectors = $this->embedding_client->embed_texts( $pending_queries );
            foreach ( $pending_indexes as $batch_index => $query_index ) {
                if ( ! isset( $batch_vectors[ $batch_index ] ) || ! is_array( $batch_vectors[ $batch_index ] ) || empty( $batch_vectors[ $batch_index ] ) ) {
                    continue;
                }
                $vectors[ $query_index ] = $batch_vectors[ $batch_index ];
                $this->cache_query_embedding( $unique_queries[ $query_index ], $batch_vectors[ $batch_index ] );
            }
            $this->trace_finish(
                'embedding_query_batch',
                $stage_started,
                array(
                    'queries' => count( $pending_queries ),
                    'vectors' => count( (array) $batch_vectors ),
                )
            );
        }

        $states            = array();
        $expansion_vectors = array();
        // The primary structured query keeps its normal lexical lane. Broad
        // descendant plans share one additional LIKE pool for only the compact
        // variants; narrow plans use FULLTEXT + complete semantic variants.
        $batched_like_rows = array();
        $use_batched_like_variants = count( $unique_queries ) > 1
            && ! empty( $filters['_batch_like_variants'] )
            && method_exists( $this->content_store, 'like_search_many' );
        if ( $use_batched_like_variants ) {
            $variant_queries = array_slice( $unique_queries, 1 );
            $like_type = ! empty( $filters['post_type'] ) ? $filters['post_type'] : null;
            $stage_started = $this->trace_start();
            $batched_like_rows = $this->content_store->like_search_many( $variant_queries, $limit * 2, $like_type );
            $this->trace_finish(
                'search_like_batch',
                $stage_started,
                array(
                    'queries' => count( $variant_queries ),
                    'results' => array_sum( array_map( 'count', (array) $batched_like_rows ) ),
                )
            );
        }
        $primary_like_only = count( $unique_queries ) > 1
            && $this->should_force_product_semantic_scan( $filters )
            && ! $use_batched_like_variants;
        foreach ( $unique_queries as $index => $query ) {
            if ( isset( $exact_rows[ $index ] ) ) {
                $states[ $index ] = array(
                    'query'          => $query,
                    'rows'           => $exact_rows[ $index ],
                    'search_started' => $this->trace_start(),
                    'expand'         => false,
                    'force_semantic' => false,
					'exact_identity' => true,
                );
                continue;
            }

            if ( isset( $vectors[ $index ] ) && is_array( $vectors[ $index ] ) && ! empty( $vectors[ $index ] ) ) {
                $this->cached_query_key = $query;
                $this->cached_query_vec = $vectors[ $index ];
            }

            $search_started = $this->trace_start();
            $query_vec      = $this->embed_query( $query );

            $stage_started = $this->trace_start();
            $rows          = $this->content_store->fulltext_search( $query, $limit * 4, $filters );
            $this->trace_finish( 'search_fulltext', $stage_started, array( 'results' => count( (array) $rows ) ) );

            $like_type = ! empty( $filters['post_type'] ) ? $filters['post_type'] : null;
            if ( $use_batched_like_variants && 0 === $index ) {
                $like_rows = array();
                if ( '' !== $this->trace_request_id && $this->latency_tracker ) {
                    $this->latency_tracker->add_stage(
                        $this->trace_request_id,
                        'search_like',
                        0,
                        array(
                            'results' => 0,
                            'skipped' => true,
                            'reason'  => 'prefetch_primary_already_scanned',
                        )
                    );
                }
            } elseif ( $index > 0 && array_key_exists( $query, $batched_like_rows ) ) {
                $like_rows = is_array( $batched_like_rows[ $query ] ) ? $batched_like_rows[ $query ] : array();
                if ( '' !== $this->trace_request_id && $this->latency_tracker ) {
                    $this->latency_tracker->add_stage(
                        $this->trace_request_id,
                        'search_like',
                        0,
                        array(
                            'results' => count( $like_rows ),
                            'shared'  => true,
                            'reason'  => 'shared_descendant_variant_pool',
                        )
                    );
                }
            } elseif ( $primary_like_only && $index > 0 ) {
                $like_rows = array();
                if ( '' !== $this->trace_request_id && $this->latency_tracker ) {
                    $this->latency_tracker->add_stage(
                        $this->trace_request_id,
                        'search_like',
                        0,
                        array(
                            'results' => 0,
                            'skipped' => true,
                            'reason'  => 'complete_semantic_variant_lane',
                        )
                    );
                }
            } else {
                $stage_started = $this->trace_start();
                $like_rows     = $this->content_store->like_search( $query, $limit * 2, $like_type );
                $this->trace_finish( 'search_like', $stage_started, array( 'results' => count( (array) $like_rows ) ) );
            }
            if ( ! empty( $like_rows ) ) {
                $rows = $this->merge_rows_by_score( $rows, $like_rows );
            }

            $stage_started = $this->trace_start();
            $rows          = $this->rerank_rows( $query, $rows );
            $this->trace_finish( 'search_vector_rerank', $stage_started, array( 'results' => count( (array) $rows ) ) );

            $force_semantic  = $this->should_force_product_semantic_scan( $filters );
            $needs_expansion = $force_semantic || $this->needs_vector_expansion( $rows, $limit, $query );
            if ( $needs_expansion && ! empty( $query_vec ) ) {
                $expansion_vectors[ $index ] = $query_vec;
            } elseif ( '' !== $this->trace_request_id && $this->latency_tracker ) {
                $this->latency_tracker->add_stage(
                    $this->trace_request_id,
                    'search_vector_expansion',
                    0,
                    array(
                        'results' => 0,
                        'skipped' => true,
                        'reason'  => $needs_expansion ? 'missing_query_vector' : 'lexical_sufficient',
                    )
                );
            }

            $states[ $index ] = array(
                'query'          => $query,
                'rows'           => $rows,
                'search_started' => $search_started,
                'expand'         => isset( $expansion_vectors[ $index ] ),
                'force_semantic' => $force_semantic,
				'exact_identity' => false,
            );

            $this->cached_query_vec = null;
            $this->cached_query_key = '';
        }

        $vector_rows = array();
        if ( ! empty( $expansion_vectors ) ) {
            $stage_started = $this->trace_start();
            $vector_rows   = $this->vector_only_search_vectors( $expansion_vectors, $limit, $filters );
            $this->trace_finish(
                'search_vector_expansion_batch',
                $stage_started,
                array(
                    'queries' => count( $expansion_vectors ),
                    'results' => array_sum( array_map( 'count', $vector_rows ) ),
                )
            );
        }

        $results = array();
        foreach ( $states as $index => $state ) {
            $rows     = $state['rows'];
            $expanded = array();
            if ( $state['expand'] ) {
                $expanded = isset( $vector_rows[ $index ] ) ? $vector_rows[ $index ] : array();
                if ( ! empty( $expanded ) ) {
                    $rows = $this->merge_rows_by_score( $rows, $expanded );
                }
                if ( '' !== $this->trace_request_id && $this->latency_tracker ) {
                    $this->latency_tracker->add_stage(
                        $this->trace_request_id,
                        'search_vector_expansion',
                        0,
                        array(
                            'results' => count( $expanded ),
                            'skipped' => false,
                            'shared'  => true,
                        )
                    );
                }
            }

            if ( ! empty( $state['force_semantic'] ) && ! empty( $state['expand'] ) && empty( $expanded ) ) {
                $rows = $this->remove_unverified_product_candidates( $rows, $state['query'] );
            }

            $stage_started = $this->trace_start();
            $rows          = $this->dedupe_product_rows( $rows );
            $this->trace_finish( 'search_finalize', $stage_started, array( 'results' => count( (array) $rows ) ) );

            $rows = array_slice( $rows, 0, $limit );
            $this->trace_finish( 'search_total', $state['search_started'], array( 'results' => count( $rows ) ) );
            $results[ $state['query'] ] = $rows;
			$semantic_required = ! empty( $state['force_semantic'] );
			$semantic_attempted = $semantic_required && ! empty( $state['expand'] );
			$key = $this->normalize_query_cache_key( $state['query'] );
			$this->last_search_meta[ $key ] = array(
				'semantic_required'  => $semantic_required,
				'semantic_attempted' => $semantic_attempted,
				'semantic_complete'  => ! $semantic_required || $semantic_attempted || ! empty( $state['exact_identity'] ),
				'exact_identity'     => ! empty( $state['exact_identity'] ),
			);
        }

        return $results;
    }

    public function rerank_rows( $query, $rows, $weights = array() ) {
        if ( empty( $rows ) || ! is_array( $rows ) ) {
            return $rows;
        }

        $query_vec = $this->embed_query( $query );
        if ( empty( $query_vec ) ) {
            return $rows;
        }

        $weights = wp_parse_args(
            $weights,
            array(
                'lexical' => 0.6,
                'vector'  => 0.4,
            )
        );

        $lex_max = 0.0;
        $ids     = array();
        foreach ( $rows as $row ) {
            $ids[]  = isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0;
            $score  = isset( $row['_score'] ) ? floatval( $row['_score'] ) : 0.0;
            if ( $score > $lex_max ) {
                $lex_max = $score;
            }
        }

        $embeddings = $this->content_store ? $this->content_store->get_embeddings_by_post_ids( $ids ) : array();
        $vec_scores = array();
        $vec_max    = 0.0;
        if ( ! empty( $embeddings ) ) {
            foreach ( $embeddings as $e ) {
                $pid = isset( $e['post_id'] ) ? intval( $e['post_id'] ) : 0;
                $vec = $this->decode_embedding_row( $e );
                if ( $pid <= 0 || empty( $vec ) ) {
                    continue;
                }
                $sim = $this->cosine_similarity( $query_vec, $vec );
                if ( $sim > $vec_max ) {
                    $vec_max = $sim;
                }
                if ( ! isset( $vec_scores[ $pid ] ) || $sim > $vec_scores[ $pid ] ) {
                    $vec_scores[ $pid ] = $sim;
                }
            }
        }

        foreach ( $rows as &$row ) {
            $pid = isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0;
            $lex = isset( $row['_score'] ) ? floatval( $row['_score'] ) : 0.0;
            $vec = isset( $vec_scores[ $pid ] ) ? floatval( $vec_scores[ $pid ] ) : 0.0;

            $lex_n = $lex_max > 0 ? $lex / $lex_max : 0.0;
            $vec_n = $vec_max > 0 ? $vec / $vec_max : 0.0;
            $row['_vector_score'] = $vec;
            $row['_score']        = ( $weights['lexical'] * $lex_n ) + ( $weights['vector'] * $vec_n );
        }
        unset( $row );

        usort(
            $rows,
            function ( $a, $b ) {
                $sa = isset( $a['_score'] ) ? floatval( $a['_score'] ) : 0.0;
                $sb = isset( $b['_score'] ) ? floatval( $b['_score'] ) : 0.0;
                return $sb <=> $sa;
            }
        );

        return $rows;
    }

    public function vector_only_search( $query, $limit = 12, $filters = array() ) {
        $query_vec = $this->embed_query( $query );
        if ( empty( $query_vec ) || ! $this->content_store ) {
            return array();
        }

        $rows = $this->vector_only_search_vectors( array( 0 => $query_vec ), $limit, $filters );
        return isset( $rows[0] ) ? $rows[0] : array();
    }

    /**
     * Score several query vectors in one complete pass over stored embeddings.
     *
     * Each embedding row is decoded once. Chunk scores are aggregated with max
     * similarity independently for every query and product.
     *
     * @param array $query_vectors Query-state index => embedding vector.
     * @param int   $limit         Maximum rows per query.
     * @param array $filters       Search filters.
     * @return array<int,array> Product rows keyed by query-state index.
     */
    private function vector_only_search_vectors( $query_vectors, $limit = 12, $filters = array() ) {
        if ( empty( $query_vectors ) || ! $this->content_store ) {
            return array();
        }

        $limit      = max( 1, min( 20, intval( $limit ) ) );
        $batch_size = 200;
        $cursor     = array();

        $scores        = array();
        $best_doc_keys = array();
        $query_norms   = array();
        foreach ( $query_vectors as $query_index => $query_vec ) {
            if ( is_array( $query_vec ) && ! empty( $query_vec ) ) {
                $scores[ $query_index ]        = array();
                $best_doc_keys[ $query_index ] = array();
                $query_norms[ $query_index ]   = $this->vector_norm( $query_vec );
            }
        }
        if ( empty( $scores ) ) {
            return array();
        }

        while ( true ) {
            $batch_filters = array();
            if ( ! empty( $filters['_semantic_post_type'] ) ) {
                $batch_filters['post_type'] = $filters['_semantic_post_type'];
            } elseif ( ! empty( $filters['post_type'] ) ) {
                $batch_filters['post_type'] = $filters['post_type'];
            }
            $batch = $this->content_store->get_embedding_batch(
                $batch_size,
                $cursor,
                $batch_filters
            );
            if ( empty( $batch ) ) {
                break;
            }

            foreach ( $batch as $row ) {
                $pid = isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0;
                if ( $pid <= 0 ) {
                    continue;
                }
                $vec = $this->decode_embedding_row( $row );
                if ( empty( $vec ) ) {
                    continue;
                }

                $vec_length = count( $vec );
                $vec_norm   = $this->vector_norm( $vec );

                foreach ( $scores as $query_index => &$query_scores ) {
                    if ( count( $query_vectors[ $query_index ] ) === $vec_length ) {
                        $sim = $this->cosine_similarity_with_norms(
                            $query_vectors[ $query_index ],
                            $vec,
                            $query_norms[ $query_index ],
                            $vec_norm
                        );
                    } else {
                        $sim = $this->cosine_similarity( $query_vectors[ $query_index ], $vec );
                    }
                    if ( ! isset( $query_scores[ $pid ] ) || $sim > $query_scores[ $pid ] ) {
                        $query_scores[ $pid ]                    = $sim;
                        $best_doc_keys[ $query_index ][ $pid ] = isset( $row['doc_key'] ) ? (string) $row['doc_key'] : '';
                    }
                }
                unset( $query_scores );
            }

            $last_row = end( $batch );
            $next_cursor = array(
                'post_id' => is_array( $last_row ) ? intval( $last_row['post_id'] ?? 0 ) : 0,
                'doc_key' => is_array( $last_row ) ? (string) ( $last_row['doc_key'] ?? '' ) : '',
            );
            if ( ! empty( $cursor ) && $next_cursor === $cursor ) {
                break;
            }
            $cursor = $next_cursor;

            if ( count( $batch ) < $batch_size ) {
                break;
            }
        }

        $results = array();
        $minimum_score = $this->semantic_min_score();
        foreach ( $scores as $query_index => $query_scores ) {
            $query_scores = array_filter(
                $query_scores,
                static function ( $score ) use ( $minimum_score ) {
                    return is_finite( floatval( $score ) ) && floatval( $score ) >= $minimum_score;
                }
            );
            if ( empty( $query_scores ) ) {
                $results[ $query_index ] = array();
                continue;
            }

            arsort( $query_scores );
            $ids  = array_slice( array_keys( $query_scores ), 0, $limit );
            $rows = $this->content_store->get_products_by_ids( $ids );

            $rows_by_doc_key = array();
            foreach ( $rows as $candidate_row ) {
                $doc_key = isset( $candidate_row['doc_key'] ) ? (string) $candidate_row['doc_key'] : '';
                if ( '' !== $doc_key ) {
                    $rows_by_doc_key[ $doc_key ] = $candidate_row;
                }
            }

            foreach ( $rows as &$row ) {
                $pid           = isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0;
                $row['_score'] = isset( $query_scores[ $pid ] ) ? floatval( $query_scores[ $pid ] ) : 0.0;
                $best_doc_key  = isset( $best_doc_keys[ $query_index ][ $pid ] )
                    ? (string) $best_doc_keys[ $query_index ][ $pid ]
                    : '';
                if ( '' !== $best_doc_key && isset( $rows_by_doc_key[ $best_doc_key ] ) ) {
                    $row['_best_evidence_row'] = $rows_by_doc_key[ $best_doc_key ];
                }
            }
            unset( $row );

            $results[ $query_index ] = $this->apply_filters_to_rows( $rows, $filters );
        }

        return $results;
    }

    public function search( $query, $limit = 10, $filters = array() ) {
		$this->last_search_meta = array();
		$query_cache_key = $this->normalize_query_cache_key( $query );
		if ( '' !== $query_cache_key ) {
			$this->last_semantic_rows[ $query_cache_key ] = array();
		}
        if ( ! $this->content_store ) {
            return array();
        }
		$search_started = $this->trace_start();

        // Allow up to 20 results (was 15).
        $limit = max( 1, min( 20, intval( $limit ) ) );

        $exact_rows = $this->resolve_exact_product_rows( $query, $filters, $limit );
        if ( ! empty( $exact_rows ) ) {
			$this->last_search_meta[ $this->normalize_query_cache_key( $query ) ] = array(
				'semantic_required'  => false,
				'semantic_attempted' => false,
				'semantic_complete'  => true,
				'exact_identity'     => true,
			);
            $this->trace_finish( 'search_total', $search_started, array( 'results' => count( $exact_rows ), 'exact' => true ) );
            return array_slice( $exact_rows, 0, $limit );
        }

        // Pre-warm the query embedding cache — one API call, reused in Stages 3 & 4.
        $this->embed_query( $query );

        // STAGE 1: Fulltext — candidates 4x (was 3x) for a wider net.
		$stage_started = $this->trace_start();
		$rows = $this->content_store->fulltext_search( $query, $limit * 4, $filters );
		$this->trace_finish( 'search_fulltext', $stage_started, array( 'results' => count( (array) $rows ) ) );

        // STAGE 2: LIKE keyword search — supplementary candidates for exact terms
        // (dates, short words, numeric entities that FULLTEXT minimum word length misses).
        $like_type = null;
        if ( ! empty( $filters['post_type'] ) ) {
            $like_type = $filters['post_type'];
        }
		$stage_started = $this->trace_start();
		$like_rows = $this->content_store->like_search( $query, $limit * 2, $like_type );
		$this->trace_finish( 'search_like', $stage_started, array( 'results' => count( (array) $like_rows ) ) );
        if ( ! empty( $like_rows ) ) {
            $rows = $this->merge_rows_by_score( $rows, $like_rows );
        }

        // STAGE 3: Rerank combined candidates with vector embedding (uses cached embedding).
		$stage_started = $this->trace_start();
		$rows = $this->rerank_rows( $query, $rows );
		$this->trace_finish( 'search_vector_rerank', $stage_started, array( 'results' => count( (array) $rows ) ) );

        // STAGE 4: Vector expansion — skip if fulltext+LIKE already produced enough good results.
        $force_semantic = $this->should_force_product_semantic_scan( $filters );
		$semantic_attempted = false;
        if ( $force_semantic || $this->needs_vector_expansion( $rows, $limit, $query ) ) {
			$stage_started = $this->trace_start();
			$semantic_attempted = ! empty( $this->cached_query_vec );
            $vector = $this->vector_only_search( $query, $limit, $filters );
			if ( '' !== $query_cache_key ) {
				$this->last_semantic_rows[ $query_cache_key ] = is_array( $vector ) ? $vector : array();
			}
			$this->trace_finish( 'search_vector_expansion', $stage_started, array( 'results' => count( (array) $vector ), 'skipped' => false ) );
            if ( ! empty( $vector ) ) {
                $rows = $this->merge_rows_by_score( $rows, $vector );
            } elseif ( $force_semantic && ! empty( $this->cached_query_vec ) ) {
                $rows = $this->remove_unverified_product_candidates( $rows, $query );
            }
		} elseif ( '' !== $this->trace_request_id && $this->latency_tracker ) {
			$this->latency_tracker->add_stage( $this->trace_request_id, 'search_vector_expansion', 0, array( 'results' => 0, 'skipped' => true ) );
        }

		$stage_started = $this->trace_start();
        $rows = $this->dedupe_product_rows( $rows );
		$this->trace_finish( 'search_finalize', $stage_started, array( 'results' => count( (array) $rows ) ) );

        // Clear only the legacy single-query pointer. The normalized request
        // cache intentionally survives subsequent tool calls in this request.
        $this->cached_query_vec = null;
        $this->cached_query_key = '';

		$rows = array_slice( $rows, 0, $limit );
		$this->trace_finish( 'search_total', $search_started, array( 'results' => count( $rows ) ) );
		$this->last_search_meta[ $this->normalize_query_cache_key( $query ) ] = array(
			'semantic_required'  => $force_semantic,
			'semantic_attempted' => $force_semantic && $semantic_attempted,
			'semantic_complete'  => ! $force_semantic || $semantic_attempted,
			'exact_identity'     => false,
		);

		return $rows;
    }

    /**
     * Decide whether to run the expensive vector-only scan.
     *
     * Skip when fulltext + LIKE + reranking already produced enough results
     * with decent quality — saves 500-2000ms on well-covered queries.
     */
    private function needs_vector_expansion( $rows, $limit, $query = '' ) {
        // Always expand if we have fewer results than requested.
        if ( count( $rows ) < $limit ) {
            return true;
        }

        // Lexical candidate count alone is not enough. If no result covers all
        // meaningful query roots, run semantic expansion so descriptions and
        // conceptually close wording can still surface the intended product.
        if ( '' !== trim( (string) $query ) && ! $this->has_complete_lexical_candidate( $rows, $query ) ) {
            return true;
        }

        // Check average score of top 3 — if strong enough, skip vector scan.
        $top = array_slice( $rows, 0, min( 3, count( $rows ) ) );
        $avg = 0.0;
        foreach ( $top as $r ) {
            $avg += isset( $r['_score'] ) ? floatval( $r['_score'] ) : 0.0;
        }
        $avg /= count( $top );

        // Threshold 0.6: lower than old 0.55 gate (which was too aggressive).
        // Means: only skip vector scan when we already have confidently good results.
        return $avg < 0.6;
    }

    private function should_force_product_semantic_scan( $filters ) {
        return ! empty( $filters['_require_semantic_product_scan'] );
    }

    private function semantic_min_score() {
        $score = self::DEFAULT_SEMANTIC_MIN_SCORE;
        if ( function_exists( 'apply_filters' ) ) {
            $score = apply_filters( 'niwa_ai_frontend_semantic_min_score', $score );
        }
        return max( -1.0, min( 1.0, floatval( $score ) ) );
    }

    private function normalize_query_cache_key( $query ) {
        $query = trim( (string) $query );
        if ( '' === $query ) {
            return '';
        }
        if ( class_exists( 'Normalizer' ) ) {
            $normalized = \Normalizer::normalize( $query, \Normalizer::FORM_KC );
            if ( is_string( $normalized ) && '' !== $normalized ) {
                $query = $normalized;
            }
        }
        $query = function_exists( 'mb_strtolower' ) ? mb_strtolower( $query, 'UTF-8' ) : strtolower( $query );
        if ( function_exists( 'remove_accents' ) ) {
            $query = remove_accents( $query );
        }
        $query = preg_replace( '/[^\p{L}\p{N}]+/u', ' ', $query );
        return trim( preg_replace( '/\s+/u', ' ', (string) $query ) );
    }

    private function cache_query_embedding( $query, $vector ) {
        if ( ! is_array( $vector ) || empty( $vector ) ) {
            return;
        }
        $key = $this->normalize_query_cache_key( $query );
        if ( '' === $key ) {
            return;
        }
        if ( ! isset( $this->query_embedding_cache[ $key ] ) && count( $this->query_embedding_cache ) >= self::QUERY_EMBEDDING_CACHE_LIMIT ) {
            array_shift( $this->query_embedding_cache );
        }
        $this->query_embedding_cache[ $key ] = array_values( $vector );
    }

    private function resolve_exact_product_rows( $query, $filters, $limit ) {
        if ( ! $this->content_store || ! $this->filters_allow_products( $filters ) ) {
            return array();
        }

        $matches = array();
        foreach ( $this->extract_explicit_product_ids( $query ) as $product_id ) {
            $this->add_exact_product_match( $matches, $product_id, 'id', 400 );
        }

        if ( function_exists( 'wc_get_product_id_by_sku' ) ) {
            foreach ( $this->extract_exact_sku_candidates( $query ) as $sku ) {
                $product_id = intval( wc_get_product_id_by_sku( $sku ) );
                if ( $product_id <= 0 ) {
                    continue;
                }
                if ( function_exists( 'wc_get_product' ) ) {
                    $product = wc_get_product( $product_id );
                    if ( $product && method_exists( $product, 'get_parent_id' ) && intval( $product->get_parent_id() ) > 0 ) {
                        $product_id = intval( $product->get_parent_id() );
                    }
                }
                $this->add_exact_product_match( $matches, $product_id, 'sku', 350 );
            }
        }

        $title_map = $this->get_exact_title_map();
        $query_key = $this->normalize_query_cache_key( $query );
        if ( '' !== $query_key && isset( $title_map[ $query_key ] ) ) {
            $this->add_exact_product_match( $matches, $title_map[ $query_key ]['id'], 'title', 300 );
        }

        foreach ( $this->extract_exact_model_candidates( $query ) as $model ) {
            $model_key = $this->normalize_query_cache_key( $model );
            if ( '' === $model_key ) {
                continue;
            }
            $needle = ' ' . $model_key . ' ';
            foreach ( $title_map as $title_key => $entry ) {
                if ( false !== mb_strpos( ' ' . $title_key . ' ', $needle ) ) {
                    $this->add_exact_product_match( $matches, $entry['id'], 'model', 250 );
                }
            }
        }

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

        uasort(
            $matches,
            static function ( $left, $right ) {
                $priority = intval( $right['priority'] ) <=> intval( $left['priority'] );
                return 0 !== $priority ? $priority : intval( $left['id'] ) <=> intval( $right['id'] );
            }
        );
        $matches = array_slice( $matches, 0, max( 1, min( 10, intval( $limit ) ) ), true );

        $rows_by_id = array();
        $missing_ids = array();
        foreach ( $matches as $match ) {
            $product_id = intval( $match['id'] );
            $row = method_exists( $this->content_store, 'get_product_detail' )
                ? $this->content_store->get_product_detail( $product_id )
                : array();
            if ( is_array( $row ) && ! empty( $row ) ) {
                $rows_by_id[ $product_id ] = $row;
            } else {
                $missing_ids[] = $product_id;
            }
        }
        if ( ! empty( $missing_ids ) && method_exists( $this->content_store, 'get_products_by_ids' ) ) {
            foreach ( (array) $this->content_store->get_products_by_ids( $missing_ids ) as $row ) {
                $product_id = is_array( $row ) ? intval( $row['post_id'] ?? 0 ) : 0;
                if ( $product_id > 0 && ! isset( $rows_by_id[ $product_id ] ) ) {
                    $rows_by_id[ $product_id ] = $row;
                }
            }
        }

        $rows = array();
        foreach ( $matches as $match ) {
            $product_id = intval( $match['id'] );
            if ( ! isset( $rows_by_id[ $product_id ] ) || ! is_array( $rows_by_id[ $product_id ] ) ) {
                continue;
            }
            $row = $rows_by_id[ $product_id ];
            if ( isset( $row['post_type'] ) && '' !== (string) $row['post_type'] && 'product' !== (string) $row['post_type'] ) {
                continue;
            }
            $row['post_id']       = $product_id;
            $row['post_type']     = 'product';
            $row['_score']        = 2.0 + ( intval( $match['priority'] ) / 1000 );
            $row['_exact_match']  = (string) $match['type'];
            $row['_exact_pinned'] = true;
            $filtered = $this->apply_filters_to_rows( array( $row ), $filters );
            if ( ! empty( $filtered ) ) {
                $rows[] = $filtered[0];
            }
        }

        return $rows;
    }

    private function filters_allow_products( $filters ) {
        if ( empty( $filters['post_type'] ) ) {
            return true;
        }
        $types = is_array( $filters['post_type'] ) ? $filters['post_type'] : array( $filters['post_type'] );
        return in_array( 'product', array_map( 'strval', $types ), true );
    }

    private function add_exact_product_match( &$matches, $product_id, $type, $priority ) {
        $product_id = intval( $product_id );
        if ( $product_id <= 0 ) {
            return;
        }
        if ( ! isset( $matches[ $product_id ] ) || intval( $priority ) > intval( $matches[ $product_id ]['priority'] ) ) {
            $matches[ $product_id ] = array(
                'id'       => $product_id,
                'type'     => (string) $type,
                'priority' => intval( $priority ),
            );
        }
    }

    private function extract_explicit_product_ids( $query ) {
        $query = trim( (string) $query );
        $ids   = array();
        if ( preg_match( '/^#?(\d{1,12})$/', $query, $match ) ) {
            $ids[] = intval( $match[1] );
        }
        if ( preg_match_all( '/(?:^|[^\p{L}\p{N}_])(?:product_id|post_id|product|proizvod|artikal|item|id)\s*[:#=\-]?\s*(\d{1,12})(?=$|[^\d])/iu', $query, $matches ) ) {
            foreach ( (array) $matches[1] as $id ) {
                $ids[] = intval( $id );
            }
        }
        return array_values( array_filter( array_unique( $ids ) ) );
    }

    private function extract_exact_sku_candidates( $query ) {
        $query      = trim( (string) $query );
        $candidates = array();
        if ( preg_match_all( '/(?:^|[^\p{L}\p{N}_])(?:sku|sifra|šifra)\s*[:#=\-]?\s*([\p{L}\p{N}][\p{L}\p{N}._\/\-]{0,79})/iu', $query, $matches ) ) {
            $candidates = array_merge( $candidates, (array) $matches[1] );
        }
        if (
            preg_match( '/^[\p{L}\p{N}][\p{L}\p{N}._\/\-]{1,79}$/u', $query )
            && preg_match( '/\p{L}/u', $query )
            && preg_match( '/\p{N}/u', $query )
        ) {
            $candidates[] = $query;
        }
        return array_values( array_unique( array_filter( array_map( 'trim', $candidates ) ) ) );
    }

    private function extract_exact_model_candidates( $query ) {
        $query      = trim( (string) $query );
        $candidates = array();
        if ( preg_match_all( '/(?:^|[^\p{L}\p{N}_])model\s*[:#=\-]?\s*([\p{L}\p{N}][\p{L}\p{N}._\/\-]{0,79})/iu', $query, $matches ) ) {
            $candidates = array_merge( $candidates, (array) $matches[1] );
        }
        if (
            preg_match( '/^[\p{L}\p{N}][\p{L}\p{N}._\/\-]{1,79}$/u', $query )
            && preg_match( '/\p{L}/u', $query )
            && preg_match( '/\p{N}/u', $query )
        ) {
            $candidates[] = $query;
        }
        return array_values( array_unique( array_filter( array_map( 'trim', $candidates ) ) ) );
    }

    private function get_exact_title_map() {
        if ( is_array( $this->exact_title_map ) ) {
            return $this->exact_title_map;
        }
        $this->exact_title_map = array();
        if ( ! $this->content_store || ! method_exists( $this->content_store, 'get_name_to_id_map' ) ) {
            return $this->exact_title_map;
        }
        foreach ( (array) $this->content_store->get_name_to_id_map() as $key => $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }
            $product_id = intval( $entry['id'] ?? 0 );
            $title      = isset( $entry['title'] ) ? (string) $entry['title'] : (string) $key;
            $title_key  = $this->normalize_query_cache_key( $title );
            if ( $product_id > 0 && '' !== $title_key ) {
                $this->exact_title_map[ $title_key ] = array( 'id' => $product_id, 'title' => $title );
            }
        }
        return $this->exact_title_map;
    }

    private function remove_unverified_product_candidates( $rows, $query ) {
        $verified = array();
        foreach ( (array) $rows as $row ) {
            if ( ! is_array( $row ) ) {
                continue;
            }
            if ( 'product' !== (string) ( $row['post_type'] ?? '' ) || $this->row_has_complete_lexical_coverage( $row, $query ) ) {
                $verified[] = $row;
            }
        }
        return $verified;
    }

    /**
     * Compute confidence based on quality of top results, not just count.
     */
    public function compute_confidence( $rows ) {
        if ( empty( $rows ) || ! is_array( $rows ) ) {
            return 0.0;
        }

        $top = array_slice( $rows, 0, 3 );
        $sum = 0.0;
        foreach ( $top as $r ) {
            $sum += isset( $r['_score'] ) ? floatval( $r['_score'] ) : 0.0;
        }

        return min( 1.0, $sum / count( $top ) );
    }

    private function dedupe_product_rows( $rows ) {
        if ( empty( $rows ) || ! is_array( $rows ) ) {
            return array();
        }

        $out     = array();
        $seen    = array();
        $replace = array();

        foreach ( $rows as $row ) {
            if ( ! is_array( $row ) ) {
                continue;
            }

            $post_type = isset( $row['post_type'] ) ? (string) $row['post_type'] : '';
            $post_id   = isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0;
            if ( 'product' !== $post_type || $post_id <= 0 ) {
                $out[] = $row;
                continue;
            }

            if ( ! array_key_exists( $post_id, $seen ) ) {
                $seen[ $post_id ] = count( $out );
                $out[]            = $row;
                continue;
            }

            $idx      = intval( $seen[ $post_id ] );
            $existing = isset( $out[ $idx ] ) && is_array( $out[ $idx ] ) ? $out[ $idx ] : array();
            $current_score  = isset( $row['_score'] ) ? floatval( $row['_score'] ) : 0.0;
            $existing_score = isset( $existing['_score'] ) ? floatval( $existing['_score'] ) : 0.0;
            if ( $current_score > $existing_score ) {
                $replace[ $idx ] = $row;
            }
        }

        foreach ( $replace as $idx => $row ) {
            $out[ $idx ] = $row;
        }

        return array_values( $out );
    }

    /**
     * Build clean, structured tool results — no raw JSON dumps.
     */
    public function build_tool_results( $rows, $query = '' ) {
        if ( empty( $rows ) || ! is_array( $rows ) ) {
            return array();
        }

        $out = array();
        foreach ( $rows as $row ) {
            $post_type   = isset( $row['post_type'] ) ? (string) $row['post_type'] : '';
            $is_product  = ( 'product' === $post_type );
            $evidence_row = isset( $row['_best_evidence_row'] ) && is_array( $row['_best_evidence_row'] )
                ? $row['_best_evidence_row']
                : $row;
            if ( ! empty( $row['_exact_match'] ) && empty( $evidence_row['_exact_match'] ) ) {
                $evidence_row['_exact_match'] = (string) $row['_exact_match'];
            }
            $match_analysis = $this->analyze_query_evidence( $evidence_row, $query );
            $matched_fields = $match_analysis['matched_fields'];

            $item = array(
                'post_id'        => isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0,
                'title'          => $this->clip_utf8_bytes( isset( $row['title'] ) ? (string) $row['title'] : '', self::TOOL_TITLE_MAX_BYTES ),
                'type'           => $this->clip_utf8_bytes( $post_type, 40 ),
                'score'          => isset( $row['_score'] ) ? round( floatval( $row['_score'] ), 3 ) : 0.0,
                'matched_fields' => $matched_fields,
                'evidence'       => $this->build_candidate_evidence( $evidence_row, $query, $match_analysis ),
            );
            if ( ! empty( $row['_exact_match'] ) ) {
                $item['exact_match'] = $this->clip_utf8_bytes( (string) $row['_exact_match'], 24 );
            }

            if ( $is_product ) {
                if ( isset( $row['price'] ) && '' !== $row['price'] ) {
                    $item['price'] = $this->clip_utf8_bytes( (string) $row['price'], 80 );
                }
                if ( isset( $row['sale_price'] ) && '' !== $row['sale_price'] ) {
                    $item['sale_price'] = $this->clip_utf8_bytes( (string) $row['sale_price'], 80 );
                }
                if ( ! empty( $row['brand'] ) ) {
                    $item['brand'] = $this->clip_utf8_bytes( (string) $row['brand'], self::TOOL_BRAND_MAX_BYTES );
                }
                if ( ! empty( $row['stock_status'] ) ) {
                    $item['stock_status'] = $this->clip_utf8_bytes( (string) $row['stock_status'], 40 );
                }
            }

            // Factual indexed fields remain separate from discovery evidence.
            if ( ! empty( $row['categories_text'] ) ) {
                $item['categories'] = $this->clip_utf8_bytes( $row['categories_text'], self::TOOL_CATEGORIES_MAX_BYTES );
            }
            if ( ! empty( $row['tags_text'] ) ) {
                $item['tags'] = $this->clip_utf8_bytes( $row['tags_text'], self::TOOL_TAGS_MAX_BYTES );
            }
            if ( ! empty( $row['attributes_text'] ) ) {
                $item['attributes'] = $this->query_aware_excerpt(
                    $row['attributes_text'],
                    $this->query_roots( $query ),
                    self::TOOL_ATTRIBUTES_MAX_BYTES
                );
            }
            if ( ! empty( $row['url'] ) ) {
                $item['url'] = $this->clip_utf8_bytes( (string) $row['url'], self::TOOL_URL_MAX_BYTES );
            }

            $out[] = $this->enforce_candidate_payload_budget( $item, $query );
        }

        return $out;
    }

    private function build_candidate_evidence( $row, $query, $match_analysis ) {
        $sources = array(
            'memory_text' => isset( $row['memory_text'] ) ? (string) $row['memory_text'] : '',
            'short_desc'  => isset( $row['short_desc'] ) ? (string) $row['short_desc'] : '',
            'content_text'=> isset( $row['content_text'] ) ? (string) $row['content_text'] : '',
        );
        $roots      = $this->query_roots( $query );
        $best_field = '';
        $best_text  = '';
        $best_score = -1;

        foreach ( $sources as $field => $text ) {
            $text = trim( $text );
            if ( '' === $text ) {
                continue;
            }

            $normalized = $this->normalize_search_text( $text );
            $score      = 0;
            foreach ( $roots as $root ) {
                if ( false !== mb_strpos( $normalized, $root ) ) {
                    ++$score;
                }
            }
            if ( $score > $best_score ) {
                $best_score = $score;
                $best_field = $field;
                $best_text  = $text;
            }
        }

        $matched_fields   = isset( $match_analysis['matched_fields'] ) ? (array) $match_analysis['matched_fields'] : array();
        $factual_roots    = isset( $match_analysis['factual_roots'] ) ? (array) $match_analysis['factual_roots'] : array();
        $identity_roots   = isset( $match_analysis['identity_roots'] ) ? (array) $match_analysis['identity_roots'] : array();
        $unmatched_roots  = isset( $match_analysis['unmatched_roots'] ) ? (array) $match_analysis['unmatched_roots'] : array();
        $exact_match      = isset( $row['_exact_match'] ) ? (string) $row['_exact_match'] : '';
        $is_semantic_only = empty( $matched_fields );
        if ( '' !== $exact_match ) {
            $retrieval_basis = 'exact_' . sanitize_key( $exact_match );
            $claim_scope     = 'exact_product_identity';
        } elseif ( $is_semantic_only ) {
            $retrieval_basis = 'semantic_similarity';
            $claim_scope     = 'candidate_discovery_not_fact_equivalence';
        } elseif ( ! empty( $factual_roots ) && empty( $unmatched_roots ) ) {
            $retrieval_basis = 'indexed_field_overlap';
            $claim_scope     = 'indexed_catalog_evidence';
        } elseif ( ! empty( $factual_roots ) ) {
            $retrieval_basis = 'indexed_field_overlap';
            $claim_scope     = 'partial_indexed_evidence_verify_unmatched_constraints';
        } elseif ( ! empty( $identity_roots ) ) {
            $retrieval_basis = 'indexed_field_overlap';
            $claim_scope     = 'product_identity_overlap_not_attribute_proof';
        } else {
            $retrieval_basis = 'indexed_field_overlap';
            $claim_scope     = 'discovery_field_overlap_not_fact_equivalence';
        }
        return array(
            'excerpt'         => $this->query_aware_excerpt( $best_text, $roots, self::TOOL_EVIDENCE_MAX_BYTES ),
            'source_field'    => $best_field,
            'source_doc_key'  => $this->clip_utf8_bytes( isset( $row['doc_key'] ) ? (string) $row['doc_key'] : '', 200 ),
            'retrieval_basis' => $retrieval_basis,
            'claim_scope'     => $claim_scope,
            'factual_constraint_overlap' => ! empty( $factual_roots ),
            'unmatched_query_terms' => $this->clip_utf8_bytes( implode( ' ', array_slice( $unmatched_roots, 0, 8 ) ), 200 ),
        );
    }

    private function query_aware_excerpt( $text, $roots, $max_bytes ) {
        $text = trim( (string) $text );
        if ( '' === $text ) {
            return '';
        }
        if ( strlen( $text ) <= $max_bytes ) {
            return $text;
        }

        $position = $this->find_query_character_position( $text, $roots );

        if ( false === $position ) {
            return $this->representative_excerpt( $text, $max_bytes );
        }

        $text_characters = max( 1, mb_strlen( $text ) );
        $average_bytes   = max( 1.0, strlen( $text ) / $text_characters );
        $character_limit = max( 80, intval( floor( $max_bytes / $average_bytes ) ) );
        $start            = max( 0, intval( $position ) - intval( $character_limit / 3 ) );
        $excerpt          = mb_substr( $text, $start, $character_limit );
        if ( $start > 0 ) {
            $excerpt = '... ' . ltrim( $excerpt );
        }
        if ( $start + $character_limit < mb_strlen( $text ) ) {
            $excerpt = rtrim( $excerpt ) . ' ...';
        }

        return $this->clip_utf8_bytes( $excerpt, $max_bytes );
    }

    private function find_query_character_position( $text, $roots ) {
        if ( empty( $roots ) ) {
            return false;
        }

        $matched = preg_match_all( '/[\p{L}\p{N}]+/u', (string) $text, $tokens, PREG_OFFSET_CAPTURE );
        if ( false === $matched || empty( $tokens[0] ) ) {
            return false;
        }

        foreach ( $tokens[0] as $token ) {
            $normalized_token = $this->normalize_search_text( isset( $token[0] ) ? $token[0] : '' );
            foreach ( (array) $roots as $root ) {
                if ( '' !== $root && false !== mb_strpos( $normalized_token, $root ) ) {
                    $byte_offset = isset( $token[1] ) ? intval( $token[1] ) : 0;
                    return mb_strlen( substr( (string) $text, 0, $byte_offset ) );
                }
            }
        }

        return false;
    }

    private function representative_excerpt( $text, $max_bytes ) {
        $text      = trim( (string) $text );
        $max_bytes = max( 32, intval( $max_bytes ) );
        $length    = strlen( $text );
        if ( $length <= $max_bytes ) {
            return $text;
        }

        $separator = ' ... ';
        $part_size = max( 8, intval( floor( ( $max_bytes - ( 2 * strlen( $separator ) ) ) / 3 ) ) );
        $middle_at = max( 0, intval( floor( ( $length - $part_size ) / 2 ) ) );
        $tail_at   = max( 0, $length - $part_size );
        $cut       = static function ( $value, $offset, $size ) {
            return function_exists( 'mb_strcut' )
                ? mb_strcut( $value, $offset, $size, 'UTF-8' )
                : substr( $value, $offset, $size );
        };

        return $this->clip_utf8_bytes(
            rtrim( $cut( $text, 0, $part_size ) )
                . $separator
                . trim( $cut( $text, $middle_at, $part_size ) )
                . $separator
                . ltrim( $cut( $text, $tail_at, $part_size ) ),
            $max_bytes
        );
    }

    private function enforce_candidate_payload_budget( $item, $query = '' ) {
        $targets = array(
            array( 'evidence', 'excerpt', 96 ),
            array( '', 'tags', 0 ),
            array( '', 'attributes', 160 ),
            array( '', 'categories', 80 ),
            array( '', 'url', 160 ),
            array( '', 'brand', 40 ),
            array( '', 'title', 64 ),
            array( 'evidence', 'source_doc_key', 80 ),
            array( 'evidence', 'unmatched_query_terms', 0 ),
        );

        for ( $pass = 0; $pass < 24 && $this->candidate_json_size( $item ) > self::TOOL_RESULT_MAX_JSON_BYTES; ++$pass ) {
            $changed = false;
            foreach ( $targets as $target ) {
                list( $parent, $field, $minimum ) = $target;
                if ( '' === $parent ) {
                    if ( ! isset( $item[ $field ] ) || ! is_string( $item[ $field ] ) ) {
                        continue;
                    }
                    $value =& $item[ $field ];
                } else {
                    if ( ! isset( $item[ $parent ][ $field ] ) || ! is_string( $item[ $parent ][ $field ] ) ) {
                        continue;
                    }
                    $value =& $item[ $parent ][ $field ];
                }

                $current_bytes = strlen( $value );
                if ( $current_bytes <= $minimum ) {
                    unset( $value );
                    continue;
                }

                $excess      = $this->candidate_json_size( $item ) - self::TOOL_RESULT_MAX_JSON_BYTES;
                $target_size = max( $minimum, $current_bytes - max( 16, intval( ceil( $excess / 2 ) ) ) );
                if ( 'attributes' === $field || ( 'evidence' === $parent && 'excerpt' === $field ) ) {
                    $value = $this->query_aware_excerpt( $value, $this->query_roots( $query ), $target_size );
                } else {
                    $value = $this->clip_utf8_bytes( $value, $target_size );
                }
                $changed     = true;
                unset( $value );

                if ( $this->candidate_json_size( $item ) <= self::TOOL_RESULT_MAX_JSON_BYTES ) {
                    break;
                }
            }

            if ( ! $changed ) {
                break;
            }
        }

        return $item;
    }

    private function candidate_json_size( $item ) {
        $encoded = wp_json_encode( $item );
        return is_string( $encoded ) ? strlen( $encoded ) : PHP_INT_MAX;
    }

    private function clip_utf8_bytes( $text, $max_bytes ) {
        $text      = trim( (string) $text );
        $max_bytes = max( 0, intval( $max_bytes ) );
        if ( 0 === $max_bytes ) {
            return '';
        }
        if ( strlen( $text ) <= $max_bytes ) {
            return $text;
        }
        if ( $max_bytes <= 3 ) {
            return str_repeat( '.', $max_bytes );
        }

        $clipped = function_exists( 'mb_strcut' )
            ? mb_strcut( $text, 0, $max_bytes - 3, 'UTF-8' )
            : substr( $text, 0, $max_bytes - 3 );
        return rtrim( $clipped ) . '...';
    }

    private function has_complete_lexical_candidate( $rows, $query ) {
        foreach ( (array) $rows as $row ) {
            if ( is_array( $row ) && $this->row_has_complete_lexical_coverage( $row, $query ) ) {
                return true;
            }
        }

        return false;
    }

    private function row_has_complete_lexical_coverage( $row, $query ) {
        $analysis = $this->analyze_query_evidence( $row, $query );
        return empty( $analysis['unmatched_roots'] );
    }

    private function find_matched_fields( $row, $query ) {
        $analysis = $this->analyze_query_evidence( $row, $query );
        return $analysis['matched_fields'];
    }

    private function analyze_query_evidence( $row, $query ) {
        $roots = $this->query_roots( $query );
        if ( empty( $roots ) ) {
            return array(
                'matched_fields' => array(),
                'identity_roots' => array(),
                'factual_roots'  => array(),
                'unmatched_roots'=> array(),
            );
        }

        $fields = array(
            'title'             => isset( $row['title'] ) ? $row['title'] : '',
            'categories'        => isset( $row['categories_text'] ) ? $row['categories_text'] : '',
            'tags'              => isset( $row['tags_text'] ) ? $row['tags_text'] : '',
            'attributes'        => isset( $row['attributes_text'] ) ? $row['attributes_text'] : '',
            'short_description' => isset( $row['short_desc'] ) ? $row['short_desc'] : '',
            'description'       => isset( $row['content_text'] ) ? $row['content_text'] : '',
        );

        $matched        = array();
        $matched_roots  = array();
        $identity_roots = array();
        $factual_roots  = array();
        $identity_fields = array( 'title', 'categories' );
        $factual_fields  = array( 'attributes', 'short_description', 'description' );
        foreach ( $fields as $name => $value ) {
            $haystack = $this->normalize_search_text( $value );
            foreach ( $roots as $root ) {
                if ( $this->normalized_text_has_root( $haystack, $root ) ) {
                    $matched[]             = $name;
                    $matched_roots[ $root ] = true;
                    if ( in_array( $name, $identity_fields, true ) ) {
                        $identity_roots[ $root ] = true;
                    }
                    if ( in_array( $name, $factual_fields, true ) ) {
                        $factual_roots[ $root ] = true;
                    }
                }
            }
        }

        return array(
            'matched_fields' => array_values( array_unique( $matched ) ),
            'identity_roots' => array_keys( $identity_roots ),
            'factual_roots'  => array_keys( $factual_roots ),
            'unmatched_roots'=> array_values( array_diff( $roots, array_keys( $matched_roots ) ) ),
        );
    }

    /**
     * Match a query root as a complete word with at most one inflection letter.
     *
     * query_roots() removes at most one trailing character. Keeping the same
     * bound here preserves simple Serbian inflections (dlak-a/dlak-e) without
     * treating a short shared prefix as factual proof (koz-e versus koz-ija).
     */
    private function normalized_text_has_root( $haystack, $root ) {
        $haystack = trim( (string) $haystack );
        $root     = trim( (string) $root );
        if ( '' === $haystack || '' === $root ) {
            return false;
        }

        return 1 === preg_match(
            '/(?:^|\s)' . preg_quote( $root, '/' ) . '\p{L}?(?=$|\s)/u',
            $haystack
        );
    }

    private function query_roots( $query ) {
        $tokens = preg_split( '/\s+/u', $this->normalize_search_text( $query ) );
        $roots  = array();
        foreach ( (array) $tokens as $token ) {
            $length = mb_strlen( $token );
            if ( $length < 3 ) {
                continue;
            }
            if ( $length >= 4 && ! preg_match( '/\p{N}/u', $token ) ) {
                $token = mb_substr( $token, 0, $length - 1 );
            }
            if ( mb_strlen( $token ) >= 3 ) {
                $roots[] = $token;
            }
        }
        return array_values( array_unique( $roots ) );
    }

    private function normalize_search_text( $text ) {
        $text = function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $text, 'UTF-8' ) : strtolower( (string) $text );
        if ( function_exists( 'remove_accents' ) ) {
            $text = remove_accents( $text );
        }
        $text = preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $text );
        return trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
    }

    private function embed_query( $query ) {
        if ( ! $this->embedding_client ) {
            return array();
        }
        $query = trim( (string) $query );
        if ( '' === $query ) {
            return array();
        }

        $cache_key = $this->normalize_query_cache_key( $query );
        if ( '' !== $cache_key && isset( $this->query_embedding_cache[ $cache_key ] ) ) {
            $this->cached_query_key = $query;
            $this->cached_query_vec = $this->query_embedding_cache[ $cache_key ];
            return $this->cached_query_vec;
        }

        // Return cached embedding if same query (avoids duplicate API calls).
        if ( $query === $this->cached_query_key && ! empty( $this->cached_query_vec ) ) {
            return $this->cached_query_vec;
        }

		$stage_started = $this->trace_start();
		$vec = $this->embedding_client->embed_text( $query );
		$this->trace_finish( 'embedding_query', $stage_started, array( 'success' => ! empty( $vec ), 'dimensions' => count( (array) $vec ) ) );
        if ( ! empty( $vec ) ) {
            $this->cached_query_key = $query;
            $this->cached_query_vec = $vec;
            $this->cache_query_embedding( $query, $vec );
        }

        return $vec;
    }

	private function trace_start() {
		if ( ! $this->latency_tracker || '' === $this->trace_request_id || ! method_exists( $this->latency_tracker, 'start_stage' ) ) {
			return 0;
		}

		return (int) $this->latency_tracker->start_stage( $this->trace_request_id );
	}

	private function trace_finish( $stage, $started_ns, $meta = array() ) {
		if ( ! $this->latency_tracker || ! method_exists( $this->latency_tracker, 'finish_stage' ) ) {
			return 0.0;
		}

		return (float) $this->latency_tracker->finish_stage( $this->trace_request_id, $stage, $started_ns, $meta );
	}

    private function decode_embedding_row( $row ) {
        if ( ! is_array( $row ) ) {
            return array();
        }

        if ( class_exists( 'Niwa_AI_Content_Store' ) && method_exists( 'Niwa_AI_Content_Store', 'decode_embedding_row' ) ) {
            $decoded = Niwa_AI_Content_Store::decode_embedding_row( $row );
            if ( is_array( $decoded ) && ! empty( $decoded ) ) {
                return $decoded;
            }

            // Valid metadata lets the SQL projection omit the large JSON
            // payload. If PHP then rejects the bytes (for example NaN/Inf bit
            // corruption with the correct length), perform one targeted JSON
            // refetch rather than silently dropping this document.
            $json_was_projected_out = array_key_exists( 'embedding_json', $row )
                && null === $row['embedding_json'];
            $doc_key = isset( $row['doc_key'] ) ? trim( (string) $row['doc_key'] ) : '';
            if (
                $json_was_projected_out
                && '' !== $doc_key
                && $this->content_store
                && method_exists( $this->content_store, 'get_embedding_json_by_doc_key' )
            ) {
                $legacy_json = $this->content_store->get_embedding_json_by_doc_key( $doc_key );
                $decoded = Niwa_AI_Content_Store::decode_embedding_row(
                    array( 'embedding_json' => $legacy_json )
                );
                if ( is_array( $decoded ) && ! empty( $decoded ) ) {
                    return $decoded;
                }
            }
        }

        return $this->decode_json_embedding( isset( $row['embedding_json'] ) ? $row['embedding_json'] : '' );
    }

    private function decode_json_embedding( $json ) {
        if ( empty( $json ) ) {
            return array();
        }
        $decoded = json_decode( (string) $json, true );
        return is_array( $decoded ) ? $decoded : array();
    }

    private function cosine_similarity( $a, $b ) {
        if ( empty( $a ) || empty( $b ) ) {
            return 0.0;
        }
        $len = min( count( $a ), count( $b ) );
        if ( $len <= 0 ) {
            return 0.0;
        }
        $dot    = 0.0;
        $norm_a = 0.0;
        $norm_b = 0.0;
        for ( $i = 0; $i < $len; $i++ ) {
            $av      = floatval( $a[ $i ] );
            $bv      = floatval( $b[ $i ] );
            $dot    += $av * $bv;
            $norm_a += $av * $av;
            $norm_b += $bv * $bv;
        }
        if ( $norm_a <= 0 || $norm_b <= 0 ) {
            return 0.0;
        }
        return $dot / ( sqrt( $norm_a ) * sqrt( $norm_b ) );
    }

    private function vector_norm( $vector ) {
        $sum = 0.0;
        foreach ( (array) $vector as $value ) {
            $value = floatval( $value );
            $sum  += $value * $value;
        }
        return $sum > 0.0 ? sqrt( $sum ) : 0.0;
    }

    private function cosine_similarity_with_norms( $a, $b, $norm_a, $norm_b ) {
        $norm_a = floatval( $norm_a );
        $norm_b = floatval( $norm_b );
        if ( $norm_a <= 0.0 || $norm_b <= 0.0 ) {
            return 0.0;
        }

        $dot = 0.0;
        $len = count( $a );
        for ( $i = 0; $i < $len; $i++ ) {
            $dot += floatval( $a[ $i ] ) * floatval( $b[ $i ] );
        }

        return $dot / ( $norm_a * $norm_b );
    }

    private function apply_filters_to_rows( $rows, $filters ) {
        if ( empty( $filters ) || empty( $rows ) ) {
            return $rows;
        }

        $filtered = array();
        foreach ( $rows as $row ) {
            if ( ! empty( $filters['post_type'] ) ) {
                $post_type = isset( $row['post_type'] ) ? (string) $row['post_type'] : '';
                if ( is_array( $filters['post_type'] ) ) {
                    $types = array_values( array_filter( array_map( 'strval', $filters['post_type'] ) ) );
                    if ( ! empty( $types ) && ! in_array( $post_type, $types, true ) ) {
                        continue;
                    }
                } else {
                    if ( '' !== $post_type && $post_type !== (string) $filters['post_type'] ) {
                        continue;
                    }
                }
            }

            if ( ! empty( $filters['brand'] ) ) {
                $brands = is_array( $filters['brand'] ) ? $filters['brand'] : array( $filters['brand'] );
                $brands = array_map( 'strtolower', array_map( 'trim', $brands ) );
                $brand  = isset( $row['brand'] ) ? strtolower( (string) $row['brand'] ) : '';
                if ( '' !== $brand && ! in_array( $brand, $brands, true ) ) {
                    continue;
                }
            }

            if ( isset( $filters['price_min'] ) && is_numeric( $filters['price_min'] ) ) {
                $price = isset( $row['price'] ) ? floatval( $row['price'] ) : null;
                if ( null !== $price && $price < floatval( $filters['price_min'] ) ) {
                    continue;
                }
            }

            if ( isset( $filters['price_max'] ) && is_numeric( $filters['price_max'] ) ) {
                $price = isset( $row['price'] ) ? floatval( $row['price'] ) : null;
                if ( null !== $price && $price > floatval( $filters['price_max'] ) ) {
                    continue;
                }
            }

            if ( ! empty( $filters['categories'] ) && ! empty( $row['categories_text'] ) ) {
                $hay  = strtolower( (string) $row['categories_text'] );
                $cats = is_array( $filters['categories'] ) ? $filters['categories'] : array( $filters['categories'] );
                $hit  = false;
                foreach ( $cats as $cat ) {
                    $cat = strtolower( (string) $cat );
                    if ( '' !== $cat && false !== strpos( $hay, $cat ) ) {
                        $hit = true;
                        break;
                    }
                }
                if ( ! $hit ) {
                    continue;
                }
            }

            $filtered[] = $row;
        }

        return $filtered;
    }

    private function clip_text( $text, $max = 220 ) {
        $text = trim( (string) $text );
        if ( mb_strlen( $text ) <= $max ) {
            return $text;
        }
        return rtrim( mb_substr( $text, 0, $max ) ) . '...';
    }

    /**
     * Search past conversation entries for context relevant to the current query.
     *
     * Uses the content_store to find conversation-type entries that are
     * semantically similar to the query. Returns formatted snippets suitable
     * for injection into the system prompt.
     *
     * Uses the conversation store directly to find past entries that are
     * semantically similar to the query. Falls back to keyword overlap
     * when the embedding client is unavailable.
     *
     * @param string                            $query              The current user query.
     * @param string                            $session_id         Session ID to scope search.
     * @param int                               $limit              Max results to return.
     * @param Niwa_AI_Conversation_Store|null    $conversation_store Conversation store instance.
     * @return array Array of formatted conversation snippets.
     */
    public function search_conversation_memory( $query, $session_id = '', $limit = 5, $conversation_store = null ) {
        if ( '' === trim( (string) $query ) ) {
            return array();
        }

        if ( $this->agent_memory_manager && method_exists( $this->agent_memory_manager, 'search' ) ) {
            return $this->agent_memory_manager->search( $query, $session_id, $limit, $conversation_store );
        }

        return array();
    }

    public function search_agent_memory( $query, $session_id = '', $limit = 6, $conversation_store = null ) {
        if ( ! $this->agent_memory_manager || ! method_exists( $this->agent_memory_manager, 'search_entries' ) ) {
            return array();
        }

        return $this->agent_memory_manager->search_entries( $query, $session_id, $limit, $conversation_store );
    }

    public function read_agent_memory_entry( $path, $from = 1, $lines = 20 ) {
        if ( ! $this->agent_memory_manager || ! method_exists( $this->agent_memory_manager, 'read_entry' ) ) {
            return array(
                'path'  => (string) $path,
                'text'  => '',
                'error' => 'Agent memory manager nije ucitan.',
            );
        }

        return $this->agent_memory_manager->read_entry( $path, $from, $lines );
    }

    private function merge_rows_by_score( $a, $b ) {
        $merged = array();

        foreach ( array_merge( (array) $a, (array) $b ) as $row ) {
            $pid = isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0;
            if ( $pid <= 0 ) {
                continue;
            }

            $score = isset( $row['_score'] ) ? floatval( $row['_score'] ) : 0.0;
            if ( ! isset( $merged[ $pid ] ) ) {
                $merged[ $pid ] = $row;
                continue;
            }

            $existing_score = isset( $merged[ $pid ]['_score'] ) ? floatval( $merged[ $pid ]['_score'] ) : 0.0;
            if ( $score > $existing_score ) {
                $merged[ $pid ] = $row;
            }
        }

        $rows = array_values( $merged );
        usort(
            $rows,
            function ( $left, $right ) {
                $ls = isset( $left['_score'] ) ? floatval( $left['_score'] ) : 0.0;
                $rs = isset( $right['_score'] ) ? floatval( $right['_score'] ) : 0.0;
                return $rs <=> $ls;
            }
        );

        return $rows;
    }
}
