<?php
/**
 * Theme-integration: rewrite WordPress's main search query so results pages
 * (`?s=foo&post_type=product` or any WC product search) use FindAstra
 * ranking instead of WP's default LIKE search.
 *
 * Works without buyer-side code changes — the theme renders its normal
 * product results page, but the IDs in `post__in` are our semantic ranking.
 *
 * Tier 1 (OpenAI) and Tier 2 (Hugging Face) embed the query server-side and
 * work for any URL-based search (bookmarks, direct links, theme submits).
 *
 * Tier 3 (Local) can't embed in PHP — the search vector has to come from the
 * browser. We solve this with a short-lived "search token": when the
 * autocomplete intercepts a form submit, JS POSTs the precomputed ranked
 * IDs to `/search/stash`, gets back a token, and redirects to
 * `?s=foo&ss=<token>`. This hijack reads the token from a transient and
 * uses those IDs. Tier 3 direct URLs without a token fall back to default
 * WP search — buyer's existing behavior, no regression.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_Query_Hijack {

	public const TRANSIENT_PREFIX = 'findastra_srt_';
	public const TOKEN_TTL        = 300; // seconds
	public const TOKEN_QUERY_VAR  = 'ss';

	/** Query var set on the main WP_Query when our shop-page fallback fired. */
	public const FALLBACK_QUERY_VAR = 'findastra_fallback';

	public function register(): void {
		// request: runs BEFORE WP parses the query and decides conditional
		// tags (is_search / is_post_type_archive / is_shop). If a search URL
		// arrives without `post_type=product`, inject it here so WP routes
		// the page to WooCommerce's product archive template (which renders
		// proper product cards — images, prices, add-to-cart). Without this,
		// `?s=foo` lands on the post-search template which lists products as
		// plain title links.
		add_filter( 'request', array( $this, 'force_product_post_type' ), 99 );

		add_action( 'pre_get_posts', array( $this, 'maybe_hijack' ), 20 );

		// posts_search returns the SQL WHERE clause WordPress adds for the
		// `s` query var. When our hijack applies, we override it to nothing
		// so the LIKE filter doesn't fight `post__in`. Keeping `s` in the
		// query (instead of unsetting it) lets the theme display the
		// "Search results for 'foo'" title correctly.
		add_filter( 'posts_search', array( $this, 'suppress_like_clause' ), 10, 2 );

		// Log shopper searches that come through the theme search bar / direct
		// `?s=foo` URLs. The widget shortcode's `/search` REST endpoint logs
		// itself; the autocomplete-keystroke path deliberately doesn't (would
		// flood the table with prefix substrings). This covers the third path
		// — Enter/button submits on the theme search bar — so all real shopper
		// intent lands in the Catalog gaps view.
		add_action( 'wp', array( $this, 'log_main_search' ) );

		// When our shop-page fallback fires, the theme renders popular
		// products instead of "No products found." Add the same "No exact
		// matches — here are some popular products:" notice the autocomplete
		// dropdown uses, so shoppers aren't confused into thinking these are
		// matches.
		add_action( 'woocommerce_before_shop_loop', array( $this, 'maybe_render_fallback_notice' ) );

		// WooCommerce's auto-redirect-to-single-product-on-search is a
		// foot-gun for semantic-search plugins: a shopper searches "hiking",
		// our ranking returns one French product, WC teleports them to that
		// single product page instead of rendering the results template.
		// Disable WC's redirect whenever our hijack fired, so shoppers
		// always land on a real results page with the option to compare.
		add_filter( 'woocommerce_redirect_single_search_result', array( $this, 'maybe_skip_single_redirect' ) );
	}

	/**
	 * Tell WooCommerce NOT to redirect to a single product when our
	 * hijack populated the search results (semantic ranking OR fallback
	 * path). Always returns the original value when we didn't hijack, so
	 * buyer's non-FindAstra search behaviour is unchanged.
	 */
	public function maybe_skip_single_redirect( $do_redirect ): bool {
		global $wp_query;
		if ( $wp_query instanceof WP_Query && $wp_query->get( 'findastra_hijacked' ) ) {
			return false;
		}
		return (bool) $do_redirect;
	}

	/**
	 * Record a single front-end search submitted via `/?s=foo`. Runs once
	 * the main query has resolved so `found_posts` reflects the actual
	 * number of products WordPress will render (post hijack post__in if our
	 * pre_get_posts hook fired, or LIKE results if it didn't).
	 *
	 * Skips: admin requests, AJAX, REST, non-main / non-search queries,
	 * empty query strings. Never throws — analytics failures must not
	 * break the page render.
	 */
	public function log_main_search(): void {
		if ( is_admin() || wp_doing_ajax() ) {
			return;
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return;
		}

		global $wp_query;
		if ( ! ( $wp_query instanceof WP_Query ) ) {
			return;
		}
		if ( ! $wp_query->is_main_query() || ! $wp_query->is_search() ) {
			return;
		}

		$search = trim( (string) $wp_query->get( 's' ) );
		if ( '' === $search ) {
			return;
		}

		// Search analytics is a paid-tier feature; FindAstra_Search_Log is absent
		// from the free build, so only log when the class is present.
		if ( ! class_exists( 'FindAstra_Search_Log' ) ) {
			return;
		}

		$count    = (int) $wp_query->found_posts;
		$provider = FindAstra_Provider_Factory::current();

		try {
			( new FindAstra_Search_Log() )->log( $search, $count, $provider->key(), 0 );
		} catch ( Throwable $e ) {
			// Analytics never breaks the page.
		}
	}

	/**
	 * Make `?s=foo` behave as `?s=foo&post_type=product` for the main query
	 * when FindAstra is enabled and there's a product index. This forces
	 * WP to use the product archive template path so the theme renders
	 * WooCommerce product cards instead of post-style search results.
	 *
	 * @param array<string, mixed> $query_vars
	 * @return array<string, mixed>
	 */
	public function force_product_post_type( array $query_vars ): array {
		if ( is_admin() ) {
			return $query_vars;
		}
		if ( ! isset( $query_vars['s'] ) || '' === trim( (string) $query_vars['s'] ) ) {
			return $query_vars;
		}
		// Respect explicit post_type (lets buyers search non-product content with `?s=&post_type=post`).
		if ( isset( $query_vars['post_type'] ) && '' !== $query_vars['post_type'] ) {
			return $query_vars;
		}

		$opts = get_option( FindAstra_Admin::SETTINGS_OPTION, array() );
		if ( isset( $opts['hijack_results_page'] ) && '0' === (string) $opts['hijack_results_page'] ) {
			return $query_vars;
		}

		$query_vars['post_type'] = 'product';
		return $query_vars;
	}

	public function maybe_hijack( WP_Query $q ): void {
		// Only the main front-end search query for products.
		if ( is_admin() || ! $q->is_main_query() || ! $q->is_search() ) {
			return;
		}

		// Buyers can flip this off.
		$opts = get_option( FindAstra_Admin::SETTINGS_OPTION, array() );
		if ( isset( $opts['hijack_results_page'] ) && '0' === (string) $opts['hijack_results_page'] ) {
			return;
		}

		$search = trim( (string) $q->get( 's' ) );
		if ( '' === $search ) {
			return;
		}

		// Only apply when the query is for products (or unrestricted — most
		// WC stores treat `?s=` as product-aware via WC_Query).
		$post_type = $q->get( 'post_type' );
		if ( ! empty( $post_type ) && 'product' !== $post_type && ( ! is_array( $post_type ) || ! in_array( 'product', $post_type, true ) ) ) {
			return;
		}

		// 1) Did the client send a precomputed token? (Tier 3 path.)
		// This runs on the public product-search request, which carries no nonce
		// by design; the value is unslashed and sanitized before use, and
		// ids_from_token() validates the signed payload, so nonce verification
		// does not apply.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$token_ids = $this->ids_from_token( isset( $_GET[ self::TOKEN_QUERY_VAR ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::TOKEN_QUERY_VAR ] ) ) : '' );
		if ( is_array( $token_ids ) ) {
			$this->apply_ids( $q, $token_ids );
			return;
		}

		// 2) Otherwise, can we embed server-side?
		$provider = FindAstra_Provider_Factory::current();
		if ( ! $provider->is_server_side() || ! $provider->is_configured() ) {
			return; // Tier 3 with no token — leave WP default behavior alone.
		}

		$store = new FindAstra_Vector_Store();
		if ( 0 === $store->count_for( $provider->key(), $provider->model() ) ) {
			return; // Nothing indexed for this provider yet.
		}

		$vectors = $provider->embed( array( $search ) );
		if ( $vectors instanceof WP_Error || empty( $vectors[0] ) ) {
			return;
		}

		// On a WPML/Polylang site the shopper's current page language
		// constrains the candidate set so a French archive doesn't return
		// English products. Mono-lingual sites return '' here and skip the
		// filter entirely (search engine treats null as "all languages").
		$language = FindAstra_Assets::current_request_language();
		$engine   = new FindAstra_Search_Engine( $store, $provider );
		$hits     = $engine->search( $vectors[0], 24, $search, '' !== $language ? $language : null );
		if ( empty( $hits ) ) {
			// Match the autocomplete dropdown's behaviour instead of letting
			// WP render "No products found": ask the fallback chain for
			// admin-curated → featured → best-sellers → recent products and
			// apply them as post__in. The maybe_render_fallback_notice hook
			// then prints the "No exact matches — here are some popular
			// products:" header that the dropdown already shows. The
			// alternative is a hard dead-end on the shop page, which is a
			// real conversion killer.
			$fallback_ids = ( new FindAstra_Fallback() )->ids( 12 );
			if ( ! empty( $fallback_ids ) ) {
				$this->apply_ids( $q, $fallback_ids );
				$q->set( self::FALLBACK_QUERY_VAR, true );
			}
			return;
		}

		$ids = array_map( static fn( $h ) => (int) $h['product_id'], $hits );
		$this->apply_ids( $q, $ids );
	}

	/**
	 * Render the "No exact matches — here are some popular products:" notice
	 * on the search results page when our shop-page fallback fired.
	 *
	 * Hooked to `woocommerce_before_shop_loop` so the notice prints inside
	 * the products grid context, above the actual product cards. Themes that
	 * heavily customise the shop loop and skip this hook simply won't show
	 * the notice; that's a graceful degradation — products still render.
	 */
	public function maybe_render_fallback_notice(): void {
		global $wp_query;
		if ( ! ( $wp_query instanceof WP_Query ) ) {
			return;
		}
		if ( ! $wp_query->get( self::FALLBACK_QUERY_VAR ) ) {
			return;
		}

		$query = trim( (string) $wp_query->get( 's' ) );
		$text  = '' !== $query
			? sprintf(
				/* translators: %s: shopper's typed search query */
				__( 'No exact matches for "%s" — here are some popular products:', 'findastra' ),
				$query
			)
			: __( 'No exact matches — here are some popular products:', 'findastra' );

		/**
		 * Filter the fallback notice text shown above popular-products on
		 * a no-match shop search page. Return empty string to suppress.
		 *
		 * @param string $text   Default localised message.
		 * @param string $query  The shopper's typed search query.
		 */
		$text = (string) apply_filters( 'findastra_fallback_notice_text', $text, $query );
		if ( '' === $text ) {
			return;
		}

		printf(
			'<p class="findastra-fallback-notice" data-testid="findastra-fallback-notice">%s</p>',
			esc_html( $text )
		);
	}

	/**
	 * @param int[] $ids
	 */
	private function apply_ids( WP_Query $q, array $ids ): void {
		if ( empty( $ids ) ) {
			return;
		}

		// Flag the query so our posts_search filter knows to suppress the
		// LIKE clause for this exact WP_Query and nobody else's.
		$q->set( 'findastra_hijacked', true );

		$q->is_search   = true;
		$q->is_singular = false;

		$q->set( 'post__in', $ids );
		$q->set( 'orderby', 'post__in' );
		$q->set( 'post_type', 'product' );
	}

	/**
	 * Strip the LIKE clause from a hijacked search query. We still want the
	 * `s` query var present (so themes can display the search term in the
	 * page title), but we DON'T want WordPress AND-ing a post_title LIKE
	 * filter on top of our `post__in` narrowing — that would drop products
	 * whose titles don't literally contain the query word, which is the
	 * whole point of semantic search.
	 */
	public function suppress_like_clause( string $where, WP_Query $query ): string {
		if ( $query->get( 'findastra_hijacked' ) ) {
			return '';
		}
		return $where;
	}

	/**
	 * Stash ranked IDs in a transient + return a short opaque token.
	 *
	 * Called by the REST controller when the front-end intercepts a search
	 * submit. We keep the lifetime short (5 min) because tokens are
	 * single-purpose: produce → redirect → consume.
	 *
	 * @param int[] $ids
	 */
	public static function stash_ids( array $ids ): string {
		$ids = array_values( array_filter( array_map( 'intval', $ids ), static fn( $i ) => $i > 0 ) );
		if ( empty( $ids ) ) {
			return '';
		}
		$ids   = array_slice( $ids, 0, 50 );
		$token = wp_generate_password( 16, false, false );
		set_transient( self::TRANSIENT_PREFIX . $token, $ids, self::TOKEN_TTL );
		return $token;
	}

	/**
	 * @return int[]|null
	 */
	private function ids_from_token( string $token ): ?array {
		if ( '' === $token || ! preg_match( '/^[A-Za-z0-9]{8,32}$/', $token ) ) {
			return null;
		}
		$value = get_transient( self::TRANSIENT_PREFIX . $token );
		if ( ! is_array( $value ) ) {
			return null;
		}
		// One-time use — burn it.
		delete_transient( self::TRANSIENT_PREFIX . $token );
		return array_values( array_map( 'intval', $value ) );
	}
}
