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

/**
 * Handles live search AJAX requests.
 */
final class GLSE_Ajax_Handler {

    /**
     * Register AJAX action hooks for live search, click tracking and search recording.
     *
     * @since 1.4.0
     */
    public function __construct() {
        /* WC_AJAX endpoints (faster - skips full admin bootstrap) */
        add_action( 'wc_ajax_glse_live_search',    [ $this, 'handle_search' ] );

        /* Fallback: wp-admin/admin-ajax.php (if WC_AJAX unavailable) */
        add_action( 'wp_ajax_glse_live_search',        [ $this, 'handle_search' ] );
        add_action( 'wp_ajax_nopriv_glse_live_search', [ $this, 'handle_search' ] );

        // Click tracking is a paid feature; its handler is absent from the free
        // build, so the endpoints must not be registered there either.
        

        /* Nonce refresh - see verify_nonce() for why this endpoint exists. */
        add_action( 'wc_ajax_glse_refresh_nonce',        [ $this, 'handle_refresh_nonce' ] );
        add_action( 'wp_ajax_glse_refresh_nonce',        [ $this, 'handle_refresh_nonce' ] );
        add_action( 'wp_ajax_nopriv_glse_refresh_nonce', [ $this, 'handle_refresh_nonce' ] );
    }

    /**
     * Validates the request nonce, reporting staleness instead of dying.
     *
     * The nonce is printed into the page HTML by wp_localize_script. On a site
     * with full-page caching - the norm for WooCommerce - the cached HTML long
     * outlives the nonce's 24h validity, so every visitor served that page sends
     * an expired nonce. check_ajax_referer()'s default behaviour is to wp_die()
     * with a 403, which the front-end can only report as "Search unavailable":
     * live search stays broken until the page cache happens to be purged.
     *
     * Instead we answer with a machine-readable code so the client can fetch a
     * fresh nonce from handle_refresh_nonce() and retry once. CSRF protection is
     * unchanged - a forged request still has no valid nonce and gets nowhere,
     * it just fails loudly rather than looking like an outage.
     *
     * @since 1.6.1
     */
    private function verify_nonce(): void {
        $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';

        if ( wp_verify_nonce( $nonce, 'glse_search_nonce' ) ) {
            return;
        }

        wp_send_json_error( [
            'code'    => 'glse_stale_nonce',
            'message' => __( 'Session expired.', 'glance-live-search' ),
        ], 403 );
    }

    /**
     * Issues a fresh search nonce.
     *
     * Deliberately requires no nonce of its own: it hands out exactly what any
     * visitor already receives by loading a page of the site, so it grants no
     * capability they did not have. Rate limited so it cannot be used as a
     * cheap way to spin WordPress up in a loop.
     *
     * @since 1.6.1
     */
    public function handle_refresh_nonce(): void {
        $this->check_rate_limit( 'glse_rl_nonce', 30, 60 );

        wp_send_json_success( [ 'nonce' => wp_create_nonce( 'glse_search_nonce' ) ] );
    }

    /**
     * Best-effort client IP for rate limiting.
     *
     * REMOTE_ADDR alone is wrong the moment the store sits behind Cloudflare, a
     * load balancer or any reverse proxy: every visitor then shares the proxy's
     * address and therefore a single 60-requests-per-minute bucket, so ordinary
     * traffic rate-limits the whole site out of live search.
     *
     * WC_Geolocation::get_ip_address() is what WooCommerce itself trusts for
     * order IPs and its own rate limiting, so we follow the store's existing
     * decision rather than inventing a second, conflicting one. Forwarded
     * headers are client-supplied and therefore spoofable - an attacker can
     * rotate them to dodge the limit - but the failure mode of *not* reading
     * them (locking out every genuine visitor) is far worse. Sites that
     * terminate TLS directly can pin this back to REMOTE_ADDR via the filter.
     *
     * @since 1.6.1
     */
    private function client_ip(): string {
        $ip = '';

        if ( class_exists( 'WC_Geolocation' ) ) {
            $ip = (string) WC_Geolocation::get_ip_address();
        }

        if ( '' === $ip && isset( $_SERVER['REMOTE_ADDR'] ) ) {
            $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
        }

        /** Filter the client IP used for rate limiting.
         * @param string $ip Resolved client IP. */
        $ip = (string) apply_filters( 'glse/rate_limit/client_ip', $ip );

        return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : 'unknown';
    }

    /**
     * IP-based rate limiter using transients.
     *
     * @param string $prefix  Transient key prefix (unique per endpoint).
     * @param int    $limit   Max hits within the window.
     * @param int    $window  Time window in seconds.
     */
    private function check_rate_limit( string $prefix = 'glse_rl', int $limit = 60, int $window = 60 ): void {
        $ip = $this->client_ip();

        $rate_key = $prefix . '_' . md5( $ip );

        if ( wp_using_ext_object_cache() ) {
            // External caches (Redis / Memcached) support a true atomic INCR.
            $new_hits = wp_cache_incr( $rate_key, 1, 'glse_rl' );
            if ( false === $new_hits ) {
                // Key absent - seed with TTL and allow this request.
                wp_cache_set( $rate_key, 1, 'glse_rl', $window );
                $new_hits = 1;
            }
        } else {
            /*
             * Transient fallback. The counter carries its own window expiry so
             * that re-saving it does not extend the window: calling
             * set_transient() with $window on every hit turns this into a
             * sliding window, where a user typing continuously never gets their
             * quota back until they stop for the full period.
             *
             * Minor read/write race is acceptable for a rate limiter.
             */
            $now   = time();
            $entry = get_transient( $rate_key );

            if ( is_array( $entry ) && isset( $entry['expires'], $entry['hits'] ) && $entry['expires'] > $now ) {
                $entry['hits'] = (int) $entry['hits'] + 1;
            } else {
                $entry = [ 'hits' => 1, 'expires' => $now + $window ];
            }

            $new_hits = (int) $entry['hits'];
            set_transient( $rate_key, $entry, max( 1, $entry['expires'] - $now ) );
        }

        if ( $new_hits > $limit ) {
            wp_send_json_error( [ 'message' => __( 'Too many requests.', 'glance-live-search' ) ], 429 );
        }
    }

