<?php
/**
 * This file is part of the Wisewand WordPress plugin.
 *
 * @package Wisewand
 * @since 1.6.13
 * @license GPL-2.0-or-later
 */

namespace Wisewand\Utils;

// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Wisewand\Logging\Logger;
use Wisewand\Repositories\Keyword_Repository;

/**
 * Semantic Deduplication Engine
 *
 * Uses OpenAI Embeddings (Vector Search) to find semantically similar keywords.
 * Replaces/Augments the legacy string-matching deduplication.
 */
class Semantic_Deduplication_Engine {

	private $logger;
	private $openai_client;
	private $repository;

	// Configurable thresholds (defaults match previous constants)
	private $threshold_duplicate;
	private $threshold_related;

	/**
	 * Constructor
	 *
	 * @param Logger             $logger                   Logger instance.
	 * @param OpenAI_Client      $openai_client           OpenAI client instance.
	 * @param Keyword_Repository $repository              Keyword repository instance.
	 * @param float              $threshold_duplicate     Semantic duplicate threshold (0.70-0.95). Default: 0.85.
	 * @param float              $threshold_related       Semantic related threshold (0.70-0.95). Default: 0.85.
	 *
	 * @since 2.4.2
	 */
	public function __construct( Logger $logger, OpenAI_Client $openai_client, Keyword_Repository $repository, $threshold_duplicate = 0.85, $threshold_related = 0.85 ) {
		$this->logger              = $logger;
		$this->openai_client       = $openai_client;
		$this->repository          = $repository;
		$this->threshold_duplicate = max( 0.70, min( 0.95, (float) $threshold_duplicate ) );
		$this->threshold_related   = max( 0.70, min( 0.95, (float) $threshold_related ) );
	}

	/**
	 * Generate embeddings for keywords that don't have them yet.
	 *
	 * CRITICAL: This must be called BEFORE detect_semantic_clusters()
	 * because existing keywords in DB may have NULL embeddings.
	 *
	 * @param array $keywords All keywords from library
	 * @param int   $batch_size Number of keywords per API call (max 100)
	 * @return array Stats about generation
	 */
	public function generate_missing_embeddings( $keywords, $batch_size = 50 ) {
		$stats = array(
			'total'     => count( $keywords ),
			'missing'   => 0,
			'generated' => 0,
			'failed'    => 0,
		);

		// Safety check
		if ( empty( $keywords ) ) {
			return $stats;
		}

		try {
			// Find keywords without embeddings
			$keywords_needing_embeddings = array();
			foreach ( $keywords as $kw ) {
				// Defensive: ensure we have the required keys
				if ( ! isset( $kw['id'] ) || ! isset( $kw['keyword'] ) ) {
					continue;
				}

				// Check if this keyword has an embedding
				$db_row = $this->repository->get_keyword( $kw['id'] );
				if ( $db_row && empty( $db_row->embedding ) ) {
					$keywords_needing_embeddings[] = array(
						'id'      => $kw['id'],
						'keyword' => $kw['keyword'],
					);
				}
			}

			$stats['missing'] = count( $keywords_needing_embeddings );

			if ( empty( $keywords_needing_embeddings ) ) {
				$this->logger->info( 'All keywords already have embeddings' );
				return $stats;
			}

			$this->logger->info( 'Generating embeddings for ' . $stats['missing'] . ' keywords' );

			// Process in batches
			$batches = array_chunk( $keywords_needing_embeddings, $batch_size );

			foreach ( $batches as $batch ) {
				$keyword_texts = array_column( $batch, 'keyword' );

				// Skip empty batches
				if ( empty( $keyword_texts ) ) {
					continue;
				}

				// Call OpenAI to get embeddings
				$embeddings = $this->openai_client->get_embeddings( $keyword_texts );

				if ( is_wp_error( $embeddings ) ) {
					$this->logger->error( 'Failed to generate batch embeddings', array( 'error' => $embeddings->get_error_message() ) );
					$stats['failed'] += count( $batch );
					continue;
				}

				// Safety check for embeddings response
				if ( ! is_array( $embeddings ) ) {
					$this->logger->error( 'Invalid embeddings response', array( 'type' => gettype( $embeddings ) ) );
					$stats['failed'] += count( $batch );
					continue;
				}

				// Save embeddings to database
				foreach ( $batch as $i => $kw_data ) {
					if ( isset( $embeddings[ $i ] ) && is_array( $embeddings[ $i ] ) ) {
						$serialized = serialize( $embeddings[ $i ] );
						$result     = $this->repository->update_keyword( $kw_data['id'], array( 'embedding' => $serialized ) );

						if ( $result ) {
							++$stats['generated'];
						} else {
							++$stats['failed'];
						}
					} else {
						++$stats['failed'];
					}
				}
			}

			$this->logger->info( 'Embedding generation complete', $stats );

		} catch ( \Exception $e ) {
			$this->logger->error( 'Exception in generate_missing_embeddings', array( 'error' => $e->getMessage() ) );
		}

		return $stats;
	}

