<?php
/**
 * Index Consumer Service
 *
 * Read-only interface for consuming the sharded AI Store Index.
 * Used by internal features like Category Description Generator and Chatbot.
 *
 * @package AIPT\Api\StoreIndex
 * @since 2.5.0
 * @updated 2.5.0 Refactored to use sharded index only (legacy single-file removed)
 */

namespace AIPT\Api\StoreIndex;

use AIPT\Api\StoreIndex\Util\QueryTokenizer;

/**
 * IndexConsumer class
 *
 * Provides read-only access to the AI Product Index for consumer features.
 * Uses ShardManager for intelligent shard selection and LRU caching.
 */
class IndexConsumer {

	/**
	 * ShardManager instance for sharded index access.
	 *
	 * @var ShardManager
	 */
	private $shard_manager;

	/**
	 * Profile ID.
	 *
	 * @var int
	 */
	private $profile_id;

	/**
	 * Constructor.
	 *
	 * @param int|null $profile_id Profile ID for the index to consume. If null/0, uses active profile.
	 */
	public function __construct( ?int $profile_id = null ) {
		if ( empty( $profile_id ) ) {

			$profile_manager = new ProfileManager();
			$active          = $profile_manager->getActive();

			if ( $active ) {
				$this->profile_id = (int) $active['id'];
			} else {

				$default          = $profile_manager->getDefault();
				$this->profile_id = $default ? (int) $default['id'] : 1;
			}
		} else {
			$this->profile_id = $profile_id;
		}
		$this->shard_manager = new ShardManager( $this->profile_id );
	}

	/**
	 * Check if index is available and valid.
	 *
	 * @return bool True if index can be consumed.
	 */
	public function isAvailable(): bool {
		return $this->shard_manager->isAvailable();
	}

	/**
	 * Get index metadata.
	 *
	 * @return array|null Meta section or null if unavailable.
	 */
	public function getMeta(): ?array {
		$map = $this->shard_manager->loadMap();
		return $map['meta'] ?? null;
	}

	/**
	 * Get all product IDs for a category.
	 *
	 * Uses intelligent shard selection for efficient retrieval.
	 *
	 * @param int $category_id WooCommerce category term ID.
	 * @return int[] Array of product IDs.
	 */
	public function getProductsByCategory( int $category_id ): array {
		$result = $this->shard_manager->getProductsByCategory( $category_id );
		return $result['product_ids'] ?? array();
	}

	/**
	 * Get AI profiles for specific product IDs.
	 *
	 * @param int[] $product_ids Array of product IDs.
	 * @return array<int, array> Map of product_id => AIProfile.
	 */
	public function getProductsByIds( array $product_ids ): array {
		return $this->shard_manager->getProductsByIds( $product_ids );
	}

	/**
	 * Filter products by price range.
	 *
	 * When $shard_hints (category IDs) are provided, only the matching shards are loaded,
	 * which is significantly more memory-efficient for large catalogs (50K+ products).
	 * Without hints, falls back to loadAllProducts() — see its deprecation notice.
	 *
	 * @param float $min         Minimum price (inclusive).
	 * @param float $max         Maximum price (inclusive).
	 * @param int[] $shard_hints Optional category IDs used to resolve relevant shards.
	 *                           Pass category IDs to avoid loading all shards into memory.
	 * @return int[] Matching product IDs.
	 */
	public function filterByPrice( float $min, float $max, array $shard_hints = array(), bool $allow_full_scan = false ): array {
		$products = $this->loadProductsForFilter( $shard_hints, $allow_full_scan );

		if ( empty( $products ) ) {
			return array();
		}

		$results = array();
		foreach ( $products as $id => $profile ) {
			$price = (float) ( $profile['price'] ?? 0 );
			if ( $price >= $min && $price <= $max ) {
				$results[] = (int) $id;
			}
		}

		return $results;
	}

