<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }

/**
 * GLSE_Search_Engine - WooCommerce product search.
 *
 * Uses direct SQL queries against wp_posts + wp_postmeta,
 * bypassing WP_Query / pre_get_posts hooks for reliable results.
 */
final class GLSE_Search_Engine {

    /**
     * Matching products from the last search.
     *
     * Exact when the whole result set fit inside the requested limit,
     * -1 when more matches exist than were returned (count not computed).
     */
    private int $last_total_found = 0;

    /** Whether the last search had more matches than it returned. */
    private bool $last_has_more = false;

    /**
     * Returns the total found count from the last search() call.
     * Avoids a second COUNT(*) query.
     *
     * @since 1.4.0
     */
    public function get_last_total_found(): int {
        return $this->last_total_found;
    }

    /**
     * Whether the last search matched more products than it returned.
     *
     * Use this rather than comparing get_last_total_found() to the result
     * count: the total is reported as -1 once it exceeds the limit.
     *
     * @since 1.6.1
     */
    public function has_more_results(): bool {
        return $this->last_has_more;
    }

    /* ══════════════════════════════════════════════
       Public API
       ══════════════════════════════════════════════ */

    

    

    /**
     * Searches products by term.
     *
     * Uses the fast index path when available, falls back to WP_Query otherwise.
     *
     * @since 1.4.0
     *
     * @param string $term    Search term.
     * @param array  $filters Optional filters:
     *                        - 'category'  (string|int)  Product category slug or term_id.
     *                        - 'min_price' (float)        Minimum price.
     *                        - 'max_price' (float)        Maximum price.
     *                        - 'in_stock'  (bool)         Only in-stock products.
     *                        - 'limit'     (int)          Maximum results. Default: 5.
     * @return array<int, array{id: int, title: string, url: string, price: float,
     *                          price_html: string, currency: string, image: string,
     *                          sku: string, rating: float, review_count: int}>
     */
    public function search( string $term, array $filters = [] ): array {
        $min_chars = (int) glse_get_option( 'min_chars' );
        if ( mb_strlen( $term ) < $min_chars ) {
            return [];
        }

        $filters = apply_filters( 'glse/search/filters', $filters, $term );
        // Backward compat - deprecated:
        $filters = apply_filters( 'glse_search_filters', $filters, $term );


        /*
         * Filters are Pro because they are features; the result count is not
         * gated, because capping how many results a shipped search returns is
         * exactly the artificial restriction the free build was cleared of.
         */
        

        $limit = max( 1, min( 100, isset( $filters['limit'] ) ? absint( $filters['limit'] ) : 5 ) );

        $lang      = self::current_lang();
        $cache_key = 'glse_' . md5( $term . wp_json_encode( $filters ) . $lang . self::render_signature() );

        $enable_cache   = (bool) glse_get_option( 'enable_cache' );
        $cache_duration = (int) glse_get_option( 'cache_duration' );

        if ( $enable_cache ) {
            $cached = self::cache_read( $cache_key );
            if ( is_array( $cached ) && isset( $cached['_glse'] ) ) {
                $this->last_total_found = (int) ( $cached['_glse_total'] ?? 0 );
                $this->last_has_more    = (bool) ( $cached['_glse_more'] ?? false );
                $cached_results = $cached['_glse'];
                do_action( 'glse/search/after', $term, $cached_results, 0, $filters['category'] ?? '' );
                do_action( 'glse_after_search', $term, $cached_results, 0, $filters['category'] ?? '' );
                return $cached_results;
            }
        }

        $t0 = microtime( true );

        $results = $this->search_via_sql( $term, $filters, $limit );

        $response_ms = (int) round( ( microtime( true ) - $t0 ) * 1000 );

        $results = apply_filters( 'glse/search/results', $results, $term );
        $results = apply_filters( 'glse_modify_results', $results, $term );

        if ( $enable_cache ) {
            self::cache_write( $cache_key, [
                '_glse'       => $results,
                '_glse_total' => $this->last_total_found,
                '_glse_more'  => $this->last_has_more,
            ], $cache_duration * MINUTE_IN_SECONDS );
        }

        do_action( 'glse/search/after', $term, $results, $response_ms, $filters['category'] ?? '' );
        // Backward compat - deprecated:
        do_action( 'glse_after_search', $term, $results, $response_ms, $filters['category'] ?? '' );

        return $results;
    }