	/**
	 * Find semantic duplicates for a keyword
	 *
	 * @param string $keyword The keyword to check
	 * @param string $language_code Language code
	 * @return array List of duplicates with similarity scores
	 */
	public function find_duplicates( $keyword, $language_code = 'en' ) {
		// 1. Generate embedding for the new keyword
		$embedding = $this->openai_client->get_embeddings( $keyword );

		if ( is_wp_error( $embedding ) ) {
			$this->logger->error( 'Failed to generate embedding for deduplication', array( 'error' => $embedding->get_error_message() ) );
			return array();
		}

		// 2. Fetch candidates from DB
		// Optimization: We can't compare against ALL keywords in PHP (too slow).
		// We fetch "rough candidates" first (e.g. same language, maybe partial word match if possible, or recent 1000).
		// For now, we'll fetch all keywords with embeddings in the same language.
		// TODO: Implement "Hybrid Filter" if > 2000 keywords.
		$candidates = $this->repository->get_keywords_with_embeddings( $language_code );

		$matches = array();

		foreach ( $candidates as $candidate ) {
			if ( empty( $candidate->embedding ) ) {
				continue;
			}

			// Unpack binary blob to array
			$candidate_vector = maybe_unserialize( $candidate->embedding );
			if ( ! is_array( $candidate_vector ) ) {
				continue;
			}

			// 3. Calculate Cosine Similarity
			$score = $this->cosine_similarity( $embedding, $candidate_vector );

			if ( $score >= $this->threshold_related ) {
				$matches[] = array(
					'keyword'      => $candidate->keyword,
					'id'           => $candidate->id,
					'score'        => $score,
					'is_duplicate' => $score >= $this->threshold_duplicate,
				);
			}
		}

		// Sort by score DESC
		usort(
			$matches,
			function ( $a, $b ) {
				return $b['score'] <=> $a['score'];
			}
		);

		return $matches;
	}

	/**
	 * Find semantic duplicates using a pre-calculated vector (Optimized for batching)
	 *
	 * @param array  $embedding_vector The 256-dim vector
	 * @param string $keyword The keyword text (for logging/reference)
	 * @param string $language_code Language code
	 * @return array List of duplicates
	 */
	public function find_duplicates_by_vector( $embedding_vector, $keyword, $language_code = 'en' ) {
		// Fetch candidates from DB
		// Optimization: We fetch all for the language. With 256 dims, 10k keywords = ~10MB RAM.
		// This is safe for now. If scaling > 50k, we need a limit or hybrid filter.
		$candidates = $this->repository->get_keywords_with_embeddings( $language_code );

		$matches = array();

		foreach ( $candidates as $candidate ) {
			if ( empty( $candidate->embedding ) ) {
				continue;
			}

			// Unpack binary blob to array
			$candidate_vector = maybe_unserialize( $candidate->embedding );
			if ( ! is_array( $candidate_vector ) ) {
				continue;
			}

			// Calculate Cosine Similarity
			$score = $this->cosine_similarity( $embedding_vector, $candidate_vector );

			if ( $score >= $this->threshold_related ) {
				$matches[] = array(
					'keyword'      => $candidate->keyword,
					'id'           => $candidate->id,
					'score'        => $score,
					'is_duplicate' => $score >= $this->threshold_duplicate,
				);
			}
		}

		// Sort by score DESC
		usort(
			$matches,
			function ( $a, $b ) {
				return $b['score'] <=> $a['score'];
			}
		);

		return $matches;
	}

