<?php
/**
 * Embeddings Manager.
 *
 * Handles the generation, storage, and retrieval of vector embeddings for
 * WordPress content. Provides semantic search capabilities and content
 * awareness for LLM interactions. Uses mysql-vector library for vector storage
 * and similarity search.
 *
 * @package   Agenistrator
 * @since     1.0.0
 * @author    Alex Ruban <alex@maximum.guru>
 * @author    Maximum Software Inc <contact@maximum.software>
 * @license   GPL-3.0-only
 * @link      https://maximum.software/
 *
 * SPDX-FileCopyrightText: 2025-2026 Alex Ruban <alex@maximum.guru>
 * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc <contact@maximum.software>
 * SPDX-License-Identifier: GPL-3.0-only
 */

namespace Agenistrator;

use MHz\MysqlVector\VectorTable;

// Direct access prevention
if ( ! defined( 'ABSPATH' ) ) {
	return;
}

/**
 * Embeddings_Manager class.
 */
class Embeddings_Manager extends Manager {
	/**
	 * Vector table instance for optimized vector operations (lazy loaded)
	 * @var VectorTable|null
	 */
	private $vector_table = null;

	/**
	 * Constructor - initializes the manager
	 * @since     1.0.0
	 */
	protected function __construct() {
		// Call parent constructor with option suffix
		parent::__construct( 'embeddings_settings' );
	}

	/**
	 * Get default configuration
	 *
	 * Returns default embeddings settings values.
	 *
	 * @return array Default configuration
	 * @since     1.0.0
	 */
	protected function get_default_config() {
		return array(
			'current_embedding_model'     => '',
			'current_embedding_dimension' => 0,
			'last_sync_timestamp'         => 0,
		);
	}

	/**
	 * Plugin activation tasks for embeddings
	 * @since     1.0.0
	 */
	public function activate_plugin() {
		// Call parent method to set default config
		parent::activate_plugin();

		// Schedule sync if embeddings model is configured
		if ( LLM_Manager::get_instance()->is_embeddings_model_configured() ) {
			if ( $this->has_existing_content_embeddings() ) {
				// Reactivation: incremental sync + cleanup orphans
				$this->schedule_sync_content_embeddings( false, true, true );
			} else {
				// Fresh start: full sync, no cleanup needed
				$this->schedule_sync_content_embeddings( true, false, false );
			}
		}
	}

	/**
	 * Plugin deactivation tasks for embeddings
	 *
	 * Preserve vector table and config on deactivation - only cancel any in-progress operations.
	 * The table is only dropped on uninstall or when user explicitly disables embeddings.
	 * @since     1.0.0
	 */
	public function deactivate_plugin() {
		$this->cancel_embeddings_generation();
	}

	/**
	 * Uninstall plugin
	 * @since     1.0.0
	 */
	public function uninstall_plugin() {
		$this->disable_embeddings();

		// Call parent method to delete config
		parent::uninstall_plugin();
	}

	/**
	 * Check if the database server supports required features for embeddings.
	 *
	 * Uses the mysql-vector library's feature detection to verify that
	 * virtual generated columns with indexes are supported.
	 * MySQL 5.7.8+ or MariaDB 10.2.7+ is required.
	 *
	 * @return bool True if database is compatible
	 * @since     1.0.0
	 */
	public function is_database_compatible() {
		return $this->make_vector_table( 1 )
				->isFeatureSupported( 'VirtualGeneratedColumns' );
	}

	/**
	 * Check if content embeddings exist in the vector table.
	 *
	 * Used during activation to determine whether to run incremental sync or full sync.
	 *
	 * @return bool True if embeddings exist, false otherwise.
	 */
	private function has_existing_content_embeddings() {
		$stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 );
		if ( 0 === $stored_dimension ) {
			return false;
		}