    /**
     * Lit le cache des résultats, là où il survit réellement.
     *
     * wp_cache_get() ne dure que le temps d'une requête tant qu'aucun cache
     * objet persistant n'est installé - ce qui est le cas de la plupart des
     * boutiques. Sur ces sites, cocher « mettre les résultats en cache » ne
     * changeait rien, alors que l'écran de réglages annonce un stockage en
     * base. C'est ce repli qui rend cette phrase vraie.
     *
     * @since 1.7.0
     * @return array|false
     */
    private static function cache_read( string $key ) {
        if ( wp_using_ext_object_cache() ) {
            $hit = wp_cache_get( $key, 'glse' );
            return is_array( $hit ) ? $hit : false;
        }

        $hit = get_transient( $key );

        return is_array( $hit ) ? $hit : false;
    }

    /**
     * Écrit le cache des résultats.
     *
     * La durée vient du réglage et se compte en minutes ; un transient expiré
     * est retiré par WordPress, donc la table des options ne gonfle pas
     * indéfiniment même sur une boutique où l'on cherche beaucoup.
     *
     * @since 1.7.0
     */
    private static function cache_write( string $key, array $value, int $ttl ): void {
        $ttl = max( 60, $ttl );

        if ( wp_using_ext_object_cache() ) {
            wp_cache_set( $key, $value, 'glse', $ttl );
            return;
        }

        set_transient( $key, $value, $ttl );
    }

    /**
     * L'expression BOOLEAN MODE cherchée pour un terme.
     *
     * Chaque mot devient un groupe de ses variantes : « chaussures » est
     * cherché comme « (chaussures chaussure) », si bien qu'un catalogue au
     * singulier répond à un client qui tape le pluriel.
     *
     * La grammaire d'origine est conservée : le premier mot reste obligatoire,
     * le dernier porte le joker de préfixe. Élargir cela changerait quels
     * produits remontent, ce qui n'est pas le sujet ici.
     *
     * @since 1.7.0
     */
    private static function boolean_expression( string $term ): string {
        $words = preg_split( '/\s+/u', $term, -1, PREG_SPLIT_NO_EMPTY ) ?: [];

        if ( ! $words ) {
            return '';
        }

        $last   = count( $words ) - 1;
        $groups = [];

        foreach ( $words as $i => $word ) {
            $forms = class_exists( 'GLSE_Morphology' )
                ? GLSE_Morphology::variants( $word )
                : [ $word ];

            /*
             * Le joker ne va qu'au dernier mot, comme avant : le poser partout
             * transformerait chaque mot en préfixe et ramènerait large.
             */
            if ( $i === $last ) {
                $forms = array_map( static fn( $f ) => $f . '*', $forms );
            }

            $group = count( $forms ) > 1
                ? '(' . implode( ' ', $forms ) . ')'
                : $forms[0];

            $groups[] = ( 0 === $i ? '+' : '' ) . $group;
        }

        return implode( ' ', $groups );
    }

    /**
     * La même phrase, chaque mot ramené à son singulier probable.
     *
     * Renvoie une chaîne vide quand rien ne change, pour que l'appelant sache
     * qu'il n'y a pas de seconde recherche à faire. Une seule réécriture, pas
     * toutes les combinaisons : sur trois mots au pluriel, les combiner en
     * produirait huit.
     *
     * @since 1.7.0
     */
    private static function singularised_phrase( string $term ): string {
        if ( ! class_exists( 'GLSE_Morphology' ) || ! GLSE_Morphology::applies() ) {
            return '';
        }

        $words = preg_split( '/\s+/u', trim( $term ), -1, PREG_SPLIT_NO_EMPTY ) ?: [];

        if ( ! $words ) {
            return '';
        }

        $changed = false;
        foreach ( $words as $i => $word ) {
            $singulars = GLSE_Morphology::singulars( $word );
            if ( $singulars ) {
                $words[ $i ] = $singulars[0];
                $changed     = true;
            }
        }

        return $changed ? implode( ' ', $words ) : '';
    }

    

    /* ══════════════════════════════════════════════
       Search - direct SQL (bypasses pre_get_posts)
       ══════════════════════════════════════════════ */