    /**
     * Handle the live search AJAX request.
     *
     * @since 1.4.0
     */
    public function handle_search(): void {
        if ( ! class_exists( 'WooCommerce' ) ) {
            wp_send_json_error( [ 'message' => __( 'WooCommerce is not active.', 'glance-live-search' ) ], 410 );
        }

        $this->verify_nonce();
        $this->check_rate_limit( 'glse_rl', 60, 60 );

        $term = isset( $_POST['term'] ) ? sanitize_text_field( wp_unslash( $_POST['term'] ) ) : '';

        /** Filter the search phrase before processing.
         * @param string $term Sanitized search term. */
        $term = (string) apply_filters( 'glse/search/phrase', $term );

        /** Filter the minimum characters required to trigger search.
         * @param int    $min_chars Default minimum.
         * @param string $term      Current search term. */
        $min_chars = (int) apply_filters( 'glse/search/min_chars', (int) glse_get_option( 'min_chars' ), $term );
        if ( mb_strlen( $term ) < $min_chars ) {
            wp_send_json_error( [ 'message' => __( 'Term too short.', 'glance-live-search' ) ] );
        }

        $engine = new GLSE_Search_Engine();

        /*
         * ── Budget for everything that is not a product ───────────────────
         *
         * Each extra section carried its own cap and they simply added up,
         * putting a dozen rows in a dropdown before a single product appeared.
         * Every option looks reasonable on its own, and nobody ever sees the
         * sum.
         *
         * One budget covers them all, spent in order of how much each earns
         * its place: categories lead somewhere, recommendations answer a
         * question the shopper has not asked yet. Turning one on therefore
         * costs another rather than lengthening the panel.
         *
         * Products sit outside it. Their count is the merchant's explicit
         * choice and the reason anyone opened the box.
         */
        $extras_left = max( 0, (int) apply_filters( 'glse/results/extras_budget', 6 ) );

        /* ── Category filter: Pro only ── */
        $categories = [];
        



        /* The bar's category menu is a paid control; the free build never sends one. */
        $cat = '';
        

        /* ── Max results: runtime enforcement (anti-bypass) ── */
        $max_results = max( 1, min( 100, (int) glse_get_option( 'max_results' ) ) );

        $search_filters = [
            'limit'    => $max_results,
            'category' => $cat ?: null,
        ];

        /*
         * The engine has supported an in-stock filter since 1.4 but nothing
         * ever set it - the option below turns that dormant code into a real
         * setting instead of leaving it reachable only from a PHP filter.
         */
        

        /* Price range chosen from the chips under the bar. */
        

        $results = $engine->search( $term, $search_filters );

        /*
         * Record the search here rather than from a separate, delayed request.
         *
         * A dedicated endpoint firing after a couple of seconds of inactivity
         * lets a visitor who types and clicks straight away produce a click
         * with no search behind it. Writing the row while serving the results
         * guarantees it exists before any click can happen, and costs no extra
         * HTTP round trip. Repeated calls within one typing session carry the
         * same search_id and refine a single row.
         */
        $search_id = isset( $_POST['search_id'] ) ? sanitize_text_field( wp_unslash( $_POST['search_id'] ) ) : '';
        GLSE_Analytics::record( $search_id, $term, count( $results ) );

        /*
         * ── How many matched ──
         * Exact while the result set fits the limit, -1 beyond that. The
         * template shows a number in the first case and a plain "View all
         * results" in the second, which is what lets us skip the COUNT query.
         */
        $total_found = $engine->get_last_total_found();
        $has_more    = $engine->has_more_results();

        /* ── Full search results page URL ── */
        $search_url_args = [ 's' => $term, 'post_type' => 'product' ];
        if ( $cat ) {
            $search_url_args['product_cat'] = $cat;
        }
        /* WPML/Polylang: add the language parameter if needed */
        if ( defined( 'ICL_LANGUAGE_CODE' ) ) {
            $search_url_args['lang'] = ICL_LANGUAGE_CODE;
        }
        $search_url = add_query_arg( $search_url_args, home_url( '/' ) );

        /** Filter the "View all results" URL.
         * @param string $search_url Full search page URL.
         * @param string $term       Search term.
         * @param string $cat        Active category filter (slug or empty). */
        $search_url = (string) apply_filters( 'glse/search/view_all_url', $search_url, $term, $cat );

        /* ── Recommendations / upsells: Pro only ── */
        $recommendations = [];
        

        /*
         * ── Spelling correction: Pro only ─────────────────────────────────
         * Only ever consulted when the search found nothing, so the cost lands
         * on the request that has nothing better to do. Resolved here rather
         * than through a second endpoint, which would mean an extra round trip
         * on the one result a shopper is least patient with.
         */
        $did_you_mean = '';
        

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

        /** Filter the final JSON response data before sending.
         * @param array  $response JSON response array (html, results_count, total_found).
         * @param string $term     Search term.
         * @param array  $results  Formatted product results. */
        $response = apply_filters( 'glse/results/output', [
            'html'          => $html,
            'results_count' => count( $results ),
            'total_found'   => $total_found,
            'has_more'      => $has_more,
        ], $term, $results );

        wp_send_json_success( $response );
    }

    
}
