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

/**
 * Front-end management: assets + shortcode.
 */
final class GLSE_Public {

    /** Tracks whether inline CSS variables have been added (avoids duplicates). */
    private bool $inline_css_added = false;

    /**
     * Initialize hooks for front-end assets, shortcode and security headers.
     *
     * @since 1.4.0
     */
    public function __construct() {
        add_action( 'wp_enqueue_scripts',        [ $this, 'enqueue_assets' ] );
        add_shortcode( 'glance_search',          [ $this, 'render_shortcode' ] );
        add_filter( 'wp_headers',                [ $this, 'add_security_headers' ] );
        add_action( 'save_post_wp_template',      [ $this, 'flush_fse_cache' ] );
        add_action( 'save_post_wp_template_part', [ $this, 'flush_fse_cache' ] );
    }

    /**
     * Deletes the cached FSE block-detection result.
     *
     * @since 1.4.0
     */
    public function flush_fse_cache(): void {
        delete_transient( 'glse_fse_has_block' );
    }

    /* ─── Assets front ─── */

    /**
     * Returns true when the current request needs GLSE assets.
     *
     * Checks (in order):
     *  1. glse_force_load_assets filter (escape hatch for custom setups)
     *  2. WooCommerce shop / category / tag archives
     *  3. Post content: shortcode
     *  4. Page builder metas: Elementor (_elementor_data), Beaver Builder (_fl_builder_data),
     *     Oxygen (ct_builder_shortcodes)
     *  5. Full Site Editing wp_template / wp_template_part (object-cached)
     *  6. Active widget in any registered sidebar
     */
    private function should_load_assets(): bool {
        if ( apply_filters( 'glse_force_load_assets', false ) ) {
            return true;
        }

        // WooCommerce pages (shop, archives, single product) always show the search.
        if ( function_exists( 'is_woocommerce' ) && ( is_shop() || is_product_category() || is_product_tag() || is_product() ) ) {
            return true;
        }

        global $post;

        if ( $post instanceof WP_Post ) {
            if ( has_shortcode( $post->post_content, 'glance_search' ) ) {
                return true;
            }
            if ( function_exists( 'has_block' ) && has_block( 'glance-live-search/search', $post ) ) {
                return true;
            }

            // Page builders store content outside post_content - scan their metas.
            foreach ( [ '_elementor_data', '_fl_builder_data', 'ct_builder_shortcodes' ] as $meta_key ) {
                $meta = get_post_meta( $post->ID, $meta_key, true );
                if ( $meta && is_string( $meta ) && false !== strpos( $meta, 'glance_search' ) ) {
                    return true;
                }
            }
        }

        // Full Site Editing: scan wp_template / wp_template_part for the block.
        if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
            $hit = get_transient( 'glse_fse_has_block' );
            if ( false === $hit ) {
                $hit = 0;
                if ( function_exists( 'get_block_templates' ) && function_exists( 'has_block' ) ) {
                    foreach ( array_merge(
                        get_block_templates( [], 'wp_template' ),
                        get_block_templates( [], 'wp_template_part' )
                    ) as $tpl ) {
                        if ( isset( $tpl->content ) && has_block( 'glance-live-search/search', $tpl->content ) ) {
                            $hit = 1;
                            break;
                        }
                    }
                }
                set_transient( 'glse_fse_has_block', $hit, HOUR_IN_SECONDS );
            }
            if ( $hit ) {
                return true;
            }
        }

        // Widget in any active sidebar.
        if ( is_active_widget( false, false, 'glse_search_widget', true ) ) {
            return true;
        }