	/**
	 * Filter products by stock status.
	 *
	 * When $shard_hints (category IDs) are provided, only the matching shards are loaded,
	 * which is significantly more memory-efficient for large catalogs (50K+ products).
	 * Without hints, falls back to loadAllProducts() — see its deprecation notice.
	 *
	 * @param string $status      'instock'|'outofstock'|'onbackorder'.
	 * @param int[]  $shard_hints Optional category IDs used to resolve relevant shards.
	 *                            Pass category IDs to avoid loading all shards into memory.
	 * @return int[] Matching product IDs.
	 */
	public function filterByStock( string $status, array $shard_hints = array(), bool $allow_full_scan = false ): array {
		$products = $this->loadProductsForFilter( $shard_hints, $allow_full_scan );

		if ( empty( $products ) ) {
			return array();
		}

		$results = array();
		foreach ( $products as $id => $profile ) {

			if ( ( $profile['stock_status'] ?? '' ) === $status ) {
				$results[] = (int) $id;
			}
		}

		return $results;
	}

	/**
	 * Filter products by attribute value.
	 *
	 * When $shard_hints (category IDs) are provided, only the matching shards are loaded,
	 * which is significantly more memory-efficient for large catalogs (50K+ products).
	 * Without hints, falls back to loadAllProducts() — see its deprecation notice.
	 *
	 * @param string $attribute   Attribute slug (e.g., 'pa_color').
	 * @param string $value       Attribute value (e.g., 'Red').
	 * @param int[]  $shard_hints Optional category IDs used to resolve relevant shards.
	 *                            Pass category IDs to avoid loading all shards into memory.
	 * @return int[] Matching product IDs.
	 */
	public function filterByAttribute( string $attribute, string $value, array $shard_hints = array(), bool $allow_full_scan = false ): array {
		$products = $this->loadProductsForFilter( $shard_hints, $allow_full_scan );

		if ( empty( $products ) ) {
			return array();
		}

		$results = array();
		foreach ( $products as $id => $profile ) {
			$attributes = $profile['attributes'] ?? array();
			if ( isset( $attributes[ $attribute ] ) && stripos( $attributes[ $attribute ], $value ) !== false ) {
				$results[] = (int) $id;
			}
		}

		return $results;
	}

	/**
	 * Apply multiple filters with AND logic.
	 *
	 * When $shard_hints (category IDs) are provided — or when 'category' is present in $filters —
	 * only the matching shards are loaded, which is significantly more memory-efficient for large
	 * catalogs (50K+ products). Without hints, falls back to loadAllProducts() — see its deprecation notice.
	 *
	 * @param array $filters     Array of filter criteria:
	 *   - 'category'   => int
	 *   - 'price_min'  => float
	 *   - 'price_max'  => float
	 *   - 'stock'      => string
	 *   - 'attributes' => ['slug' => 'value', ...].
	 * @param int[] $shard_hints Optional category IDs used to resolve relevant shards.
	 *                           When 'category' key is present in $filters it is automatically
	 *                           added to the hint set; explicit hints extend that set.
	 * @return int[] Matching product IDs (intersection of all filters).
	 */
	public function filterProducts( array $filters, array $shard_hints = array(), bool $allow_full_scan = false ): array {

		if ( isset( $filters['category'] ) ) {
			$shard_hints = array_unique( array_merge( $shard_hints, array( (int) $filters['category'] ) ) );
		}

		$products = $this->loadProductsForFilter( $shard_hints, $allow_full_scan );

		if ( empty( $products ) ) {
			return array();
		}

		if ( isset( $filters['category'] ) ) {
			$result_ids = $this->getProductsByCategory( (int) $filters['category'] );
		} else {
			$result_ids = array_map( 'intval', array_keys( $products ) );
		}

		if ( isset( $filters['price_min'] ) || isset( $filters['price_max'] ) ) {
			$min        = (float) ( $filters['price_min'] ?? 0 );
			$max        = (float) ( $filters['price_max'] ?? PHP_FLOAT_MAX );
			$price_ids  = $this->filterByPrice( $min, $max, $shard_hints, $allow_full_scan );
			$result_ids = array_values( array_intersect( $result_ids, $price_ids ) );
		}

		if ( ! empty( $filters['stock'] ) ) {
			$stock_ids  = $this->filterByStock( $filters['stock'], $shard_hints, $allow_full_scan );
			$result_ids = array_values( array_intersect( $result_ids, $stock_ids ) );
		}

		if ( ! empty( $filters['attributes'] ) && is_array( $filters['attributes'] ) ) {
			foreach ( $filters['attributes'] as $attr_slug => $attr_value ) {
				$attr_ids   = $this->filterByAttribute( $attr_slug, $attr_value, $shard_hints, $allow_full_scan );
				$result_ids = array_values( array_intersect( $result_ids, $attr_ids ) );
			}
		}

		return $result_ids;
	}