		try {
			$count = $this->get_vector_table()->count( array( '$.collection' => 'content' ) );
			return $count > 0;
		} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			return false;
		}
	}

	/**
	 * Initialize mysql-vector table with metadata indexes
	 *
	 * @param VectorTable $vector_table The vector table instance
	 * @throws \Exception If database is incompatible or initialization fails
	 */
	private function create_database_tables( $vector_table ) {
		// Check database compatibility before attempting initialization
		if ( ! $this->is_database_compatible() ) {
			throw new \Exception(
				__( 'Your database does not support the features required for embeddings. MySQL 5.7.8+ or MariaDB 10.2.7+ is required.', 'agenistrator' ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages are not HTML output
			);
		}

		try {
			// Clean possibly existing table that wasn't properly deinitialized previously
			$vector_table->deinitialize();
		} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Ignore drop errors
		}
		try {
			$vector_table->initialize(
				array(
					'$.collection'   => 'VARCHAR(50)',
					'$.content_type' => 'VARCHAR(50)',
					'$.content_id'   => 'BIGINT',
				)
			);
		} catch ( \Throwable $e ) {
			$error_message = String_Utils::replace_tags(
				__( 'Agenistrator: Vector table initialization failed: {error}', 'agenistrator' ),
				array( 'error' => $e->getMessage() )
			);
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for vector table failures
			error_log( $error_message );
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages are not HTML output
			throw new \Exception(
				String_Utils::replace_tags(
					__( 'Failed to initialize vector tables: {error}', 'agenistrator' ),
					array( 'error' => $e->getMessage() )
				)
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}
	}

	/**
	 * Disable embeddings by dropping the vector table and clearing config.
	 *
	 * Called when the user selects "None" for embeddings model, and also
	 * during plugin deactivation and uninstall.
	 * This is safe to call even if no vector table exists.
	 * @since     1.0.0
	 */
	public function disable_embeddings() {
		// Cancel any in-progress embeddings generation
		$this->cancel_embeddings_generation();

		// Get stored dimension to create vector table instance
		$stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 );

		// Only try to drop if we have a stored dimension (table was initialized)
		if ( $stored_dimension > 0 ) {
			try {
				$vector_table = $this->make_vector_table( $stored_dimension );
				$vector_table->deinitialize();
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Table might not exist, continue to clear config
			}
		}

		// Clear config state to indicate tables no longer exist
		$this->set_config( 'current_embedding_model', '' );
		$this->set_config( 'current_embedding_dimension', 0 );
		$this->save_config();

		// Clear cached vector table instance
		$this->vector_table = null;
	}

	/**
	 * Cancel any in-progress embeddings sync or cleanup.
	 *
	 * Cancels all running embeddings operations (sync and cleanup) and clears scheduled cron events.
	 * Safe to call even if no operations are running.
	 * @since     1.0.0
	 */
	public function cancel_embeddings_generation() {
		// Clear all scheduled cron events for sync and cleanup
		wp_unschedule_hook( 'agenistrator_sync_content_embeddings_batch' );
		wp_unschedule_hook( 'agenistrator_cleanup_orphaned_content_embeddings_batch' );

		// Cancel all running embeddings sync operations
		$running_trackers = Progress_Tracker::get_running( 'embeddings' );
		foreach ( $running_trackers as $tracker ) {
			$tracker->cancel();
		}

		// Cancel all running embeddings cleanup operations
		$running_cleanup_trackers = Progress_Tracker::get_running( 'embeddings_cleanup' );
		foreach ( $running_cleanup_trackers as $tracker ) {
			$tracker->cancel();
		}
	}

	/**
	 * Register all embeddings hooks
	 * @since     1.0.0
	 */
	public function register_hooks() {
		// Post hooks
		add_action( 'save_post', array( $this, 'save_post' ), 10, 2 );
		add_action( 'deleted_post', array( $this, 'deleted_post' ) );

		// Content embeddings sync batch action
		add_action( 'agenistrator_sync_content_embeddings_batch', array( $this, 'sync_content_embeddings_batch_action' ), 10, 6 );

		// Orphan embeddings cleanup batch action
		add_action( 'agenistrator_cleanup_orphaned_content_embeddings_batch', array( $this, 'cleanup_orphaned_content_embeddings_batch_action' ), 10, 3 );

		// Single post embeddings sync action
		add_action( 'agenistrator_sync_post_embeddings', array( $this, 'sync_post_embeddings_action' ), 10, 1 );
	}

	/**
	 * Schedule content embeddings sync via WP-Cron.
	 *
	 * Starts a batch processing operation that processes posts in chunks,
	 * self-scheduling continuation batches until all posts are processed.
	 *
	 * @param bool $force_all       If true, regenerate embeddings for all posts even if they already have embeddings.
	 * @param bool $run_cleanup     If true, schedule orphan cleanup after sync completes.
	 * @param bool $since_last_sync If true, only process posts modified since last_sync_timestamp (incremental).
	 *                              If false, walk through all posts to check for consistency.
	 * @throws \Exception If no embeddings model is configured.
	 * @since     1.0.0
	 */
	public function schedule_sync_content_embeddings( $force_all = false, $run_cleanup = false, $since_last_sync = true ) {
		// Fail early if no embeddings model is configured
		if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \Exception( __( 'No embeddings model configured. Please select an embeddings model in settings.', 'agenistrator' ) );
		}

		// Cancel any existing sync before starting a new one
		$this->cancel_embeddings_generation();

		// Determine modified_since timestamp (snapshot at schedule time to avoid race conditions)
		$modified_since = null;
		if ( $since_last_sync ) {
			$modified_since = $this->get_config( 'last_sync_timestamp', null );
			// If null or 0, this is first run - will process all posts
			if ( empty( $modified_since ) ) {
				$modified_since = null;
			}
		}

		// Get max post ID for progress tracking
		$max_post_id = $this->get_max_post_id();
		if ( 0 === $max_post_id ) {
			// No posts to process
			if ( $run_cleanup ) {
				$this->schedule_cleanup_orphaned_content_embeddings();
			}
			return;
		}

		// Start progress tracker
		$progress_tracker = Progress_Tracker::start( 'embeddings' );

		// Schedule the first sync batch
		wp_schedule_single_event(
			time() + 2,
			'agenistrator_sync_content_embeddings_batch',
			array(
				$progress_tracker->get_instance_id(),
				$modified_since,
				(bool) $force_all,
				(bool) $run_cleanup,
				$max_post_id,
				0, // last_post_id cursor
			)
		);
	}

	/**
	 * Cron action handler: process a batch of content embeddings sync.
	 *
	 * Processes posts in batches until time runs out or all posts are processed.
	 * Self-schedules the next batch if more posts remain.
	 *
	 * @param string   $instance_id    Progress tracker instance ID.
	 * @param int|null $modified_since Only sync posts modified after this Unix timestamp (null = all posts).
	 * @param bool     $force_all      If true, regenerate all embeddings.
	 * @param bool     $run_cleanup    If true, schedule orphan cleanup after sync completes.
	 * @param int      $max_post_id    Maximum post ID for progress calculation (batch state).
	 * @param int      $last_post_id   Last processed post ID cursor (batch state).
	 * @since     1.0.0
	 */
	public function sync_content_embeddings_batch_action( $instance_id, $modified_since, $force_all, $run_cleanup, $max_post_id, $last_post_id ) {
		$posts_per_batch  = 10;
		$batch_time_limit = PHP_Utils::get_available_execution_time( 0.5, 15, 300 );
		$start_time       = time();

		try {
			// Check if embeddings model is still configured
			if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) {
				return;
			}

			// Restore progress tracker
			$progress_tracker = Progress_Tracker::get_by_instance_id( $instance_id );
			if ( null === $progress_tracker || $progress_tracker->is_cancelled() ) {
				return;
			}

			// Process posts until time runs out or no more posts
			while ( ( time() - $start_time ) < $batch_time_limit ) {
				if ( $progress_tracker->is_cancelled() ) {
					return;
				}

				$posts = $this->query_posts_after_id( $last_post_id, $posts_per_batch, $modified_since );
				if ( empty( $posts ) ) {
					// Sync complete
					$total_posts = $this->count_posts_up_to_id( $max_post_id );
					$progress_tracker->complete( $total_posts );

					// Schedule cleanup after sync completes (if requested)
					if ( $run_cleanup ) {
						$this->schedule_cleanup_orphaned_content_embeddings();
					}
					return;
				}

				// process_posts() updates last_sync_timestamp based on post_modified_gmt
				$this->process_posts( $posts, $force_all );
				$last_post_id = end( $posts )->ID;

				// Update progress
				$processed_count = $this->count_posts_up_to_id( $last_post_id );
				$total_posts     = $this->count_posts_up_to_id( $max_post_id );
				$progress_tracker->update( $processed_count, $total_posts );
			}

			// Time limit reached - schedule next batch
			wp_schedule_single_event(
				time() + 2, // 2 second delay to avoid duplicate detection
				'agenistrator_sync_content_embeddings_batch',
				array( $instance_id, $modified_since, $force_all, $run_cleanup, $max_post_id, $last_post_id )
			);
		} catch ( \Throwable $e ) {
			$error_message = String_Utils::replace_tags(
				__( 'Agenistrator: Content embeddings sync failed: {error}', 'agenistrator' ),
				array( 'error' => $e->getMessage() )
			);
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for batch embeddings failures
			error_log( $error_message );
		}
	}

	/**
	 * Cron action handler: generate embeddings for a single post.
	 *
	 * @param int $post_id The post ID to generate embeddings for.
	 * @since     1.0.0
	 */
	public function sync_post_embeddings_action( $post_id ) {
		try {
			// Check if embeddings model is still configured (may have changed since scheduling)
			if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) {
				return;
			}

			$post = get_post( $post_id );
			if ( ! $post ) {
				return;
			}

			// Only process posts of supported types
			if ( ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
				return;
			}

			$this->process_posts( array( $post ), true );
		} catch ( \Throwable $e ) {
			$error_message = String_Utils::replace_tags(
				__( 'Agenistrator: Scheduled post embeddings generation failed for post {post_id}: {error}', 'agenistrator' ),
				array(
					'post_id' => $post_id,
					'error'   => $e->getMessage(),
				)
			);
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for post embeddings failures
			error_log( $error_message );
		}
	}

	/**
	 * Schedule orphaned content embeddings cleanup via WP-Cron.
	 *
	 * Cleans up vectors for content that no longer exists. Uses batch processing
	 * with pagination to handle large numbers of vectors.
	 */
	private function schedule_cleanup_orphaned_content_embeddings() {
		// Get total vector count for progress tracking
		try {
			$total_vectors = $this->get_vector_table()->count( array( '$.collection' => 'content' ) );
		} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			return; // Table doesn't exist or error
		}

		if ( 0 === $total_vectors ) {
			return; // Nothing to clean
		}

		// Start progress tracker (separate from sync)
		$progress_tracker = Progress_Tracker::start( 'embeddings_cleanup' );

		// Schedule the first cleanup batch
		wp_schedule_single_event(
			time() + 2,
			'agenistrator_cleanup_orphaned_content_embeddings_batch',
			array(
				$progress_tracker->get_instance_id(),
				$total_vectors,
				0, // offset
			)
		);
	}

	/**
	 * Cron action handler: process a batch of orphaned content embeddings cleanup.
	 *
	 * Uses mysql-vector's selectByMetadata() with pagination to iterate through vectors.
	 * Caches content existence checks per batch for efficiency.
	 *
	 * @param string $instance_id   Progress tracker instance ID.
	 * @param int    $total_vectors Total vectors for progress calculation.
	 * @param int    $offset        Current offset in vector table.
	 * @since     1.0.0
	 */
	public function cleanup_orphaned_content_embeddings_batch_action( $instance_id, $total_vectors, $offset ) {
		$vectors_per_batch = 100;
		$cache_limit       = 1000; // Per content_type
		$batch_time_limit  = PHP_Utils::get_available_execution_time( 0.5, 15, 300 );
		$start_time        = time();

		try {
			// Check if embeddings model is still configured
			if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) {
				return;
			}

			// Restore progress tracker
			$progress_tracker = Progress_Tracker::get_by_instance_id( $instance_id );
			if ( null === $progress_tracker || $progress_tracker->is_cancelled() ) {
				return;
			}

			// Bounded FIFO caches indexed by content_type to avoid unbounded memory growth
			$existence_cache = array(); // content_type => [ content_id => bool ]
			$deleted_cache   = array(); // content_type => [ content_id => true ]

			// Process vectors until time runs out or no more vectors
			while ( ( time() - $start_time ) < $batch_time_limit ) {
				if ( $progress_tracker->is_cancelled() ) {
					return;
				}

				// Fetch batch using library pagination
				$vectors = $this->get_vector_table()->selectByMetadata(
					array( '$.collection' => 'content' ),
					$vectors_per_batch,
					$offset,
					array( 'id' => 'ASC' )
				);

				if ( empty( $vectors ) ) {
					$progress_tracker->complete( $total_vectors );
					return;
				}

				$deleted_in_batch = 0;

				foreach ( $vectors as $vector ) {
					$metadata = $vector['metadata'];
					if ( ! isset( $metadata['content_id'] ) ) {
						continue;
					}

					$content_id   = (int) $metadata['content_id'];
					$content_type = $metadata['content_type'] ?? 'post';

					// Skip if already deleted earlier (within cache window)
					if ( isset( $deleted_cache[ $content_type ][ $content_id ] ) ) {
						++$deleted_in_batch;
						continue;
					}

					// Check existence (bounded cache with FIFO eviction per content_type)
					if ( ! isset( $existence_cache[ $content_type ][ $content_id ] ) ) {
						if ( ! isset( $existence_cache[ $content_type ] ) ) {
							$existence_cache[ $content_type ] = array();
						}
						if ( count( $existence_cache[ $content_type ] ) >= $cache_limit ) {
							array_shift( $existence_cache[ $content_type ] );
						}
						$existence_cache[ $content_type ][ $content_id ] = $this->content_exists( $content_type, $content_id );
					}

					if ( ! $existence_cache[ $content_type ][ $content_id ] ) {
						// Delete immediately - removes vectors for this content_type + content_id
						$this->delete_content_embeddings( $content_type, $content_id );

						// Add to deletion cache (bounded with FIFO eviction per content_type)
						if ( ! isset( $deleted_cache[ $content_type ] ) ) {
							$deleted_cache[ $content_type ] = array();
						}
						if ( count( $deleted_cache[ $content_type ] ) >= $cache_limit ) {
							array_shift( $deleted_cache[ $content_type ] );
						}
						$deleted_cache[ $content_type ][ $content_id ] = true;
						++$deleted_in_batch;
					}
				}

				// Adjust offset: subtract deleted vectors to avoid skipping unprocessed vectors
				$offset += count( $vectors ) - $deleted_in_batch;
				$progress_tracker->update( $offset, $total_vectors );
			}

			// Time limit reached - schedule next batch
			wp_schedule_single_event(
				time() + 2, // 2 second delay to avoid duplicate detection
				'agenistrator_cleanup_orphaned_content_embeddings_batch',
				array( $instance_id, $total_vectors, $offset )
			);
		} catch ( \Throwable $e ) {
			$error_message = String_Utils::replace_tags(
				__( 'Agenistrator: Orphan cleanup failed: {error}', 'agenistrator' ),
				array( 'error' => $e->getMessage() )
			);
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for orphan cleanup failures
			error_log( $error_message );
		}
	}

	/**
	 * Check if content exists for a given content type and ID.
	 *
	 * @param string $content_type The type of content (e.g., 'post').
	 * @param int    $content_id   The content ID.
	 * @return bool True if content exists, false otherwise.
	 */
	private function content_exists( $content_type, $content_id ) {
		switch ( $content_type ) {
			case 'post':
				return null !== get_post( $content_id );
			// Future content types: 'media', etc.
			default:
				// Unknown content type - treat as orphan
				return false;
		}
	}

	/**
	 * Create or replace the VectorTable instance for the given dimension
	 *
	 * @param int $dimension Vector dimension
	 * @return VectorTable
	 */
	private function make_vector_table( $dimension ) {
		global $wpdb;
		$table_base_name = $wpdb->prefix . 'agenistrator_embeddings';
		return new VectorTable( $wpdb->dbh, $table_base_name, $dimension );
	}

	/**
	 * Get the vector table instance, initializing it lazily if needed.
	 *
	 * @return VectorTable The vector table instance
	 * @throws \Exception If vector table initialization fails or no embeddings model configured
	 */
	private function get_vector_table() {
		$llm_manager = LLM_Manager::get_instance();

		// Fail if embeddings model is not configured
		if ( ! $llm_manager->is_embeddings_model_configured() ) {
			throw new \Exception( 'No embeddings model configured' );
		}

		// Determine current model
		$current_model = $llm_manager->get_embeddings_model_id();

		// Load stored state
		$stored_model     = $this->get_config( 'current_embedding_model', '' );
		$stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 );

		// Determine current dimension (only fetch dimension if model changed or not yet stored)
		// We are storing the dimension to 'cache' it to avoid repeated expensive get_embedding_model() calls
		// This works fine because normally the dimension does not change for a given model
		if ( $stored_model !== $current_model || 0 === $stored_dimension ) {
			// Model changed or set for the first time, we need to get dimension
			$current_dimension = $llm_manager->get_embedding_model()->get_dimension();
		} else {
			// Use previously stored dimension from config
			$current_dimension = $stored_dimension;
		}

		// Ensure instance exists
		if ( null === $this->vector_table ) {
			$this->vector_table = $this->make_vector_table( $current_dimension );
		}

		// Detect state changes
		$new_vector_table = '' === $stored_model || 0 === $stored_dimension;
		$model_changed    = ( $stored_model !== $current_model );

		// Handle table initialization or changes
		if ( $new_vector_table || $model_changed ) {
			try {
				// Model changed or first time - recreate vector table
				$this->create_database_tables( $this->vector_table ); // drops vector table if it already exists

				// Persist new state
				$this->set_config( 'current_embedding_model', $current_model );
				$this->set_config( 'current_embedding_dimension', $current_dimension );
				$this->save_config();
			} catch ( \Throwable $e ) {
				$error_message = String_Utils::replace_tags(
					__( 'Embedding model switch failed: {error}', 'agenistrator' ),
					array( 'error' => $e->getMessage() )
				);
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for model switch failures
				error_log( $error_message );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
				throw new \Exception( $error_message );
			}
		}

		return $this->vector_table;
	}

	/**
	 * Generate embedding for a single post.
	 *
	 * @param      int       $post_id     The post ID.
	 * @param      WP_Post   $post       The post object.
	 * @param      boolean   $update     Whether this is an existing post being updated or not.
	 * @since     1.0.0
	 */
	public function save_post( $post_id, $post = null ) {
		// If this is an autosave, don't do anything
		// WordPress creates a separate revision with a post status of auto-draft
		// The original post is not modified, so, we should ignore the action
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		// Only generate embeddings for posts and pages
		$post_type = $post ? $post->post_type : get_post_type( $post_id );
		if ( ! in_array( $post_type, array( 'post', 'page' ), true ) ) {
			return;
		}

		// Skip if embeddings model is not configured
		if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) {
			return;
		}

		// TODO: Determine what to do if embeddings regeneration is in progress, there may be a race condition caused by trying to generate embeddings twice for the same post at the same time.

		// Schedule embedding sync via WP-Cron to avoid blocking post saves.
		// Clear any existing scheduled event for this post first, so that rapid saves
		// result in only the final content being processed.
		$args = array( $post_id );
		wp_clear_scheduled_hook( 'agenistrator_sync_post_embeddings', $args );
		wp_schedule_single_event( time() + 2, 'agenistrator_sync_post_embeddings', $args );
	}

	/**
	 * Delete post embeddings when post is deleted.
	 *
	 * @param      int       $post_id     The post ID.
	 * @since     1.0.0
	 */
	public function deleted_post( $post_id ) {
		try {
			$this->delete_content_embeddings( 'post', $post_id );
		} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Silently fail - embedding deletion should not block post deletion
		}
	}

	/**
	 * Delete all embeddings for a specific content item.
	 *
	 * @param string $content_type The content type (e.g., 'post').
	 * @param int    $content_id   The content ID.
	 */
	private function delete_content_embeddings( $content_type, $content_id ) {
		$this->get_vector_table()->deleteByMetadata(
			array(
				'$.content_id'   => (int) $content_id,
				'$.content_type' => (string) $content_type,
				'$.collection'   => 'content',
			)
		);
	}

	/**
	 * Get the maximum post ID for supported post types.
	 *
	 * Used for progress tracking in batch processing.
	 *
	 * @return int Maximum post ID, or 0 if no posts exist.
	 */
	private function get_max_post_id() {
		global $wpdb;

		$post_types        = "'post', 'page'";
		$excluded_statuses = "'inherit', 'auto-draft', 'trash'";

		$query = "SELECT MAX(ID) FROM {$wpdb->posts}
			WHERE post_type IN ({$post_types})
			AND post_status NOT IN ({$excluded_statuses})";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Query uses only hardcoded string literals and $wpdb->posts table name; caching not applicable.
		$max_id = $wpdb->get_var( $query );
		return (int) $max_id;
	}

	/**
	 * Count posts with ID up to and including the given value.
	 *
	 * Used for accurate progress tracking - counts actual posts rather than using
	 * post IDs which may have gaps due to deleted posts.
	 *
	 * @param int $max_id Maximum post ID to count up to (inclusive).
	 * @return int Number of posts with ID <= $max_id.
	 */
	private function count_posts_up_to_id( $max_id ) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Progress tracking query; caching not applicable.
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->posts}
				WHERE ID <= %d
				AND post_type IN ('post', 'page')
				AND post_status NOT IN ('inherit', 'auto-draft', 'trash')",
				$max_id
			)
		);
	}

	/**
	 * Query posts with ID greater than the given value.
	 *
	 * @param int      $last_post_id   The last processed post ID (cursor).
	 * @param int      $limit          Maximum number of posts to return.
	 * @param int|null $modified_since Only return posts modified after this Unix timestamp (null = all posts).
	 * @return \WP_Post[] Array of post objects.
	 */
	private function query_posts_after_id( $last_post_id, $limit, $modified_since = null ) {
		$args = array(
			'post_type'               => array( 'post', 'page' ),
			'post_status'             => 'any',
			'posts_per_page'          => $limit,
			'orderby'                 => 'ID',
			'order'                   => 'ASC',
			'_agenistrator_id_gt'     => (int) $last_post_id,
			'_agenistrator_mod_since' => $modified_since,
		);

		$filter_callback = function ( $where, $query ) {
			global $wpdb;

			// ID > cursor
			$id = $query->get( '_agenistrator_id_gt' );
			if ( is_int( $id ) && $id >= 0 ) {
				$where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $id );
			}

			// post_modified_gmt >= timestamp (use GMT column for timezone safety)
			$mod_since = $query->get( '_agenistrator_mod_since' );
			if ( ! empty( $mod_since ) && is_int( $mod_since ) ) {
				$where .= $wpdb->prepare(
					" AND {$wpdb->posts}.post_modified_gmt >= %s",
					gmdate( 'Y-m-d H:i:s', $mod_since )
				);
			}

			return $where;
		};

		add_filter( 'posts_where', $filter_callback, 10, 2 );
		$query = new \WP_Query( $args );
		remove_filter( 'posts_where', $filter_callback, 10 );

		return $query->posts;
	}

	/**
	 * Process multiple posts for embedding generation using chunk-based batching.
	 *
	 * This method extracts chunks from posts and processes them in batches to
	 * leverage batch processing capabilities of provider APIs efficiently.
	 *
	 * Each chunk's metadata includes:
	 * - collection, content_type, content_id: Standard identifiers
	 * - chunk_source: 'title', 'excerpt', or 'content'
	 * - chunk_position: Position in markdown content (null for title/excerpt)
	 * - chunk_length: Length of chunk in characters (null for title/excerpt)
	 * - content_md5: MD5 hash of original post_content for change detection
	 *
	 * @param      array   $posts         Array of WP_Post objects to process
	 * @param      bool    $force_update  Whether to force update existing embeddings
	 */
	private function process_posts( $posts, $force_update = false ) {
		if ( empty( $posts ) ) {
			return;
		}

		$batch_size            = 100;   // Batch size for embedding generation
		$chunk_batch           = array();   // Current batch of items (max $batch_size)
		$max_post_modified_gmt = 0;     // Track max post_modified_gmt for timestamp update

		foreach ( $posts as $post ) {
			try {
				// Track max post_modified_gmt for timestamp update (true Unix timestamp)
				$post_modified_gmt_ts  = get_post_timestamp( $post, 'modified' );
				$max_post_modified_gmt = max( $max_post_modified_gmt, false !== $post_modified_gmt_ts ? $post_modified_gmt_ts : 0 );

				// Build base metadata for this post
				$base_metadata = array(
					'collection'   => 'content',
					'content_type' => 'post',
					'content_id'   => $post->ID,
				);

				if ( ! $force_update && $this->embedding_exists( $base_metadata ) ) {
					continue;
				}

				// Delete existing embeddings for this post
				$this->delete_content_embeddings( 'post', $post->ID );

				// Skip embedding generation for statuses that should not have embeddings.
				$excluded_statuses = array( 'inherit', 'auto-draft', 'trash' );
				if ( in_array( $post->post_status, $excluded_statuses, true ) ) {
					continue;
				}

				// Extract chunks from this post
				$result      = Chunk_Extractor::get_instance()->extract_chunks( $post );
				$chunks      = $result['chunks'];
				$content_md5 = $result['content_md5'];

				// Build items with metadata for each chunk
				foreach ( $chunks as $chunk_data ) {
					$chunk_metadata = array_merge(
						$base_metadata,
						array(
							'chunk_source'   => $chunk_data['source'],
							'chunk_position' => $chunk_data['position'],
							'chunk_length'   => $chunk_data['length'],
							'content_md5'    => $content_md5,
						)
					);

					$chunk_batch[] = array(
						'chunk'    => $chunk_data['content'],
						'metadata' => $chunk_metadata,
					);

					// Process batch when it reaches the configured batch size
					if ( count( $chunk_batch ) >= $batch_size ) {
						$this->store_chunk_batch( $chunk_batch );
						$chunk_batch = array();
					}
				}
			} catch ( \Throwable $e ) {
				$error_message = String_Utils::replace_tags(
					__( 'Agenistrator: Failed to process post {post_id} for embeddings: {error}', 'agenistrator' ),
					array(
						'post_id' => $post->ID,
						'error'   => $e->getMessage(),
					)
				);
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for embedding generation failures
				error_log( $error_message );
			}
		}

		// Process remaining chunks in final batch
		if ( ! empty( $chunk_batch ) ) {
			$this->store_chunk_batch( $chunk_batch );
		}

		// Update last_sync_timestamp (only advance, never regress)
		if ( $max_post_modified_gmt > 0 ) {
			$current_ts = (int) $this->get_config( 'last_sync_timestamp', 0 );
			if ( $max_post_modified_gmt > $current_ts ) {
				$this->set_config( 'last_sync_timestamp', $max_post_modified_gmt );
				$this->save_config();
			}
		}
	}

	/**
	 * Store a batch of chunks by generating embeddings and storing them.
	 *
	 * @param      array $items Array of items, each with 'chunk' and 'metadata' keys
	 */
	private function store_chunk_batch( $items ) {
		if ( empty( $items ) ) {
			return;
		}

		try {
			// Extract chunks for embedding generation
			$chunks = array_column( $items, 'chunk' );

			// Generate embeddings for all chunks in batch
			$embedding_model = LLM_Manager::get_instance()->get_embedding_model();
			$embeddings      = $embedding_model->generate_embeddings( $chunks );

			// Prepare batch insert items with metadata
			$vector_items = array();
			foreach ( $embeddings as $index => $embedding ) {
				if ( isset( $items[ $index ] ) && is_array( $embedding ) ) {
					$vector_items[] = array(
						'vector'   => $embedding,
						'metadata' => $items[ $index ]['metadata'],
					);
				}
			}

			if ( ! empty( $vector_items ) ) {
				$this->get_vector_table()->batchInsert( $vector_items );
			}
		} catch ( \Throwable $e ) {
			$error_message = String_Utils::replace_tags(
				__( 'Agenistrator: Chunk batch storage failed: {error}', 'agenistrator' ),
				array( 'error' => $e->getMessage() )
			);
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Production error logging for chunk batch failures
			error_log( $error_message );
		}
	}

	/**
	 * Check if an embedding exists for the given metadata.
	 *
	 * @param      array   $metadata  The metadata to check (e.g., ['collection' => 'content', 'content_id' => 123, 'content_type' => 'post'])
	 * @return     bool    True if embedding exists, false otherwise
	 */
	private function embedding_exists( $metadata ) {
		// Build metadata filter from provided metadata
		$filter = array();
		foreach ( $metadata as $key => $value ) {
			$filter[ '$.' . $key ] = $value;
		}

		$vectors = $this->get_vector_table()->selectByMetadata( $filter, 1 );
		return ! empty( $vectors );
	}

	/**
	 * Check if semantic search is operational.
	 *
	 * Verifies that embeddings model is configured AND vector tables have been
	 * initialized (stored model/dimension exist in config).
	 *
	 * @return bool True if semantic search can be performed
	 * @since     1.0.0
	 */
	public function is_search_operational() {
		if ( ! LLM_Manager::get_instance()->is_embeddings_model_configured() ) {
			return false;
		}
		$stored_model     = $this->get_config( 'current_embedding_model', '' );
		$stored_dimension = (int) $this->get_config( 'current_embedding_dimension', 0 );
		return '' !== $stored_model && $stored_dimension > 0;
	}

	/**
	 * Get the count of stored embeddings.
	 *
	 * @param      string|null  $collection  Optional collection to filter by
	 * @return     int          The number of stored embeddings.
	 * @since     1.0.0
	 */
	public function get_embeddings_count( $collection = null ) {
		try {
			$conditions = null !== $collection ? array( '$.collection' => $collection ) : null;
			return $this->get_vector_table()->count( $conditions );
		} catch ( \Throwable $e ) {
			// Return 0 if no embeddings model configured or any other error
			return 0;
		}
	}

	/**
	 * Search for similar content using semantic embeddings.
	 *
	 * @param string $query The search query string.
	 * @param int    $limit Maximum number of results.
	 * @return array Array of raw results with content_type and content_id.
	 * @throws \Exception If embeddings not configured or search fails.
	 * @since     1.0.0
	 */
	public function search_similar_content( $query, $limit = 5 ) {
		// Get the embedding model from the LLM Manager
		$embedding_model  = LLM_Manager::get_instance()->get_embedding_model();
		$query_embeddings = $embedding_model->generate_embeddings( array( $query ) );

		// Validate the embedding result
		if ( ! is_array( $query_embeddings ) || empty( $query_embeddings ) || ! is_array( $query_embeddings[0] ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \Exception( __( 'Query embedding generation failed', 'agenistrator' ) );
		}

		return $this->find_similar_content( $query_embeddings[0], $limit );
	}

	/**
	 * Find similar embeddings using mysql-vector optimized search with similarity threshold filtering.
	 *
	 * @param      array      $query_embedding    The query embedding.
	 * @param      string     $collection         The collection to search in.
	 * @param      int        $limit              The maximum number of results to return.
	 * @return     array                          Array of metadata from similar vectors.
	 */
	private function find_similar_embeddings( $query_embedding, $collection, $limit = 10 ) {

		// Minimum cosine similarity threshold
		$min_similarity = 0.3;

		$metadata_filter = array( '$.collection' => $collection );

		$vector_results = $this->get_vector_table()->search(
			$query_embedding,
			$metadata_filter,
			$limit,
			null,
			$min_similarity
		);

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

		$results = array();
		foreach ( $vector_results as $vector_result ) {
			if ( isset( $vector_result['metadata'] ) ) {
				$results[] = $vector_result['metadata'];
			}
		}

		return $results;
	}

	/**
	 * Find similar content from the 'content' collection with deduplication.
	 *
	 * Groups results by content_id while preserving all matching chunk metadata.
	 *
	 * @param      array      $query_embedding    The query embedding.
	 * @param      int        $limit              The maximum number of unique content items to return.
	 * @return     array                          Array of content results grouped by content_id, each with 'chunks' array.
	 */
	private function find_similar_content( $query_embedding, $limit = 10 ) {
		// Account for multiple vectors per content item (title, excerpt, sentences)
		// Each content item can potentially be mapped to multiple vectors, so we need to search more
		// vectors than the requested limit to find roughly the requested number of unique content items
		$vectors_per_content_item = 10; // Assume ~10 vectors per content item on average
		$vector_search_limit      = $limit * $vectors_per_content_item;

		// Get raw results from the low-level method
		$vector_results = $this->find_similar_embeddings( $query_embedding, 'content', $vector_search_limit );

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

		// Group results by content_id, collecting all matching chunks
		$results = array();
		foreach ( $vector_results as $metadata ) {
			if ( ! isset( $metadata['content_type'], $metadata['content_id'] ) ) {
				continue;
			}

			$content_key = $metadata['content_type'] . '_' . $metadata['content_id'];

			// Initialize new content item if not seen
			if ( ! isset( $results[ $content_key ] ) ) {
				// Stop if we've reached the limit of unique content items
				if ( count( $results ) >= $limit ) {
					break;
				}
				$results[ $content_key ] = array(
					'content_id'   => $metadata['content_id'],
					'content_type' => $metadata['content_type'],
					'chunks'       => array(),
				);
			}

			// Add chunk metadata
			$results[ $content_key ]['chunks'][] = array(
				'source'      => $metadata['chunk_source'] ?? null,
				'position'    => $metadata['chunk_position'] ?? null,
				'length'      => $metadata['chunk_length'] ?? null,
				'content_md5' => $metadata['content_md5'] ?? null,
			);
		}

		return array_values( $results );
	}

	/**
	 * Reconstruct a chunk from a post based on stored metadata.
	 *
	 * @param      \WP_Post $post The post object
	 * @param      array $chunk_meta Chunk metadata with 'source', 'position', 'length', 'content_md5'
	 * @return     string|null The chunk content, or null if content has changed
	 * @since     1.0.0
	 */
	public function get_chunk( $post, $chunk_meta ) {
		return Chunk_Extractor::get_instance()->reconstruct_chunk(
			$post,
			$chunk_meta['source'] ?? '',
			$chunk_meta['position'] ?? null,
			$chunk_meta['length'] ?? null,
			$chunk_meta['content_md5'] ?? null
		);
	}

	/**
	 * Delete all embeddings (optionally filtered by collection)
	 *
	 * @param      string|null  $collection  Optional collection to filter by.
	 * @throws \Exception If no embeddings model configured or deletion fails
	 * @since     1.0.0
	 */
	public function delete_all_embeddings( $collection = null ) {
		// get_vector_table() will throw if no embeddings model configured
		if ( null === $collection ) {
			// Delete all embeddings across all collections
			$this->get_vector_table()->truncate();
		} else {
			// Delete embeddings for specific collection
			$this->get_vector_table()->deleteByMetadata(
				array(
					'$.collection' => $collection,
				)
			);
		}
	}
}