        return false;
    }

    /** Returns '.min' suffix when SCRIPT_DEBUG is off (production). */
    private function asset_suffix(): string {
        return defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min';
    }

    /**
     * Enqueue front-end CSS and JS assets when needed.
     *
     * @since 1.4.0
     */
    public function enqueue_assets(): void {
        if ( ! $this->should_load_assets() ) {
            return;
        }

        $suffix = $this->asset_suffix();

        wp_enqueue_style(
            'glse-style',
            GLSE_URL . 'public/css/search' . $suffix . '.css',
            [],
            GLSE_VERSION
        );

        /*
         * Rules for the Pro-only rows — suggestions, the correction, SKU and
         * rating lines — live in their own stylesheet. Freemius drops whole
         * files but never part of one, so keeping them in the shared sheet
         * would ship styling for markup the free build never renders.
         */
        

        /*
         * Emit the design variables before the live_search gate.
         *
         * Appearance and behaviour are separate concerns. Past the gate,
         * turning live search off discards every colour, radius and height the
         * merchant configured, while the Search Bar tab goes on presenting
         * those settings as active.
         */
        wp_add_inline_style( 'glse-style', $this->generate_inline_css() );

        if ( ! glse_get_option( 'live_search' ) ) {
            return;
        }

        /*
         * Not gated on wp_is_mobile(). That is user-agent sniffing decided
         * server-side, so it disagrees with the CSS breakpoint below - a tablet
         * can be "mobile" for one and "desktop" for the other - and a full-page
         * cache then serves whichever variant it stored first to every visitor
         * alike. The script loads either way and switches itself off
         * client-side, so both settings mean the same thing by screen width.
         * Cost: it downloads even where it stays inert, which is worth it.
         */

        wp_enqueue_script(
            'glse-script',
            GLSE_URL . 'public/js/search' . $suffix . '.js',
            [],
            GLSE_VERSION,
            true
        );

        /*
         * Paid handlers - click tracking, suggestions, price chips - ship in
         * their own file. It depends on glse-script so the core has already put
         * window.glseCore in place by the time this runs.
         */
        

        // Detect current language for Polylang / WPML / fallback locale.
        // pll_current_language() can return false on untranslated pages - we fall back
        // to get_locale() to guarantee a non-empty language in analytics.
        if ( defined( 'ICL_LANGUAGE_CODE' ) ) {
            $current_lang = ICL_LANGUAGE_CODE;
        } elseif ( function_exists( 'pll_current_language' ) ) {
            $current_lang = pll_current_language() ?: get_locale();
        } else {
            $current_lang = get_locale();
        }


        /** Filter the localized JS parameters.
         * @param array $params Default parameters passed to glseSearch JS object. */
        wp_localize_script( 'glse-script', 'glseSearch', apply_filters( 'glse/scripts/localize', [
            'ajax_url'      => admin_url( 'admin-ajax.php' ),
            'wc_ajax_url'   => class_exists( 'WC_AJAX' ) ? WC_AJAX::get_endpoint( '%%endpoint%%' ) : '',
            'nonce'         => wp_create_nonce( 'glse_search_nonce' ),
            'delay'         => (int) glse_get_option( 'ajax_delay' ),
            'min_chars'     => (int) glse_get_option( 'min_chars' ),
            'lang'          => $current_lang,
            'hint_message'  => sprintf(
                /* translators: %d: minimum number of characters */
                __( 'Type at least %d characters…', 'glance-live-search' ),
                (int) glse_get_option( 'min_chars' )
            ),
            'error_message' => __( 'Search unavailable. Please try again.', 'glance-live-search' ),
            'disable_mobile'            => glse_get_option( 'disable_mobile' ) ? '1' : '0',
            'mobile_overlay'            => glse_get_option( 'mobile_overlay' ) ? '1' : '0',
            'mobile_overlay_breakpoint' => (int) glse_get_option( 'mobile_overlay_breakpoint' ),
            'show_history'  => glse_get_option( 'show_history' ) ? '1' : '0',
            'history_max'   => (int) glse_get_option( 'history_max' ),
            'i18n'          => [
                'close'         => __( 'Close', 'glance-live-search' ),
                'recent'        => __( 'Recent searches', 'glance-live-search' ),
                'clear_history' => __( 'Clear', 'glance-live-search' ),
                /* translators: %d: number of results shown in the dropdown */
                'results_count' => __( '%d results available', 'glance-live-search' ),
                /* translators: %s: the corrected search term */
                'correction_offered' => __( 'No results. Did you mean %s?', 'glance-live-search' ),
                /* translators: %d: number of past searches listed on focus */
                'history_count'      => __( '%d recent searches', 'glance-live-search' ),
            ],
        ] ) );
    }

    /* ─── Shortcode [glance_search] ─── */

    /**
     * Render the [glance_search] shortcode.
     *
     * @since 1.4.0
     */
    public function render_shortcode( $atts ): string {
        /*
         * Guarantee assets are enqueued whenever the shortcode actually renders.
         *
         * The shortcode may execute in the header (theme compat) AFTER
         * wp_enqueue_scripts has already fired. If should_load_assets()
         * returned false at that point (e.g. the front page is not a
         * WooCommerce archive and has no shortcode in post_content),
         * the CSS / JS would be missing - producing an unstyled form.
         *
         * Calling wp_enqueue_style / wp_enqueue_script late is safe:
         * WordPress will print them in wp_footer for scripts, and
         * wp_enqueue_style called after wp_head will still be output
         * if the style hasn't been printed yet.
         */
        $this->ensure_assets();

        $default_ph = glse_get_option( 'placeholder' );
        if ( '' === $default_ph ) {
            $default_ph = __( 'Search for a product (name or SKU)…', 'glance-live-search' );
        }

        $atts = shortcode_atts( [
            'placeholder' => $default_ph,
        ], $atts, 'glance_search' );

        ob_start();
        $tpl = locate_template( [ 'glance-live-search/search-form.php' ] );
        /** Filter the search-form template path.
         * @param string $tpl  Located template path (empty string = use plugin default).
         * @param array  $atts Shortcode attributes. */
        $tpl = (string) apply_filters( 'glse/template/search_form', $tpl, $atts );
        include $tpl ?: GLSE_PATH . 'public/templates/search-form.php';
        $html = ob_get_clean() ?: '';

        /** Filter the complete search form HTML.
         * @param string $html Final form HTML.
         * @param array  $atts Shortcode attributes. */
        return wp_kses( (string) apply_filters( 'glse/form/html', $html, $atts ), self::kses_form_html() );
    }

    /**
     * KSES allowlist for the search form HTML returned by the shortcode.
     *
     * @return array<string, array<string, bool>>
     */
    public static function kses_form_html(): array {
        $global = [
            'id'         => true,
            'class'      => true,
            'style'      => true,
            'role'       => true,
            'aria-label'        => true,
            'aria-controls'     => true,
            'aria-expanded'     => true,
            'aria-haspopup'     => true,
            'aria-autocomplete' => true,
            'aria-hidden'       => true,
            // Without these the status region survives kses but stops announcing.
            'aria-live'         => true,
            'aria-atomic'       => true,
            'aria-describedby'  => true,
            'aria-pressed'      => true,
            'data-glse-min'     => true,
            'data-glse-max'     => true,
        ];
        return [
            'div'    => $global,
            'span'   => $global,
            'form'   => array_merge( $global, [ 'action' => true, 'method' => true ] ),
            'input'  => array_merge( $global, [
                'type'           => true,
                'name'           => true,
                'value'          => true,
                'placeholder'    => true,
                'autocomplete'   => true,
                'autocorrect'    => true,
                'autocapitalize' => true,
                'spellcheck'     => true,
                'inputmode'      => true,
            ] ),
            'button' => array_merge( $global, [ 'type' => true, 'form' => true, 'disabled' => true ] ),
            'label'  => array_merge( $global, [ 'for' => true ] ),
            /*
             * The category filter dropdown (Pro). Without these two entries
             * wp_kses() strips the <select>/<option> tags and dumps the option
             * labels as raw text inside the search bar.
             */
            'select' => array_merge( $global, [
                'name'     => true,
                'disabled' => true,
                'multiple' => true,
            ] ),
            'option' => array_merge( $global, [
                'value'    => true,
                'selected' => true,
                'disabled' => true,
            ] ),
            'svg'    => [
                'xmlns'           => true,
                'width'           => true,
                'height'          => true,
                'viewbox'         => true,
                'fill'            => true,
                'stroke'          => true,
                'stroke-width'    => true,
                'stroke-linecap'  => true,
                'stroke-linejoin' => true,
                'class'           => true,
                'aria-hidden'     => true,
            ],
            'circle' => [ 'cx' => true, 'cy' => true, 'r' => true ],
            'line'   => [ 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true ],
            'path'   => [
                'd'               => true,
                'fill'            => true,
                'stroke'          => true,
                'stroke-width'    => true,
                'stroke-linecap'  => true,
                'stroke-linejoin' => true,
            ],
        ];
    }

    /**
     * Ensures CSS (and optionally JS) are enqueued.
     *
     * Safe to call multiple times - wp_enqueue_* is a no-op when already queued.
     * Called from render_shortcode() as a late-enqueue safety net.
     */
    private function ensure_assets(): void {
        $suffix = $this->asset_suffix();

        if ( ! wp_style_is( 'glse-style', 'enqueued' ) ) {
            wp_enqueue_style(
                'glse-style',
                GLSE_URL . 'public/css/search' . $suffix . '.css',
                [],
                GLSE_VERSION
            );
        }

        // Design variables apply whether or not live search is on - see enqueue_assets().
        if ( ! wp_style_is( 'glse-style', 'done' ) && ! $this->inline_css_added ) {
            wp_add_inline_style( 'glse-style', $this->generate_inline_css() );
            $this->inline_css_added = true;
        }

        if ( ! glse_get_option( 'live_search' ) ) {
            return;
        }


        if ( ! wp_script_is( 'glse-script', 'enqueued' ) ) {
            wp_enqueue_script(
                'glse-script',
                GLSE_URL . 'public/js/search' . $suffix . '.js',
                [],
                GLSE_VERSION,
                true
            );

            if ( defined( 'ICL_LANGUAGE_CODE' ) ) {
                $current_lang = ICL_LANGUAGE_CODE;
            } elseif ( function_exists( 'pll_current_language' ) ) {
                $current_lang = pll_current_language() ?: get_locale();
            } else {
                $current_lang = get_locale();
            }


            wp_localize_script( 'glse-script', 'glseSearch', apply_filters( 'glse/scripts/localize', [
                'ajax_url'      => admin_url( 'admin-ajax.php' ),
                'wc_ajax_url'   => class_exists( 'WC_AJAX' ) ? WC_AJAX::get_endpoint( '%%endpoint%%' ) : '',
                'nonce'         => wp_create_nonce( 'glse_search_nonce' ),
                'delay'         => (int) glse_get_option( 'ajax_delay' ),
                'min_chars'     => (int) glse_get_option( 'min_chars' ),
                    'lang'          => $current_lang,
                'hint_message'  => sprintf(
                    /* translators: %d: minimum number of characters before searching. */
                    __( 'Type at least %d characters…', 'glance-live-search' ),
                    (int) glse_get_option( 'min_chars' )
                ),
                'error_message' => __( 'Search unavailable. Please try again.', 'glance-live-search' ),
            'disable_mobile'            => glse_get_option( 'disable_mobile' ) ? '1' : '0',
            'mobile_overlay'            => glse_get_option( 'mobile_overlay' ) ? '1' : '0',
                'mobile_overlay_breakpoint' => (int) glse_get_option( 'mobile_overlay_breakpoint' ),
                'show_history'  => glse_get_option( 'show_history' ) ? '1' : '0',
                'history_max'   => (int) glse_get_option( 'history_max' ),
                'i18n'          => [
                    'close'         => __( 'Close', 'glance-live-search' ),
                    'recent'        => __( 'Recent searches', 'glance-live-search' ),
                    'clear_history' => __( 'Clear', 'glance-live-search' ),
                    /* translators: %d: number of results shown in the dropdown */
                    'results_count' => __( '%d results available', 'glance-live-search' ),
                    /* translators: %s: the corrected search term */
                    'correction_offered' => __( 'No results. Did you mean %s?', 'glance-live-search' ),
                    /* translators: %d: number of past searches listed on focus */
                    'history_count'      => __( '%d recent searches', 'glance-live-search' ),
                ],
            ] ) );
        }
    }

    /* ─── Security headers ─── */

    /**
     * Adds HTTP security headers on front-end pages.
     *
     * Off by default, and on purpose: these two headers cover the whole site,
     * and a search plugin has no business changing a shop's HTTP headers unless
     * asked. Nothing the plugin does depends on them - they are hardening
     * offered to a merchant who wants it, not a requirement.
     *
     * Neither is ever forced: both are set only when absent, so a server or a
     * security plugin already sending them keeps its own values.
     *
     * A page served from a full-page cache never reaches PHP, so it carries
     * neither header. On a WooCommerce shop that leaves cart, checkout and
     * account covered - the pages that matter most - and little else.
     *
     * The restrictive CSP is deliberately not here: it would break themes and
     * plugins that load anything from another domain. It lives in
     * GLSE_Settings::maybe_send_admin_csp(), scoped to one screen.
     *
     * @since 1.4.0
     *
     * @param array<string, string> $headers Existing headers.
     * @return array<string, string>
     */
    public function add_security_headers( array $headers ): array {
        if ( ! glse_get_option( 'frontend_security_headers' ) ) {
            return $headers;
        }

        /*
         * Stops a browser guessing the type of a file the server already
         * declared - an upload that claims to be an image but reads as script
         * stays inert.
         *
         * Not for the search response, whatever an earlier comment here said:
         * that one is JSON, declared by wp_send_json(), and its panel markup is
         * escaped server-side. This header never sees it - wp_headers only runs
         * in WP::send_headers(), which admin-ajax.php never calls.
         */
        if ( ! isset( $headers['X-Content-Type-Options'] ) ) {
            $headers['X-Content-Type-Options'] = 'nosniff';
        }

        /* Limits referrer information sent to third-party sites. */
        if ( ! isset( $headers['Referrer-Policy'] ) ) {
            $headers['Referrer-Policy'] = 'strict-origin-when-cross-origin';
        }

        return $headers;
    }

    /* ─── Dynamic CSS based on admin options ─── */


    /**
     * The stylesheet the storefront gets, built from the resolved design.
     *
     * The resolution itself lives in glse_resolved_design(), shared with the
     * settings preview. It used to live here, and the screen had its own copy
     * that read the saved options directly - so a preset made the admin show
     * one bar and the shop serve another.
     */
    private function generate_inline_css(): string {
        $d = glse_resolved_design();

        $hex = static fn( string $key ) => sanitize_hex_color( (string) $d[ $key ] )
            ?: (string) glse_get_default( $key );

        $css  = ".glse-search-wrapper {\n";
        $css .= '  --glse-bar-height: ' . (int) $d['bar_height'] . "px;\n";
        $css .= '  --glse-bar-bg: ' . $hex( 'bar_bg_color' ) . ";\n";
        $css .= '  --glse-bar-bg-focus: ' . $hex( 'bar_bg_focus' ) . ";\n";
        $css .= '  --glse-border-width: ' . (int) $d['border_size'] . "px;\n";
        $css .= '  --glse-border-color: ' . $hex( 'border_color' ) . ";\n";
        $css .= '  --glse-radius: ' . (int) $d['border_radius'] . "px;\n";
        $css .= '  --glse-text-color: ' . $hex( 'text_color' ) . ";\n";
        $css .= '  --glse-placeholder-color: ' . $hex( 'placeholder_color' ) . ";\n";
        $css .= '  --glse-btn-bg: ' . $hex( 'button_color' ) . ";\n";
        $css .= '  --glse-btn-hover: ' . $hex( 'button_hover_color' ) . ";\n";
        $css .= '  --glse-btn-color: ' . $hex( 'button_text_color' ) . ";\n";
        $css .= '  --glse-btn-radius: ' . (int) $d['button_radius'] . "px;\n";
        $css .= '  --glse-focus-ring-color: ' . $hex( 'focus_ring_color' ) . ";\n";
        $css .= "}\n";

        return $css;
    }
}