    /**
     * Narrows a search with what the shopper picked: stock, price, category.
     *
     * Lifted out of search_via_sql(), which ran to 338 lines. This block is the
     * one part that only reads $filters and only appends - it touches nothing
     * of the matching logic above it, which is why it comes out cleanly while
     * the rest stays put.
     *
     * The three arrays are taken by reference because that is what the caller
     * does with them either way: it keeps building on them afterwards, and
     * returning copies would only add a merge step.
     *
     * @since 1.7.0
     *
     * @param array $filters Active filters.
     * @param array $join    JOIN clauses, appended to.
     * @param array $where   WHERE clauses, appended to.
     * @param array $params  Bound values, appended to in clause order.
     */
    private function apply_shopper_filters( array $filters, array &$join, array &$where, array &$params ): void {
        global $wpdb;

        if ( ! empty( $filters['in_stock'] ) ) {
            $join[]  = "INNER JOIN {$wpdb->postmeta} pm_stock ON pm_stock.post_id = p.ID AND pm_stock.meta_key = '_stock_status'";
            $where[] = "pm_stock.meta_value = 'instock'";
        }

        if ( isset( $filters['min_price'] ) || isset( $filters['max_price'] ) ) {
            $join[] = "INNER JOIN {$wpdb->postmeta} pm_price ON pm_price.post_id = p.ID AND pm_price.meta_key = '_price'";

            if ( isset( $filters['min_price'] ) ) {
                $where[]  = 'CAST(pm_price.meta_value AS DECIMAL(10,2)) >= %f';
                $params[] = (float) $filters['min_price'];
            }
            if ( isset( $filters['max_price'] ) ) {
                $where[]  = 'CAST(pm_price.meta_value AS DECIMAL(10,2)) <= %f';
                $params[] = (float) $filters['max_price'];
            }
        }

        if ( empty( $filters['category'] ) ) {
            return;
        }

        // Accepte un identifiant comme un slug : la barre envoie l'un ou l'autre
        // selon qu'elle a ete rendue par le bloc ou par le raccourci.
        $term_obj = ctype_digit( (string) $filters['category'] )
            ? get_term( (int) $filters['category'], 'product_cat' )
            : get_term_by( 'slug', (string) $filters['category'], 'product_cat' );

        if ( ! $term_obj || is_wp_error( $term_obj ) ) {
            return;
        }

        $join[]   = "INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID";
        $join[]   = "INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'product_cat'";
        $where[]  = 'tt.term_id = %d';
        $params[] = (int) $term_obj->term_id;
    }

