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

/**
 * Plugin settings page.
 *
 * Registers the menu, Settings API fields and page rendering.
 */
final class GLSE_Settings {

    /**
     * Where buying happens when the dashboard cannot offer it.
     *
     * Reachable in every state the plugin can be in, which is the whole point:
     * it is what a purchase link falls back to. It cannot apply a licence to
     * this install, so it is the second choice, never the first.
     */
    private const PRICING_URL = 'https://www.glancelivesearch.com/pricing/';

    /**
     * Kses whitelist for inline SVG admin icons.
     * These icons are hardcoded in PHP (no user input).
     */
    private static function kses_svg(): array {
        $attrs = [ 'xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true,
                   'fill' => true, 'stroke' => true, 'stroke-width' => true,
                   'stroke-linecap' => true, 'stroke-linejoin' => true,
                   'x' => true, 'y' => true, 'x1' => true, 'y1' => true,
                   'x2' => true, 'y2' => true, 'cx' => true, 'cy' => true,
                   'r' => true, 'rx' => true, 'ry' => true,
                   'd' => true, 'points' => true ];
        return [
            'svg'      => $attrs,
            'path'     => $attrs,
            'line'     => $attrs,
            'circle'   => $attrs,
            'rect'     => $attrs,
            'polygon'  => $attrs,
            'polyline' => $attrs,
        ];
    }