	/**
	 * Detect semantic clusters across all keywords
	 *
	 * Compatible with Keyword_Cluster_Manager format
	 *
	 * @param array $keywords All keywords from library
	 * @return array Format: ['clusters' => [...], 'stats' => [...]]
	 */
	public function detect_semantic_clusters( $keywords ) {
		$clusters       = array();
		$processed_ids  = array();
		$total_keywords = count( $keywords );
		$cluster_id     = 1;

		// Group keywords by language first for efficiency
		$by_language = array();
		foreach ( $keywords as $kw ) {
			$lang                   = $kw['language_code'] ?? 'en';
			$by_language[ $lang ][] = $kw;
		}

		foreach ( $by_language as $lang => $lang_keywords ) {
			// Fetch all existing embeddings for this language
			$existing_embeddings = $this->repository->get_keywords_with_embeddings( $lang );
			$embedding_map       = array();

			foreach ( $existing_embeddings as $emb ) {
				$vector = maybe_unserialize( $emb->embedding );
				if ( is_array( $vector ) ) {
					$embedding_map[ $emb->id ] = array(
						'keyword' => $emb->keyword,
						'vector'  => $vector,
					);
				}
			}

			// Process each keyword
			foreach ( $lang_keywords as $kw ) {
				$kw_id = $kw['id'];

				// Skip if already in a cluster
				if ( in_array( $kw_id, $processed_ids ) ) {
					continue;
				}

				// Skip if no embedding
				if ( ! isset( $embedding_map[ $kw_id ] ) ) {
					continue;
				}

				$kw_vector = $embedding_map[ $kw_id ]['vector'];
				$similar   = array();

				// Compare against all other keywords in this language
				foreach ( $embedding_map as $candidate_id => $candidate_data ) {
					// Skip self
					if ( $candidate_id == $kw_id ) {
						continue;
					}

					// Skip if already processed
					if ( in_array( $candidate_id, $processed_ids ) ) {
						continue;
					}

					$score = $this->cosine_similarity( $kw_vector, $candidate_data['vector'] );

					if ( $score >= $this->threshold_related ) {
						$similar[] = array(
							'id'      => $candidate_id,
							'keyword' => $candidate_data['keyword'],
							'score'   => $score,
						);
					}
				}

				// If we found similar keywords, create a cluster
				if ( ! empty( $similar ) ) {
					// Sort by score
					usort(
						$similar,
						function ( $a, $b ) {
							return $b['score'] <=> $a['score'];
						}
					);

					// Extract variant IDs and similarities for Cluster Manager format
					$variant_ids  = array_column( $similar, 'id' );
					$similarities = array();
					$total_score  = 0;
					foreach ( $similar as $s ) {
						$similarities[ $s['id'] ] = $s['score'];
						$total_score             += $s['score'];
					}

					// Calculate average similarity
					$avg_similarity = count( $similar ) > 0 ? $total_score / count( $similar ) : 0;

					// Build cluster in format expected by Keyword_Cluster_Manager
					$cluster = array(
						'cluster_id'      => $cluster_id++,
						'primary_id'      => $kw_id,
						'primary_keyword' => $kw['keyword'],
						'variant_ids'     => $variant_ids,
						'keyword_ids'     => array_merge( array( $kw_id ), $variant_ids ), // All IDs including primary
						'variants'        => $similar,
						'similarities'    => $similarities,
						'size'            => 1 + count( $variant_ids ),
						'avg_similarity'  => round( $avg_similarity, 4 ),
						'language_code'   => $lang,
					);

					$clusters[] = $cluster;

					// Mark all as processed
					$processed_ids[] = $kw_id;
					foreach ( $similar as $variant ) {
						$processed_ids[] = $variant['id'];
					}
				}
			}
		}

		// Calculate stats
		$total_clusters = count( $clusters );
		$total_variants = 0;
		foreach ( $clusters as $cluster ) {
			$total_variants += count( $cluster['variants'] );
		}
		$unique_keywords = $total_keywords - $total_variants;

		return array(
			'clusters' => $clusters,
			'stats'    => array(
				'total_keywords' => $total_keywords,
				'clusters'       => $total_clusters,
				'variants'       => $total_variants,
				'unique'         => $unique_keywords,
			),
		);
	}

	/**
	 * Calculate Cosine Similarity between two vectors
	 *
	 * @param array $vec_a
	 * @param array $vec_b
	 * @return float Similarity score (-1 to 1)
	 */
	private function cosine_similarity( $vec_a, $vec_b ) {
		$dot_product = 0;
		$norm_a      = 0;
		$norm_b      = 0;

		foreach ( $vec_a as $key => $val ) {
			$dot_product += $val * $vec_b[ $key ];
			$norm_a      += $val * $val;
			$norm_b      += $vec_b[ $key ] * $vec_b[ $key ];
		}

		if ( $norm_a == 0 || $norm_b == 0 ) {
			return 0;
		}

		return $dot_product / ( sqrt( $norm_a ) * sqrt( $norm_b ) );
	}
}