	/**
	 * Get all product IDs in the index.
	 *
	 * @return int[] Array of all product IDs.
	 */
	public function getAllProductIds(): array {
		$products = $this->loadAllProducts();

		if ( empty( $products ) ) {
			return array();
		}

		return array_map( 'intval', array_keys( $products ) );
	}

	/**
	 * Get all category IDs in the index.
	 *
	 * @return int[] Array of all category IDs.
	 */
	public function getAllCategoryIds(): array {
		$map = $this->shard_manager->loadMap();

		if ( $map === null || empty( $map['categories'] ) ) {
			return array();
		}

		return array_map( 'intval', array_keys( $map['categories'] ) );
	}

	/**
	 * Load products from specific shards identified by category hints.
	 *
	 * When category IDs are provided, resolves the relevant shards via ShardManager
	 * and loads only those — avoiding the full-catalog memory blow-up on large stores.
	 *
	 * When NO hints are provided, falls back to `loadAllProducts()` ONLY when the
	 * caller explicitly opts in via `$allow_full_scan = true`. Otherwise returns
	 * `[]` so a 50K-product catalog cannot be loaded into memory accidentally by
	 * a REST/chatbot caller that forgot to pass shard hints (T2-Mem). Admin
	 * scripts and tests that genuinely need a full scan call
	 * `loadAllProductsUnsafe()` instead.
	 *
	 * @param int[] $category_ids    Category IDs to use as shard hints. Empty = no hint.
	 * @param bool  $allow_full_scan When true, no-hint queries fall through to
	 *                               the full-load path. Default false.
	 * @return array Products array (product_id => profile).
	 */
	private function loadProductsForFilter( array $category_ids, bool $allow_full_scan = false ): array {
		if ( ! empty( $category_ids ) ) {
			$shard_ids = $this->shard_manager->getShardsForCategories( $category_ids );

			if ( ! empty( $shard_ids ) ) {
				$merged = $this->shard_manager->loadAndMergeShards( $shard_ids );
				return $merged['products'] ?? array();
			}
		}

		if ( ! $allow_full_scan ) {

			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {

				error_log(
					'[AIPT][IndexConsumer] full-scan blocked — no shard hints and $allow_full_scan=false. ' .
					'Pass shard_hints (category IDs) or call loadAllProductsUnsafe() for admin/test use.'
				);
			}
			return array();
		}

		return $this->loadAllProducts();
	}

	/**
	 * Public escape hatch for admin scripts and tests that legitimately
	 * need a true full-catalog scan.
	 *
	 * REST and chatbot consumers MUST NOT call this directly — they should
	 * pass `shard_hints` to `filterBy*` or, in genuinely unusual cases, pass
	 * `$allow_full_scan = true`. This wrapper exists so admin tooling can
	 * still get at every product without bypassing the public API surface.
	 *
	 * Memory cost is the same as before T2-Mem — the only behavioural change
	 * is that the caller has explicitly named the cost in code.
	 *
	 * @since 2.6.1 (T2-Mem)
	 * @return array Products array keyed by product ID.
	 */
	public function loadAllProductsUnsafe(): array {
		return $this->loadAllProducts();
	}