    /**
     * Register admin hooks for menu, settings, CSV export and assets.
     *
     * @since 1.4.0
     */
    public function __construct() {
        add_action( 'admin_menu', [ $this, 'add_menu_page' ] );

        /*
         * The upsell is a runtime check, not a build marker: an unlicensed Pro
         * install should see it too. Marking it premium would strip it from the
         * free build, which is the one audience that needs it most.
         */
        if ( ! glse_is_pro() ) {
            add_action( 'admin_notices', [ $this, 'render_upgrade_notice' ] );
            add_action( 'wp_ajax_glse_dismiss_upgrade_notice', [ $this, 'dismiss_upgrade_notice' ] );
        }
        add_action( 'admin_init', [ $this, 'register_settings' ] );
        // CSV export is a paid feature: its handler is stripped from the free
        // build, so registering it there would point at a missing method.
        
        add_action( 'admin_init', [ $this, 'maybe_send_admin_csp' ] );
        add_action( 'admin_init', [ $this, 'handle_fulltext_index' ] );
        // Licence handling belongs to the paid build; the free one is served by
        // Freemius' own account screens.
        
        add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );

    }

    /**
     * Checks if the current screen is the plugin page.
     */
    private function is_plugin_screen(): bool {
        $screen = get_current_screen();
        return $screen instanceof WP_Screen && 'woocommerce_page_glse-settings' === $screen->id;
    }

    /**
     * Sends a strict Content-Security-Policy header only on the plugin admin page.
     *
     * Called via admin_init. Uses is_plugin_screen() which relies on get_current_screen(),
     * available since admin_init from WP 3.3.
     *
     * The 'unsafe-inline' directive is required because WordPress core generates
     * inline scripts and styles. frame-ancestors 'self' prevents clickjacking.
     *
     * @since 1.4.0
     */
    public function maybe_send_admin_csp(): void {
        if ( ! $this->is_plugin_screen() || headers_sent() ) {
            return;
        }
        if ( ! glse_get_option( 'admin_csp' ) ) {
            return;
        }

        header(
            "Content-Security-Policy: " .
            "default-src 'self'; " .
            "script-src 'self' 'unsafe-inline'; " .
            "style-src 'self' 'unsafe-inline'; " .
            "img-src 'self' data: https:; " .
            "connect-src 'self'; " .
            "font-src 'self' data:; " .
            "object-src 'none'; " .
            "base-uri 'self'; " .
            "frame-src 'none'; " .
            "frame-ancestors 'self'"
        );
    }

    /**
     * Retention periods offered, in days, keyed by value.
     *
     * The field and its sanitiser must read this one list. A stored value the
     * dropdown does not offer leaves nothing selected, so the browser falls
     * back to the first option - and the screen then shows a retention policy
     * nobody chose, which saving would make real.
     *
     * @return array<int, string>
     */
    private static function retention_periods(): array {
        $periods = [
            0   => __( 'Indefinitely (no purge)', 'glance-live-search' ),
            7   => __( '7 days', 'glance-live-search' ),
            30  => __( '30 days', 'glance-live-search' ),
            60  => __( '60 days', 'glance-live-search' ),
            90  => __( '90 days', 'glance-live-search' ),
        ];

        

        return $periods;
    }

    

    /**
     * Where every "buy" button points.
     *
     * Freemius serves its own pricing page inside the dashboard, and going
     * through it ties the purchase to this install: the licence is applied on
     * return instead of being copied by hand out of an email. The marketing
     * site is a fallback - it cannot activate anything.
     *
     * That fallback is needed far more often than "the SDK failed to load".
     * get_upgrade_url() always hands back admin.php?page=glse-settings-pricing,
     * whether or not Freemius has registered a page under that slug - and it
     * declines to register one in several ordinary situations:
     *
     *   - activation mode, i.e. a paid build whose licence is not activated yet,
     *     or a free install that has not answered the opt-in screen
     *     (should_add_submenu_or_action_links(), class-freemius.php:18856);
     *   - white-label mode, which agencies get through their licence and which
     *     skips the whole pricing block (class-freemius.php:18981);
     *   - demo mode.
     *
     * When the page is missing WordPress answers "Sorry, you are not allowed to
     * access this page." The button looks broken and the sale is lost at the
     * click - the worst possible moment.
     *
     * Rather than restate Freemius' conditions here, where they would drift
     * apart at the next SDK release, this asks WordPress the only question that
     * matters: is that page registered? If not, send people to the site, which
     * is always reachable.
     *
     * @since 1.6.1
     */
    private function upgrade_url(): string {
        $fs = function_exists( 'glse_fs' ) ? glse_fs() : null;

        if ( ! $fs ) {
            return self::PRICING_URL;
        }

        $url = (string) $fs->get_upgrade_url();

        if ( ! str_starts_with( $url, admin_url() ) ) {
            return $url;
        }

        $slug = [];
        parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $slug );

        return self::admin_page_registered( (string) ( $slug['page'] ?? '' ) )
            ? $url
            : self::PRICING_URL;
    }

    /**
     * Whether WordPress has an admin page under this slug.
     *
     * Menu registration happens on `admin_menu`, long before anything here
     * renders, so by the time a button is drawn the answer is settled. Called
     * earlier - from an AJAX handler, say - the list is empty and this returns
     * false, which sends the visitor to the public page: a link that works
     * rather than one that apologises.
     *
     * @since 1.7.0
     */
    private static function admin_page_registered( string $slug ): bool {
        if ( '' === $slug || empty( $GLOBALS['_registered_pages'] ) ) {
            return false;
        }

        foreach ( array_keys( (array) $GLOBALS['_registered_pages'] ) as $hook ) {
            // Hooks read "<parent>_page_<slug>", the parent varying with where
            // the page was hung.
            if ( str_ends_with( (string) $hook, '_page_' . $slug ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Where "Already have a license key?" leads.
     *
     * The licence tab is registered by the paid build alone, so the free build
     * hands the key to Freemius' own dialog - which also fetches the paid
     * files, something a tab of ours could not do. Linking to the tab
     * unconditionally dropped free users back on the first tab with no notice,
     * since render_page() falls back when a tab is unknown.
     *
     * @since 1.6.1
     */
    private function license_url(): string {
        // Removed from the free build, which falls through to the SDK below.
        

        // Before opting in there is no account page; the connect screen offers
        // the same "Activate License" entry.
        return glse_fs()->is_registered()
            ? glse_fs()->get_account_url( false, [ 'activate_license' => 'true' ] )
            : glse_fs()->get_activation_url();
    }

    /**
     * Attributes for a link built from upgrade_url().
     *
     * A new tab makes sense for the marketing site and not for a dashboard
     * page, and the value changes with the SDK, so it is decided here rather
     * than written out at each call site.
     *
     * @since 1.6.1
     */
    private function upgrade_link_target(): string {
        return str_starts_with( $this->upgrade_url(), admin_url() )
            ? ''
            : ' target="_blank" rel="noopener noreferrer"';
    }

    /**
     * Generates a Pro badge + CTA to display on locked features.
     *
     * @param string $label Feature name (optional).
     * @return string HTML.
     */
    private function pro_lock( string $label = '' ): string {
        $badge = '<span class="glse-pro-badge">Pro</span>';

        $fs          = function_exists( 'glse_fs' ) ? glse_fs() : null;
        $had_license = $fs && $fs->has_any_license();

        if ( $had_license ) {
            // User had a license before (expired/deactivated) - show renewal message.
            $text = $label
                /* translators: %s: name of the setting, e.g. "Spelling correction". */
                ? sprintf( esc_html__( '%s requires an active Pro license.', 'glance-live-search' ), '<strong>' . esc_html( $label ) . '</strong>' )
                : esc_html__( 'Requires an active Pro license.', 'glance-live-search' );
            $cta  = '<a href="' . esc_url( $this->license_url() ) . '">'
                   . esc_html__( 'Renew or reactivate your license →', 'glance-live-search' )
                   . '</a>';
        } else {
            // Never had a license - show upgrade CTA.
            $text = $label
                /* translators: %s: name of the setting, e.g. "Spelling correction". */
                ? sprintf( esc_html__( '%s is a Pro feature.', 'glance-live-search' ), '<strong>' . esc_html( $label ) . '</strong>' )
                : esc_html__( 'Pro feature.', 'glance-live-search' );
            $cta  = '<a href="' . esc_url( $this->upgrade_url() ) . '"' . $this->upgrade_link_target() . '>'
                   . esc_html__( 'Upgrade to Pro →', 'glance-live-search' )
                   . '</a>';
        }

        return '<div class="glse-pro-locked">' . $badge . ' ' . $text . ' ' . $cta . '</div>';
    }

    /**
     * Loads CSS/JS only on our settings page.
     *
     * @since 1.4.0
     */
    public function enqueue_admin_assets( string $hook ): void {
        $is_main_page     = 'woocommerce_page_glse-settings' === $hook;
        $is_freemius_page = false !== strpos( $hook, 'glse-settings' );

        if ( ! $is_main_page && ! $is_freemius_page ) {
            return;
        }

        /*
         * `wp-color-picker` est declaree comme dependance, pas seulement mise en
         * file plus bas : WordPress place ainsi sa feuille AVANT la notre. Sans
         * cet ordre, ses regles et les notres se disputent a specificite egale
         * et les siennes gagnent - le bouton du selecteur reaffiche alors son
         * libelle, que l'on retire ici.
         */
        wp_enqueue_style(
            'glse-admin-style',
            GLSE_URL . 'admin/css/settings.css',
            [ 'wp-admin', 'wp-color-picker' ],
            GLSE_VERSION
        );

        // Licence panel, upgrade cards and Pro locks — markup the free build
        // never prints, so its styling ships separately.
        

        if ( $is_freemius_page && ! $is_main_page ) {
            wp_enqueue_style(
                'glse-freemius-style',
                GLSE_URL . 'admin/css/freemius-account.css',
                [ 'glse-admin-style' ],
                GLSE_VERSION
            );
        }

        if ( ! $is_main_page ) {
            return;
        }

        wp_enqueue_script(
            'glse-admin-script',
            GLSE_URL . 'admin/js/settings.js',
            [ 'jquery' ],
            GLSE_VERSION,
            true
        );

        // Recommendations toggling, CSV export, licence activation.
        

        wp_localize_script( 'glse-admin-script', 'glseAdminL10n', [
            'saving' => __( 'Saving…', 'glance-live-search' ),
        ] );

        /*
         * WordPress' own colour picker, and the reason is the format.
         * `<input type="color">` opens the operating system's dialog, whose
         * starting tab - wheel, sliders, hex - is the OS's business and cannot
         * be set from a page. This one opens inside the page and its main
         * control is a hex box, always. It is also the picker every WordPress
         * admin already knows, which is worth more than a bespoke one.
         *
         * It costs ~62 KB on this screen alone, never on the storefront. Its
         * stylesheet is pulled in as a dependency of ours above, so that ours
         * is the one that lands last.
         */
        wp_enqueue_script(
            'glse-admin-design',
            GLSE_URL . 'admin/js/design.js',
            [ 'wp-color-picker' ],
            GLSE_VERSION,
            true
        );

        /*
         * The option-to-variable map is handed over rather than written into the
         * script, because it already exists twice - in the preview markup and in
         * the front-end stylesheet. A third copy in JS would be the one nobody
         * updates.
         *
         */
        wp_localize_script( 'glse-admin-design', 'glseDesignL10n', [
            'vars'        => self::preview_variables(),
            'placeholder' => __( 'Search for a product…', 'glance-live-search' ),
        ] );
    }

    /**
     * Which option drives which CSS variable in the preview.
     *
     * `unit` marks the ones measured in pixels; the rest are colours.
     *
     * @since 1.7.0
     *
     * @return array<string, array{css:string, unit:string}>
     */
    private static function preview_variables(): array {
        return [
            'bar_height'        => [ 'css' => '--glse-bar-height', 'unit' => 'px' ],
            'border_size'       => [ 'css' => '--glse-border-width', 'unit' => 'px' ],
            'border_radius'     => [ 'css' => '--glse-radius', 'unit' => 'px' ],
            'button_radius'     => [ 'css' => '--glse-btn-radius', 'unit' => 'px' ],
            'bar_bg_color'      => [ 'css' => '--glse-bar-bg', 'unit' => '' ],
            'bar_bg_focus'      => [ 'css' => '--glse-bar-bg-focus', 'unit' => '' ],
            'border_color'      => [ 'css' => '--glse-border-color', 'unit' => '' ],
            'text_color'        => [ 'css' => '--glse-text-color', 'unit' => '' ],
            'placeholder_color' => [ 'css' => '--glse-placeholder-color', 'unit' => '' ],
            'button_color'      => [ 'css' => '--glse-btn-bg', 'unit' => '' ],
            'button_hover_color' => [ 'css' => '--glse-btn-hover', 'unit' => '' ],
            'button_text_color' => [ 'css' => '--glse-btn-color', 'unit' => '' ],
            'focus_ring_color'  => [ 'css' => '--glse-focus-ring-color', 'unit' => '' ],
        ];
    }

    /* ─── Menu ─── */

    /**
     * Add the plugin submenu page under WooCommerce.
     *
     * @since 1.4.0
     */
    public function add_menu_page(): void {
        add_submenu_page(
            'woocommerce',
            __( 'Glance Live Search', 'glance-live-search' ),
            __( 'Glance Search', 'glance-live-search' ),
            'manage_options',
            'glse-settings',
            [ $this, 'render_page' ]
        );
    }


    /* ─── Settings API ─── */

    /**
     * Register plugin settings, sections and fields via the Settings API.
     *
     * @since 1.4.0
     */
    public function register_settings(): void {
        register_setting( 'glse_settings_group', 'glse_settings', [
            'sanitize_callback' => [ $this, 'sanitize' ],
        ] );

        /*
         * Une methode par section. Elles etaient toutes dans celle-ci, ce qui
         * faisait 190 lignes ou la seule facon de trouver un champ etait de
         * lire les separateurs en commentaire.
         */
        $this->register_behavior_section();
        $this->register_searchbar_section();
        $this->register_results_section();
        $this->register_performance_section();
    }

    /**
     * Behavior: what the engine looks through and how it answers.
     *
     * @since 1.7.0
     */
    private function register_behavior_section(): void {
        add_settings_section(
            'glse_behavior_section',
            __( 'Behavior', 'glance-live-search' ),
            '__return_null',
            'glse-settings'
        );

        add_settings_field( 'live_search',        __( 'Enable Live Search', 'glance-live-search' ),      [ $this, 'field_live_search' ],         'glse-settings', 'glse_behavior_section' );
        add_settings_field( 'min_chars',          __( 'Minimum Characters', 'glance-live-search' ),    [ $this, 'field_min_chars' ],           'glse-settings', 'glse_behavior_section', [ 'label_for' => 'glse-field-min-chars' ]);
        add_settings_field( 'placeholder',        __( 'Placeholder', 'glance-live-search' ),           [ $this, 'field_placeholder' ],         'glse-settings', 'glse_behavior_section', [ 'label_for' => 'glse-field-placeholder' ]);
        /*
         * What the engine looks through, kept together. No glse-dep-live_search
         * here: roughly twenty settings go inert when live search is off, and
         * hiding one of them while leaving the rest visible only looked
         * arbitrary - the notice at the top of the form says it once instead.
         */
        add_settings_field( 'search_in_description', __( 'Search in Descriptions', 'glance-live-search' ), [ $this, 'field_search_in_description' ], 'glse-settings', 'glse_behavior_section' );
        // Belongs here, not with the colours: it filters what is searched.
        add_settings_field( 'search_button',      __( 'Search Button', 'glance-live-search' ),         [ $this, 'field_search_button' ],       'glse-settings', 'glse_behavior_section' );
        add_settings_field( 'search_button_mode', __( 'Button Mode', 'glance-live-search' ),           [ $this, 'field_search_button_mode' ],  'glse-settings', 'glse_behavior_section', [ 'class' => 'glse-dep-search_button', 'label_for' => 'glse-field-search-button-mode' ]);
        add_settings_field( 'button_position',    __( 'Button Position', 'glance-live-search' ),       [ $this, 'field_button_position' ],     'glse-settings', 'glse_behavior_section', [ 'class' => 'glse-dep-search_button', 'label_for' => 'glse-field-button-position' ]);
        /*
         * Autocompletion sits immediately before history because the two fill
         * the same panel with terms rather than products: history is what this
         * visitor typed before, suggestions what everyone else did. Splitting
         * them across tabs would leave a merchant tuning one with no sight of
         * the other.
         */
        
        add_settings_field( 'show_history',       __( 'Search History', 'glance-live-search' ),        [ $this, 'field_show_history' ],         'glse-settings', 'glse_behavior_section' );
        add_settings_field( 'history_max',        __( 'Recent Searches Limit', 'glance-live-search' ), [ $this, 'field_history_max' ],          'glse-settings', 'glse_behavior_section', [ 'class' => 'glse-dep-show_history', 'label_for' => 'glse-field-history-max' ]);
    }

    /**
     * Search bar: its shape, its colours, and the preset that governs them.
     *
     * @since 1.7.0
     */
    private function register_searchbar_section(): void {
        add_settings_section(
            'glse_searchbar_section',
            __( 'Search Bar', 'glance-live-search' ),
            [ $this, 'render_searchbar_preview' ],
            'glse-settings'
        );

        /*
         * Dimensions first, then colours: grouped by kind so the panel reads as
         * two blocks rather than eleven unrelated fields.
         */
        add_settings_field( 'bar_design_preset',   __( 'Design Preset', 'glance-live-search' ),          [ $this, 'field_bar_design_preset' ],   'glse-settings', 'glse_searchbar_section' );
        add_settings_field( 'bar_height',          __( 'Height (px)', 'glance-live-search' ),              [ $this, 'field_bar_height' ],          'glse-settings', 'glse_searchbar_section', [ 'label_for' => 'glse-field-bar-height' ]);
        /*
         * Colour controls only exist for Pro. Registering ten separate locked
         * rows for everyone else put ten identical upsells behind
         * .glse-design-row - which JS hides whenever the preset is not Custom,
         * and Free is pinned to Sharp. The result was that the people the
         * upsell targets were the only ones who never saw it. One always-
         * visible row says it once instead.
         */
        $glse_colours_registered = false;

        /*
         * Two nested tests, and the order matters. The outer one is a build
         * marker: the colour renderers are absent from the free build, so
         * registering them there would point at nothing. The inner one is the
         * licence check. The locked row sits outside both, or the free build
         * would lose the upsell along with the feature.
         */
        

        if ( ! $glse_colours_registered ) {
            add_settings_field( 'colors_locked', __( 'Colors', 'glance-live-search' ), [ $this, 'field_colors_locked' ], 'glse-settings', 'glse_searchbar_section' );
        }
    }

    /**
     * Results: how many, and what each row shows.
     *
     * @since 1.7.0
     */
    private function register_results_section(): void {
        add_settings_section(
            'glse_results_section',
            __( 'Results', 'glance-live-search' ),
            '__return_null',
            'glse-settings'
        );

        /*
         * How many, then the taxonomy blocks, then what a single product row
         * shows. "Product Counts" governs the category and tag blocks, so it
         * belongs beside them rather than among the product-row settings.
         */
        add_settings_field( 'max_results',           __( 'Number of Results', 'glance-live-search' ),  [ $this, 'field_max_results' ],         'glse-settings', 'glse_results_section', [ 'label_for' => 'glse-field-max-results' ]);
            /* Category and tag blocks */
            /* Inside a product row */
        add_settings_field( 'show_image',           __( 'Show Image', 'glance-live-search' ),         [ $this, 'field_show_image' ],          'glse-settings', 'glse_results_section' );
        add_settings_field( 'show_price',           __( 'Show Price', 'glance-live-search' ),          [ $this, 'field_show_price' ],          'glse-settings', 'glse_results_section' );


        /*
         * Registered by the paid build alone. A field the free plugin does
         * not register draws no row, so nothing there is greyed out and
         * labelled Pro - the pattern wp.org reads as a shipped feature held
         * behind payment. What the paid version offers is described on the
         * Pro Features tab instead, which is upselling and is allowed.
         */
        
    }

    /**
     * Performance, security and analytics settings.
     *
     * @since 1.7.0
     */
    private function register_performance_section(): void {
        add_settings_section(
            'glse_performance_section',
            __( 'Performance', 'glance-live-search' ),
            '__return_null',
            'glse-settings'
        );

        /* ── Mobile ── */
        add_settings_section(
            'glse_mobile_section',
            __( 'Mobile', 'glance-live-search' ),
            '__return_null',
            'glse-settings'
        );
        /*
         * The switch that hides the other two comes first, as everywhere else on
         * this page: placed below them, ticking it makes the fields above it
         * disappear under the cursor.
         */
        add_settings_field( 'disable_mobile',            __( 'Disable on Small Screens', 'glance-live-search' ),    [ $this, 'field_disable_mobile' ],            'glse-settings', 'glse_mobile_section' );
        add_settings_field( 'mobile_overlay',            __( 'Mobile Mode', 'glance-live-search' ),              [ $this, 'field_mobile_overlay' ],            'glse-settings', 'glse_mobile_section', [ 'class' => 'glse-dep-not-disable_mobile', 'label_for' => 'glse-field-mobile-overlay' ]);
        add_settings_field( 'mobile_overlay_breakpoint', __( 'Mobile Breakpoint (px)', 'glance-live-search' ),   [ $this, 'field_mobile_overlay_breakpoint' ], 'glse-settings', 'glse_mobile_section', [ 'class' => 'glse-dep-not-disable_mobile' ] );

        /* ── Performance ── */
        add_settings_field( 'ajax_delay',               __( 'AJAX Delay (ms)', 'glance-live-search' ),          [ $this, 'field_ajax_delay' ],               'glse-settings', 'glse_performance_section', [ 'label_for' => 'glse-field-ajax-delay' ]);
        add_settings_field( 'enable_cache',             __( 'Enable Cache', 'glance-live-search' ),             [ $this, 'field_enable_cache' ],             'glse-settings', 'glse_performance_section' );
        add_settings_field( 'cache_duration',           __( 'Cache Duration (min)', 'glance-live-search' ),     [ $this, 'field_cache_duration' ],           'glse-settings', 'glse_performance_section', [ 'class' => 'glse-dep-enable_cache' ] );
        /* ── Security (rendered inside the Performance tab) ── */
        add_settings_section( 'glse_security_section', __( 'Security', 'glance-live-search' ), '__return_null', 'glse-settings' );
        add_settings_field( 'frontend_security_headers', __( 'Storefront Headers', 'glance-live-search' ), [ $this, 'field_frontend_security_headers' ], 'glse-settings', 'glse_security_section' );
        add_settings_field( 'admin_csp',                 __( 'Admin Content-Security-Policy', 'glance-live-search' ), [ $this, 'field_admin_csp' ], 'glse-settings', 'glse_security_section' );

        /* ── Analytics settings (displayed inside the Analytics panel) ── */
        add_settings_section( 'glse_analytics_settings_section', '', '__return_null', 'glse-settings' );
        add_settings_field( 'data_retention', __( 'Analytics Retention (days)', 'glance-live-search' ), [ $this, 'field_data_retention' ], 'glse-settings', 'glse_analytics_settings_section', [ 'label_for' => 'glse-field-data-retention' ]);
    }

    /* ─── Sanitization ─── */

    /**
     * Sanitize and validate all plugin settings before saving.
     *
     * @since 1.4.0
     */
    public function sanitize( array $input ): array {

        /*
         * The unlicensed Pro build matches the wp.org build exactly: 100 results,
         * 90 days. WordPress.org forbids artificially restricting a shipped
         * feature, so the free plugin cannot be capped - and a Pro build stingier
         * than the free one would be absurd. Pro sells capability, not quantity.
         */
        $max_results = self::bounded_int( $input, 'max_results', 1, 100 );

        /*
         * The ceiling this build enforces. One number and no licence test in
         * the free plugin: it is the ceiling that build has, not a shipped
         * capability withheld until someone pays for it.
         */
        $cache_max = 30;
        
        $cache_duration = min( max( 1, absint( $input['cache_duration'] ?? 5 ) ), $cache_max );

        $clean = [
            /* Behavior */
            'live_search'         => ! empty( $input['live_search'] ),
            'search_button'       => ! empty( $input['search_button'] ),
            'search_button_mode'  => in_array( $input['search_button_mode'] ?? 'icon', [ 'icon', 'text' ], true ) ? $input['search_button_mode'] : 'icon',
            'button_position'     => in_array( $input['button_position'] ?? 'inside', [ 'inside', 'outside' ], true ) ? $input['button_position'] : 'inside',
            'max_results'         => $max_results,
            'ajax_delay'          => absint( $input['ajax_delay'] ?? glse_get_default( 'ajax_delay' ) ),
            'min_chars'           => max( 1, absint( $input['min_chars'] ?? glse_get_default( 'min_chars' ) ) ),
            'placeholder'         => sanitize_text_field( $input['placeholder'] ?? '' ),
            'search_in_description' => ! empty( $input['search_in_description'] ),
            'show_history'        => ! empty( $input['show_history'] ),
            'history_max'         => self::bounded_int( $input, 'history_max', 1, 20 ),
            'did_you_mean'        => false,

            /* Search Bar */
            /*
             * Fallback must match the declared default in glse_get_option(),
             * otherwise a fresh install renders as "Custom" and silently flips
             * to "Sharp" on the first save. Non-Pro input still narrows to the
             * free whitelist below.
             */
            'bar_design_preset'   => array_key_exists( $input['bar_design_preset'] ?? '', glse_get_presets() )
                                     ? $input['bar_design_preset'] : 'sharp',
            'bar_category_filter' => false,
            'bar_height'          => self::bounded_int( $input, 'bar_height', 36, 80 ),
            /*
             * Les couleurs retombent sur la table unique plutot que sur une
             * copie ecrite ici. La copie existait, et le commentaire au-dessus
             * disait deja qu'elle devait rester alignee - une consigne qu'aucun
             * test ne tenait. Le paye les remplace plus bas par ce qui a ete
             * soumis ; le gratuit, qui n'a pas ces controles, garde le defaut.
             */
            'bar_bg_color'        => glse_get_default( 'bar_bg_color' ),
            'bar_bg_focus'        => glse_get_default( 'bar_bg_focus' ),
            'border_size'         => glse_get_default( 'border_size' ),
            'border_color'        => glse_get_default( 'border_color' ),
            'border_radius'       => glse_get_default( 'border_radius' ),
            'text_color'          => glse_get_default( 'text_color' ),
            'placeholder_color'   => glse_get_default( 'placeholder_color' ),
            'button_color'        => glse_get_default( 'button_color' ),
            'button_hover_color'  => glse_get_default( 'button_hover_color' ),
            'button_text_color'   => glse_get_default( 'button_text_color' ),
            'button_radius'       => glse_get_default( 'button_radius' ),
            'focus_ring_color'    => glse_get_default( 'focus_ring_color' ),

            /* Results */
            'show_categories'     => false,
            'show_image'          => ! empty( $input['show_image'] ),
            'show_price'          => ! empty( $input['show_price'] ),
            'show_sku'            => false,
            'show_rating'         => false,
            'show_category_count' => false,
            'in_stock_only'       => false,
            'price_filter'        => false,

            /* Recommendations (Pro only) */
            'enable_recommendations' => false,
            'recommendations_count'  => self::bounded_int( $input, 'recommendations_count', 1, 6 ),
            'recommendations_label'  => sanitize_text_field( $input['recommendations_label'] ?? '' ),

            /* Mobile */
            'mobile_overlay'            => ! isset( $input['mobile_overlay'] ) || '1' === (string) $input['mobile_overlay'],
            'mobile_overlay_breakpoint' => self::bounded_int( $input, 'mobile_overlay_breakpoint', 320, 1024 ),

            /* Performance */
            'enable_cache'            => ! empty( $input['enable_cache'] ),
            'cache_duration'          => $cache_duration,
            'disable_mobile'          => ! empty( $input['disable_mobile'] ),
            'data_retention'          => array_key_exists( (int) ( $input['data_retention'] ?? 90 ), self::retention_periods() )
                                         ? (int) $input['data_retention'] : 90,
            'frontend_security_headers' => ! empty( $input['frontend_security_headers'] ),
            'admin_csp'                 => ! empty( $input['admin_csp'] ),
            'search_variations'       => false,
        ];

        /*
         * Everything a licence governs is decided elsewhere, in a method the
         * free build does not carry. What is left above is the free plugin
         * stating its own values - no branch, nothing to unlock.
         */
        

        return $clean;
    }

    

    /* ═══════════════════════════════════════════════════
       Panel - Behavior (includes Mobile sub-section)
       ═══════════════════════════════════════════════════ */

    /**
     * Render the Behavior settings panel.
     *
     * @since 1.4.0
     */
    public function render_behavior_panel(): void {
        ?>
        <table class="form-table" role="presentation">
            <?php do_settings_fields( 'glse-settings', 'glse_behavior_section' ); ?>
        </table>

        <div class="glse-subsection">
            <h3 class="glse-subsection-title">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
                <?php esc_html_e( 'Mobile', 'glance-live-search' ); ?>
            </h3>
            <table class="form-table" role="presentation">
                <?php do_settings_fields( 'glse-settings', 'glse_mobile_section' ); ?>
            </table>
        </div>
        <?php
    }

    /**
     * Render the Performance panel, with Security as a sub-section.
     *
     * Security is nested rather than mixed into the performance settings: the
     * two are separate concerns and read as separate groups.
     *
     * @since 1.6.1
     */
    public function render_performance_panel(): void {
        $this->print_pending_notice();
        ?>
        <table class="form-table" role="presentation">
            <?php do_settings_fields( 'glse-settings', 'glse_performance_section' ); ?>
        </table>

        <div class="glse-subsection">
            <h3 class="glse-subsection-title">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
                <?php esc_html_e( 'Fast Search Index', 'glance-live-search' ); ?>
            </h3>
            <?php $this->render_fulltext_panel(); ?>
        </div>

        <div class="glse-subsection">
            <h3 class="glse-subsection-title">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
                <?php esc_html_e( 'Security', 'glance-live-search' ); ?>
            </h3>
            <table class="form-table" role="presentation">
                <?php do_settings_fields( 'glse-settings', 'glse_security_section' ); ?>
            </table>
        </div>
        <?php
    }

    /**
     * Prints the notice left behind by the last admin action, if any.
     *
     * Shared: the licence screen and the index builder both report through the
     * same transient, and the index builder lives on a tab the free build also
     * renders - so this cannot sit inside a premium-only panel.
     *
     * @since 1.7.0
     */
    private function print_pending_notice(): void {
        $notice = get_transient( 'glse_license_notice' );
        if ( ! $notice || ! is_array( $notice ) ) {
            return;
        }

        delete_transient( 'glse_license_notice' );

        $type = in_array( $notice['type'] ?? '', [ 'success', 'error', 'info' ], true )
            ? $notice['type']
            : 'info';

        printf(
            '<div class="notice notice-%s is-dismissible" style="margin:0 0 18px"><p>%s</p></div>',
            esc_attr( $type ),
            esc_html( (string) ( $notice['message'] ?? '' ) )
        );
    }

    /**
     * Builds or removes the fast search indexes.
     *
     * Runs on admin_init rather than through AJAX: the ALTER can take minutes
     * on a large table, and a plain request survives that where an AJAX call
     * would time out and leave the merchant unsure whether it worked.
     *
     * @since 1.7.0
     */
    public function handle_fulltext_index(): void {
        if ( ! isset( $_POST['glse_fulltext_op'] ) || ! current_user_can( 'manage_options' ) ) {
            return;
        }

        check_admin_referer( 'glse_fulltext' );

        $op = sanitize_key( wp_unslash( $_POST['glse_fulltext_op'] ) );
        if ( ! in_array( $op, [ 'create', 'drop' ], true ) ) {
            return;
        }

        if ( ! class_exists( 'GLSE_Fulltext' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-fulltext.php';
        }

        $errors = [];
        foreach ( GLSE_Fulltext::names() as $index ) {
            $result = ( 'create' === $op )
                ? GLSE_Fulltext::create( $index )
                : GLSE_Fulltext::drop( $index );

            if ( is_wp_error( $result ) ) {
                $errors[] = $result->get_error_message();
            }
        }

        /*
         * Reported through the same transient the licence screen uses, so a
         * failure lands where the merchant is already looking.
         */
        if ( $errors ) {
            set_transient( 'glse_license_notice', [
                'type'    => 'error',
                'message' => implode( ' ', array_unique( $errors ) ),
            ], 30 );
        } else {
            set_transient( 'glse_license_notice', [
                'type'    => 'success',
                'message' => 'create' === $op
                    ? __( 'Fast search index built. Searches now match the beginning of words.', 'glance-live-search' )
                    : __( 'Fast search index removed. Searches match inside words again.', 'glance-live-search' ),
            ], 30 );
        }

        wp_safe_redirect( admin_url( 'admin.php?page=glse-settings&tab=performance' ) );
        exit;
    }

    /**
     * The fast search index: what it costs, what it changes, and the switch.
     *
     * Deliberately verbose about the trade-off. The index makes MySQL match on
     * word beginnings rather than scan every row, which is the difference
     * between a usable and an unusable search on a large catalogue - but it
     * also stops matching inside words, so a shop where that matters must be
     * able to decline it knowingly rather than discover it later.
     *
     * @since 1.7.0
     */
    private function render_fulltext_panel(): void {
        if ( ! class_exists( 'GLSE_Fulltext' ) ) {
            require_once GLSE_PATH . 'includes/class-glse-fulltext.php';
        }

        $status    = GLSE_Fulltext::status();
        $supported = $status['supported'];
        $active    = ! in_array( false, $status['indexes'], true );

        echo '<p class="description" style="margin:0 0 14px">';
        esc_html_e( 'Without it, every keystroke reads the whole posts table. With it, the database jumps straight to the matching words. The difference is invisible on a small catalogue and decisive past a few thousand products.', 'glance-live-search' );
        echo '</p>';

        if ( is_wp_error( $supported ) ) {
            printf(
                '<div class="notice notice-warning inline" style="margin:0"><p>%s</p></div>',
                esc_html( $supported->get_error_message() )
            );
            return;
        }

        /* State first: the question is "is it on", everything else is detail. */
        printf(
            '<p style="margin:0 0 12px"><span class="glse-license-chip glse-license-chip--%1$s">%2$s</span></p>',
            $active ? 'on' : 'off',
            $active
                ? esc_html__( 'Active', 'glance-live-search' )
                : esc_html__( 'Not created', 'glance-live-search' )
        );

        echo '<table class="glse-license-facts-table" style="margin-bottom:14px"><tbody>';
        printf(
            '<tr><th scope="row">%s</th><td>%s</td></tr>',
            esc_html__( 'Rows to index', 'glance-live-search' ),
            esc_html( number_format_i18n( $status['rows'] ) )
        );
        printf(
            '<tr><th scope="row">%s</th><td>%s</td></tr>',
            esc_html__( 'Shortest indexed word', 'glance-live-search' ),
            esc_html( sprintf(
                /* translators: %d: number of characters. */
                _n( '%d character', '%d characters', $status['min_token'], 'glance-live-search' ),
                $status['min_token']
            ) )
        );
        echo '</tbody></table>';

        /*
         * Said before the button, not after: a merchant who learns this once
         * results have changed has already lost trust.
         */
        echo '<div class="notice notice-info inline" style="margin:0 0 14px"><p>';
        printf(
            /* translators: %d: minimum word length. */
            esc_html__( 'The index matches the beginning of words. A search for “shirt” finds “Shirt Blue” but no longer finds “Sweatshirt”, and words under %d characters fall back to the slower search automatically.', 'glance-live-search' ),
            (int) $status['min_token']
        );
        echo '</p></div>';

        if ( $status['rows'] > 100000 ) {
            echo '<div class="notice notice-warning inline" style="margin:0 0 14px"><p>';
            esc_html_e( 'Building the index locks the posts table while it runs. On a table this size that can take several minutes, during which the site cannot save content. Do it outside opening hours.', 'glance-live-search' );
            echo '</p></div>';
        }

        printf(
            '<p style="margin:0"><button type="button" class="button %1$s glse-fulltext-btn"
                data-op="%2$s" data-nonce="%3$s" data-action="%4$s"
                data-confirm="%5$s">%6$s</button></p>',
            $active ? '' : 'button-primary',
            $active ? 'drop' : 'create',
            esc_attr( wp_create_nonce( 'glse_fulltext' ) ),
            esc_url( admin_url( 'admin.php?page=glse-settings&tab=performance' ) ),
            $active ? '' : esc_attr__( 'Build the index now? The site may be briefly unresponsive.', 'glance-live-search' ),
            $active
                ? esc_html__( 'Remove the index', 'glance-live-search' )
                : esc_html__( 'Build the index', 'glance-live-search' )
        );
    }

    /* ═══════════════════════════════════════════════════
       Fields - Behavior
       ═══════════════════════════════════════════════════ */

    /**
     * Render the Enable Live Search field.
     *
     * @since 1.4.0
     */
    public function field_live_search(): void {
        $val = glse_get_option( 'live_search' );
        echo '<label><input type="checkbox" name="glse_settings[live_search]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Enable real-time AJAX search', 'glance-live-search' ) . '</label>';
        echo '<p class="description">' . esc_html__( 'Results appear as the shopper types. Switch off to keep a plain box that submits to the standard results page - the bar keeps its styling either way.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Search Button field.
     *
     * @since 1.4.0
     */
    public function field_search_button(): void {
        $val = glse_get_option( 'search_button' );
        echo '<label><input type="checkbox" name="glse_settings[search_button]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Show search button in the bar', 'glance-live-search' ) . '</label>';
        echo '<p class="description">' . esc_html__( 'The shopper can always search by pressing Enter, with or without a button.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Button Mode field.
     *
     * @since 1.4.0
     */
    public function field_search_button_mode(): void {
        $val = glse_get_option( 'search_button_mode' );
        echo '<select id="glse-field-search-button-mode" name="glse_settings[search_button_mode]">';
        echo '<option value="icon"'  . selected( $val, 'icon', false )  . '>' . esc_html__( 'Icon (magnifying glass)', 'glance-live-search' ) . '</option>';
        echo '<option value="text"'  . selected( $val, 'text', false )  . '>' . esc_html__( 'Text (Search)', 'glance-live-search' ) . '</option>';
        echo '</select>';
        echo '<p class="description">' . esc_html__( 'Text buttons switch to the icon automatically on small screens.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Button Position field.
     *
     * @since 1.4.0
     */
    public function field_button_position(): void {
        $val = glse_get_option( 'button_position' );
        echo '<select id="glse-field-button-position" name="glse_settings[button_position]">';
        echo '<option value="inside"'  . selected( $val, 'inside', false )  . '>' . esc_html__( 'Inside the bar', 'glance-live-search' ) . '</option>';
        echo '<option value="outside"' . selected( $val, 'outside', false ) . '>' . esc_html__( 'Outside the bar', 'glance-live-search' ) . '</option>';
        echo '</select>';
        echo '<p class="description">' . esc_html__( 'Inside keeps the button within the border of the bar. Outside places it alongside.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Number of Results field.
     *
     * @since 1.4.0
     */
    public function field_max_results(): void {
        $val = max( 1, min( 100, (int) glse_get_option( 'max_results' ) ) );

        echo '<input id="glse-field-max-results" type="number" name="glse_settings[max_results]" value="' . esc_attr( $val ) . '" min="1" max="100" step="1" class="small-text">';
        echo '<p class="description">' . esc_html__( 'How many products the dropdown lists at once (1–100). A longer list does not make the search slower.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the panel height summary on its own full-width row.
     *
     * @since 1.6.1
     */
    public function field_panel_size_hint(): void {
        $products = max( 1, min( 100, (int) glse_get_option( 'max_results' ) ) );

        echo $this->panel_size_hint( $products ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built internally, already escaped.
    }

    /**
     * Says how tall the dropdown will actually be.
     *
     * Each option reads as harmless where it sits, and their effect only shows
     * up together on the storefront - which is the one place the merchant never
     * looks while configuring. Counting them here turns "show categories, why
     * not" into a visible trade.
     *
     * @param int $products Configured product count.
     * @return string Escaped HTML.
     */
    private function panel_size_hint( int $products ): string {
        $budget = max( 0, (int) apply_filters( 'glse/results/extras_budget', 6 ) );

        /* The free build has no category or recommendation row to count. */
        $is_pro = false;
        

        /*
         * Spent in the same order as GLSE_Ajax_Handler: categories first, then
         * recommendations on whatever is left. Counting it any other way lists
         * rows the panel will not draw, and the figures stop adding up - the
         * sentence then contradicts itself in its own second half.
         */
        $left   = $budget;
        $wanted = 0;
        $named  = [];

        if ( $is_pro && glse_get_option( 'show_categories' ) ) {
            $wanted += 4;
            $shown   = min( 4, $left );
            $left   -= $shown;
            if ( $shown > 0 ) {
                /* translators: %d: how many category rows the panel will show. */
                $named[] = sprintf( _n( '%d category', '%d categories', $shown, 'glance-live-search' ), $shown );
            }
        }
        if ( $is_pro && glse_get_option( 'enable_recommendations' ) ) {
            $n       = (int) glse_get_option( 'recommendations_count' );
            $wanted += $n;
            $shown   = min( $n, $left );
            $left   -= $shown;
            if ( $shown > 0 ) {
                /* translators: %d: how many recommendation rows the panel will show. */
                $named[] = sprintf( _n( '%d recommendation', '%d recommendations', $shown, 'glance-live-search' ), $shown );
            }
        }

        $extras = min( $wanted, $budget );
        $total  = $products + $extras;

        $line = sprintf(
            /* translators: 1: total rows, 2: product count */
            esc_html( _n(
                'Your dropdown will show up to %1$d row: %2$d product',
                'Your dropdown will show up to %1$d rows: %2$d products',
                $total,
                'glance-live-search'
            ) ),
            $total,
            $products
        );

        if ( $named ) {
            $line .= ' ' . sprintf(
                /* translators: %s: comma-separated list, e.g. "4 suggestions, up to 4 tags" */
                esc_html__( 'plus %s.', 'glance-live-search' ),
                esc_html( implode( ', ', $named ) )
            );
        } else {
            $line .= '.';
        }

        // Say so when the budget is actually cutting something off. The list
        // above already shows what survives, so this only has to name the gap.
        if ( $wanted > $budget ) {
            $line .= ' <strong>' . sprintf(
                /* translators: 1: extra rows enabled, 2: rows the panel has room for */
                esc_html__( 'You enabled %1$d extra rows and the panel has room for %2$d.', 'glance-live-search' ),
                $wanted,
                $budget
            ) . '</strong>';
        }

        return '<p class="description glse-panel-size">' . $line . '</p>';
    }

    /**
     * Render the AJAX Delay field.
     *
     * @since 1.4.0
     */
    public function field_ajax_delay(): void {
        $val = glse_get_option( 'ajax_delay' );
        echo '<input id="glse-field-ajax-delay" type="number" name="glse_settings[ajax_delay]" value="' . esc_attr( $val ) . '" min="0" max="2000" step="50" class="small-text">';
        echo '<p class="description">' . esc_html__( 'How long to wait after the last keystroke before searching (0–2000). Lower feels more instant but sends more requests.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Minimum Characters field.
     *
     * @since 1.4.0
     */
    public function field_min_chars(): void {
        $val = glse_get_option( 'min_chars' );
        echo '<input id="glse-field-min-chars" type="number" name="glse_settings[min_chars]" value="' . esc_attr( $val ) . '" min="1" max="10" step="1" class="small-text">';
        echo '<p class="description">' . esc_html__( 'How many characters a shopper must type before the first search runs.', 'glance-live-search' ) . '</p>';
        if ( $val < 3 && glse_get_option( 'search_in_description' ) ) {
            /*
             * With a FULLTEXT index in play, MySQL discards tokens shorter than
             * innodb_ft_min_token_size (3 by default), so 1 and 2 quietly do
             * nothing. Say so rather than let the setting look broken.
             */
            echo '<p class="description glse-warn">' . esc_html__( 'Descriptions are matched by whole words, so with description search on, terms under 3 characters find nothing.', 'glance-live-search' ) . '</p>';
        }
    }

    /**
     * Render the Placeholder field.
     *
     * @since 1.4.0
     */
    public function field_placeholder(): void {
        $val = glse_get_option( 'placeholder' );
        echo '<input id="glse-field-placeholder" type="text" name="glse_settings[placeholder]" value="' . esc_attr( $val ) . '" class="regular-text" placeholder="' . esc_attr__( 'Search for a product (name or SKU)…', 'glance-live-search' ) . '">';
        echo '<p class="description">' . esc_html__( 'Hint text inside the empty box. Leave blank to use the wording shown in the field.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Search in Content field.
     *
     * @since 1.4.0
     */
    /**
     * Render the Search in Descriptions field.
     *
     * One control, because the engine cannot separate the two: with a FULLTEXT
     * index MySQL matches title, content and excerpt as a single unit.
     *
     * @since 1.6.1
     */
    public function field_search_in_description(): void {
        $val = glse_get_option( 'search_in_description' );
        echo '<label><input type="checkbox" name="glse_settings[search_in_description]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Also search the long and short product descriptions', 'glance-live-search' ) . '</label>';
        echo '<p class="description">' . esc_html__( 'Finds more, but is slower on large catalogs. Ask your host about a full-text index if searches feel sluggish.', 'glance-live-search' ) . '</p>';
    }

    

    

    

    

    /**
     * Render the Search History field.
     *
     * @since 1.4.0
     */
    public function field_show_history(): void {
        $val = glse_get_option( 'show_history' );
        echo '<label><input type="checkbox" name="glse_settings[show_history]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Show recent searches on focus', 'glance-live-search' ) . '</label>';
        echo '<p class="description">' . esc_html__( 'Stored in their own browser and never sent to your site.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Recent Searches Limit field.
     *
     * @since 1.4.0
     */
    public function field_history_max(): void {
        $val = (int) glse_get_option( 'history_max' );
        echo '<input id="glse-field-history-max" type="number" name="glse_settings[history_max]" value="' . esc_attr( $val ) . '" min="1" max="20" step="1" style="width:70px">';
        echo '<p class="description">' . esc_html__( 'How many recent searches to list (1–20).', 'glance-live-search' ) . '</p>';
    }

    /* ═══════════════════════════════════════════════════
       Fields - Search Bar
       ═══════════════════════════════════════════════════ */

    

    /**
     * Render the Design Preset field.
     *
     * @since 1.4.0
     */
    public function field_bar_design_preset(): void {
        $presets = glse_get_presets();
        $val     = glse_get_option( 'bar_design_preset' );

        /*
         * Only the presets this build ships are drawn, and every one of them is
         * selectable. A card rendered with a padlock over a disabled radio is
         * the shape wp.org reads as a feature withheld until payment; a preset
         * the build never defines simply is not on screen.
         */
        if ( ! array_key_exists( $val, $presets ) ) {
            $val = 'sharp';
        }

        /* Kept for the paid build without a licence, where the extra presets
           exist but may not be applied. */
        $locked_keys = [];
        

        /*
         * Un fieldset, comme les quatre lignes voisines.
         *
         * `label_for` ne convient pas a un groupe de boutons radio - il en
         * designerait un seul - et c'est pour cette raison que cette ligne etait
         * restee sans nom de groupe quand les autres en ont recu un. Un lecteur
         * d'ecran annoncait « Sharp », « Clean », « Pill », « Custom » sans
         * jamais dire de quoi il s'agissait.
         */
        $this->group_open( __( 'Design Preset', 'glance-live-search' ), 'glse-control-fieldset' );

        echo '<div class="glse-preset-grid">';
        foreach ( $presets as $key => $p ) {
            $locked = in_array( $key, $locked_keys, true );
            $active = ( $val === $key ) ? ' glse-preset-card--active' : '';
            /*
             * « Personnalise » ne porte pas de couleurs - il ne doit pas en
             * porter, sans quoi il ecraserait celles du marchand. Sa vignette se
             * dessine donc a partir des reglages enregistres, ce qui la rend
             * exacte : la carte montre ce qui est configure.
             */
            $swatch = static fn( string $k ) => (string) ( $p[ $k ] ?? glse_get_option( $k ) );

            $bg     = esc_attr( $swatch( 'bar_bg_color' ) );
            $border = esc_attr( $swatch( 'border_color' ) );
            $radius = esc_attr( (int) $swatch( 'border_radius' ) . 'px' );
            $ph     = esc_attr( $swatch( 'placeholder_color' ) );
            $btn    = esc_attr( $swatch( 'button_color' ) );

            $locked_class = $locked ? ' glse-preset-card--locked' : '';
            echo '<label class="glse-preset-card' . esc_attr( $active . $locked_class ) . '">';
            if ( $locked ) {
                echo '<input type="radio" name="glse_settings[bar_design_preset]" value="' . esc_attr( $key ) . '" disabled>';
            } else {
                echo '<input type="radio" name="glse_settings[bar_design_preset]" value="' . esc_attr( $key ) . '"' . checked( $val, $key, false ) . '>';
            }
            echo '<div class="glse-preset-preview' . ( $locked ? ' glse-preset-preview--locked' : '' ) . '" style="background:' . esc_attr( $bg ) . '">';
            if ( $locked ) {
                echo '<div class="glse-preset-lock-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>';
            }
            echo '<div class="glse-preset-bar" style="border:1.5px solid ' . esc_attr( $border ) . ';border-radius:' . esc_attr( $radius ) . ';background:#fff;">';
            echo '<span class="glse-preset-bar-ph" style="color:' . esc_attr( $ph ) . '">' . esc_html__( 'Search…', 'glance-live-search' ) . '</span>';
            echo '<span class="glse-preset-bar-icon" style="background:' . esc_attr( $btn ) . ';border-radius:calc(' . esc_attr( $radius ) . ' - 4px);">';
            echo '<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
            echo '</span></div></div>';
            if ( 'custom' === $key && ! $locked ) {
                echo '<div class="glse-preset-custom-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></div>';
            }
            echo '<div class="glse-preset-name">' . esc_html( $p['label'] ) . ( $locked ? ' <span class="glse-pro-badge">Pro</span>' : '' ) . '</div>';
            echo '</label>';
        }
        echo '</div>';

        if ( $locked_keys ) {
            echo $this->pro_lock( __( 'Pill and Custom presets', 'glance-live-search' ) );
        }

        $this->group_close();
        echo '<p class="description">' . esc_html__( 'Presets set colours and corners in one click. Choose Custom to set each value yourself.', 'glance-live-search' ) . '</p>';
    }

    

    /**
     * Single locked row standing in for the ten Pro colour controls.
     *
     * @since 1.6.1
     */
    public function field_colors_locked(): void {
        echo '<p class="description">' . esc_html__( 'Bar, border, text, placeholder and button colours, plus the Pill and Custom presets.', 'glance-live-search' ) . '</p>';
        echo $this->pro_lock( __( 'Color customization', 'glance-live-search' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built internally, already escaped.
    }

    /* ═══════════════════════════════════════════════════
       Live preview
       ═══════════════════════════════════════════════════ */

    /**
     * The bar as it will look, above the controls that shape it.
     *
     * Twelve settings decide how the bar looks and not one of them showed its
     * effect on this screen. Judging a 14px radius or a placeholder grey meant
     * saving, opening the storefront, coming back - per setting. The preview
     * removes that round trip; the values below are the same ones the front-end
     * stylesheet reads, so what is drawn here is what ships.
     *
     * Both states are shown because half these settings are invisible at rest:
     * the focus background and the focus ring only appear once someone reaches
     * the bar with a keyboard, which is exactly when they matter most.
     *
     * @since 1.7.0
     */
    public function render_searchbar_preview(): void {
        /*
         * Resolu comme la façade le resout, pas lu dans les options.
         *
         * Ces deux chemins avaient diverge : l'apercu montrait les valeurs
         * enregistrees pendant qu'un preset en servait d'autres en boutique.
         */
        $d = glse_resolved_design();

        $vars = [
            '--glse-bar-height'        => (int) $d['bar_height'] . 'px',
            '--glse-bar-bg'            => (string) $d['bar_bg_color'],
            '--glse-bar-bg-focus'      => (string) $d['bar_bg_focus'],
            '--glse-border-width'      => (int) $d['border_size'] . 'px',
            '--glse-border-color'      => (string) $d['border_color'],
            '--glse-radius'            => (int) $d['border_radius'] . 'px',
            '--glse-text-color'        => (string) $d['text_color'],
            '--glse-placeholder-color' => (string) $d['placeholder_color'],
            '--glse-btn-bg'            => (string) $d['button_color'],
            '--glse-btn-hover'         => (string) $d['button_hover_color'],
            '--glse-btn-color'         => (string) $d['button_text_color'],
            '--glse-btn-radius'        => (int) $d['button_radius'] . 'px',
            '--glse-focus-ring-color'  => (string) $d['focus_ring_color'],
        ];

        $style = '';
        foreach ( $vars as $name => $value ) {
            $style .= $name . ':' . $value . ';';
        }

        $placeholder = (string) glse_get_option( 'placeholder' );
        if ( '' === trim( $placeholder ) ) {
            $placeholder = __( 'Search for a product…', 'glance-live-search' );
        }

        $icon = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';

        ?>
        <div class="glse-preview" id="glse-preview" style="<?php echo esc_attr( $style ); ?>">
            <p class="glse-preview-caption"><?php esc_html_e( 'Preview - follows the settings below as you change them.', 'glance-live-search' ); ?></p>
            <div class="glse-preview-stage">
                <?php foreach ( [ 'rest' => __( 'At rest', 'glance-live-search' ), 'focus' => __( 'Keyboard focus', 'glance-live-search' ) ] as $state => $caption ) : ?>
                    <div class="glse-preview-state">
                        <span class="glse-preview-state-label"><?php echo esc_html( $caption ); ?></span>
                        <div class="glse-preview-bar glse-preview-bar--<?php echo esc_attr( $state ); ?>">
                            <span class="glse-preview-text"><?php echo esc_html( $placeholder ); ?></span>
                            <span class="glse-preview-btn"><?php echo $icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static markup. ?></span>
                        </div>
                    </div>
                <?php endforeach; ?>
            </div>
        </div>
        <?php
    }

    /* ═══════════════════════════════════════════════════
       Field building blocks
       ═══════════════════════════════════════════════════ */

    /**
     * The DOM id a settings field answers to.
     *
     * Every control needs one, and not for styling: `label_for` in
     * add_settings_field() turns the row heading into a real <label> only when
     * it has an id to point at. Without it the heading is loose text - clicking
     * it does nothing, and a screen reader reads the control with no name.
     *
     * @since 1.7.0
     */
    private static function field_id( string $key ): string {
        return 'glse-field-' . str_replace( '_', '-', $key );
    }

    /**
     * A colour control, plus what its pair scores.
     *
     * A plain text field carrying the hex, which WordPress' own picker attaches
     * itself to. That picker is the reason: `<input type="color">` opens the
     * operating system's dialog, and which tab that dialog opens on - wheel,
     * sliders, hex - belongs to the OS. No web API can ask it for hex. WordPress'
     * picker opens inside the page and the control it opens on *is* a hex box,
     * which is what a merchant works from when copying a brand colour.
     *
     * Opening and closing are left to that picker. Holding the field open with
     * CSS looked tidier - every value readable at a glance - but it cancelled
     * close(), so a picker that had been closed still showed its field and its
     * "Default" button. Two of them looked open at once.
     *
     * The field also stands on its own: with no JavaScript at all it is still a
     * text box that accepts #E30613 and saves it.
     *
     * @since 1.7.0
     *
     * @param string $key   Option key.
     * @param array  $args  label       Visible label above the control.
     *                      description Help text under the control.
     */
    private function color_control( string $key, array $args = [] ): void {
        $val     = (string) glse_get_option( $key );
        $id      = self::field_id( $key );
        $default = self::default_color( $key );

        echo '<span class="glse-color-control">';

        if ( ! empty( $args['label'] ) ) {
            printf(
                '<label class="glse-control-label" for="%1$s">%2$s</label>',
                esc_attr( $id ),
                esc_html( $args['label'] )
            );
        }

        printf(
            '<input type="text" id="%1$s" name="glse_settings[%2$s]" value="%3$s" class="glse-color-hex" data-default-color="%4$s" spellcheck="false" autocomplete="off">',
            esc_attr( $id ),
            esc_attr( $key ),
            esc_attr( $val ),
            esc_attr( $default )
        );

        if ( ! empty( $args['description'] ) ) {
            echo '<span class="description glse-control-help">' . esc_html( $args['description'] ) . '</span>';
        }

        echo '</span>';
    }

    /**
     * The shipped value for a colour, for the picker's "Default" button.
     *
     * Read from the one table the plugin keeps, so the button restores what is
     * actually shipped rather than a second opinion written here.
     *
     * @since 1.7.0
     */
    private static function default_color( string $key ): string {
        return function_exists( 'glse_get_default' )
            ? (string) ( glse_get_default( $key ) ?? '' )
            : '';
    }



    /**
     * A bounded number control.
     *
     * @since 1.7.0
     */
    private function number_control( string $key, int $min, int $max, array $args = [] ): void {
        $id = self::field_id( $key );

        echo '<span class="glse-number-control">';

        if ( ! empty( $args['label'] ) ) {
            printf(
                '<label class="glse-control-label" for="%1$s">%2$s</label>',
                esc_attr( $id ),
                esc_html( $args['label'] )
            );
        }

        printf(
            '<span class="glse-number-field"><input type="number" id="%1$s" name="glse_settings[%2$s]" value="%3$s" min="%4$d" max="%5$d" step="1" class="small-text">',
            esc_attr( $id ),
            esc_attr( $key ),
            esc_attr( (string) glse_get_option( $key ) ),
            $min,
            $max
        );

        if ( ! empty( $args['unit'] ) ) {
            echo '<span class="glse-unit">' . esc_html( $args['unit'] ) . '</span>';
        }

        echo '</span>';

        if ( ! empty( $args['help'] ) ) {
            echo '<span class="description glse-control-help">' . esc_html( $args['help'] ) . '</span>';
        }

        echo '</span>';

        if ( ! empty( $args['description'] ) ) {
            echo '<p class="description">' . esc_html( $args['description'] ) . '</p>';
        }
    }

    /**
     * Reads a colour the way a merchant actually pastes one.
     *
     * sanitize_hex_color() is strict: it wants "#rrggbb" and nothing else. A
     * brand sheet says "E30613" as often as "#E30613", and copying from a
     * document brings a trailing space along. Both were rejected and silently
     * replaced by the shipped colour - the merchant saw their brand colour
     * vanish with no explanation.
     *
     * The picker already repairs those two cases in the browser. This does the
     * same on the server, so the result no longer depends on JavaScript having
     * run.
     *
     * Anything else still falls back: an unreadable colour must not reach a
     * stylesheet.
     *
     * @since 1.7.0
     */
    private static function to_hex_color( $value, string $fallback ): string {
        if ( ! is_string( $value ) ) {
            return $fallback;
        }

        $value = trim( $value );

        if ( '' === $value ) {
            return $fallback;
        }

        if ( '#' !== $value[0] ) {
            $value = '#' . $value;
        }

        // Une seule forme en base, quelle que soit la casse saisie.
        return strtolower( (string) sanitize_hex_color( $value ) ) ?: $fallback;
    }

    /**
     * Reads a bounded whole number from submitted input.
     *
     * Replaces absint(), which returns an absolute value: "-10" became 10, and
     * on a setting capped at 5 that landed on the maximum rather than the
     * minimum. Entering a negative border width gave the thickest border.
     *
     * An empty or non-numeric field also falls back to what the plugin ships,
     * not to the lowest allowed value. Clearing the height box used to save
     * 36px - the minimum - when 48 is the default.
     *
     * @since 1.7.0
     */
    private static function bounded_int( array $input, string $key, int $min, int $max ): int {
        $raw = $input[ $key ] ?? null;

        if ( ! is_scalar( $raw ) || ! is_numeric( trim( (string) $raw ) ) ) {
            $raw = glse_get_default( $key );
        }

        return max( $min, min( $max, (int) $raw ) );
    }

    /**
     * Opens a row that holds more than one control.
     *
     * `label_for` names exactly one control, so a row with three would leave
     * two of them anonymous - the defect this screen was just cleared of. A
     * fieldset carries the name for the whole group instead, and each control
     * keeps its own visible label. A screen reader then reads "Outline,
     * thickness" rather than "Outline" once and nothing afterwards.
     *
     * @since 1.7.0
     *
     * @param string $legend Nom du groupe, pour les lecteurs d'ecran.
     * @param string $class  Classe du fieldset. Par defaut la grille des lignes
     *                       fusionnees ; les cartes de preset ont la leur.
     */
    private function group_open( string $legend, string $class = 'glse-control-group' ): void {
        printf(
            '<fieldset class="%1$s"><legend class="screen-reader-text">%2$s</legend>',
            esc_attr( $class ),
            esc_html( $legend )
        );
    }

    /**
     * Closes a grouped row.
     *
     * @since 1.7.0
     */
    private function group_close(): void {
        echo '</fieldset>';
    }

    

    

    

    

    /**
     * Render the Bar Height field.
     *
     * @since 1.4.0
     */
    public function field_bar_height(): void {
        $this->number_control( 'bar_height', 36, 80, [
            'unit'        => __( 'px', 'glance-live-search' ),
            'description' => __( 'Height of the bar (36–80). Applies whatever preset is selected.', 'glance-live-search' ),
        ] );
    }











    

    /* ═══════════════════════════════════════════════════
       Fields - Results
       ═══════════════════════════════════════════════════ */

    

    

    /**
     * Render the Show Image field.
     *
     * @since 1.4.0
     */
    public function field_show_image(): void {
        $val = glse_get_option( 'show_image' );
        echo '<label><input type="checkbox" name="glse_settings[show_image]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Product image', 'glance-live-search' ) . '</label>';
    }

    /**
     * Render the Show Price field.
     *
     * @since 1.4.0
     */
    public function field_show_price(): void {
        $val = glse_get_option( 'show_price' );
        echo '<label><input type="checkbox" name="glse_settings[show_price]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Product price', 'glance-live-search' ) . '</label>';
    }

    

    

    

    

    

    

    

    

    /* ═══════════════════════════════════════════════════
       Fields - Performance
       ═══════════════════════════════════════════════════ */

    /**
     * Render the Enable Cache field.
     *
     * @since 1.4.0
     */
    public function field_enable_cache(): void {
        $val = glse_get_option( 'enable_cache' );
        echo '<label><input type="checkbox" name="glse_settings[enable_cache]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Cache search results', 'glance-live-search' ) . '</label>';

        /*
         * The default depends on the environment, so say which one applies
         * rather than leave the initial state looking arbitrary.
         */
        if ( wp_using_ext_object_cache() ) {
            echo '<p class="description">' . esc_html__( 'Your site has a memory cache, so results are stored there. Cleared the moment a product changes.', 'glance-live-search' ) . '</p>';
        } else {
            echo '<p class="description">' . esc_html__( 'Without a memory cache, results are stored in the database. Worth it on a slow catalog, less so on a busy store.', 'glance-live-search' ) . '</p>';
        }
    }

    /**
     * Render the Cache Duration field.
     *
     * @since 1.4.0
     */
    public function field_cache_duration(): void {
        /* The ceiling this build has. No licence is consulted in the free one. */
        $max = 30;
        
        $val = min( (int) glse_get_option( 'cache_duration' ), $max );
        echo '<input type="number" name="glse_settings[cache_duration]" value="' . esc_attr( $val ) . '" min="1" max="' . esc_attr( $max ) . '" step="1" class="small-text">';
        echo '<span class="description"> ' . esc_html__( 'minutes', 'glance-live-search' ) . '</span>';
        echo '<p class="description">' . esc_html__( 'How long a result stays cached. A product change clears it immediately, whatever this value.', 'glance-live-search' ) . '</p>';
        
    }

    /**
     * Render the Mobile Overlay field.
     *
     * @since 1.4.0
     */
    public function field_mobile_overlay(): void {
        $val = glse_get_option( 'mobile_overlay' );
        $options = [
            '1' => __( 'Full-screen overlay', 'glance-live-search' ),
            '0' => __( 'Classic dropdown', 'glance-live-search' ),
        ];
        echo '<select id="glse-field-mobile-overlay" name="glse_settings[mobile_overlay]">';
        foreach ( $options as $key => $label ) {
            echo '<option value="' . esc_attr( $key ) . '"' . selected( $val ? '1' : '0', $key, false ) . '>' . esc_html( $label ) . '</option>';
        }
        echo '</select>';
        echo '<p class="description">' . esc_html__( 'Overlay opens a fullscreen panel, easier to tap and read. Dropdown keeps the desktop behaviour.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Mobile Overlay Breakpoint field.
     *
     * @since 1.4.0
     */
    public function field_mobile_overlay_breakpoint(): void {
        $val = (int) glse_get_option( 'mobile_overlay_breakpoint' );
        echo '<input type="number" name="glse_settings[mobile_overlay_breakpoint]" value="' . esc_attr( $val ) . '" min="320" max="1024" step="1" style="width:90px">';
        echo '<p class="description">' . esc_html__( 'Screens narrower than this use the mobile behaviour (320–1024). Default 768.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Disable Mobile field.
     *
     * @since 1.4.0
     */
    public function field_disable_mobile(): void {
        $val = glse_get_option( 'disable_mobile' );
        echo '<label><input type="checkbox" name="glse_settings[disable_mobile]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Keep a plain search box on small screens', 'glance-live-search' ) . '</label>';
        /*
         * This one is device-based (server-side browser detection), unlike the
         * breakpoint above which is viewport-based. Saying so avoids the two
         * settings looking like they share a definition of "mobile".
         */
        echo '<p class="description">' . esc_html__( 'Phones and tablets get a plain search box instead. Based on the browser identifying itself, not the width above - and unreliable if your pages are served from a cache.', 'glance-live-search' ) . '</p>';
    }

    /**
     * Render the Data Retention field.
     *
     * @since 1.4.0
     */
    public function field_data_retention(): void {
        /* The list itself is shorter in the free build; nothing is refused. */
        $show_upsell = false;
        
        $val = (int) glse_get_option( 'data_retention' );

        echo '<select id="glse-field-data-retention" name="glse_settings[data_retention]">';
        foreach ( self::retention_periods() as $days => $label ) {
            echo '<option value="' . esc_attr( $days ) . '"' . selected( $val, $days, false ) . '>' . esc_html( $label ) . '</option>';
        }
        echo '</select>';
        echo '<p class="description">' . esc_html__( 'Recorded searches older than this are deleted every night. Keep at least twice your analytics period, or the trend comparison has nothing to compare against.', 'glance-live-search' ) . '</p>';
        if ( $show_upsell ) {
            echo $this->pro_lock( __( 'Retention up to 1 year', 'glance-live-search' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built internally, already escaped.
        }
    }

    /**
     * Render the HTTP Security Headers field.
     *
     * @since 1.4.0
     */
    public function field_frontend_security_headers(): void {
        $val = glse_get_option( 'frontend_security_headers' );
        echo '<label><input type="checkbox" name="glse_settings[frontend_security_headers]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Send X-Content-Type-Options and Referrer-Policy on the storefront', 'glance-live-search' ) . '</label>';
        echo '<p class="description">' . esc_html__( 'Two low-risk headers that limit what browsers infer from your pages. Off by default: nothing in this plugin needs them, and they apply to your whole site. Leave off if your host or a security plugin already sends them.', 'glance-live-search' ) . '</p>';
    }

    public function field_admin_csp(): void {
        /*
         * Split out from the storefront headers: those are inert, while this
         * one sends a strict Content-Security-Policy that can break other
         * plugins' admin screens. Two very different risks did not belong
         * behind a single checkbox.
         */
        $val = glse_get_option( 'admin_csp' );
        echo '<label><input type="checkbox" name="glse_settings[admin_csp]" value="1"' . checked( $val, true, false ) . '>';
        echo ' ' . esc_html__( 'Send a strict Content-Security-Policy on the Glance settings page', 'glance-live-search' ) . '</label>';
        echo '<p class="description">' . esc_html__( 'Protects this page only. Leave off if another plugin adds scripts here, as a strict policy can block them.', 'glance-live-search' ) . '</p>';
    }

    

    

    /* ─── Page rendering ─── */

    /**
     * Tab definitions with SVG icons (Lucide).
     */
    private function get_tabs(): array {
        $tabs = [
            'behavior'    => [
                'label'    => __( 'Behavior', 'glance-live-search' ),
                'section'  => '',
                'callback' => [ $this, 'render_behavior_panel' ],
                'icon'     => '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="5" width="22" height="14" rx="7" ry="7"/><circle cx="16" cy="12" r="3"/></svg>',
            ],
            'searchbar'   => [
                'label'   => __( 'Search Bar', 'glance-live-search' ),
                'section' => 'glse_searchbar_section',
                'icon'    => '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
            ],
            'results'     => [
                'label'   => __( 'Results', 'glance-live-search' ),
                'section' => 'glse_results_section',
                'intro'   => [ $this, 'field_panel_size_hint' ],
                'icon'    => '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
            ],
            'performance' => [
                'label'    => __( 'Performance', 'glance-live-search' ),
                'section'  => '',
                'callback' => [ $this, 'render_performance_panel' ],
                'icon'    => '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
            ],
            'analytics'   => [
                'label'    => __( 'Analytics', 'glance-live-search' ),
                'section'  => '',
                'callback' => [ $this, 'render_analytics_panel' ],
                'icon'     => '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="18" y="3" width="4" height="18"/><rect x="10" y="8" width="4" height="13"/><rect x="2" y="13" width="4" height="8"/></svg>',
            ],
        ];

        // Shown to anyone without a licence, in either build.
        if ( ! glse_is_pro() ) {
            $tabs['upgrade'] = [
                'label'    => __( 'Pro Features', 'glance-live-search' ),
                'section'  => '',
                'callback' => [ $this, 'render_upgrade_panel' ],
                'icon'     => '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
                'is_cta'   => true,
            ];
        }

        /*
         * The licence tab belongs to the paid build only. Registering it with a
         * null callback would leave the free build showing an empty tab, so the
         * entry is added rather than blanked - and an if block is what the
         * processor removes, which a ternary is not.
         */
        

        return $tabs;
    }

    /**
     * Render the main plugin settings page.
     *
     * @since 1.4.0
     */
    public function render_page(): void {
        $tabs       = $this->get_tabs();
        $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'behavior';
        if ( ! array_key_exists( $active_tab, $tabs ) ) {
            $active_tab = 'behavior';
        }
        ?>
        <div class="wrap glse-wrap">
            <h1 class="screen-reader-text"><?php esc_html_e( 'Glance Live Search', 'glance-live-search' ); ?></h1>

            <?php
            /*
             * Tells WordPress where the admin notices belong.
             *
             * They are printed at the top of #wpbody-content, then common.js
             * moves them - after this marker if it exists, otherwise after the
             * first heading in .wrap. Ours is a screen-reader-text h1, which is
             * absolutely positioned, so without the marker notices landed in a
             * spot the layout does not account for. Placed here, they sit above
             * the header, ahead of everything the page draws.
             */
            ?>
            <hr class="wp-header-end">

            <!-- Header -->
            <div class="glse-header">
                <div class="glse-header-inner">
                    <div class="glse-header-brand">
                        <img src="<?php echo esc_url( GLSE_URL . 'admin/images/logo.svg' ); ?>" alt="Glance Live Search" class="glse-header-logo">
                        <?php
                        /*
                         * Same promise as the readme's short description, kept
                         * word for word: the merchant meets this sentence on
                         * wp.org before installing, and reading something else
                         * here is what makes a plugin feel like two products.
                         */
                        ?>
                        <span class="glse-header-tagline">
                            <?php esc_html_e( 'Instant product search for WooCommerce - results appear as your customers type, with no page reload.', 'glance-live-search' ); ?>
                        </span>
                    </div>
                </div>
            </div>

            <!-- Layout -->
            <div class="glse-layout">

                <!-- Main card -->
                <div class="glse-card">
                    <div class="glse-card-inner">

                        <!-- Vertical Nav -->
                        <nav class="glse-nav" role="tablist">
                            <?php foreach ( $tabs as $slug => $tab ) : ?>
                                <?php
                                /*
                                 * A real address, not a fragment: the tab then
                                 * survives a copied link, and the page still
                                 * navigates with scripting off, since the
                                 * server reads ?tab itself. The click handler
                                 * cancels it and switches in place.
                                 */
                                ?>
                                <a href="<?php echo esc_url( add_query_arg( 'tab', $slug, admin_url( 'admin.php?page=glse-settings' ) ) ); ?>"
                                   class="glse-nav-item<?php
                                       // Values are hardcoded tab slugs.
                                       echo $slug === $active_tab ? ' glse-nav-item--active' : '';
                                       echo ! empty( $tab['is_cta'] ) ? ' glse-nav-item--cta' : '';
                                   ?>"
                                   data-tab="<?php echo esc_attr( $slug ); ?>"
                                   role="tab"
                                   id="glse-tab-<?php echo esc_attr( $slug ); ?>"
                                   aria-controls="glse-panel-<?php echo esc_attr( $slug ); ?>"
                                   tabindex="<?php echo esc_attr( $slug === $active_tab ? '0' : '-1' ); ?>"
                                   aria-selected="<?php echo esc_attr( $slug === $active_tab ? 'true' : 'false' ); ?>">
                                    <span class="glse-nav-icon"><?php echo wp_kses( $tab['icon'], self::kses_svg() ); ?></span>
                                    <span class="glse-nav-label"><?php echo esc_html( $tab['label'] ); ?></span>
                                </a>
                            <?php endforeach; ?>
                        </nav>

                        <!-- Content -->
                        <div class="glse-content">
                            <form method="post" action="options.php" class="glse-settings-form">
                                <?php settings_fields( 'glse_settings_group' ); ?>

                                <?php if ( isset( $_GET['settings-updated'] ) && 'true' === $_GET['settings-updated'] ) : ?>
                                <div class="glse-save-notice glse-save-notice--success" role="status">
                                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
                                    <?php esc_html_e( 'Settings saved.', 'glance-live-search' ); ?>
                                </div>
                                <?php endif; ?>


                                <?php
                                /*
                                 * live_search is a master switch: with it off,
                                 * roughly twenty controls across four tabs stop
                                 * doing anything. Hiding them all would leave
                                 * empty screens, so say it once instead. The
                                 * glse-dep-not- class keeps it in sync with the
                                 * checkbox without a reload.
                                 */
                                ?>
                                <div class="notice notice-warning inline glse-dep-not-live_search">
                                    <p><?php esc_html_e( 'Live search is off. The search bar still renders and still submits, but result display, mobile and performance settings below have no effect until you turn it back on.', 'glance-live-search' ); ?></p>
                                </div>
                                <?php foreach ( $tabs as $slug => $tab ) : ?>
                                    <div class="glse-tab-panel<?php echo $slug === $active_tab ? ' glse-tab-panel--active' : ''; // Values are hardcoded tab slugs. ?>"
                                         id="glse-panel-<?php echo esc_attr( $slug ); ?>"
                                         data-panel="<?php echo esc_attr( $slug ); ?>"
                                         role="tabpanel"
                                         aria-labelledby="glse-tab-<?php echo esc_attr( $slug ); ?>">
                                        <div class="glse-panel-header">
                                            <h2><?php echo esc_html( $tab['label'] ); ?></h2>
                                        </div>
                                        <?php if ( ! empty( $tab['callback'] ) && is_callable( $tab['callback'] ) ) : ?>
                                            <?php call_user_func( $tab['callback'] ); ?>
                                        <?php elseif ( ! empty( $tab['section'] ) ) : ?>
                                        <?php
                                        /*
                                         * A note about the section as a whole
                                         * belongs outside the table. Inside it,
                                         * a cell can only occupy its column -
                                         * hiding the label cell does not merge
                                         * the two, it just leaves the note in a
                                         * 200px column.
                                         */
                                        if ( ! empty( $tab['intro'] ) && is_callable( $tab['intro'] ) ) {
                                            call_user_func( $tab['intro'] );
                                        }
                                        ?>
                                        <table class="form-table" role="presentation">
                                            <?php do_settings_fields( 'glse-settings', $tab['section'] ); ?>
                                        </table>
                                        <?php endif; ?>
                                    </div>
                                <?php endforeach; ?>

                                <div class="glse-form-footer">
                                    <button type="submit" class="button button-primary button-hero">
                                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
                                        <?php esc_html_e( 'Save Settings', 'glance-live-search' ); ?>
                                    </button>
                                </div>
                            </form>
                        </div>

                    </div>
                </div>

                <!-- Sidebar -->
                <aside class="glse-sidebar">

                    <?php if ( ! glse_is_pro() ) : ?>
                    <!-- Sidebar CTA upgrade (free version only) -->
                    <div class="glse-sidebar-card glse-sidebar-upgrade-card">
                        <div class="glse-sidebar-upgrade-badge">
                            <span class="glse-pro-badge"><?php esc_html_e( 'Pro', 'glance-live-search' ); ?></span>
                        </div>
                        <p class="glse-sidebar-upgrade-title"><?php esc_html_e( 'Unlock all features', 'glance-live-search' ); ?></p>
                        <ul class="glse-sidebar-upgrade-list">
                            <?php /* Every line here must name a feature the paid build still ships. */ ?>
                            <li>✓ <?php esc_html_e( 'Spelling correction on empty searches', 'glance-live-search' ); ?></li>
                            <li>✓ <?php esc_html_e( 'Category filter in the bar', 'glance-live-search' ); ?></li>
                            <li>✓ <?php esc_html_e( 'Upsell recommendations', 'glance-live-search' ); ?></li>
                            <li>✓ <?php esc_html_e( 'Analytics CTR & 90 days', 'glance-live-search' ); ?></li>
                            <li>✓ <?php esc_html_e( 'Custom design', 'glance-live-search' ); ?></li>
                        </ul>
                        <a href="<?php echo esc_url( $this->upgrade_url() ); ?>"
                           <?php echo $this->upgrade_link_target(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
                           class="glse-sidebar-upgrade-btn">
                            <?php esc_html_e( 'Upgrade to Pro', 'glance-live-search' ); ?>
                        </a>
                        <a href="<?php echo esc_url( $this->license_url() ); ?>"
                           class="glse-sidebar-license-link">
                            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
                            <?php esc_html_e( 'Already have a license key?', 'glance-live-search' ); ?>
                        </a>
                    </div>
                    <?php endif; ?>

                    <div class="glse-sidebar-card">
                        <div class="glse-sidebar-header">
                            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
                            <?php esc_html_e( 'Integration', 'glance-live-search' ); ?>
                        </div>
                        <div class="glse-sidebar-body">
                            <p class="glse-sidebar-label">
                                <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
                                <?php esc_html_e( 'Shortcode', 'glance-live-search' ); ?>
                            </p>
                            <div class="glse-code-block"><code>[glance_search]</code></div>
                            <p class="glse-sidebar-label"><?php esc_html_e( 'With custom placeholder', 'glance-live-search' ); ?></p>
                            <div class="glse-code-block"><code>[glance_search placeholder="Search…"]</code></div>
                        </div>
                    </div>


                </aside>

            </div>
        </div>
        <?php
    }

    /* ─── Analytics panel ─── */

    /**
     * Renders the analytics panel.
     *
     * Four figures, each tied to a decision the merchant can act on:
     * volume (is search being used?), success rate (are results relevant?),
     * no-result terms (what to stock next) and top terms (what to promote).
     *
     * Everything is shown against the preceding period of the same length,
     * because an absolute number answers nothing on its own - "14% no-result"
     * is only meaningful next to what it was last week.
     */
    public function render_analytics_panel(): void {
        global $wpdb;

        $table    = $wpdb->prefix . GLSE_Analytics::TABLE;
        $table_ok = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;

        if ( ! $table_ok ) {
            GLSE_Analytics::create_table();
            $table_ok = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table;
        }

        if ( ! $table_ok ) {
            echo '<div class="notice notice-error"><p>';
            esc_html_e( 'The analytics table could not be created. Check that the database user has CREATE TABLE rights, then deactivate and reactivate the plugin.', 'glance-live-search' );
            echo '</p></div>';
            return;
        }

        /*
         * What this build reports on. The free plugin states one window and one
         * page size and consults no licence for either - it is the reporting it
         * ships, not a fuller report withheld pending payment.
         */
        $is_pro = false;
        

        $periods = $is_pro ? [ 7, 14, 30, 90 ] : [ 7 ];
        $days    = isset( $_GET['glse_period'] ) ? (int) $_GET['glse_period'] : ( $is_pro ? 14 : 7 ); // phpcs:ignore WordPress.Security.NonceVerification
        if ( ! in_array( $days, $periods, true ) ) {
            $days = $is_pro ? 14 : 7;
        }

        $per_page = $is_pro ? 10 : 5;
        $base_url = admin_url( 'admin.php?page=glse-settings&tab=analytics' );

        $summary = GLSE_Analytics::get_summary( $days );
        $volume  = GLSE_Analytics::get_daily_volume( min( $days, 30 ) );

        $kw_total  = GLSE_Analytics::count_keywords( $days );
        $kw_pages  = max( 1, (int) ceil( $kw_total / $per_page ) );
        $kw_page   = min( max( 1, (int) ( $_GET['glse_kw_page'] ?? 1 ) ), $kw_pages ); // phpcs:ignore WordPress.Security.NonceVerification
        $keywords  = GLSE_Analytics::get_top_keywords( $per_page, $days, ( $kw_page - 1 ) * $per_page );

        $zr_total  = GLSE_Analytics::count_zero_results( $days );
        $zr_pages  = max( 1, (int) ceil( $zr_total / $per_page ) );
        $zr_page   = min( max( 1, (int) ( $_GET['glse_zr_page'] ?? 1 ) ), $zr_pages ); // phpcs:ignore WordPress.Security.NonceVerification
        $zero_kw   = GLSE_Analytics::get_zero_results( $per_page, $days, ( $zr_page - 1 ) * $per_page );

        $rate      = $summary['searches']      ? $summary['clicked'] / $summary['searches'] * 100      : 0.0;
        $prev_rate = $summary['prev_searches'] ? $summary['prev_clicked'] / $summary['prev_searches'] * 100 : 0.0;
        $zero      = $summary['searches']      ? $summary['zero'] / $summary['searches'] * 100         : 0.0;
        $prev_zero = $summary['prev_searches'] ? $summary['prev_zero'] / $summary['prev_searches'] * 100 : 0.0;

        $pager = static function ( int $page, int $pages, int $total, string $param ) use ( $days, $base_url ) {
            if ( $pages <= 1 ) {
                return;
            }
            $url = add_query_arg( 'glse_period', $days, $base_url );
            ?>
            <div class="glse-pager">
                <span><?php
                /* translators: %s: formatted count of indexed terms. */
                echo esc_html( sprintf( _n( '%s term', '%s terms', $total, 'glance-live-search' ), number_format_i18n( $total ) ) ); ?></span>
                <span class="glse-pager-links">
                    <?php if ( $page > 1 ) : ?>
                        <a href="<?php echo esc_url( add_query_arg( $param, $page - 1, $url ) ); ?>">‹</a>
                    <?php endif; ?>
                    <?php echo esc_html( $page . ' / ' . $pages ); ?>
                    <?php if ( $page < $pages ) : ?>
                        <a href="<?php echo esc_url( add_query_arg( $param, $page + 1, $url ) ); ?>">›</a>
                    <?php endif; ?>
                </span>
            </div>
            <?php
        };
        ?>
        <div class="glse-analytics">

            <?php
            /*
             * Le message suit ce que la base a repondu. Il annoncait le succes
             * dans tous les cas, y compris quand l'hebergeur refuse l'operation
             * - le marchand repartait convaincu que ses donnees etaient parties.
             */
            if ( isset( $_GET['cleared'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification
                $glse_vide = '0' !== $_GET['cleared']; // phpcs:ignore WordPress.Security.NonceVerification
                ?>
                <div class="notice <?php echo $glse_vide ? 'notice-success' : 'notice-error'; ?> is-dismissible">
                    <p>
                        <?php
                        echo esc_html(
                            $glse_vide
                                ? __( 'Analytics data cleared.', 'glance-live-search' )
                                : __( 'The statistics could not be cleared: your host refused the operation. Nothing was deleted.', 'glance-live-search' )
                        );
                        ?>
                    </p>
                </div>
            <?php endif; ?>

            <?php if ( isset( $_GET['glse_export_limited'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification ?>
                <div class="notice notice-warning is-dismissible"><p><?php esc_html_e( 'Export limit reached (10 per hour). Try again later.', 'glance-live-search' ); ?></p></div>
            <?php endif; ?>

            <div class="glse-analytics-bar">
                <label for="glse-period" class="screen-reader-text"><?php esc_html_e( 'Period', 'glance-live-search' ); ?></label>
                <select id="glse-period" onchange="if(this.value)window.location=this.value">
                    <?php foreach ( [ 7, 14, 30, 90 ] as $p ) :
                        $allowed = in_array( $p, $periods, true );
                        ?>
                        <option value="<?php echo $allowed ? esc_url( add_query_arg( 'glse_period', $p, $base_url ) ) : ''; ?>"
                            <?php selected( $p, $days ); ?><?php disabled( ! $allowed ); ?>>
                            <?php
                            /* translators: %d: number of days */
                            echo esc_html( sprintf( __( 'Last %d days', 'glance-live-search' ), $p ) );
                            echo $allowed ? '' : ' (Pro)';
                            ?>
                        </option>
                    <?php endforeach; ?>
                </select>
                <?php if ( $is_pro ) : ?>
                    <button type="button" class="button glse-export-csv-btn"
                            data-nonce="<?php echo esc_attr( wp_create_nonce( 'glse_export_csv' ) ); ?>"
                            data-period="<?php echo esc_attr( $days ); ?>">
                        <?php esc_html_e( 'Export CSV', 'glance-live-search' ); ?>
                    </button>
                <?php endif; ?>
            </div>

            <?php if ( 0 === $summary['searches'] ) : ?>
                <div class="glse-empty">
                    <p><strong><?php esc_html_e( 'No searches recorded yet for this period.', 'glance-live-search' ); ?></strong></p>
                    <p><?php esc_html_e( 'Searches your visitors run on the storefront will show up here.', 'glance-live-search' ); ?></p>
                </div>
            <?php else : ?>

                <div class="glse-stats">
                    <?php
                    $this->stat(
                        __( 'Searches', 'glance-live-search' ),
                        number_format_i18n( $summary['searches'] ),
                        self::delta( $summary['searches'], $summary['prev_searches'] ),
                        __( 'How much the search bar is used.', 'glance-live-search' )
                    );

                    if ( $is_pro ) {
                        $this->stat(
                            __( 'Success rate', 'glance-live-search' ),
                            number_format_i18n( round( $rate, 1 ) ) . ' %',
                            self::delta( $rate, $prev_rate ),
                            sprintf(
                                /* translators: 1: searches that opened a product, 2: searches that opened a category. */
                                __( '%1$s opened a product, %2$s a category.', 'glance-live-search' ),
                                number_format_i18n( $summary['opened_product'] ),
                                number_format_i18n( $summary['opened_category'] )
                            )
                        );

                        /*
                         * Compte a part, jamais dans le taux de reussite.
                         * Demander la page complete dit souvent que le panneau
                         * n'a pas montre le bon produit : le fondre dans le taux
                         * flatterait le chiffre en effacant ce signal.
                         */
                        $this->stat(
                            __( 'Full results asked for', 'glance-live-search' ),
                            number_format_i18n( $summary['viewed_all'] ),
                            null,
                            /*
                             * Le fait le moins evident tient en une phrase : ce
                             * chiffre n'entre pas dans le taux. L'interpretation
                             * - un panneau trop court - vit dans le changelog,
                             * pas sur une carte qu'on parcourt du regard.
                             */
                            __( 'Opened instead of a result. Not counted as a success.', 'glance-live-search' )
                        );
                    } else {
                        $this->stat( __( 'Success rate', 'glance-live-search' ), '-', null, __( 'Available in Pro.', 'glance-live-search' ) );
                    }

                    $this->stat(
                        __( 'No results', 'glance-live-search' ),
                        number_format_i18n( round( $zero, 1 ) ) . ' %',
                        self::delta( $zero, $prev_zero, /* lower_is_better */ true ),
                        __( 'Searches that returned nothing.', 'glance-live-search' )
                    );
                    ?>
                </div>

                <?php if ( array_sum( $volume ) > 0 ) : ?>
                    <div class="glse-panel">
                        <h3><?php esc_html_e( 'Search volume', 'glance-live-search' ); ?></h3>
                        <?php $peak = max( $volume ); ?>
                        <div class="glse-spark" role="img"
                             aria-label="<?php echo esc_attr( sprintf(
                                 /* translators: 1: number of days, 2: peak daily searches */
                                 __( 'Daily search volume over %1$d days. Busiest day: %2$d searches.', 'glance-live-search' ),
                                 count( $volume ),
                                 $peak
                             ) ); ?>">
                            <?php foreach ( $volume as $day => $count ) : ?>
                                <div class="glse-spark-col"
                                     title="<?php echo esc_attr( sprintf(
                                         /* translators: 1: date, 2: number of searches */
                                         __( '%1$s - %2$d searches', 'glance-live-search' ),
                                         wp_date( get_option( 'date_format' ), strtotime( $day . ' 12:00:00' ) ),
                                         $count
                                     ) ); ?>">
                                    <?php
                                    /*
                                     * Le plancher de 2 % ne vaut que pour un jour
                                     * qui a eu au moins une recherche : il est la
                                     * pour qu'une petite journee reste visible a
                                     * cote d'un pic. Applique a zero, il dessinait
                                     * une barre la ou il ne s'etait rien passe, a
                                     * la meme hauteur qu'une journee a une
                                     * recherche - le graphique montrait une
                                     * activite qui n'avait pas eu lieu.
                                     *
                                     * Un jour vide recoit desormais un simple trait
                                     * de base, qui se lit comme une absence.
                                     */
                                    if ( $count > 0 ) :
                                        $glse_hauteur = $peak > 0 ? max( 2, (int) round( $count / $peak * 100 ) ) : 100;
                                        ?>
                                        <div class="glse-spark-bar" style="height:<?php echo esc_attr( (string) $glse_hauteur ); ?>%"></div>
                                    <?php else : ?>
                                        <div class="glse-spark-bar glse-spark-bar--empty"></div>
                                    <?php endif; ?>
                                </div>
                            <?php endforeach; ?>
                        </div>
                    </div>
                <?php endif; ?>

                <div class="glse-grid">

                    <div class="glse-panel">
                        <h3><?php esc_html_e( 'What visitors search for', 'glance-live-search' ); ?></h3>
                        <?php if ( empty( $keywords ) ) : ?>
                            <p class="glse-muted"><?php esc_html_e( 'Nothing recorded yet.', 'glance-live-search' ); ?></p>
                        <?php else : ?>
                            <table class="glse-table">
                                <thead>
                                    <tr>
                                        <th><?php esc_html_e( 'Term', 'glance-live-search' ); ?></th>
                                        <th><?php esc_html_e( 'Searches', 'glance-live-search' ); ?></th>
                                        <?php if ( $is_pro ) : ?><th><?php esc_html_e( 'Success', 'glance-live-search' ); ?></th><?php endif; ?>
                                    </tr>
                                </thead>
                                <tbody>
                                <?php foreach ( $keywords as $row ) :
                                    $kw_rate = $row['searches'] ? round( $row['clicked'] / $row['searches'] * 100 ) : 0;
                                    ?>
                                    <tr>
                                        <td><?php echo esc_html( $row['query'] ); ?></td>
                                        <td><?php echo esc_html( number_format_i18n( $row['searches'] ) ); ?></td>
                                        <?php if ( $is_pro ) : ?>
                                            <?php /* A high-volume term with a low success rate is the clearest signal that results are wrong. */ ?>
                                            <td class="<?php echo $kw_rate < 20 ? 'glse-bad' : ''; ?>">
                                                <?php echo esc_html( $kw_rate . ' %' ); ?>
                                            </td>
                                        <?php endif; ?>
                                    </tr>
                                <?php endforeach; ?>
                                </tbody>
                            </table>
                            <?php $pager( $kw_page, $kw_pages, $kw_total, 'glse_kw_page' ); ?>
                        <?php endif; ?>
                    </div>

                    <div class="glse-panel">
                        <h3>
                            <?php esc_html_e( 'Searched but not found', 'glance-live-search' ); ?>
                            <?php if ( ! $is_pro ) : ?><span class="glse-pro-badge">Pro</span><?php endif; ?>
                        </h3>
                        <?php if ( ! $is_pro ) : ?>
                            <p class="glse-muted"><?php esc_html_e( 'See exactly which terms return nothing, so you know what to stock or rename. Available in Pro.', 'glance-live-search' ); ?></p>
                        <?php elseif ( empty( $zero_kw ) ) : ?>
                            <p class="glse-muted"><?php esc_html_e( 'Every search returned results.', 'glance-live-search' ); ?></p>
                        <?php else : ?>
                            <table class="glse-table">
                                <thead>
                                    <tr>
                                        <th><?php esc_html_e( 'Term', 'glance-live-search' ); ?></th>
                                        <th><?php esc_html_e( 'Searches', 'glance-live-search' ); ?></th>
                                    </tr>
                                </thead>
                                <tbody>
                                <?php foreach ( $zero_kw as $row ) : ?>
                                    <tr>
                                        <td><?php echo esc_html( $row['query'] ); ?></td>
                                        <td><?php echo esc_html( number_format_i18n( $row['searches'] ) ); ?></td>
                                    </tr>
                                <?php endforeach; ?>
                                </tbody>
                            </table>
                            <?php $pager( $zr_page, $zr_pages, $zr_total, 'glse_zr_page' ); ?>
                        <?php endif; ?>
                    </div>

                </div>
            <?php endif; ?>

            <div class="glse-analytics-footer">
                <table class="form-table" role="presentation">
                    <?php do_settings_fields( 'glse-settings', 'glse_analytics_settings_section' ); ?>
                </table>
                <p>
                    <button type="button" class="button button-link-delete glse-clear-analytics-btn"
                            data-nonce="<?php echo esc_attr( wp_create_nonce( 'glse_analytics_clear' ) ); ?>"
                            data-action="<?php echo esc_url( $base_url ); ?>"
                            data-confirm="<?php echo esc_attr__( 'Permanently delete all recorded searches?', 'glance-live-search' ); ?>">
                        <?php esc_html_e( 'Clear data', 'glance-live-search' ); ?>
                    </button>
                    <span class="glse-muted">
                        <?php
                        echo esc_html( sprintf(
                            /* translators: %s: number of stored rows */
                            _n( '%s search stored', '%s searches stored', GLSE_Analytics::get_total_count(), 'glance-live-search' ),
                            number_format_i18n( GLSE_Analytics::get_total_count() )
                        ) );
                        ?>
                    </span>
                </p>

                <?php
                /*
                 * Ce que ces chiffres valent, dit une fois plutot que suppose.
                 *
                 * Les recherches et les clics sont enregistres depuis la
                 * boutique, donc par des visiteurs non identifies - c'est la
                 * seule facon de mesurer ce que font les clients. Le point
                 * d'entree est limite en debit, mais rien n'empeche de le
                 * solliciter : les tendances sont fiables, un chiffre isole ne
                 * l'est pas au point de fonder une decision commerciale dessus.
                 */
                ?>
                <p class="description glse-analytics-caveat">
                    <?php esc_html_e( 'These figures come from your storefront, where visitors are not signed in. Read them as trends rather than exact counts.', 'glance-live-search' ); ?>
                </p>
            </div>

        </div>
        <?php
    }

    /**
     * Renders one headline figure with its trend.
     *
     * @param string     $label Metric name.
     * @param string     $value Formatted value.
     * @param array|null $delta Output of self::delta(), or null for no trend.
     * @param string     $hint  One line explaining what the figure is for.
     */
    private function stat( string $label, string $value, ?array $delta, string $hint ): void {
        ?>
        <div class="glse-stat">
            <span class="glse-stat-label"><?php echo esc_html( $label ); ?></span>
            <span class="glse-stat-value"><?php echo esc_html( $value ); ?></span>
            <?php if ( $delta ) : ?>
                <span class="glse-stat-delta glse-stat-delta--<?php echo esc_attr( $delta['tone'] ); ?>">
                    <?php echo esc_html( $delta['label'] ); ?>
                </span>
            <?php endif; ?>
            <span class="glse-stat-hint"><?php echo esc_html( $hint ); ?></span>
        </div>
        <?php
    }

    /**
     * Compares a figure with the previous period.
     *
     * Returns null when there is no previous period to compare against, so the
     * UI shows nothing rather than a meaningless "+100%".
     *
     * @param float $now             Current value.
     * @param float $before          Previous-period value.
     * @param bool  $lower_is_better Invert the good/bad colouring (no-result rate).
     * @return array{label:string, tone:string}|null
     */
    private static function delta( float $now, float $before, bool $lower_is_better = false ): ?array {
        if ( $before <= 0.0 ) {
            return null;
        }

        $change = ( $now - $before ) / $before * 100;

        if ( abs( $change ) < 1 ) {
            return [
                'label' => __( 'stable', 'glance-live-search' ),
                'tone'  => 'flat',
            ];
        }

        $improving = $lower_is_better ? $change < 0 : $change > 0;

        return [
            'label' => sprintf( '%s%s %%', $change > 0 ? '+' : '−', number_format_i18n( abs( round( $change ) ) ) ),
            'tone'  => $improving ? 'up' : 'down',
        ];
    }


    /* ─── License key activation handler ─── */

    

    /* ─── Panneau License Pro ─── */

    

    

    /* ─── Upgrade panel ─── */

    public function render_upgrade_panel(): void {
        $upgrade_url    = $this->upgrade_url();
        $feature_groups = [
            __( 'Design', 'glance-live-search' )     => [
                __( '4 design presets: Sharp, Clean, Pill, and Custom', 'glance-live-search' ),
                __( 'Full color customization - bar, border, text, placeholder, button', 'glance-live-search' ),
            ],
            __( 'Search', 'glance-live-search' )     => [
                __( 'Category filter in the search bar', 'glance-live-search' ),
                __( 'Spelling correction when a search finds nothing', 'glance-live-search' ),
                __( 'Price ranges and in-stock filtering', 'glance-live-search' ),
                __( 'Variation search by attribute (color, size, etc.)', 'glance-live-search' ),
                __( 'Categories shown in search results', 'glance-live-search' ),
                __( 'Show SKU, ratings, and category count in results', 'glance-live-search' ),
            ],
            __( 'Analytics', 'glance-live-search' )  => [
                __( 'Click tracking on every result', 'glance-live-search' ),
                __( 'Success rate per term, 7 / 14 / 30 / 90-day views', 'glance-live-search' ),
                __( 'CSV export', 'glance-live-search' ),
            ],
            __( 'Other', 'glance-live-search' )      => [
                __( 'Upsell recommendations below search results', 'glance-live-search' ),
                __( 'Priority support', 'glance-live-search' ),
            ],
        ];
        ?>
        <div class="glse-upgrade-panel">
            <div class="glse-upgrade-hero">
                <div class="glse-upgrade-icon">
                    <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
                </div>
                <h2><?php esc_html_e( 'Upgrade to Glance Live Search Pro', 'glance-live-search' ); ?></h2>
                <p><?php esc_html_e( 'Everything in Free, plus advanced features to convert more visitors into customers.', 'glance-live-search' ); ?></p>
                <a href="<?php echo esc_url( $upgrade_url ); ?>" class="button button-primary glse-upgrade-cta"<?php echo $this->upgrade_link_target(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
                    <?php esc_html_e( 'Upgrade to Pro →', 'glance-live-search' ); ?>
                </a>
            </div>

            <div class="glse-upgrade-grid">
                <?php foreach ( $feature_groups as $group_title => $features ) : ?>
                <div class="glse-upgrade-group">
                    <h3><?php echo esc_html( $group_title ); ?></h3>
                    <ul>
                        <?php foreach ( $features as $feature ) : ?>
                        <li><?php echo esc_html( $feature ); ?></li>
                        <?php endforeach; ?>
                    </ul>
                </div>
                <?php endforeach; ?>
            </div>
        </div>
        <?php
    }

    /* ─── One-time upgrade notice ─── */

    public function render_upgrade_notice(): void {
        if ( get_user_meta( get_current_user_id(), 'glse_upgrade_notice_dismissed', true ) ) {
            return;
        }
        $screen = get_current_screen();
        if ( ! $screen instanceof WP_Screen ) {
            return;
        }
        if ( ! in_array( $screen->id, [ 'woocommerce_page_glse-settings', 'plugins' ], true ) ) {
            return;
        }
        $upgrade_url = $this->upgrade_url();
        $nonce       = wp_create_nonce( 'glse_dismiss_notice' );
        $ajax_url    = admin_url( 'admin-ajax.php' );
        ?>
        <div class="notice notice-info" id="glse-upgrade-admin-notice">
            <p>
                <strong><?php esc_html_e( 'Glance Live Search Pro', 'glance-live-search' ); ?></strong>
                &mdash;
                <?php esc_html_e( 'Add category filters, variation search, color customization, and advanced analytics to your store.', 'glance-live-search' ); ?>
                <a href="<?php echo esc_url( $upgrade_url ); ?>" class="button button-small" style="margin-left:8px"<?php echo $this->upgrade_link_target(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
                    <?php esc_html_e( 'See Pro Features', 'glance-live-search' ); ?>
                </a>
                <?php /* Colour comes from the stylesheet: the inline #999 was 2.8:1 on white, below AA. */ ?>
                <a href="#" class="glse-notice-dismiss"
                   style="margin-left:10px;font-size:12px"
                   data-nonce="<?php echo esc_attr( $nonce ); ?>"
                   data-ajaxurl="<?php echo esc_attr( $ajax_url ); ?>">
                    <?php esc_html_e( 'Don\'t show again', 'glance-live-search' ); ?>
                </a>
            </p>
        </div>
        <script>
        (function(){
            var notice = document.getElementById('glse-upgrade-admin-notice');
            if (!notice) return;
            var link = notice.querySelector('.glse-notice-dismiss');
            if (!link) return;
            link.addEventListener('click', function(e){
                e.preventDefault();
                var fd = new FormData();
                fd.append('action', 'glse_dismiss_upgrade_notice');
                fd.append('nonce', this.getAttribute('data-nonce'));
                fetch(this.getAttribute('data-ajaxurl'), { method: 'POST', body: fd });
                notice.remove();
            });
        })();
        </script>
        <?php
    }

    public function dismiss_upgrade_notice(): void {
        check_ajax_referer( 'glse_dismiss_notice', 'nonce' );
        update_user_meta( get_current_user_id(), 'glse_upgrade_notice_dismissed', '1' );
        wp_send_json_success();
    }
}