    /**
     * Direct SQL search against wp_posts + wp_postmeta.
     * Bypasses WP_Query / pre_get_posts hooks for reliable results.
     */
    private function search_via_sql( string $term, array $filters, int $limit ): array {
        global $wpdb;

        $title_like = '%' . $wpdb->esc_like( $term ) . '%';
        $sku_like   = '%' . $wpdb->esc_like( $term ) . '%';

        /*
         * SKU matching is resolved up front by its own indexed query rather than
         * a LEFT JOIN on wp_postmeta.
         *
         * The join forced MySQL into a nested loop over every published product,
         * reading ~40 meta rows each just to find one '_sku' row - 830k rows read
         * per search on a 20k catalog. Resolving the SKUs first lets the meta_key
         * index do the work, and hands back the exact matches needed for ranking
         * without a second correlated sub-select in ORDER BY.
         */
        [ $sku_ids, $sku_exact_ids ] = $this->match_skus( $term, $limit );

        $variation_clause = '';
        $variation_params = [];

        

        /*
         * A single option covers content and excerpt. MySQL requires a MATCH
         * column list identical to the index definition, so the FULLTEXT path
         * always searches title, content and excerpt as one unit: two separate
         * checkboxes cannot act independently, whatever the interface promises.
         */
        $search_in_description = (bool) glse_get_option( 'search_in_description' );

        /*
         * Content/excerpt search: use FULLTEXT MATCH…AGAINST in boolean mode
         * when the index exists (much faster on large catalogs). Falls back to
         * LIKE '%term%' otherwise. The index can be created manually:
         *
         *   ALTER TABLE wp_posts ADD FULLTEXT INDEX glse_ft_content (post_title, post_content, post_excerpt);
         *
         * The filter glse/search/use_fulltext lets integrations force one mode.
         */
        /*
         * Which index can serve this search. MySQL requires the MATCH column
         * list to repeat the index definition exactly, so a title-only search
         * needs its own index - it cannot borrow the three-column one.
         */
        $ft_index     = '';
        $use_fulltext = false;

        /*
         * Chargé ici plutôt que supposé présent : le moteur est instancié seul
         * par les tests et par du code tiers, et une constante de classe est
         * résolue avant tout garde placé à côté d'elle - lire IDX_CONTENT sous
         * un class_exists() du même souffle est déjà trop tard.
         */
        if ( ! class_exists( 'GLSE_Fulltext' ) && defined( 'GLSE_PATH' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-fulltext.php';
        }
        if ( ! class_exists( 'GLSE_Morphology' ) && defined( 'GLSE_PATH' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-morphology.php';
        }

        if ( class_exists( 'GLSE_Fulltext' ) ) {
            $ft_index     = $search_in_description ? GLSE_Fulltext::IDX_CONTENT : GLSE_Fulltext::IDX_TITLE;
            $use_fulltext = GLSE_Fulltext::exists( $ft_index );
        }

        /** Filter whether the FULLTEXT index drives this search.
         * @param bool   $use_fulltext Whether MATCH…AGAINST will be used.
         * @param string $term         Search term. */
        $use_fulltext = (bool) apply_filters( 'glse/search/use_fulltext', $use_fulltext, $term );

        $ft_term = '';
        if ( $use_fulltext ) {
            /*
             * Escape FULLTEXT boolean operators before building the query.
             * Characters like +, -, *, ", (, ) have special meaning in
             * BOOLEAN MODE and must be stripped from user input.
             */
            $ft_term = trim( (string) preg_replace( '/\s+/', ' ', (string) preg_replace( '/[+\-*"()~<>@^]/', ' ', $term ) ) );
            if ( '' === $ft_term ) {
                $use_fulltext = false;
            }

            /*
             * A word shorter than the server's minimum token length was never
             * written to the index, so MATCH returns nothing for it - not few
             * results, none. "XL" and "42" are exactly the terms a shopper
             * types in a clothing store, hence the fallback rather than an
             * empty panel.
             *
             * The trailing * makes a prefix search, so only the last word may
             * be short; a short word anywhere else still has to match whole.
             */
            if ( $use_fulltext ) {
                $min   = GLSE_Fulltext::min_token_size();
                $words = preg_split( '/\s+/u', $ft_term, -1, PREG_SPLIT_NO_EMPTY ) ?: [];
                $last  = count( $words ) - 1;

                foreach ( $words as $i => $word ) {
                    /*
                     * The closing word carries the * and therefore matches
                     * longer tokens by prefix, so its own length does not
                     * matter. Every other word has to exist in the index whole.
                     */
                    if ( $i !== $last && mb_strlen( $word ) < $min ) {
                        $use_fulltext = false;
                        break;
                    }
                }
            }
        }

        $match_clauses = '';
        $match_params  = [];

        if ( $use_fulltext ) {
            /*
             * MATCH must be the *only* text predicate on wp_posts here.
             *
             * OR-ing it with `post_title LIKE '%term%'` silently disables the
             * index: MySQL cannot combine a FULLTEXT scan with a non-indexable
             * LIKE under OR, so it falls back to a full scan and the index buys
             * nothing (measured: 744ms with the index, 750ms without). MATCH
             * already covers post_title, so the LIKE is redundant anyway - at
             * the cost of infix matching ("hirt" does not find "shirt"; "shi"
             * does, via the * suffix).
             *
             * MySQL requires the MATCH column list to exactly match the index
             * definition, hence all three columns regardless of which
             * search_in_* options are on.
             */
            $match_clauses  = sprintf(
                'MATCH(%s) AGAINST(%%s IN BOOLEAN MODE)',
                GLSE_Fulltext::match_columns( $ft_index, 'p' )
            );
            $match_params[] = self::boolean_expression( $ft_term );
        } else {
            /*
             * Le chemin lent cherche la phrase telle quelle. Quand la
             * morphologie propose une autre écriture des mots - « chaussures »
             * pour « chaussure » - la seconde phrase est cherchée en plus, pas
             * à la place.
             */
            $alt = self::singularised_phrase( $term );

            $match_clauses  = 'p.post_title LIKE %s';
            $match_params[] = $title_like;

            if ( '' !== $alt ) {
                $match_clauses .= ' OR p.post_title LIKE %s';
                $match_params[] = '%' . $wpdb->esc_like( $alt ) . '%';
            }

            if ( $search_in_description ) {
                $match_clauses .= ' OR p.post_content LIKE %s OR p.post_excerpt LIKE %s';
                $match_params[] = $title_like;
                $match_params[] = $title_like;

                if ( '' !== $alt ) {
                    $alt_like       = '%' . $wpdb->esc_like( $alt ) . '%';
                    $match_clauses .= ' OR p.post_content LIKE %s OR p.post_excerpt LIKE %s';
                    $match_params[] = $alt_like;
                    $match_params[] = $alt_like;
                }
            }
        }

        // SKU matches, already resolved above - inlined as integers, no join needed.
        $sku_clause = $sku_ids
            ? ' OR p.ID IN (' . implode( ',', $sku_ids ) . ')'
            : '';

        $join   = [];
        $where  = [
            "p.post_type = 'product'",
            "p.post_status = 'publish'",
            // Password-protected products are not browsable, so never surface them.
            "p.post_password = ''",
            '( ' . $match_clauses . $sku_clause . $variation_clause . ' )',
        ];
        $params = $match_params;

        /*
         * Honour the merchant's Catalog visibility setting. wp_term_relationships
         * is keyed on (object_id, term_taxonomy_id), so NOT EXISTS resolves via
         * the primary key.
         */
        $hidden_tt_ids = self::hidden_term_taxonomy_ids();
        if ( $hidden_tt_ids ) {
            $where[] = 'NOT EXISTS (' .
                " SELECT 1 FROM {$wpdb->term_relationships} tr_vis" .
                ' WHERE tr_vis.object_id = p.ID' .
                ' AND tr_vis.term_taxonomy_id IN (' . implode( ',', $hidden_tt_ids ) . ')' .
                ')';
        }

        /* Bound off the clause itself: empty when the build has no such clause. */
        foreach ( $variation_params as $vp ) {
            $params[] = $vp;
        }

        $this->apply_shopper_filters( $filters, $join, $where, $params );

        /** Filter the SQL query components before execution.
         * @param array  $query_args { join: string[], where: string[], params: mixed[] }
         * @param string $term       Search term.
         * @param array  $filters    Active filters. */
        $query_args = apply_filters( 'glse/search/query_args', [
            'join'   => $join,
            'where'  => $where,
            'params' => $params,
        ], $term, $filters );

        // Anti-bypass: ensure filter output is well-typed (prevents injection via malicious plugins).
        $join   = is_array( $query_args['join'] ?? null )   ? array_filter( $query_args['join'], 'is_string' )   : $join;
        $where  = is_array( $query_args['where'] ?? null )  ? array_filter( $query_args['where'], 'is_string' )  : $where;
        $params = is_array( $query_args['params'] ?? null ) ? array_values( $query_args['params'] )              : $params;

        $join_sql  = implode( ' ', $join );
        $where_sql = implode( ' AND ', $where );

        /*
         * De-duplication is only needed when a filter JOIN can fan out rows.
         * GROUP BY (rather than DISTINCT) keeps the query legal under
         * ONLY_FULL_GROUP_BY: p.ID is the primary key, so every p.* column
         * referenced in ORDER BY is functionally dependent on it. SELECT
         * DISTINCT + ORDER BY on non-selected columns raises MySQL error 1055
         * and only worked before because WordPress strips that sql_mode.
         */
        $group_sql = $join ? 'GROUP BY p.ID' : '';

        /*
         * Priority: 0=exact title, 1=title prefix, 2=exact SKU, 3=title contains, 4=other.
         * Exact-SKU ids come from match_skus() so ORDER BY needs no sub-select.
         * p.ID is appended as a tiebreaker to keep paging/ordering deterministic.
         */
        $exact_sku_case = $sku_exact_ids
            ? ' WHEN p.ID IN (' . implode( ',', $sku_exact_ids ) . ') THEN 2'
            : '';

        $order_params = [ $term, $wpdb->esc_like( $term ) . '%', $title_like ];

        /*
         * Fetch one row more than requested: if it comes back, we know more
         * matches exist without paying for a second COUNT(*) over the whole
         * result set (which used to cost as much as the search itself).
         */
        $fetch_limit = $limit + 1;
        $all_params  = array_merge( $params, $order_params, [ $fetch_limit ] );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $join_sql/$where_sql/$group_sql contain only hardcoded strings, %s/%d/%f placeholders and integer id lists; all dynamic values are in $all_params passed to prepare().
        $ids = $wpdb->get_col( $wpdb->prepare(
            "SELECT p.ID
             FROM {$wpdb->posts} p
             {$join_sql}
             WHERE {$where_sql}
             {$group_sql}
             ORDER BY
               CASE WHEN p.post_title = %s       THEN 0
                    WHEN p.post_title LIKE %s     THEN 1
                    {$exact_sku_case}
                    WHEN p.post_title LIKE %s     THEN 3
                    ELSE 4 END,
               p.ID DESC
             LIMIT %d",
            ...$all_params
        ) );

        $this->last_has_more = count( $ids ) > $limit;
        $ids                 = array_slice( $ids, 0, $limit );

        /*
         * total_found is exact only while the result set fits within the
         * requested limit. Past that we report -1 = "more than we returned,
         * count unknown" so callers can say "View all results" without a
         * number instead of running a full COUNT on every keystroke.
         */
        $this->last_total_found = $this->last_has_more ? -1 : count( $ids );

        if ( empty( $ids ) ) {
            return [];
        }

        // Batch-load products to avoid N+1 queries.
        $products_raw = wc_get_products( [
            'include' => array_map( 'intval', $ids ),
            'limit'   => count( $ids ),
            'orderby' => 'include',
            'status'  => 'publish',
        ] );

        $this->prime_image_caches( $products_raw );

        $results = [];
        foreach ( $products_raw as $product ) {
            if ( $product instanceof WC_Product ) {
                $data = $this->format_product( $product );

                /** Filter individual product data in search results.
                 * @param array      $data    Formatted product data.
                 * @param WC_Product $product WooCommerce product object.
                 * @param string     $term    Search term. */
                $results[] = apply_filters( 'glse/results/product_data', $data, $product, $term );
            }
        }

        return $results;
    }

    /**
     * term_taxonomy_ids of the product_visibility terms that must never appear
     * in live search results.
     *
     * Always excludes 'exclude-from-search' - the flag merchants set in the
     * product's Catalog visibility box. The direct SQL path bypasses WP_Query,
     * so nothing else applies it: without this, products explicitly hidden from
     * search are returned like any other, and the ID tiebreaker can even rank
     * them first.
     *
     * 'outofstock' is added only when the store is configured to hide
     * out-of-stock items, mirroring what WooCommerce does everywhere else.
     *
     * @return int[]
     */
    private static function hidden_term_taxonomy_ids(): array {
        $cached = wp_cache_get( 'glse_hidden_tt_ids', 'glse' );
        if ( is_array( $cached ) ) {
            return $cached;
        }

        $slugs = [ 'exclude-from-search' ];

        if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
            $slugs[] = 'outofstock';
        }

        /** Filter the product_visibility slugs excluded from live search.
         * @param string[] $slugs Visibility term slugs to exclude. */
        $slugs = (array) apply_filters( 'glse/search/hidden_visibility_slugs', $slugs );

        $ids = [];
        foreach ( $slugs as $slug ) {
            $term = get_term_by( 'slug', (string) $slug, 'product_visibility' );
            if ( $term && ! is_wp_error( $term ) ) {
                $ids[] = (int) $term->term_taxonomy_id;
            }
        }

        wp_cache_set( 'glse_hidden_tt_ids', $ids, 'glse', HOUR_IN_SECONDS );

        return $ids;
    }

    /**
     * Resolves products whose SKU matches the term, using the meta_key index.
     *
     * Returns two id lists: every SKU match (for the WHERE clause) and the
     * subset matching the term exactly (for result ranking). Both are plain
     * integers so they can be inlined into SQL safely.
     *
     * @param string $term  Search term.
     * @param int    $limit Requested result count, used to size the id cap.
     * @return array{0: int[], 1: int[]}
     */
    private function match_skus( string $term, int $limit ): array {
        global $wpdb;

        /*
         * Cap the id list so a very generic term cannot build an enormous
         * IN() clause. Title matches outrank SKU matches anyway, and we only
         * ever display $limit rows, so a few hundred candidates is ample.
         */
        $cap = max( 200, $limit * 10 );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT post_id, meta_value
             FROM {$wpdb->postmeta}
             WHERE meta_key = '_sku' AND meta_value LIKE %s
             LIMIT %d",
            '%' . $wpdb->esc_like( $term ) . '%',
            $cap
        ), ARRAY_A );

