<?php
/**
 * REST controller for findastra/v1.
 *
 * Routes:
 *   GET  /status              — health + selected provider + index size
 *   GET  /index/needs         — next batch of products to embed (with text)
 *                               (used by Tier 3 browser-driven indexing)
 *   POST /index/batch         — accept browser-computed vectors (Tier 3)
 *   POST /index/server-batch  — server-side providers embed N products and
 *                               persist them in one call (Tier 1/2)
 *   POST /index/reset         — drop everything for the active provider+model
 *   POST /search              — search; query embed is in-browser (Tier 3) or
 *                               server-side (Tier 1/2)
 *
 * Admin routes require manage_woocommerce + a valid X-WP-Nonce header.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_REST_Controller {

	public const NAMESPACE = 'findastra/v1';

	private FindAstra_Vector_Store $store;
	private FindAstra_Indexer $indexer;
	// Search analytics is a paid-tier feature: FindAstra_Search_Log ships only in
	// the paid build, so the class is absent from the free build. Nullable here,
	// instantiated only when present, and every use is guarded.
	private ?FindAstra_Search_Log $log = null;

	public function __construct() {
		$this->store   = new FindAstra_Vector_Store();
		$this->indexer = new FindAstra_Indexer();
		if ( class_exists( 'FindAstra_Search_Log' ) ) {
			$this->log = new FindAstra_Search_Log();
		}
	}

	public function register_routes(): void {
		register_rest_route( self::NAMESPACE, '/status', array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => array( $this, 'status' ),
			'permission_callback' => array( $this, 'admin_permission' ),
		) );

		register_rest_route( self::NAMESPACE, '/index/needs', array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => array( $this, 'index_needs' ),
			'permission_callback' => array( $this, 'admin_permission' ),
			'args'                => array(
				'cursor'     => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint' ),
				'batch_size' => array(
					'type' => 'integer', 'default' => 16,
					'sanitize_callback' => 'absint',
					'validate_callback' => static fn( $v ) => is_numeric( $v ) && (int) $v > 0 && (int) $v <= 100,
				),
			),
		) );

		register_rest_route( self::NAMESPACE, '/index/batch', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'index_batch' ),
			'permission_callback' => array( $this, 'admin_permission' ),
		) );

		register_rest_route( self::NAMESPACE, '/index/server-batch', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'index_server_batch' ),
			'permission_callback' => array( $this, 'admin_permission' ),
			'args'                => array(
				'cursor'     => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint' ),
				'batch_size' => array(
					'type' => 'integer', 'default' => 16,
					'sanitize_callback' => 'absint',
					'validate_callback' => static fn( $v ) => is_numeric( $v ) && (int) $v > 0 && (int) $v <= 100,
				),
			),
		) );

		register_rest_route( self::NAMESPACE, '/index/reset', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'index_reset' ),
			'permission_callback' => array( $this, 'admin_permission' ),
		) );

		// Background (Action Scheduler) indexing for server-side engines — the
		// admin starts it once and can close the tab. See FindAstra_Background_Indexer.
		register_rest_route( self::NAMESPACE, '/index/background/start', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'index_bg_start' ),
			'permission_callback' => array( $this, 'admin_permission' ),
			'args'                => array(
				'reset' => array( 'type' => 'boolean', 'default' => true ),
			),
		) );

		register_rest_route( self::NAMESPACE, '/index/background/cancel', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'index_bg_cancel' ),
			'permission_callback' => array( $this, 'admin_permission' ),
		) );

		register_rest_route( self::NAMESPACE, '/index/background/status', array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => array( $this, 'index_bg_status' ),
			'permission_callback' => array( $this, 'admin_permission' ),
		) );

		$language_arg = array(
			'type'              => 'string',
			'default'           => '',
			'sanitize_callback' => array( __CLASS__, 'sanitize_language_arg' ),
		);

		register_rest_route( self::NAMESPACE, '/search', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'search' ),
			'permission_callback' => '__return_true',
			'args'                => array(
				'query'    => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ),
				'vector'   => array( 'type' => 'array' ),
				'language' => $language_arg,
				'limit'    => array(
					'type' => 'integer', 'default' => 12,
					'sanitize_callback' => 'absint',
					'validate_callback' => static fn( $v ) => is_numeric( $v ) && (int) $v > 0 && (int) $v <= 50,
				),
			),
		) );

		register_rest_route( self::NAMESPACE, '/search/autocomplete', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'search_autocomplete' ),
			'permission_callback' => '__return_true',
			'args'                => array(
				'query'    => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ),
				'vector'   => array( 'type' => 'array' ),
				'language' => $language_arg,
				'limit'    => array(
					'type' => 'integer', 'default' => 6,
					'sanitize_callback' => 'absint',
					'validate_callback' => static fn( $v ) => is_numeric( $v ) && (int) $v > 0 && (int) $v <= 24,
				),
			),
		) );

		register_rest_route( self::NAMESPACE, '/search/stash', array(
			'methods'             => WP_REST_Server::CREATABLE,
			'callback'            => array( $this, 'search_stash' ),
			'permission_callback' => '__return_true',
			'args'                => array(
				'ids' => array( 'type' => 'array' ),
			),
		) );
	}

	/**
	 * Hybrid autocomplete: SQL LIKE prefix matches first (so typing
	 * "Adjustabl" finds "Adjustable Dumbbells Pair" instantly), then
	 * semantic results to backfill. This gives the "type what you see"
	 * UX that shoppers expect, while still surfacing semantic hits for
	 * conceptual queries like "comfortable hiking shoes".
	 *
	 * Public — no nonce. Shoppers hit this on every keystroke.
	 */
	public function search_autocomplete( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$query = trim( (string) $request->get_param( 'query' ) );
		$limit = (int) $request->get_param( 'limit' );
		if ( '' === $query ) {
			return new WP_REST_Response( array( 'results' => array(), 'fallback' => array() ), 200 );
		}

		$prefix    = new FindAstra_Prefix_Search();
		$prefix_ids = $prefix->ids_for( $query, $limit );

		$results = $this->hydrate_ids( $prefix_ids );

		// Backfill from semantic search if we have headroom AND a vector
		// pathway available.
		if ( count( $results ) < $limit ) {
			$semantic = $this->run_search_for_autocomplete( $query, $request, $limit - count( $results ) );
			if ( is_array( $semantic ) ) {
				$seen = array();
				foreach ( $results as $r ) {
					$seen[ (int) $r['product_id'] ] = true;
				}
				foreach ( $semantic as $r ) {
					$pid = (int) $r['product_id'];
					if ( isset( $seen[ $pid ] ) ) {
						continue;
					}
					$results[] = $r;
					if ( count( $results ) >= $limit ) {
						break;
					}
				}
			}
		}

		$fallback = array();
		if ( empty( $results ) ) {
			// Scope the popular-products fallback to the same language the
			// caller searched in — on a /fr/ archive returning EN-only
			// products that Polylang then strips out leaves the shopper
			// with nothing.
			$ac_language = self::resolve_request_language( $request );
			$ids         = ( new FindAstra_Fallback() )->ids( max( 4, min( $limit, 8 ) ), $ac_language );
			$fallback    = $this->hydrate_ids( $ids );
		}

		return new WP_REST_Response(
			array(
				'results'  => $results,
				'fallback' => $fallback,
			),
			200
		);
	}

	/**
	 * Internal helper — run a regular semantic search for use as autocomplete
	 * backfill. Returns hydrated rows or null on error / unavailable.
	 *
	 * @return array<int, array<string, mixed>>|null
	 */
	private function run_search_for_autocomplete( string $query, WP_REST_Request $request, int $limit ): ?array {
		if ( $limit <= 0 ) {
			return null;
		}
		$provider = FindAstra_Provider_Factory::current();
		try {
			if ( $provider->is_server_side() ) {
				if ( ! $provider->is_configured() ) {
					return null;
				}
				$vectors = $provider->embed( array( $query ) );
				if ( $vectors instanceof WP_Error || empty( $vectors[0] ) ) {
					return null;
				}
				$qv = $vectors[0];
			} else {
				$vec = $request->get_param( 'vector' );
				$validated = FindAstra_Provider_Local::validate_vector( $vec );
				if ( $validated instanceof WP_Error ) {
					return null; // No vector from client → skip semantic backfill.
				}
				$qv = $validated;
			}
			$language = self::resolve_request_language( $request );
			$engine   = new FindAstra_Search_Engine( $this->store, $provider );
			$hits     = $engine->search( $qv, $limit, $query, $language );
			return $engine->hydrate( $hits );
		} catch ( Throwable $e ) {
			return null;
		}
	}

	/**
	 * Public stash route: client computes ranked product IDs (Tier 3 browser
	 * embedding) and POSTs them here. We persist in a short-lived transient
	 * and return an opaque token the client appends to the results URL as
	 * `?ss=<token>`. The pre_get_posts hijack reads the token, fetches the
	 * IDs, and orders the results page accordingly. Single-use, 5 min TTL.
	 */
	public function search_stash( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$payload = $request->get_json_params();
		$ids     = isset( $payload['ids'] ) && is_array( $payload['ids'] ) ? $payload['ids'] : array();
		if ( empty( $ids ) ) {
			return new WP_Error( 'findastra_empty_ids', __( 'No ids to stash.', 'findastra' ), array( 'status' => 400 ) );
		}
		$token = FindAstra_Query_Hijack::stash_ids( array_map( 'intval', $ids ) );
		if ( '' === $token ) {
			return new WP_Error( 'findastra_stash_failed', __( 'Could not stash ids.', 'findastra' ), array( 'status' => 500 ) );
		}
		return new WP_REST_Response( array( 'token' => $token ), 200 );
	}

	public function admin_permission( WP_REST_Request $request ): bool|WP_Error {
		if ( ! current_user_can( FindAstra_Admin::CAPABILITY ) ) {
			return new WP_Error(
				'findastra_forbidden',
				__( 'You do not have permission to perform this action.', 'findastra' ),
				array( 'status' => 403 )
			);
		}
		$nonce = $request->get_header( 'X-WP-Nonce' );
		if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
			return new WP_Error(
				'findastra_bad_nonce',
				__( 'Invalid or missing nonce.', 'findastra' ),
				array( 'status' => 403 )
			);
		}
		return true;
	}

	public function status( WP_REST_Request $request ): WP_REST_Response {
		$provider = FindAstra_Provider_Factory::current();
		return new WP_REST_Response(
			array(
				'plugin_version' => FINDASTRA_VERSION,
				'provider'       => $provider->key(),
				'model'          => $provider->model(),
				'dims'           => $provider->dims(),
				'is_server_side' => $provider->is_server_side(),
				'is_configured'  => $provider->is_configured(),
				'indexed'        => $this->store->count_for( $provider->key(), $provider->model() ),
				'languages'      => $this->store->languages_for( $provider->key(), $provider->model() ),
				'total_products' => $this->indexer->count_total_products(),
			),
			200
		);
	}

	public function index_needs( WP_REST_Request $request ): WP_REST_Response {
		$provider = FindAstra_Provider_Factory::current();
		$batch = $this->indexer->next_batch(
			(int) $request->get_param( 'cursor' ),
			(int) $request->get_param( 'batch_size' ),
			$provider,
			$this->store
		);

		$payload = array(
			'provider'    => $provider->key(),
			'model'       => $provider->model(),
			'dims'        => $provider->dims(),
			'items'       => $batch['items'],
			'next_cursor' => $batch['next_cursor'],
			'remaining'   => $batch['remaining'],
			'done'        => empty( $batch['items'] ) && 0 === $batch['remaining'],
		);
		return new WP_REST_Response( $payload, 200 );
	}

	public function index_batch( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$provider = FindAstra_Provider_Factory::current();
		if ( $provider->is_server_side() ) {
			return new WP_Error(
				'findastra_wrong_provider',
				__( 'Active provider is server-side — use /index/server-batch.', 'findastra' ),
				array( 'status' => 400 )
			);
		}

		$payload = $request->get_json_params();
		$entries = $payload['embeddings'] ?? null;
		if ( ! is_array( $entries ) || empty( $entries ) ) {
			return new WP_Error(
				'findastra_empty_batch',
				__( 'No embeddings provided.', 'findastra' ),
				array( 'status' => 400 )
			);
		}

		$accepted = 0;
		$errors   = array();
		foreach ( $entries as $entry ) {
			$pid       = isset( $entry['product_id'] ) ? absint( $entry['product_id'] ) : 0;
			$vec       = $entry['vector'] ?? null;
			$text_hash = isset( $entry['text_hash'] ) ? sanitize_text_field( (string) $entry['text_hash'] ) : '';
			$language  = isset( $entry['language'] ) ? FindAstra_Vector_Store::normalize_language( (string) $entry['language'] ) : '';

			if ( $pid <= 0 ) {
				$errors[] = array( 'product_id' => $pid, 'error' => 'invalid_product_id' );
				continue;
			}
			if ( strlen( $text_hash ) !== 64 ) {
				$errors[] = array( 'product_id' => $pid, 'error' => 'invalid_text_hash' );
				continue;
			}

			$validated = FindAstra_Provider_Local::validate_vector( $vec );
			if ( $validated instanceof WP_Error ) {
				$errors[] = array( 'product_id' => $pid, 'error' => $validated->get_error_code() );
				continue;
			}

			$ok = $this->store->upsert( $pid, $validated, $provider->key(), $provider->model(), $text_hash, $language );
			if ( $ok ) {
				++$accepted;
			} else {
				$errors[] = array( 'product_id' => $pid, 'error' => 'db_write_failed' );
			}
		}

		return new WP_REST_Response(
			array(
				'accepted' => $accepted,
				'errors'   => $errors,
				'indexed'  => $this->store->count_for( $provider->key(), $provider->model() ),
			),
			200
		);
	}

	public function index_server_batch( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$provider = FindAstra_Provider_Factory::current();
		if ( ! $provider->is_server_side() ) {
			return new WP_Error(
				'findastra_wrong_provider',
				__( 'Active provider is in-browser — use /index/needs + /index/batch.', 'findastra' ),
				array( 'status' => 400 )
			);
		}
		if ( ! $provider->is_configured() ) {
			return new WP_Error(
				'findastra_missing_credentials',
				__( 'Active provider is missing its credentials. Save the key/token and try again.', 'findastra' ),
				array( 'status' => 412 )
			);
		}

		$batch = $this->indexer->next_batch(
			(int) $request->get_param( 'cursor' ),
			(int) $request->get_param( 'batch_size' ),
			$provider,
			$this->store
		);

		if ( empty( $batch['items'] ) ) {
			$payload = array(
				'provider'    => $provider->key(),
				'accepted'    => 0,
				'next_cursor' => $batch['next_cursor'],
				'remaining'   => $batch['remaining'],
				'done'        => 0 === $batch['remaining'],
				'indexed'     => $this->store->count_for( $provider->key(), $provider->model() ),
			);
			return new WP_REST_Response( $payload, 200 );
		}

		$texts = array_map( static fn( $i ) => $i['text'], $batch['items'] );
		$vectors = $provider->embed( $texts );
		if ( $vectors instanceof WP_Error ) {
			return $vectors;
		}
		if ( count( $vectors ) !== count( $batch['items'] ) ) {
			return new WP_Error(
				'findastra_count_mismatch',
				/* translators: 1: expected, 2: got */
				sprintf( __( 'Provider returned %1$d vectors for %2$d inputs.', 'findastra' ), count( $vectors ), count( $batch['items'] ) ),
				array( 'status' => 502 )
			);
		}

		$accepted = 0;
		foreach ( $batch['items'] as $idx => $item ) {
			$ok = $this->store->upsert(
				(int) $item['product_id'],
				$vectors[ $idx ],
				$provider->key(),
				$provider->model(),
				(string) $item['text_hash'],
				isset( $item['language'] ) ? (string) $item['language'] : ''
			);
			if ( $ok ) {
				++$accepted;
			}
		}

		return new WP_REST_Response(
			array(
				'provider'    => $provider->key(),
				'accepted'    => $accepted,
				'next_cursor' => $batch['next_cursor'],
				'remaining'   => $batch['remaining'],
				'done'        => 0 === $batch['remaining'],
				'indexed'     => $this->store->count_for( $provider->key(), $provider->model() ),
			),
			200
		);
	}

	public function index_reset( WP_REST_Request $request ): WP_REST_Response {
		// Stop any in-flight background job before wiping the table, otherwise a
		// queued chunk would keep writing rows into the index we just cleared.
		( new FindAstra_Background_Indexer() )->cancel( true );
		$this->store->truncate();
		return new WP_REST_Response(
			array( 'reset' => true, 'indexed' => 0 ),
			200
		);
	}

	/**
	 * Start a background (Action Scheduler) index for the active server-side
	 * provider. Returns the initial progress payload, or a 400 explaining why
	 * it can't run (wrong engine / missing creds / no scheduler).
	 */
	public function index_bg_start( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$reset  = $request->get_param( 'reset' );
		$reset  = ( null === $reset ) ? true : (bool) $reset;
		$bg     = new FindAstra_Background_Indexer();
		$result = $bg->start( $reset );

		if ( isset( $result['ok'] ) && false === $result['ok'] ) {
			$code = (string) ( $result['error'] ?? 'unknown' );
			$map  = array(
				'not_server_side' => __( 'Background indexing is available for the OpenAI and Hugging Face engines only — the Local engine indexes in your browser.', 'findastra' ),
				'not_configured'  => __( 'Add and save your API key/token before indexing.', 'findastra' ),
				'no_scheduler'    => __( 'Action Scheduler is unavailable (it ships with WooCommerce). Use the in-page indexer instead.', 'findastra' ),
			);
			return new WP_Error(
				'findastra_bg_' . $code,
				$map[ $code ] ?? __( 'Could not start background indexing.', 'findastra' ),
				array( 'status' => 400 )
			);
		}
		return new WP_REST_Response( $result, 200 );
	}

	public function index_bg_cancel( WP_REST_Request $request ): WP_REST_Response {
		$bg = new FindAstra_Background_Indexer();
		$bg->cancel( true );
		return new WP_REST_Response( $bg->progress(), 200 );
	}

	public function index_bg_status( WP_REST_Request $request ): WP_REST_Response {
		return new WP_REST_Response( ( new FindAstra_Background_Indexer() )->progress(), 200 );
	}

	public function search( WP_REST_Request $request ): WP_REST_Response|WP_Error {
		$started  = microtime( true );
		$provider = FindAstra_Provider_Factory::current();
		$limit    = (int) $request->get_param( 'limit' );
		$query    = (string) $request->get_param( 'query' );

		if ( $provider->is_server_side() ) {
			if ( ! $provider->is_configured() ) {
				return new WP_Error(
					'findastra_missing_credentials',
					__( 'Search provider is not configured.', 'findastra' ),
					array( 'status' => 503 )
				);
			}
			if ( '' === trim( $query ) ) {
				return new WP_Error(
					'findastra_empty_query',
					__( 'Query is empty.', 'findastra' ),
					array( 'status' => 400 )
				);
			}
			$vectors = $provider->embed( array( $query ) );
			if ( $vectors instanceof WP_Error ) {
				return $vectors;
			}
			$query_vector = $vectors[0] ?? null;
			if ( ! is_array( $query_vector ) ) {
				return new WP_Error(
					'findastra_no_vector',
					__( 'Provider returned no query vector.', 'findastra' ),
					array( 'status' => 502 )
				);
			}
		} else {
			$vec = $request->get_param( 'vector' );
			$validated = FindAstra_Provider_Local::validate_vector( $vec );
			if ( $validated instanceof WP_Error ) {
				return $validated;
			}
			$query_vector = $validated;
		}

		$language   = self::resolve_request_language( $request );
		$engine     = new FindAstra_Search_Engine( $this->store, $provider );
		$hits       = $engine->search( $query_vector, $limit, $query, $language );
		$results    = $engine->hydrate( $hits );
		$elapsed_ms = (int) round( ( microtime( true ) - $started ) * 1000 );

		// Log against the real-match count — fallback rescues a bad query for
		// shoppers, but the catalog gap is still a gap. Analytics should know.
		//
		// The `log` param lets callers opt out — Tier 3's autocomplete submit
		// handler hits /search purely to collect ids it can stash, then
		// navigates to `/?s=…&ss=<token>` which triggers Query_Hijack's
		// wp-action logger. Logging both would produce two rows per shopper
		// search; opting the stash-precursor out keeps it one.
		$should_log = $request->get_param( 'log' );
		$should_log = null === $should_log || (bool) $should_log;
		if ( $should_log && null !== $this->log ) {
			try {
				$this->log->log( $query, count( $results ), $provider->key(), $elapsed_ms );
			} catch ( Throwable $e ) {
				// swallow
			}
		}

		$fallback = array();
		if ( empty( $results ) ) {
			// $language was resolved at the top of search() — reuse the same
			// resolution so the popular-products fallback is scoped to the
			// shopper's archive language.
			$fallback_ids = ( new FindAstra_Fallback() )->ids( max( 6, min( $limit, 12 ) ), $language ?? '' );
			$fallback     = $this->hydrate_ids( $fallback_ids );
		}

		return new WP_REST_Response(
			array(
				'provider'      => $provider->key(),
				'model'         => $provider->model(),
				'count'         => count( $results ),
				'results'       => $results,
				'fallback'      => $fallback,
				'query_time_ms' => $elapsed_ms,
			),
			200
		);
	}

	/**
	 * Strict-but-permissive sanitizer for the `language` REST arg. Keeps the
	 * column-safe character set ([a-z0-9_-], max 8) so anything malformed
	 * silently degrades to "" (no language filter) rather than 400-ing the
	 * shopper's search.
	 */
	public static function sanitize_language_arg( $value ): string {
		return FindAstra_Vector_Store::normalize_language( is_string( $value ) ? $value : '' );
	}

	/**
	 * Resolve the effective language for a search request. Order:
	 *   1. Explicit `language` param on the request (set by the [findastra]
	 *      widget config, the autocomplete JS, or the buyer's caller).
	 *   2. The `findastra_search_language` filter — buyer extension point for
	 *      themes that want to auto-detect from the shopper's current page.
	 *   3. Empty string → no restriction (search across all indexed languages).
	 *
	 * Returning null tells Search_Engine to skip the language filter
	 * entirely. We only return a non-null tag when one was explicitly
	 * resolved, so single-language sites continue to behave as before.
	 */
	public static function resolve_request_language( WP_REST_Request $request ): ?string {
		$param = (string) $request->get_param( 'language' );
		$param = FindAstra_Vector_Store::normalize_language( $param );

		/**
		 * Buyer override for the per-search language. Receives the
		 * already-sanitized request param (may be ''); return a normalized
		 * tag, '' to force "no language filter", or null to behave as if no
		 * language was specified (the engine then searches across all
		 * indexed languages).
		 *
		 * @param string|null     $language Resolved language so far ('' = no explicit value).
		 * @param WP_REST_Request $request  The REST request.
		 */
		$language = apply_filters( 'findastra_search_language', '' === $param ? null : $param, $request );

		if ( null === $language ) {
			return null;
		}
		return FindAstra_Vector_Store::normalize_language( (string) $language );
	}

	/**
	 * Hydrate a list of product IDs into the same shape as search results.
	 *
	 * @param int[] $ids
	 * @return array<int, array<string, mixed>>
	 */
	private function hydrate_ids( array $ids ): array {
		$out = array();
		foreach ( $ids as $id ) {
			$product = wc_get_product( (int) $id );
			if ( ! $product instanceof WC_Product ) {
				continue;
			}
			$thumb = wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' );
			$out[] = array(
				'product_id' => (int) $product->get_id(),
				'score'      => 0.0,
				'title'      => wp_strip_all_tags( $product->get_name() ),
				'permalink'  => get_permalink( $product->get_id() ),
				'price_html' => wp_kses_post( $product->get_price_html() ),
				'image'      => $thumb ? esc_url_raw( $thumb ) : '',
			);
		}
		return $out;
	}
}