	/**
	 * Load all products from all shards.
	 *
	 * This loads ALL shards via LRU cache and can cause significant memory pressure
	 * on large catalogs (50K+ products across 5+ shards). Prefer category-based
	 * queries via loadProductsForFilter() with explicit shard hints wherever possible.
	 *
	 * @deprecated 2.6.0 Use loadProductsForFilter(['cat-id-1', 'cat-id-2']) for production
	 *             paths that have category context. Pass $shard_hints to filterByPrice(),
	 *             filterByStock(), filterByAttribute(), or filterProducts() instead.
	 *             Admin/test callers that need the full scan should call
	 *             {@see self::loadAllProductsUnsafe()} (T2-Mem).
	 *
	 * @return array Products array (product_id => profile).
	 */
	private function loadAllProducts(): array {

		if (
			function_exists( 'is_admin' ) && ! is_admin()
			&& defined( 'WP_DEBUG' ) && WP_DEBUG
		) {

			error_log(
				'[AIPT][IndexConsumer] loadAllProducts() called from non-admin context — ' .
				'this loads ALL shards and may cause memory issues on large catalogs. ' .
				'Pass $shard_hints (category IDs) to filterByPrice/filterByStock/filterByAttribute/filterProducts instead. ' .
				'Deprecated since 2.6.0.'
			);
		}

		$map = $this->shard_manager->loadMap();
		if ( $map === null ) {
			return array();
		}

		$shard_count = $map['meta']['total_shards'] ?? 0;
		if ( $shard_count === 0 ) {
			return array();
		}

		$shard_ids = range( 0, $shard_count - 1 );
		$merged    = $this->shard_manager->loadAndMergeShards( $shard_ids );

		return $merged['products'] ?? array();
	}

	/**
	 * Clear the ShardManager cache.
	 *
	 * Call this after index is rebuilt to ensure fresh data.
	 *
	 * @return void
	 */
	public function clearCache(): void {
		$this->shard_manager->clearCache();
	}

	/**
	 * Get the number of products in the index.
	 *
	 * @return int Product count.
	 */
	public function getProductCount(): int {
		$map = $this->shard_manager->loadMap();
		return $map['meta']['total_products'] ?? 0;
	}

	/**
	 * Get the number of categories in the index.
	 *
	 * @return int Category count.
	 */
	public function getCategoryCount(): int {
		$map = $this->shard_manager->loadMap();
		return $map['meta']['total_categories'] ?? 0;
	}

	/**
	 * Get the number of shards in the index.
	 *
	 * @return int Shard count.
	 */
	public function getShardCount(): int {
		return $this->shard_manager->getShardCount();
	}

	/**
	 * Get cache statistics for debugging.
	 *
	 * @return array Cache stats from ShardManager.
	 */
	public function getCacheStats(): array {
		return $this->shard_manager->getCacheStats();
	}

	/**
	 * Search products by keywords with relevance scoring.
	 *
	 * Uses intelligent shard selection based on query analysis
	 * to minimize I/O while maximizing relevance.
	 *
	 * @param string $query       Natural language query string.
	 * @param int    $max_results Maximum number of results to return.
	 * @return array Product rows sorted by relevance, or no-match signal with products, no_match, reason, attempted_tokens.
	 */
	public function searchByKeywords( string $query, int $max_results = 10 ): array {

		$query_lower = QueryTokenizer::normalize( $query );
		if ( empty( $query_lower ) ) {
			return array();
		}

		$query_words = QueryTokenizer::tokenize( $query );

		$shard_hints = $this->shard_manager->getShardsForQuery( $query_lower );
		$shard_ids   = $shard_hints['shard_ids'];
		$confidence  = $shard_hints['confidence'];

		if ( empty( $shard_ids ) && $confidence <= 0 ) {
			return array();
		}

		if ( empty( $shard_ids ) || $confidence < 0.3 ) {
			$shard_ids = $this->shard_manager->getDefaultShards( 3 );
		}

		$max_shards = (int) apply_filters( 'aipt_max_search_shards', 5 );
		$shard_ids  = array_slice( $shard_ids, 0, $max_shards );

		$merged   = $this->shard_manager->loadAndMergeShards( $shard_ids );
		$products = $merged['products'] ?? array();

		$categories_map = $merged['categories'] ?? array();

		if ( empty( $products ) ) {
			return $this->getNoMatchSignal( $query_words );
		}

		if ( empty( $query_words ) ) {
			if ( ! $this->allowRandomFallback() ) {
				return $this->getNoMatchSignal( $query_words );
			}

			return $this->getDefaultProducts( $products, $max_results );
		}

		return $this->scoreAndRankProducts( $products, $query_words, $query_lower, $max_results, $categories_map );
	}

