<?php
/**
 * Plugin Name:       Glance Live Search
 * Plugin URI:        https://www.glancelivesearch.com
 * Description:       WooCommerce product search with AJAX live search (title, SKU, categories).
 * Version:           1.7.0
 * Tested up to:      7.0.2
 * Requires at least: 6.0
 * Requires PHP:      8.0
 * Author:            Glance Search
 * Author URI:        https://www.glancelivesearch.com
 * License:           GPL v2 or later
 * Text Domain:       glance-live-search
 * Domain Path:       /languages
 * Requires Plugins:  woocommerce
 * WC requires at least: 8.0
 * WC tested up to:      10.9

 */

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

/*
 * ── Paid bootstrap, and standing down for it ────────────────────────────
 * The sentinel constant lives in the file below, so a build that has that file
 * is the paid one. A build that does not, and finds the sentinel already
 * defined, is a free copy running beside a Pro install: it stops here rather
 * than redeclaring everything.
 */
/*
 * The bootstrap lives in includes/, so __FILE__ there is not the plugin entry
 * WordPress knows. It needs this one to recognise itself among the active
 * plugins - without it the guard mistakes the plugin for a rival copy and
 * deactivates it on the next admin request.
 */
define( 'GLSE_MAIN_FILE', __FILE__ );

$glse_pro_bootstrap = __DIR__ . '/includes/pro-bootstrap__premium_only.php';
$glse_is_pro_build  = is_readable( $glse_pro_bootstrap );

if ( ! $glse_is_pro_build && defined( 'GLSE_PRO_ACTIVE' ) ) {
    return;
}

if ( $glse_is_pro_build ) {
    require_once $glse_pro_bootstrap;
}