        $all = [];
        $exact = [];

        foreach ( (array) $rows as $row ) {
            $id = (int) $row['post_id'];
            if ( $id <= 0 ) {
                continue;
            }
            $all[] = $id;
            if ( 0 === strcasecmp( (string) $row['meta_value'], $term ) ) {
                $exact[] = $id;
            }
        }

        return [ array_unique( $all ), array_unique( $exact ) ];
    }

    /**
     * Warms the post/meta cache for product thumbnails in one query pair.
     *
     * wp_get_attachment_image_url() in format_product() otherwise issues a
     * get_post() plus a get_post_meta() per row - 2 queries per result, so
     * 100 extra queries at the Pro maximum of 50 results.
     *
     * @param array $products Products about to be formatted.
     */
    private function prime_image_caches( array $products ): void {
        $image_ids = [];

        foreach ( $products as $product ) {
            if ( ! $product instanceof WC_Product ) {
                continue;
            }
            $image_id = (int) $product->get_image_id();
            if ( $image_id > 0 ) {
                $image_ids[] = $image_id;
            }
        }

        if ( $image_ids ) {
            _prime_post_caches( array_unique( $image_ids ), false, true );
        }
    }

    /* ══════════════════════════════════════════════
       Helpers
       ══════════════════════════════════════════════ */

    /**
     * Formats a WC_Product object into the standard result array.
     * Used by search_via_sql() and get_recommendations().
     */
    private function format_product( WC_Product $product ): array {
        $image = wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' );

        return [
            'id'           => $product->get_id(),
            'title'        => $product->get_name(),
            'url'          => get_permalink( $product->get_id() ) ?: '',
            'price'        => (float) $product->get_price(),
            'price_html'   => preg_replace(
                    '/<span\s+class="screen-reader-text">[^<]*<\/span>/i',
                    '',
                    $product->get_price_html()
                ) ?? '',
            'currency'     => get_woocommerce_currency(),
            'image'        => $image ?: wc_placeholder_img_src( 'thumbnail' ),
            'sku'          => $product->get_sku(),
            'rating'       => (float) $product->get_average_rating(),
            'review_count' => (int) $product->get_review_count(),
        ];
    }


    

    /**
     * Fingerprint of every setting that changes the *rendered* result markup.
     *
     * The cache stores formatted results, so two searches with the same term
     * but different display options must not share an entry. Without this the
     * admin can toggle "Show SKU" (or the license can lapse) and keep serving
     * markup built under the previous settings until the cache expires.
     */
    private static function render_signature(): string {
        $keys = [
            'show_image',
            'show_price',
            'show_sku',
            'show_rating',
            'show_categories',
            'show_category_count',
            'enable_recommendations',
            'recommendations_count',
            'recommendations_label',
            'search_in_description',
            'search_variations',
        ];

        $sig = [];
        foreach ( $keys as $key ) {
            $sig[ $key ] = glse_get_option( $key );
        }
        /*
         * A licence changes what several of the options above produce, so a
         * cache entry filled while it was active must not be served once it
         * lapses. The free build has no such transition to guard against.
         */
        

        return (string) wp_json_encode( $sig );
    }

    private static function current_lang(): string {
        if ( defined( 'ICL_LANGUAGE_CODE' ) ) {
            return (string) ICL_LANGUAGE_CODE;
        }
        if ( function_exists( 'pll_current_language' ) ) {
            return pll_current_language() ?: get_locale();
        }
        return get_locale();
    }

    
}
