<?php
/**
 * Indexer — turns WooCommerce products into the source text we embed.
 *
 * Source-text recipe (in priority order, joined with ". ", capped at ~800 chars):
 *   product name → short description → categories → tags → attribute values
 *   → ACF text fields (if ACF is active) → truncated long description.
 *
 * Capping matters because both bge-small and text-embedding-3-small truncate
 * to ~512 tokens — we'd rather pick what gets sent than have the model pick.
 *
 * Extensibility: buyers can plug in custom fields beyond ACF via the
 * `findastra_source_text` filter — it receives the final string + the
 * product object, return whatever you want indexed.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_Indexer {

	private const MAX_SOURCE_LENGTH = 800;

	public function source_text( WC_Product $product ): string {
		$pieces = array();

		$pieces[] = $product->get_name();

		$short = wp_strip_all_tags( (string) $product->get_short_description() );
		if ( '' !== $short ) {
			$pieces[] = $short;
		}

		$cat_names = wp_get_post_terms( $product->get_id(), 'product_cat', array( 'fields' => 'names' ) );
		if ( ! is_wp_error( $cat_names ) && ! empty( $cat_names ) ) {
			$pieces[] = implode( ', ', $cat_names );
		}

		$tag_names = wp_get_post_terms( $product->get_id(), 'product_tag', array( 'fields' => 'names' ) );
		if ( ! is_wp_error( $tag_names ) && ! empty( $tag_names ) ) {
			$pieces[] = implode( ', ', $tag_names );
		}

		$attribute_values = array();
		foreach ( $product->get_attributes() as $attribute ) {
			if ( ! $attribute instanceof WC_Product_Attribute ) {
				continue;
			}
			if ( $attribute->is_taxonomy() ) {
				$terms = wc_get_product_terms( $product->get_id(), $attribute->get_name(), array( 'fields' => 'names' ) );
				if ( ! empty( $terms ) ) {
					$attribute_values[] = implode( ', ', $terms );
				}
			} else {
				$attribute_values = array_merge( $attribute_values, (array) $attribute->get_options() );
			}
		}
		if ( ! empty( $attribute_values ) ) {
			$pieces[] = implode( ', ', array_filter( $attribute_values ) );
		}

		// ACF: pull text/textarea/wysiwyg field values when ACF is active.
		// These three types cover ~95% of what buyers index as "description-like"
		// content. Image/file/relationship/select fields return non-text payloads
		// and are deliberately ignored. Buyers wanting other field types can plug
		// them in via the `findastra_source_text` filter below.
		if ( function_exists( 'get_field_objects' ) ) {
			$acf_fields = get_field_objects( $product->get_id() );
			if ( is_array( $acf_fields ) ) {
				$acf_text = array();
				foreach ( $acf_fields as $field ) {
					if ( ! is_array( $field ) || ! isset( $field['type'], $field['value'] ) ) {
						continue;
					}
					if ( ! in_array( $field['type'], array( 'text', 'textarea', 'wysiwyg' ), true ) ) {
						continue;
					}
					$value = wp_strip_all_tags( (string) $field['value'] );
					if ( '' !== $value ) {
						$acf_text[] = $value;
					}
				}
				if ( ! empty( $acf_text ) ) {
					$pieces[] = implode( '. ', $acf_text );
				}
			}
		}

		$long = wp_strip_all_tags( (string) $product->get_description() );
		if ( '' !== $long ) {
			$pieces[] = $long;
		}

		$joined = implode( '. ', array_filter( array_map( 'trim', $pieces ) ) );
		$joined = preg_replace( '/\s+/u', ' ', $joined ) ?? $joined;

		if ( function_exists( 'mb_substr' ) ) {
			$truncated = mb_substr( $joined, 0, self::MAX_SOURCE_LENGTH );
		} else {
			$truncated = substr( $joined, 0, self::MAX_SOURCE_LENGTH );
		}

		/**
		 * Filter the source text just before it's hashed and embedded.
		 *
		 * Buyers can return any string — typical use is to append values from
		 * custom fields not covered by ACF text/textarea/wysiwyg (e.g. SCF,
		 * Meta Box, Pods, plain post_meta). The result is still truncated to
		 * MAX_SOURCE_LENGTH automatically below to protect against runaway
		 * token counts.
		 *
		 * @param string     $truncated The default source text.
		 * @param WC_Product $product   The product being indexed.
		 */
		$filtered = (string) apply_filters( 'findastra_source_text', $truncated, $product );
		if ( $filtered !== $truncated ) {
			// Re-truncate in case the filter appended content that pushed us
			// past the cap — embedding models silently drop overflow tokens.
			$filtered = function_exists( 'mb_substr' )
				? mb_substr( $filtered, 0, self::MAX_SOURCE_LENGTH )
				: substr( $filtered, 0, self::MAX_SOURCE_LENGTH );
		}
		return $filtered;
	}

	public function text_hash( string $text ): string {
		return hash( 'sha256', $text );
	}

	/**
	 * Detect the language for a given post via WPML or Polylang.
	 *
	 * Returns an empty string when neither plugin is active or when the post
	 * has no assigned language — that's stored as-is in the vectors table and
	 * treated as "site default language" at search time.
	 *
	 * The `findastra_post_language` filter is the buyer extension point —
	 * useful for custom translation frameworks (TranslatePress, Multilingual
	 * Press, hand-rolled multilingual setups) that don't expose either of
	 * the two standard APIs.
	 */
	public function detect_language( int $post_id ): string {
		return self::detect_post_language( $post_id );
	}

	/**
	 * Static counterpart to detect_language() for callers (FindAstra_Fallback,
	 * etc.) that don't have an Indexer instance handy.
	 *
	 * Per-language indexing is a paid-tier capability: the WPML / Polylang
	 * resolution lives in FindAstra_Multilingual, which ships only in the paid
	 * build. When that class is absent (the free build), every product is indexed
	 * under language='' and the store behaves as a single-language catalog —
	 * which also keeps index and search aligned, since
	 * FindAstra_Assets::current_request_language() resolves to '' the same way.
	 */
	public static function detect_post_language( int $post_id ): string {
		if ( class_exists( 'FindAstra_Multilingual' ) ) {
			return FindAstra_Multilingual::detect_post_language( $post_id );
		}
		return '';
	}

	/**
	 * @return array{items: array<int, array{product_id:int,text:string,text_hash:string,language:string}>, next_cursor: int, remaining: int}
	 */
	public function next_batch( int $cursor, int $batch_size, FindAstra_Provider_Base $provider, FindAstra_Vector_Store $store ): array {
		global $wpdb;

		// Direct SQL pulls product IDs without going through WP_Query, which
		// is the only way to bypass Polylang/WPML/etc. injecting a
		// posts_where clause that restricts iteration to the current admin
		// language. We MUST see every product across every language so we
		// can write one (product_id, language) row per translation;
		// wc_get_products() does not see them all once a translation plugin
		// is active. count_after() already uses the same raw SQL so the
		// cursor + remaining math stays consistent.
		//
		// wc_get_product( $id ) below loads a specific post by ID and isn't
		// subject to the framework's posts_where hooks, so the hydration
		// step still works correctly.
		// Direct read of the core posts table to see every product across all
		// languages (translation plugins' posts_where filters would hide some);
		// the result is a live cursor page, not cacheable.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts}
				 WHERE post_type = 'product' AND post_status = 'publish' AND ID > %d
				 ORDER BY ID ASC
				 LIMIT %d",
				$cursor,
				$batch_size
			)
		);

		$items = array();
		$last  = $cursor;
		foreach ( $ids as $id ) {
			$id      = (int) $id;
			$product = wc_get_product( $id );
			if ( ! $product instanceof WC_Product ) {
				$last = $id; // count it as seen — otherwise the cursor stalls on it forever
				continue;
			}
			$language    = $this->detect_language( $id );
			$text        = $this->source_text( $product );
			$hash        = $this->text_hash( $text . '|' . $provider->model() );
			$stored_hash = $store->get_text_hash( $id, $language );
			$last        = $id;
			if ( $stored_hash === $hash ) {
				continue;
			}
			$items[] = array(
				'product_id' => $id,
				'text'       => $text,
				'text_hash'  => $hash,
				'language'   => $language,
			);
		}

		$remaining = $this->count_after( $last );

		return array(
			'items'       => $items,
			'next_cursor' => $last,
			'remaining'   => $remaining,
		);
	}

	public function count_after( int $cursor ): int {
		global $wpdb;
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish' AND ID > %d",
				$cursor
			)
		);
	}

	public function count_total_products(): int {
		global $wpdb;
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		return (int) $wpdb->get_var(
			"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish'"
		);
	}
}