	/**
	 * Score and rank products by relevance.
	 *
	 * @param array  $products    Products to score.
	 * @param array  $query_words Query words.
	 * @param string $query_lower Full query lowercase.
	 * @param int    $max_results Maximum results.
	 * @return array Ranked results.
	 */
	private function scoreAndRankProducts( array $products, array $query_words, string $query_lower, int $max_results, array $categories_map = array() ): array {
		$top_results = array();
		$min_score   = 0;

		foreach ( $products as $product_id => $profile ) {
			$score = $this->scoreProduct( $profile, $query_words, $query_lower, $categories_map );

			if ( $score <= 0 ) {
				continue;
			}

			if ( count( $top_results ) < $max_results ) {
				$top_results[ (int) $product_id ] = array(
					'score'   => $score,
					'profile' => $profile,
				);
				$min_score = min( $min_score ?: $score, $score );
			} elseif ( $score > $min_score ) {

				$min_score_value = PHP_INT_MAX;
				$min_id          = null;

				foreach ( $top_results as $pid => $data ) {
					if ( $data['score'] < $min_score_value ) {
						$min_score_value = $data['score'];
						$min_id          = $pid;
					}
				}

				if ( null !== $min_id ) {
					unset( $top_results[ $min_id ] );
				}

				$top_results[ (int) $product_id ] = array(
					'score'   => $score,
					'profile' => $profile,
				);

				$scores    = array_column( $top_results, 'score' );
				$min_score = ! empty( $scores ) ? min( $scores ) : 0;
			}
		}

		if ( empty( $top_results ) ) {
			if ( ! $this->allowRandomFallback() ) {
				return $this->getNoMatchSignal( $query_words );
			}

			return $this->getDefaultProducts( $products, $max_results );
		}

		uasort(
			$top_results,
			function ( $a, $b ) {
				return $b['score'] - $a['score'];
			}
		);

		$sorted = array();
		foreach ( $top_results as $product_id => $data ) {
			$sorted[] = array(
				'product_id' => $product_id,
				'score'      => $data['score'],
				'profile'    => $data['profile'],
			);
		}

		$count = count( $sorted );
		if ( $count > 1 ) {
			$i = 0;
			while ( $i < $count ) {
				$current_score = $sorted[ $i ]['score'];
				$tie_start     = $i;

				while ( $i < $count && $sorted[ $i ]['score'] === $current_score ) {
					++$i;
				}

				if ( $i - $tie_start > 1 ) {
					$segment = array_slice( $sorted, $tie_start, $i - $tie_start );
					shuffle( $segment );
					array_splice( $sorted, $tie_start, $i - $tie_start, $segment );
				}
			}
		}

		$results = array();
		foreach ( $sorted as $item ) {
			$results[] = array_merge(
				array( 'product_id' => $item['product_id'] ),
				$item['profile']
			);
		}

		return $results;
	}

