<?php
/**
 * Vector storage layer.
 *
 * Embeddings are written as packed little-endian float32 BLOBs (`pack('g*', ...)`).
 * We rely on producers to L2-normalize each vector — cosine similarity then
 * reduces to a plain dot product (saves a sqrt per row at search time).
 *
 * Language column (v0.10.0+): each row is keyed by (product_id, language). A
 * site without WPML / Polylang stores everything with language='' and the
 * column behaves as a no-op. A multilingual site stores one row per (post,
 * detected language) pair so a French shopper's query can be cosined only
 * against French embeddings.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

class FindAstra_Vector_Store {

	// This class is a thin data-access layer over the custom
	// {prefix}findastra_vectors table. Every method issues a direct, uncached
	// query by design: vector reads must be fresh and there is no object-cache
	// layer for packed-float BLOBs. The table name is a trusted prefixed
	// identifier ($wpdb->prefix . FindAstra_Activator::TABLE) interpolated into
	// otherwise fully-prepared statements. These custom-table DB sniffs are
	// suppressed class-wide rather than repeated on every query.
	// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

	public function table(): string {
		global $wpdb;
		return $wpdb->prefix . FindAstra_Activator::TABLE;
	}

	public static function pack( array $vector ): string {
		return pack( 'g*', ...array_map( 'floatval', $vector ) );
	}

	/**
	 * @return float[]
	 */
	public static function unpack( string $blob ): array {
		$arr = unpack( 'g*', $blob );
		return $arr === false ? array() : array_values( $arr );
	}

	/**
	 * Normalize a language tag to the on-disk form: lowercase, max 8 chars,
	 * ASCII alphanumerics + dash + underscore only. WPML returns 'en', 'fr',
	 * 'pt-br', etc.; Polylang returns 'en', 'fr', 'pt-br'. Empty string means
	 * "no language" (legacy rows + sites without a translation plugin).
	 */
	public static function normalize_language( string $language ): string {
		$lang = strtolower( trim( $language ) );
		$lang = preg_replace( '/[^a-z0-9_\-]/', '', $lang ) ?? '';
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $lang, 0, 8 );
		}
		return substr( $lang, 0, 8 );
	}

	public function upsert( int $product_id, array $vector, string $provider, string $model, string $text_hash, string $language = '' ): bool {
		global $wpdb;
		$table    = $this->table();
		$dims     = count( $vector );
		$language = self::normalize_language( $language );

		// One-shot promotion: when we now know the language for this product,
		// evict the legacy (product_id, '') row that v0.9.x indexers left
		// behind. Avoids stale duplicates after a buyer enables WPML/Polylang
		// and re-indexes.
		if ( '' !== $language ) {
			$wpdb->delete(
				$table,
				array( 'product_id' => $product_id, 'language' => '' ),
				array( '%d', '%s' )
			);
		}

		$result = $wpdb->replace(
			$table,
			array(
				'product_id' => $product_id,
				'language'   => $language,
				'dims'       => $dims,
				'provider'   => $provider,
				'model'      => $model,
				'vector'     => self::pack( $vector ),
				'text_hash'  => $text_hash,
				'indexed_at' => current_time( 'mysql', true ),
			),
			array( '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s' )
		);

		return false !== $result;
	}

	public function get_text_hash( int $product_id, string $language = '' ): ?string {
		global $wpdb;
		$table    = $this->table();
		$language = self::normalize_language( $language );
		$value    = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT text_hash FROM {$table} WHERE product_id = %d AND language = %s",
				$product_id,
				$language
			)
		);
		return null === $value ? null : (string) $value;
	}

	/**
	 * @param string|null $language null = no filter (all languages); '' = the
	 *                              legacy "no language" rows only; 'fr' =
	 *                              only French rows.
	 * @return array<int, float[]>
	 */
	public function load_all( string $provider, string $model, ?string $language = null ): array {
		global $wpdb;
		$table = $this->table();

		if ( null === $language ) {
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT product_id, vector FROM {$table} WHERE provider = %s AND model = %s",
					$provider,
					$model
				)
			);
		} else {
			$lang = self::normalize_language( $language );
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT product_id, vector FROM {$table} WHERE provider = %s AND model = %s AND language = %s",
					$provider,
					$model,
					$lang
				)
			);
		}

		$out = array();
		if ( ! is_array( $rows ) ) {
			return $out;
		}
		foreach ( $rows as $row ) {
			$out[ (int) $row->product_id ] = self::unpack( $row->vector );
		}
		return $out;
	}

	/**
	 * Load vectors for a specific list of product IDs. Used by the hybrid
	 * retrieval path — only pulls the BLOBs for the FULLTEXT candidates,
	 * which on a 50k-product store is a few hundred rows instead of all 50k.
	 *
	 * @param int[] $ids
	 * @return array<int, float[]>
	 */
	public function load_for_ids( array $ids, string $provider, string $model, ?string $language = null ): array {
		if ( empty( $ids ) ) {
			return array();
		}
		global $wpdb;
		$table = $this->table();
		$ints  = array_values( array_filter( array_map( 'intval', $ids ), static fn( $i ) => $i > 0 ) );
		if ( empty( $ints ) ) {
			return array();
		}
		$placeholders = implode( ',', array_fill( 0, count( $ints ), '%d' ) );

		// Both lookups are safe: {$table} is a trusted prefixed identifier, {$placeholders}
		// is a generated list of %d tokens, and every user value ($provider, $model, $lang,
		// and the integer IDs) is bound through $wpdb->prepare(). Plugin Check cannot resolve
		// the interpolated identifier/placeholder list statically, so its DB-prepare sniffs are
		// suppressed for this already-prepared block.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		if ( null === $language ) {
			$sql = "SELECT product_id, vector FROM {$table}
			        WHERE provider = %s AND model = %s AND product_id IN ({$placeholders})";
			$rows = $wpdb->get_results(
				$wpdb->prepare( $sql, array_merge( array( $provider, $model ), $ints ) )
			);
		} else {
			$lang = self::normalize_language( $language );
			$sql  = "SELECT product_id, vector FROM {$table}
			         WHERE provider = %s AND model = %s AND language = %s AND product_id IN ({$placeholders})";
			$rows = $wpdb->get_results(
				$wpdb->prepare( $sql, array_merge( array( $provider, $model, $lang ), $ints ) )
			);
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		$out = array();
		if ( ! is_array( $rows ) ) {
			return $out;
		}
		foreach ( $rows as $row ) {
			$out[ (int) $row->product_id ] = self::unpack( $row->vector );
		}
		return $out;
	}

	public function count_for( string $provider, string $model, ?string $language = null ): int {
		global $wpdb;
		$table = $this->table();
		if ( null === $language ) {
			return (int) $wpdb->get_var(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT COUNT(*) FROM {$table} WHERE provider = %s AND model = %s",
					$provider,
					$model
				)
			);
		}
		$lang = self::normalize_language( $language );
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT COUNT(*) FROM {$table} WHERE provider = %s AND model = %s AND language = %s",
				$provider,
				$model,
				$lang
			)
		);
	}

	/**
	 * Most-recent `indexed_at` for the active provider+model, as a unix
	 * timestamp. Returns 0 when nothing's been indexed yet. Used by the
	 * admin hero to render "last sync N ago".
	 */
	public function last_indexed_at( string $provider, string $model, ?string $language = null ): int {
		global $wpdb;
		$table = $this->table();
		if ( null === $language ) {
			$value = $wpdb->get_var(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT MAX(indexed_at) FROM {$table} WHERE provider = %s AND model = %s",
					$provider,
					$model
				)
			);
		} else {
			$lang  = self::normalize_language( $language );
			$value = $wpdb->get_var(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT MAX(indexed_at) FROM {$table} WHERE provider = %s AND model = %s AND language = %s",
					$provider,
					$model,
					$lang
				)
			);
		}
		if ( ! $value ) {
			return 0;
		}
		$ts = strtotime( $value . ' UTC' );
		return $ts ? (int) $ts : 0;
	}

	/**
	 * Per-language counts for the active provider+model.
	 *
	 * @return array<string, int> language tag => indexed count, ordered desc by count
	 */
	public function languages_for( string $provider, string $model ): array {
		global $wpdb;
		$table = $this->table();
		$rows  = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT language, COUNT(*) AS n FROM {$table}
				 WHERE provider = %s AND model = %s
				 GROUP BY language
				 ORDER BY n DESC",
				$provider,
				$model
			)
		);
		$out = array();
		if ( ! is_array( $rows ) ) {
			return $out;
		}
		foreach ( $rows as $row ) {
			$out[ (string) $row->language ] = (int) $row->n;
		}
		return $out;
	}

	public function count_all(): int {
		global $wpdb;
		$table = $this->table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
	}

	public function truncate(): void {
		global $wpdb;
		$table = $this->table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
		$wpdb->query( "TRUNCATE TABLE {$table}" );
	}

	public static function dot( array $a, array $b ): float {
		$n = min( count( $a ), count( $b ) );
		$sum = 0.0;
		for ( $i = 0; $i < $n; $i++ ) {
			$sum += $a[ $i ] * $b[ $i ];
		}
		return $sum;
	}
}