if ( function_exists( 'glse_fs' ) ) {
    // Free version is active - tell Freemius this is the premium build.
    glse_fs()->set_basename( true, __FILE__ );
} else {

    define( 'GLSE_VERSION', '1.7.0' );
    define( 'GLSE_PATH', plugin_dir_path( __FILE__ ) );
    define( 'GLSE_URL', plugin_dir_url( __FILE__ ) );

    if ( ! function_exists( 'glse_fs' ) ) {
        // Create a helper function for easy SDK access.
        function glse_fs() {
            global $glse_fs;

            if ( ! isset( $glse_fs ) ) {
                // Include Freemius SDK.
                require_once dirname( __FILE__ ) . '/vendor/freemius/start.php';

                $glse_fs = fs_dynamic_init( array(
                    'id'                  => '25523',
                    'slug'                => 'glance-live-search',
                    /*
                     * Le dossier de la version payante que Freemius génère.
                     * Sans cette clé le SDK retombe sur "<slug>-premium"
                     * (get_premium_slug(), class-freemius.php:10009), qui n'est
                     * pas le nom attendu côté tableau de bord.
                     */
                    'premium_slug'        => 'glance-live-search-pro',
                    'type'                => 'plugin',
                    'public_key'          => 'pk_8498e28bd0c79e36b5197561cfde1',
                    'is_premium'          => false,
                    'has_premium_version' => true,
                    'has_addons'          => false,
                    'has_paid_plans'      => true,
                    'is_org_compliant'    => true,
                    /*
                     * 'is_premium_only' n'est volontairement PAS repris du code
                     * fourni par le tableau de bord : il force
                     * _enable_anonymous et _anonymous_mode à false
                     * (class-freemius.php:5287), donc il rend l'inscription
                     * Freemius obligatoire pour utiliser le plugin. Sur une
                     * version gratuite hébergée par wp.org, c'est rédhibitoire,
                     * et la clé signifie de toute façon "ce produit n'a pas de
                     * version gratuite". À n'activer que si vous renoncez à
                     * publier sur wp.org.
                     */
                    'navigation'          => 'menu',
                    'icon'                => plugin_dir_url( __FILE__ ) . 'admin/images/icon-256x256.png',
                    'menu'                => array(
                        'slug'    => 'glse-settings',
                        /*
                         * The settings page is registered as a WooCommerce submenu
                         * (see GLSE_Settings::add_menu_page). Without this 'parent'
                         * entry Freemius assumes a top-level menu and hangs its own
                         * sub-pages (Account, Pricing) off the wrong parent.
                         */
                        'parent'  => array(
                            'slug' => 'woocommerce',
                        ),
                        /*
                         * Kept off the WooCommerce menu: the Licence tab already
                         * answers what the account page is opened for - plan,
                         * renewal date, sites in use - and links to it for
                         * billing. A false here only hides the menu item;
                         * Freemius still registers the page, so get_account_url()
                         * and the licence activation dialog keep working.
                         */
                        'account' => false,
                        'contact' => false,
                        'support' => false,
                    ),
                ) );
            }

            return $glse_fs;
        }

        // Init Freemius.
        glse_fs();
        // Signal that SDK was initiated.
        do_action( 'glse_fs_loaded' );
    }

    /**
     * Every option the plugin ships, with the value it ships with.
     *
     * One table, read by three callers: the option reader below, the settings
     * sanitiser, and the colour picker's "Default" button. A second copy of
     * these values somewhere else is a second opinion waiting to disagree.
     *
     * @return array<string, mixed>
     */
    function glse_defaults(): array {
        static $defaults = null;
        if ( null === $defaults ) {
            $defaults = [
                /* ── Behavior ── */
                'live_search'         => true,
                'search_button'       => true,
                'search_button_mode'  => 'icon',
                'button_position'     => 'inside',
                'max_results'         => 5,
                /*
                 * Sized against the current engine: a search costs ~20ms on a
                 * small catalogue and ~60ms at 20k products, so most of what a
                 * shopper feels is this wait, not the query. Raise it if your
                 * host is request-constrained.
                 */
                'ajax_delay'          => 200,
                'min_chars'           => 3,
                'placeholder'         => '',
                'search_in_description' => false,

                /* ── Search bar ── */
                'bar_design_preset'   => 'custom',
                'bar_category_filter' => false,
                'bar_height'          => 48,
                'bar_bg_color'        => '#f4f4f5',
                'bar_bg_focus'        => '#ffffff',
                'border_size'         => 1,
                'border_color'        => '#e5e7eb',
                'border_radius'       => 14,
                'text_color'          => '#18181b',
                'placeholder_color'   => '#6e6e78',
                /*
                 * Blue-600 plutot que blue-500, pour une raison mesurable :
                 * du blanc sur #3b82f6 donne 3,68:1, sous les 4,5:1 qu'exige
                 * WCAG pour du texte. Le bouton porte une icone par defaut, qui
                 * se contenterait de 3:1 - mais le passer en mode texte est un
                 * choix a deux clics, et le libelle fait alors 14px/600, soit du
                 * texte normal. Ce bleu-ci donne 5,17:1 et se voit a peine.
                 *
                 * L'anneau de focus suit : c'est la meme couleur de marque, et
                 * les separer aurait produit deux bleus voisins sur un meme
                 * ecran. Il y gagne au passage, de 3,35:1 a 4,7:1.
                 */
                'button_color'        => '#2563eb',
                'button_hover_color'  => '#0e2a5c',
                'button_text_color'   => '#ffffff',
                'button_radius'       => 10,
                'focus_ring_color'    => '#2563eb',

                /* ── Results ── */
                'show_categories'     => false,
                'show_image'          => true,
                'show_price'          => true,
                'show_sku'            => false,
                'show_rating'         => false,
                /*
                 * On by default: without it a category row is a bare word, and
                 * hard to tell from the tag row beside it. "Shoes - 82 products"
                 * is what makes the row worth its place over a product.
                 */
                'show_category_count' => true,
                'in_stock_only'       => false,
                'price_filter'        => false,
                'did_you_mean'        => true,
                'show_history'        => true,
                'history_max'         => 5,

                /* ── Recommendations ── */
                'enable_recommendations' => false,
                'recommendations_count'  => 3,
                /*
                 * Empty, not the translated string: __() inside this static
                 * array would resolve once - possibly before the text domain is
                 * loaded - and stay frozen. Consumers fall back with ?: at
                 * render time, as they already do for 'placeholder'.
                 */
                'recommendations_label'  => '',

                /* ── Mobile ── */
                'mobile_overlay'            => true,
                'mobile_overlay_breakpoint' => 768,

                /* ── Performance ── */
                /*
                 * On by default only where caching is genuinely cheap. With a
                 * persistent object cache (Redis, Memcached) a miss costs
                 * nothing extra; without one, every miss writes a transient row
                 * to wp_options, which is a poor trade on a busy storefront.
                 */
                'enable_cache'           => wp_using_ext_object_cache(),
                'cache_duration'         => 5,
                'disable_mobile'         => false,
                /*
                 * Must cover at least twice the default analytics period (14
                 * days), otherwise every figure's "vs previous period" trend
                 * compares against an empty window and never appears. 90 also
                 * matches the longest period the dashboard offers.
                 */
                'data_retention'         => 90,
                /*
                 * Desactive par defaut : ces deux en-tetes portent sur tout le
                 * site, et un plugin de recherche n'a pas a modifier les
                 * en-teres HTTP d'une boutique sans qu'on le lui demande. Ils
                 * ne servent d'ailleurs a rien de ce que le plugin fait - la
                 * reponse de recherche est du JSON declare par wp_send_json, et
                 * le panneau est echappe cote serveur. C'est du durcissement
                 * offert, pas une dependance.
                 */
                'frontend_security_headers' => false,
                'admin_csp'                 => false,
                'search_variations'       => false,
            ];
        }

        return $defaults;
    }

    /**
     * The shipped value for one option, ignoring whatever is saved.
     *
     * @param string $key Option key.
     * @return mixed Null when the key is unknown.
     */
    function glse_get_default( string $key ) {
        return glse_defaults()[ $key ] ?? null;
    }

    /**
     * Returns a plugin option, falling back to its shipped value.
     *
     * @param string $key Option key.
     * @return mixed
     */
    function glse_get_option( string $key ) {
        /*
         * Not statically cached: get_option() already serves from the WP
         * options cache, and a static copy would go stale as soon as anything
         * writes glse_settings mid-request (settings save, DB migration).
         */
        $options = get_option( 'glse_settings', [] );
        if ( ! is_array( $options ) ) {
            $options = [];
        }

        if ( array_key_exists( $key, $options ) ) {
            return $options[ $key ];
        }

        return glse_get_default( $key );
    }

    /**
     * Highlights the search term within a text string.
     *
     * @param string $text Text to display.
     * @param string $term Term to highlight.
     * @return string HTML with the term wrapped in <mark>.
     */
    function glse_highlight_term( string $text, string $term ): string {
        if ( '' === $term ) {
            return esc_html( $text );
        }

        // Split on the original text so HTML entities are never broken by the regex.
        $parts = preg_split( '/(' . preg_quote( $term, '/' ) . ')/iu', $text, -1, PREG_SPLIT_DELIM_CAPTURE );

        if ( false === $parts || 1 === count( $parts ) ) {
            return esc_html( $text );
        }

        $out = '';
        foreach ( $parts as $i => $part ) {
            $out .= ( 1 === $i % 2 )
                ? '<mark class="glse-highlight">' . esc_html( $part ) . '</mark>'
                : esc_html( $part );
        }

        return $out;
    }

    /**
     * Checks whether the Pro version is active via Freemius.
     *
     * @return bool
     */
    /**
     * Loads the autocompletion dictionary, reporting whether it is available.
     *
     * The class file carries the __premium_only suffix, so Freemius drops it
     * from the free build and requiring it there would be fatal. Every caller
     * asks through this one function instead of requiring the file directly:
     * the single premium marker lives here, and the free build is left with a
     * plain `return false` that callers branch on like any other condition.
     *
     * Distinct from glse_is_pro(), which answers "is this licence active".
     * This answers "does this build contain the code at all" — an unlicensed
     * Pro install still has the file.
     */
    function glse_load_suggestions(): bool {
        

        return false;
    }

    /**
     * Whether the paid features may run.
     *
     * The test below is dropped from the free build, which then answers false
     * outright - the shape it should have. No comment sits above it: one that
     * described the removed block would survive the removal and describe a
     * hole.
     */
    function glse_is_pro(): bool {
        

        return false;
    }

    /**
     * The design presets this build ships.
     *
     * The paid ones are appended by a function the free build does not carry,
     * so the free plugin has no definition of them at all - not a definition it
     * refuses to apply. wp.org reads a preset that exists behind a licence test
     * as functionality locked behind payment, which its guidelines forbid
     * outright; a preset that is simply absent raises nothing.
     *
     * @return array<string, array<string, mixed>>
     */
    function glse_get_presets(): array {
        /*
         * Chaque preset fixe aussi la couleur de survol du bouton.
         *
         * Elle lui echappait, et le resultat se voyait : les trois presets sont
         * en noir et blanc, leur bouton devenait noir, et il virait au bleu
         * marine des qu'on le survolait - parce que le survol restait celui que
         * le marchand avait regle ailleurs. Le gris #333333 garde le survol
         * perceptible sans quitter la gamme du preset, et laisse 12,6:1 au
         * libelle blanc.
         */
        $presets = [
            'sharp'  => [
                'label'             => __( 'Sharp', 'glance-live-search' ),
                'bar_bg_color'      => '#ffffff',
                'bar_bg_focus'      => '#ffffff',
                'border_size'       => 1,
                'border_color'      => '#000000',
                'border_radius'     => 0,
                'text_color'        => '#000000',
                'placeholder_color' => '#888888',
                'button_color'      => '#000000',
                'button_hover_color' => '#333333',
                'button_text_color' => '#ffffff',
                'button_radius'     => 0,
            ],
            'clean'  => [
                'label'             => __( 'Clean', 'glance-live-search' ),
                'bar_bg_color'      => '#ffffff',
                'bar_bg_focus'      => '#ffffff',
                'border_size'       => 1,
                'border_color'      => '#000000',
                'border_radius'     => 8,
                'text_color'        => '#000000',
                'placeholder_color' => '#888888',
                'button_color'      => '#000000',
                'button_hover_color' => '#333333',
                'button_text_color' => '#ffffff',
                'button_radius'     => 6,
            ],
        ];

        

        return $presets;
    }

    

    /**
     * The design values that actually reach the bar.
     *
     * Three sources, in this order: the selected preset, then what the merchant
     * set, then what the plugin ships. Two callers need the answer - the
     * stylesheet the storefront gets and the preview on the settings screen -
     * and they must agree.
     *
     * They did not. The preview read the saved options directly, while the
     * storefront resolved the preset first, so a preset other than « Custom »
     * made the screen show one bar and ship another. A preview that lies is
     * worse than no preview: it is trusted.
     *
     * The paid gate stays where it was. The free build never reads custom
     * colours - it has no control to set them - so a licence that lapses does
     * not leave a bar styled by values nobody can edit any more.
     *
     * @since 1.7.0
     *
     * @return array<string, mixed> Every design key, resolved.
     */
    function glse_resolved_design(): array {
        $preset  = glse_get_option( 'bar_design_preset' );
        $presets = glse_get_presets();

        // A preset this build does not ship resolves to nothing, and the keys
        // below fall through - no licence test needed for that.
        $p = $presets[ $preset ] ?? [];

        $custom_keys = [
            'bar_bg_color',
            'bar_bg_focus',
            'border_size',
            'border_color',
            'border_radius',
            'text_color',
            'placeholder_color',
            'button_color',
            'button_hover_color',
            'button_text_color',
            'button_radius',
        ];

        $resolved = [];

        // Height is never part of a preset: it applies whatever is selected.
        $resolved['bar_height'] = (int) glse_get_option( 'bar_height' );

        $is_pro = function_exists( 'glse_is_pro' ) && glse_is_pro();

        foreach ( $custom_keys as $key ) {
            if ( array_key_exists( $key, $p ) ) {
                $resolved[ $key ] = $p[ $key ];
                continue;
            }

            $resolved[ $key ] = $is_pro ? glse_get_option( $key ) : glse_get_default( $key );
        }

        // Colour only, and never part of a preset: turning the focus ring off
        // would leave keyboard users with no indicator at all.
        $resolved['focus_ring_color'] = glse_get_option( 'focus_ring_color' );

        return $resolved;
    }

    /* ─── Load classes ─── */

    // Always needed: theme compat (runs early), public (shortcode + assets), widget.
    require_once GLSE_PATH . 'includes/class-theme-compat.php';
    require_once GLSE_PATH . 'includes/class-glse-widget.php';
    require_once GLSE_PATH . 'public/class-glse-public.php';

    // AJAX-only: search engine + handler + analytics (not needed on regular page loads).
    // Note: WC_AJAX requests (/?wc-ajax=...) don't define DOING_AJAX until the
    // 'init' hook - but plugins_loaded fires before init. Check $_GET['wc-ajax']
    // to catch these early. This is the standard WooCommerce pattern.
    $is_ajax = wp_doing_ajax() || defined( 'REST_REQUEST' ) || isset( $_GET['wc-ajax'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    if ( $is_ajax ) {
        require_once GLSE_PATH . 'includes/class-glse-fulltext.php';
        require_once GLSE_PATH . 'includes/class-glse-morphology.php';
        require_once GLSE_PATH . 'includes/class-search-engine.php';
        require_once GLSE_PATH . 'includes/class-ajax-handler.php';
        require_once GLSE_PATH . 'includes/class-glse-analytics.php';
        glse_load_suggestions();
    }

    // Admin-only: settings page.
    if ( is_admin() ) {
        if ( ! class_exists( 'GLSE_Analytics' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-analytics.php';
        }
        glse_load_suggestions();
        require_once GLSE_PATH . 'admin/class-settings.php';
    }

    /*
     * Cron context loads neither of the above, so the nightly jobs would fatal
     * on a missing class. Cheap to require: the file only declares a class.
     */
    if ( wp_doing_cron() ) {
        require_once GLSE_PATH . 'includes/class-glse-analytics.php';
        glse_load_suggestions();
    }

    /* ─── Activation ─── */

    register_activation_hook( __FILE__, function () {
        if ( false === get_option( 'glse_settings' ) ) {
            add_option( 'glse_settings', [] );
        }
        if ( ! class_exists( 'GLSE_Analytics' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-analytics.php';
        }
        GLSE_Analytics::create_table();

        if ( glse_load_suggestions() ) {
            GLSE_Suggestions::create_table();
            // Built out of band: reading the whole catalogue must not stall the
            // activation request on a large shop.
            GLSE_Suggestions::schedule_rebuild();
        }

        if ( ! wp_next_scheduled( 'glse_daily_purge' ) ) {
            wp_schedule_event( time(), 'daily', 'glse_daily_purge' );
        }

        // Flag for post-activation redirect to License tab (first install only).
        if ( ! get_option( 'glse_activated_once' ) ) {
            add_option( 'glse_activated_once', '1' );
            set_transient( 'glse_activation_redirect', '1', 60 );
        }
    } );

    /* ─── Uninstallation ─── */

    /*
     * Cleanup hangs on Freemius' `after_uninstall`, and this plugin must NOT
     * ship an uninstall.php. That is not a preference, it is a hard gate:
     * Freemius rejects any upload whose root contains one.
     *
     * The reason is in WordPress itself. `uninstall_plugin()` looks for
     * uninstall.php first, and when it finds one it deletes the plugin's entry
     * from the `uninstall_plugins` option, includes the file, and returns -
     * never reaching the branch that runs what `register_uninstall_hook()`
     * registered (wp-admin/includes/plugin.php:1317-1329). Freemius registers
     * its uninstall there, from `_deactivate_plugin_hook()`, so an uninstall.php
     * silently disables both its reporting and everything below.
     *
     * Without that file the path is the one WordPress intends: it includes this
     * file (plugin.php:1339), fires Freemius' hook (:1351), and Freemius runs
     * `_uninstall_plugin_event()` then `after_uninstall` - us.
     *
     * wp.org accepts either mechanism, so nothing is lost by dropping the file.
     */
    glse_fs()->add_action( 'after_uninstall', 'glse_fs_uninstall_cleanup' );

    register_deactivation_hook( __FILE__, function () {
        wp_clear_scheduled_hook( 'glse_daily_purge' );
        wp_clear_scheduled_hook( 'glse_build_suggestions' );
        delete_transient( 'glse_sugg_rebuilding' );
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- No WP API for bulk transient deletion by prefix.
        $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
            $wpdb->esc_like( '_transient_glse_' ) . '%',
            $wpdb->esc_like( '_transient_timeout_glse_' ) . '%'
        ) );
    } );

    /**
     * Daily cron callback: deletes expired analytics entries.
     */
    add_action( 'glse_daily_purge', function () {
        if ( ! class_exists( 'GLSE_Analytics' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-analytics.php';
        }
        $days = (int) glse_get_option( 'data_retention' );
        if ( $days > 0 ) {
            $deleted = GLSE_Analytics::purge_before( $days );
            if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
                error_log( sprintf( '[GLSE] Daily purge: %d analytics row(s) older than %d day(s) deleted.', $deleted, $days ) );
            }
        }

        /*
         * Suggestions are refreshed on the same nightly pass, after the purge:
         * the dictionary is ranked partly on recorded searches, so rebuilding
         * first would fold rows back in that retention was about to delete.
         */
        
    } );

    /**
     * One-off cron callback: builds the suggestion dictionary.
     *
     * Fired when a lookup finds the table missing or empty - on first
     * activation, or after a merchant enables the option - so the index appears
     * without anyone waiting for midnight.
     */
    add_action( 'glse_build_suggestions', function () {
        if ( ! class_exists( 'GLSE_Analytics' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-analytics.php';
        }
        if ( glse_load_suggestions() ) {
            GLSE_Suggestions::rebuild();
        }
    } );

    function glse_fs_uninstall_cleanup(): void {
        delete_option( 'glse_settings' );
        delete_option( 'glse_db_version' );
        delete_option( 'glse_activated_once' );
        delete_option( 'glse_sugg_built_at' );
        if ( ! class_exists( 'GLSE_Analytics' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-analytics.php';
        }
        GLSE_Analytics::drop_table();
        GLSE_Analytics::drop_legacy_tables();

        if ( glse_load_suggestions() ) {
            GLSE_Suggestions::drop_table();
        }

        /*
         * The full-text indexes sit on wp_posts, a core table this plugin does
         * not own. Leaving them behind would cost every future write to that
         * table for a search nobody can run.
         */
        if ( ! class_exists( 'GLSE_Fulltext' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-fulltext.php';
        }
        GLSE_Fulltext::drop_all();

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- No WP API for bulk transient deletion by prefix.
        $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
            $wpdb->esc_like( '_transient_glse_' ) . '%',
            $wpdb->esc_like( '_transient_timeout_glse_' ) . '%'
        ) );
        // Clean up license notice dismissal meta for all users.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- No WP API for bulk user_meta deletion by key.
        $wpdb->delete( $wpdb->usermeta, [ 'meta_key' => 'glse_dismiss_license_notice' ] );

        /*
         * Deactivation already clears these, and WordPress requires a plugin to
         * be deactivated before it can be deleted - so this is belt and braces
         * for the site whose deactivation hook never ran.
         */
        wp_clear_scheduled_hook( 'glse_daily_purge' );
        wp_clear_scheduled_hook( 'glse_build_suggestions' );
    }

    /* ─── DB Migration ─── */

    add_action( 'admin_init', function () {
        global $wpdb;
        // 2.3 - colonne `target` sur les statistiques : une recherche peut mener
        // a un produit, a une categorie, ou a la page complete des resultats.
        // dbDelta ajoute la colonne aux tables existantes ; les lignes deja la
        // valent « product », ce qu'elles sont.
        $current = '2.3';
        if ( get_option( 'glse_db_version' ) === $current ) {
            return;
        }

        /*
         * 2.1 - settings renamed and merged.
         *
         * Runs before the schema work on purpose: it has no dependency on
         * GLSE_Analytics, so a failure to create the table must not also skip
         * the option renames.
         *
         * search_in_content + search_in_excerpt collapse into one option: with
         * a FULLTEXT index MySQL matches title, content and excerpt as a single
         * unit, so the two checkboxes could never act independently.
         *
         * enable_security_headers splits in two: it used to drive both the
         * harmless storefront headers and a strict admin CSP that can break
         * other plugins' screens. The storefront half keeps the old value; the
         * admin CSP starts off, because silently enabling it on upgrade could
         * break an admin the merchant never asked us to touch.
         *
         * show_focus_ring is dropped rather than migrated: the stylesheet
         * forces outline:none on the input, so switching it off left no focus
         * indicator at all.
         */
        $saved = get_option( 'glse_settings', [] );
        if ( is_array( $saved ) ) {
            $changed = false;

            if ( array_key_exists( 'search_in_content', $saved ) || array_key_exists( 'search_in_excerpt', $saved ) ) {
                $saved['search_in_description'] = ! empty( $saved['search_in_content'] ) || ! empty( $saved['search_in_excerpt'] );
                unset( $saved['search_in_content'], $saved['search_in_excerpt'] );
                $changed = true;
            }

            if ( array_key_exists( 'enable_security_headers', $saved ) ) {
                $saved['frontend_security_headers'] = ! empty( $saved['enable_security_headers'] );
                $saved['admin_csp']                 = false;
                unset( $saved['enable_security_headers'] );
                $changed = true;
            }

            if ( array_key_exists( 'show_focus_ring', $saved ) ) {
                unset( $saved['show_focus_ring'] );
                $changed = true;
            }

            if ( $changed ) {
                update_option( 'glse_settings', $saved );
            }
        }

        GLSE_Analytics::create_table();

        /*
         * 2.2 - the autocompletion dictionary.
         *
         * Only the table is created here; filling it reads the whole catalogue,
         * which has no business happening inside an admin page load. The first
         * build is queued and runs on cron a few seconds later.
         */
        if ( glse_load_suggestions() ) {
            GLSE_Suggestions::create_table();
            if ( ! get_option( 'glse_sugg_built_at' ) ) {
                GLSE_Suggestions::schedule_rebuild();
            }
        }

        /*
         * 2.0 replaces glse_analytics + glse_clicks with a single table.
         *
         * The old rows are not carried over: searches were recorded on a 2s
         * idle timer while clicks were recorded immediately, so a click could
         * never be matched to the search that produced it. Every rate derived
         * from that pairing was wrong, and importing the rows would only
         * preserve misleading history.
         */
        GLSE_Analytics::drop_legacy_tables();

        /* 1.6 - rename filter_category → show_categories in saved options */
        $saved = get_option( 'glse_settings', [] );
        if ( is_array( $saved ) && array_key_exists( 'filter_category', $saved ) ) {
            $saved['show_categories'] = $saved['filter_category'];
            unset( $saved['filter_category'] );
            update_option( 'glse_settings', $saved );
        }

        // Only record the version once the table really exists, so a failed
        // CREATE is retried on the next admin request instead of being skipped.
        $t = $wpdb->prefix . GLSE_Analytics::TABLE;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $t ) ) === $t ) {
            update_option( 'glse_db_version', $current, false );
        }
    } );

    /*
     * ── Keep the purge scheduled ────────────────────────────────────────
     * Re-armed on every admin load rather than at activation alone. Anything
     * that clears the schedule - deactivation, a migration, a cron-resetting
     * plugin - otherwise disables data retention permanently, and nothing in
     * the UI reports that it stopped.
     */
    add_action( 'admin_init', function () {
        if ( ! wp_next_scheduled( 'glse_daily_purge' ) ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'glse_daily_purge' );
        }
    } );

    /* ─── Admin action: clear analytics ─── */

    add_action( 'admin_init', function () {
        if ( ! isset( $_POST['glse_analytics_clear'] ) ) {
            return;
        }
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }
        check_admin_referer( 'glse_analytics_clear' );

        /*
         * Le resultat est repercute. Annoncer « vide » sans regarder revenait a
         * mentir sur les hebergements ou la base refuse l'operation : le
         * marchand voyait la confirmation et ses donnees etaient toujours la.
         */
        $vide = GLSE_Analytics::clear();

        wp_safe_redirect( admin_url(
            'admin.php?page=glse-settings&tab=analytics&cleared=' . ( $vide ? '1' : '0' )
        ) );
        exit;
    } );

    /* ─── Admin action: rebuild the suggestion dictionary ─── */

    add_action( 'admin_init', function () {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified immediately below.
        if ( ! isset( $_GET['glse_rebuild_suggestions'] ) ) {
            return;
        }
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }
        check_admin_referer( 'glse_rebuild_suggestions' );

        if ( ! glse_load_suggestions() ) {
            return;
        }
        // Run inline here, unlike everywhere else: an admin who clicked
        // "Rebuild now" is waiting for the new figure, and a queued job would
        // send them back to a page still showing the old one.
        delete_transient( 'glse_sugg_rebuilding' );
        $indexed = GLSE_Suggestions::rebuild();

        wp_safe_redirect( add_query_arg(
            [ 'page' => 'glse-settings', 'glse_indexed' => $indexed ],
            admin_url( 'admin.php' )
        ) );
        exit;
    } );

    add_action( 'admin_notices', function () {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a count.
        if ( ! isset( $_GET['glse_indexed'] ) || ! current_user_can( 'manage_options' ) ) {
            return;
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $indexed = absint( $_GET['glse_indexed'] );
        printf(
            '<div class="notice notice-success is-dismissible"><p>%s</p></div>',
            esc_html( sprintf(
                /* translators: %s: number of indexed search terms */
                _n(
                    'Suggestion dictionary rebuilt: %s term indexed.',
                    'Suggestion dictionary rebuilt: %s terms indexed.',
                    $indexed,
                    'glance-live-search'
                ),
                number_format_i18n( $indexed )
            ) )
        );
    } );

    /*
     * Build the dictionary the moment autocompletion is switched on, rather
     * than leaving the merchant with an empty index until the nightly pass.
     */
    /*
     * The marker wraps the hook rather than sitting inside the closure: a
     * negated one - "return unless premium" - is removed whole, which would
     * leave the body running in the free build instead of removing it.
     */
    

    /* ─── Initialization ─── */

    /*
     * Declare the 'glse' object cache group as global.
     * On persistent backends (Redis, Memcached), global groups survive
     * per-site flushes (multisite) and some Redis plugins exclude them from
     * automatic flushes triggered by operations like wc_delete_product_transients().
     * Must be called as early as possible, before any wp_cache_get/set on this group.
     */
    wp_cache_add_global_groups( [ 'glse', 'glse_rl' ] );

    /* ─── WooCommerce HPOS compatibility declaration ─── */
    add_action( 'before_woocommerce_init', function () {
        if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
            \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
        }
    } );

    add_action( 'plugins_loaded', function () {
        if ( ! class_exists( 'WooCommerce' ) ) {
            add_action( 'admin_notices', function () {
                echo '<div class="notice notice-error"><p>';
                esc_html_e( 'Glance Live Search requires WooCommerce to be installed and active.', 'glance-live-search' );
                echo '</p></div>';
            } );
            return;
        }

        if ( class_exists( 'GLSE_Settings' ) ) {
            new GLSE_Settings();
        }
        if ( class_exists( 'GLSE_Ajax_Handler' ) ) {
            new GLSE_Ajax_Handler();
        }
        new GLSE_Public();
        new GLSE_Theme_Compat();

        // Native Gutenberg block (server-side render via render.php).
        add_action( 'init', function () {
            if ( function_exists( 'register_block_type' ) ) {
                register_block_type( GLSE_PATH . 'blocks/search' );
            }
        } );

        // Elementor widget - loaded inside the hook so Elementor\Widget_Base exists.
        add_action( 'elementor/widgets/register', function ( $widgets_manager ) {
            require_once GLSE_PATH . 'includes/class-glse-elementor-widget.php';
            $widgets_manager->register( new GLSE_Elementor_Widget() );
        } );

        add_action( 'widgets_init', function () {
            register_widget( 'GLSE_Widget' );
        } );

        /*
         * ── Invalidate search cache when a product changes ──────────────
         * Purges all GLS transients so search results reflect product
         * updates, deletions, and stock changes immediately.
         */
        $glse_flush_cache = function () {
            /*
             * Runs unconditionally on purpose. Bailing out when 'enable_cache'
             * is off would strand every entry written while it was on, so
             * toggling the option off and back on would resurrect stale results.
             *
             * Les deux emplacements sont vides, pas l'un ou l'autre.
             *
             * wp_cache_flush_group() existe depuis WordPress 6.1, donc un
             * « sinon » n'est plus jamais atteint sur une installation
             * courante. Tant que rien n'écrivait de transient de résultats
             * c'était sans conséquence ; depuis que le moteur y bascule faute
             * de cache objet persistant, l'oublier laisserait un produit
             * modifié invisible jusqu'à l'expiration de l'entrée.
             */
            if ( function_exists( 'wp_cache_flush_group' ) ) {
                wp_cache_flush_group( 'glse' );
            }

            if ( ! wp_using_ext_object_cache() ) {
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                $wpdb->query( $wpdb->prepare(
                    "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
                    $wpdb->esc_like( '_transient_glse_' ) . '%',
                    $wpdb->esc_like( '_transient_timeout_glse_' ) . '%'
                ) );
            }
            wp_cache_delete( 'glse_product_cats', 'glse' );
        };
        add_action( 'save_post_product',                    $glse_flush_cache );
        add_action( 'woocommerce_update_product',           $glse_flush_cache );
        add_action( 'woocommerce_product_set_stock_status', $glse_flush_cache );
        add_action( 'woocommerce_product_set_stock',        $glse_flush_cache );
        add_action( 'deleted_post',                         $glse_flush_cache );
        /*
         * wp_trash_post() writes post_status straight to the DB - it never fires
         * save_post - so trashing a product would otherwise leave it visible in
         * cached results until the entry expired.
         */
        add_action( 'trashed_post',                         $glse_flush_cache );
        add_action( 'untrashed_post',                       $glse_flush_cache );
        add_action( 'woocommerce_delete_product',           $glse_flush_cache );
    } );

} // end else