	/**
	 * Calculate relevance score for a product.
	 *
	 * @param array  $profile     Product profile from index.
	 * @param array  $query_words Array of query words (lowercase, min 3 chars).
	 * @param string $query_lower Full query string in lowercase.
	 * @return int Relevance score (0 = no match).
	 */
	private function scoreProduct( array $profile, array $query_words, string $query_lower, array $categories_map = array() ): int {
		$score = 0;

		$title           = QueryTokenizer::normalize( $profile['title'] ?? '' );
		$description     = QueryTokenizer::normalize( $profile['short_description'] ?? '' );
		$sku             = QueryTokenizer::normalize( $profile['sku'] ?? '' );
		$attributes      = array_map(
			static function ( $attribute ) {
				return QueryTokenizer::normalize( (string) $attribute );
			},
			array_values( $profile['attributes'] ?? array() )
		);
		$attributes_text = implode( ' ', $attributes );

		$category_names = array();
		foreach ( (array) ( $profile['categories'] ?? array() ) as $cat_id ) {
			$cat_name = (string) ( $categories_map[ (int) $cat_id ]['name'] ?? '' );
			if ( $cat_name !== '' ) {
				$category_names[] = QueryTokenizer::normalize( $cat_name );
			}
		}
		$categories_text = implode( ' ', $category_names );

		foreach ( $query_words as $word ) {

			if ( strpos( $title, $word ) !== false ) {
				$score += 10;
			}

			if ( ! empty( $sku ) && strpos( $sku, $word ) !== false ) {
				$score += 8;
			}

			if ( ! empty( $categories_text ) && strpos( $categories_text, $word ) !== false ) {
				$score += 6;
			}

			if ( strpos( $description, $word ) !== false ) {
				$score += 5;
			}

			if ( strpos( $attributes_text, $word ) !== false ) {
				$score += 3;
			}
		}

		foreach ( $query_words as $word ) {
			$stem = QueryTokenizer::stem( $word );
			if ( $stem === $word || $stem === '' ) {
				continue;
			}
			if ( strpos( $title, $stem ) !== false ) {
				$score += 5;
			}
			if ( ! empty( $sku ) && strpos( $sku, $stem ) !== false ) {
				$score += 4;
			}
			if ( ! empty( $categories_text ) && strpos( $categories_text, $stem ) !== false ) {
				$score += 3;
			}
			if ( strpos( $description, $stem ) !== false ) {
				$score += 2;
			}
			if ( strpos( $attributes_text, $stem ) !== false ) {
				$score += 1;
			}
		}

		$title_normalized = $this->stripSeparators( $title );
		$query_normalized = $this->stripSeparators( $query_lower );

		if ( ! empty( $query_normalized ) && ! empty( $title_normalized ) ) {
			if ( strpos( $title_normalized, $query_normalized ) !== false ) {
				$score += 15;
			} elseif ( strpos( $query_normalized, $title_normalized ) !== false ) {
				$score += 12;
			}
		}

		if ( ! empty( $sku ) && ! empty( $query_normalized ) ) {
			$sku_normalized = $this->stripSeparators( $sku );
			if ( ! empty( $sku_normalized ) && strpos( $sku_normalized, $query_normalized ) !== false ) {
				$score += 15;
			}
		}

		if ( ! empty( $query_lower ) && strpos( $title, $query_lower ) !== false ) {
			$score += 20;
		}

		if ( $score > 0 && ( $profile['stock_status'] ?? '' ) === 'instock' ) {
			$score += 2;
		}

		return $score;
	}

	/**
	 * Strip common separators for product code matching.
	 *
	 * Removes spaces, hyphens, underscores, dots, and em/en dashes to enable
	 * matching queries like "HP301" against titles like "HP 301 XL" or "TN-2420".
	 *
	 * @param string $text Text to normalize.
	 * @return string Text with all separators removed.
	 */
	private function stripSeparators( string $text ): string {
		return str_replace(
			array( ' ', '-', '_', '.', "\xE2\x80\x93", "\xE2\x80\x94" ),
			'',
			$text
		);
	}

	/**
	 * Check whether the legacy random fallback is explicitly allowed.
	 *
	 * @return bool True when callers opt into random default products on no-match.
	 */
	private function allowRandomFallback(): bool {
		return (bool) apply_filters( 'aipt_storeindex_allow_random_fallback', false );
	}

	/**
	 * Build a structured no-match signal for callers that can handle it.
	 *
	 * @param array $query_words Query tokens attempted during search.
	 * @return array Structured no-match signal.
	 */
	private function getNoMatchSignal( array $query_words ): array {
		return array(
			'products'         => array(),
			'no_match'         => true,
			'reason'           => 'no_keyword_overlap',
			'attempted_tokens' => array_values( $query_words ),
		);
	}

	/**
	 * Get default products when no search matches.
	 *
	 * Returns a randomized selection of in-stock products first, then any products up to limit.
	 * Shuffling provides variety so users don't see the same products on every query.
	 *
	 * @param array $products    Products array from index.
	 * @param int   $max_results Maximum results to return.
	 * @return array Formatted product results.
	 */
	private function getDefaultProducts( array $products, int $max_results ): array {
		$in_stock     = array();
		$out_of_stock = array();

		foreach ( $products as $product_id => $profile ) {
			$formatted = array_merge(
				array( 'product_id' => (int) $product_id ),
				$profile
			);

		if ( ( $profile['stock_status'] ?? '' ) === 'instock' ) {
				$in_stock[] = $formatted;
			} else {
				$out_of_stock[] = $formatted;
			}
		}

		shuffle( $in_stock );
		shuffle( $out_of_stock );

		$results = array_slice( $in_stock, 0, $max_results );

		$remaining = $max_results - count( $results );
		if ( $remaining > 0 && ! empty( $out_of_stock ) ) {
			$results = array_merge( $results, array_slice( $out_of_stock, 0, $remaining ) );
		}

		return $results;
	}
}
