Source: Features/TermCleanup.php

<?php

namespace Classifai\Features;

use Classifai\Admin\SimilarTermsListTable;
use Classifai\Services\LanguageProcessing;
use Classifai\Providers\OpenAI\Embeddings as OpenAIEmbeddings;
use Classifai\Providers\Azure\Embeddings as AzureEmbeddings;
use Classifai\Providers\OpenAI\EmbeddingCalculations;
use Classifai\TermCleanupScheduler;
use WP_Error;

use function Classifai\is_elasticpress_installed;

/**
 * Class TermCleanup
 */
class TermCleanup extends Feature {

	/**
	 * ID of the current feature.
	 *
	 * @var string
	 */
	const ID = 'feature_term_cleanup';

	/**
	 * Setting page URL.
	 *
	 * @var string
	 */
	private $setting_page_url;

	/**
	 * Background process instance.
	 *
	 * @var TermCleanupScheduler
	 */
	private $background_process;

	/**
	 * Transient key for notices.
	 *
	 * @var string
	 */
	private $notices_transient_key = 'classifai_term_cleanup_notices';

	/**
	 * EPIntegration instance.
	 *
	 * @var EPIntegration
	 */
	private $ep_integration;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->label = __( 'Term Cleanup', 'classifai' );

		// Contains all providers that are registered to the service.
		$this->provider_instances = $this->get_provider_instances( LanguageProcessing::get_service_providers() );

		// Contains just the providers this feature supports.
		$this->supported_providers = [
			OpenAIEmbeddings::ID => __( 'OpenAI Embeddings', 'classifai' ),
			AzureEmbeddings::ID  => __( 'Azure OpenAI Embeddings', 'classifai' ),
		];
	}

	/**
	 * Set up necessary hooks.
	 *
	 * This will always fire even if the Feature is not enabled.
	 */
	public function setup() {
		parent::setup();

		if ( $this->is_configured() && $this->is_enabled() ) {
			// Check if ElasticPress plugin is installed and use EP selected.
			if ( is_elasticpress_installed() && '1' === $this->get_settings( 'use_ep' ) ) {
				$this->ep_integration = new TermCleanupEPIntegration( $this );
				$this->ep_integration->init();
			}
		}

		$this->setting_page_url = admin_url( 'tools.php?page=classifai-term-cleanup' );

		$this->background_process = new TermCleanupScheduler( 'classifai_schedule_term_cleanup_job' );
		$this->background_process->init();
	}

	/**
	 * Set up necessary hooks.
	 *
	 * This will only fire if the Feature is enabled.
	 */
	public function feature_setup() {
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );

		// Register the settings page for the Feature.
		add_action( 'admin_menu', [ $this, 'register_admin_menu_item' ] );
		add_action( 'admin_post_classifai_init_term_cleanup', [ $this, 'start_term_cleanup_process' ] );
		add_action( 'admin_post_classifai_cancel_term_cleanup', [ $this, 'cancel_term_cleanup_process' ] );
		add_action( 'admin_post_classifai_merge_term', [ $this, 'merge_term' ] );
		add_action( 'admin_post_classifai_skip_similar_term', [ $this, 'skip_similar_term' ] );

		// Ajax action handler
		add_action( 'wp_ajax_classifai_get_term_cleanup_status', [ $this, 'get_term_cleanup_status' ] );

		// Admin notices
		add_action( 'admin_notices', [ $this, 'render_notices' ] );
	}

	/**
	 * Enqueue the admin scripts.
	 *
	 * @param string $hook_suffix The current admin page.
	 */
	public function enqueue_admin_assets( string $hook_suffix ) {
		if ( 'tools_page_classifai-term-cleanup' !== $hook_suffix ) {
			return;
		}

		wp_localize_script(
			'classifai-admin-script',
			'classifai_term_cleanup_params',
			array(
				'ajax_url'   => esc_url( admin_url( 'admin-ajax.php' ) ),
				'ajax_nonce' => wp_create_nonce( 'classifai-term-cleanup-status' ),
			)
		);
	}

	/**
	 * Register a sub page under the Tools menu.
	 */
	public function register_admin_menu_item() {
		// Don't register the menu if no taxonomies are enabled.
		if ( empty( $this->get_all_feature_taxonomies() ) ) {
			return;
		}

		add_submenu_page(
			'tools.php',
			__( 'Term Cleanup', 'classifai' ),
			__( 'Term Cleanup', 'classifai' ),
			'manage_options',
			'classifai-term-cleanup',
			[ $this, 'render_settings_page' ]
		);
	}

	/**
	 * Render the settings page for the Term Cleanup Feature.
	 */
	public function render_settings_page() {
		$active_tax     = isset( $_GET['tax'] ) ? sanitize_text_field( wp_unslash( $_GET['tax'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$all_taxonomies = $this->get_taxonomies();
		$taxonomies     = $this->get_all_feature_taxonomies();
		?>

		<div class="classifai-content">
			<?php
			include_once CLASSIFAI_PLUGIN_DIR . '/includes/Classifai/Admin/templates/classifai-header.php';

			if ( $active_tax && ! in_array( $active_tax, $taxonomies, true ) ) {
				?>
				<p>
					<?php
					esc_html_e( 'Term Cleanup Feature not enabled for this taxonomy.', 'classifai' );
					?>
				</p>
				</div>
				<?php
				return;
			}
			?>

			<div class="classifai-settings-wrapper">
				<div class="classifai-wrap wrap classifai classifai-term-cleanup">
					<h2><?php esc_html_e( 'Term Cleanup', 'classifai' ); ?></h2>
					<h2 class="nav-tab-wrapper">
						<?php
						foreach ( $taxonomies as $name ) {
							// If we don't have an active taxonomy, set the first one as active.
							if ( ! $active_tax ) {
								$active_tax = $name;
							}

							$label  = $all_taxonomies[ $name ];
							$active = $active_tax === $name ? 'nav-tab-active' : '';
							$url    = add_query_arg( 'tax', $name, $this->setting_page_url );
							?>

							<a href="<?php echo esc_url( $url ); ?>" class="nav-tab <?php echo esc_attr( $active ); ?>">
								<?php echo esc_html( $label ); ?>
							</a>

							<?php
						}
						?>
					</h2>
					<div class="classifai-term-cleanup-wrapper classifai-wrapper">
						<div class="classifai-term-cleanup-content-wrapper">
							<h3 class="screen-reader-text"><?php echo esc_html( $all_taxonomies[ $active_tax ] ); ?></h3>
						<?php
						if ( $this->background_process && $this->background_process->in_progress() ) {
							$this->render_background_processing_status( $active_tax );
						} else {
							$plural_label   = strtolower( $this->get_taxonomy_label( $active_tax, true ) );
							$singular_label = strtolower( $this->get_taxonomy_label( $active_tax, false ) );

							// translators: %s: Taxonomy name.
							$submit_label = sprintf( __( 'Find similar %s', 'classifai' ), esc_attr( $plural_label ) );
							?>
							<p>
								<?php
								// translators: %s: Taxonomy name.
								printf( esc_html__( 'Identify potential %s duplicates to merge together', 'classifai' ), esc_html( $singular_label ) );
								?>
							</p>
							<div class="submit-wrapper">
								<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
									<input type="hidden" name="action" value="classifai_init_term_cleanup" />
									<input type="hidden" name="classifai_term_cleanup_taxonomy" value="<?php echo esc_attr( $active_tax ); ?>" />
									<?php wp_nonce_field( 'classifai_term_cleanup', 'classifai_term_cleanup_nonce' ); ?>
									<input type="submit" name="submit" id="submit" class="button button-primary" value="<?php echo esc_attr( $submit_label ); ?>">
								</form>
							</div>
							<?php
						}
						?>
							<div>
								<br/>
								<?php
								$this->render_similar_terms( $active_tax );
								?>
							</div>
						</div>
					</div>
				</div>
			</div>
		</div>
		<?php
	}

	/**
	 * Get the description for the enable field.
	 *
	 * @return string
	 */
	public function get_enable_description(): string {
		return esc_html__( 'A Term Cleanup page will be added under Tools that can be used to clean up terms.', 'classifai' );
	}

	/**
	 * Returns the default settings for the feature.
	 *
	 * @return array
	 */
	public function get_feature_default_settings(): array {
		$tax_settings = [];
		$taxonomies   = $this->get_taxonomies();

		foreach ( $taxonomies as $name => $label ) {
			if ( 'category' === $name ) {
				$tax_settings[ $name ] = 1;
			} else {
				$tax_settings[ $name ] = 0;
			}

			$tax_settings[ "{$name}_threshold" ] = 75;
		}

		$settings = [
			'provider'   => OpenAIEmbeddings::ID,
			'use_ep'     => is_elasticpress_installed() ? 1 : 0,
			'taxonomies' => $tax_settings,
		];

		return $settings;
	}

	/**
	 * Sanitizes the default feature settings.
	 *
	 * @param array $new_settings Settings being saved.
	 * @return array
	 */
	public function sanitize_default_feature_settings( array $new_settings ): array {
		if ( empty( $new_settings['use_ep'] ) || 1 !== (int) $new_settings['use_ep'] ) {
			$new_settings['use_ep'] = 'no';
		} else {
			$new_settings['use_ep'] = '1';
		}

		return $new_settings;
	}

	/**
	 * Get meta key for embeddings.
	 *
	 * @return string
	 */
	public function get_embeddings_meta_key(): string {
		$provider = $this->get_feature_provider_instance();
		$meta_key = 'classifai_openai_embeddings';

		if ( $provider instanceof AzureEmbeddings ) {
			$meta_key = 'classifai_azure_openai_embeddings';
		}

		/**
		 * Filter the meta key for embeddings.
		 *
		 * @since x.x.x
		 * @hook classifai_feature_term_cleanup_embeddings_meta_key
		 *
		 * @param {string}      $meta_key Meta key for embeddings.
		 * @param {TermCleanup} $this     Feature instance.
		 *
		 * @return {string} Meta key for embeddings.
		 */
		return apply_filters( 'classifai_' . static::ID . '_embeddings_meta_key', $meta_key, $this );
	}

	/**
	 * Get all feature taxonomies.
	 *
	 * @return array
	 */
	public function get_all_feature_taxonomies(): array {
		$taxonomies = $this->get_taxonomies();
		$settings   = $this->get_settings( 'taxonomies' );

		$enabled_taxonomies = [];
		foreach ( $taxonomies as $name => $label ) {
			if ( isset( $settings[ $name ] ) && (bool) $settings[ $name ] ) {
				$enabled_taxonomies[] = $name;
			}
		}

		return $enabled_taxonomies;
	}

	/**
	 * Start the term cleanup process.
	 */
	public function start_term_cleanup_process() {
		if (
			empty( $_POST['classifai_term_cleanup_nonce'] ) ||
			! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['classifai_term_cleanup_nonce'] ) ), 'classifai_term_cleanup' )
		) {
			wp_die( esc_html__( 'You don\'t have permission to perform this operation.', 'classifai' ) );
		}

		if ( ! $this->is_feature_enabled() ) {
			wp_die( esc_html__( 'You don\'t have permission to perform this operation.', 'classifai' ) );
		}

		$settings = $this->get_settings( 'taxonomies' );
		$taxonomy = isset( $_POST['classifai_term_cleanup_taxonomy'] ) ? sanitize_text_field( wp_unslash( $_POST['classifai_term_cleanup_taxonomy'] ) ) : '';
		$thresold = isset( $settings[ $taxonomy . '_threshold' ] ) ? absint( $settings[ $taxonomy . '_threshold' ] ) : 75;

		if ( empty( $taxonomy ) ) {
			wp_die( esc_html__( 'Invalid taxonomy.', 'classifai' ) );
		}

		// Clear previously found similar terms.
		$args = [
			'taxonomy'     => $taxonomy,
			'hide_empty'   => false,
			'fields'       => 'ids',
			'meta_key'     => 'classifai_similar_terms', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
			'meta_compare' => 'EXISTS',
		];

		$terms = get_terms( $args );

		if ( ! empty( $terms ) ) {
			foreach ( $terms as $term_id ) {
				delete_term_meta( $term_id, 'classifai_similar_terms' );
			}
		}

		$job_args = [
			[
				'taxonomy'             => $taxonomy,
				'thresold'             => $thresold,
				'action'               => 'term_cleanup',
				'embeddings_generated' => false,
				'processed'            => 0,
				'term_id'              => 0,
				'offset'               => 0,
				'started_by'           => get_current_user_id(),
				'job_id'               => str_replace( '-', '', wp_generate_uuid4() ),
			],
		];

		$this->background_process->schedule( $job_args );

		$this->add_notice(
			__( 'Process for finding similar terms has started.', 'classifai' ),
			'info'
		);

		// Redirect back to the settings page.
		wp_safe_redirect( add_query_arg( 'tax', $taxonomy, $this->setting_page_url ) );
		exit;
	}

	/**
	 * Cancel the term cleanup process.
	 */
	public function cancel_term_cleanup_process() {
		// Check the nonce for security
		if (
			empty( $_GET['_wpnonce'] ) ||
			! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'classifai_cancel_term_cleanup' )
		) {
			wp_die( esc_html__( 'You don\'t have permission to perform this operation.', 'classifai' ) );
		}

		$taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : '';

		$unschedule = $this->background_process->unschedule();

		if ( $unschedule ) {
			// Add a notice to inform the user that the process will be cancelled soon.
			$this->add_notice(
				__( 'Process for the finding similar terms will be cancelled soon.', 'classifai' ),
				'info'
			);
		}

		// Redirect back to the settings page.
		wp_safe_redirect( add_query_arg( 'tax', $taxonomy, $this->setting_page_url ) );
		exit;
	}

	/**
	 * Get the max number of terms to process.
	 *
	 * @return int
	 */
	public function get_max_terms(): int {
		return 100;
	}

	/**
	 * Generate embeddings for the terms.
	 *
	 * @param string $taxonomy Taxonomy to process.
	 * @return bool|WP_Error True if embeddings were generated, false otherwise.
	 */
	public function generate_embeddings( string $taxonomy ) {
		$exclude = [];

		// Exclude the uncategorized term.
		if ( 'category' === $taxonomy ) {
			// Exclude the uncategorized term.
			$uncat_term = get_term_by( 'name', 'Uncategorized', 'category' );
			if ( $uncat_term ) {
				$exclude = [ $uncat_term->term_id ];
			}
		}

		$meta_key = sanitize_text_field( $this->get_embeddings_meta_key() );
		$args     = [
			'taxonomy'     => $taxonomy,
			'orderby'      => 'count',
			'order'        => 'DESC',
			'hide_empty'   => false,
			'fields'       => 'ids',
			'meta_key'     => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
			'meta_compare' => 'NOT EXISTS',
			'number'       => $this->get_max_terms(),
			'exclude'      => $exclude, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
		];

		$terms = get_terms( $args );

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return false;
		}

		$provider = $this->get_feature_provider_instance();

		// Generate embedding data for each term.
		foreach ( $terms as $term_id ) {
			$result = $provider->generate_embeddings_for_term( $term_id, false, $this );

			/**
			 * Fires when an embedding is generated for a term.
			 *
			 * @since x.x.x
			 * @hook classifai_feature_term_cleanup_generate_embedding
			 *
			 * @param {int}            $term_id ID of term.
			 * @param {array|WP_Error} $result  Result of embedding generation.
			 * @param {TermCleanup}    $this    Feature instance.
			 */
			do_action( 'classifai_feature_term_cleanup_generate_embedding', $term_id, $result, $this );

			if ( is_wp_error( $result ) ) {
				return $result;
			}
		}

		return true;
	}

	/**
	 * Get similar terms.
	 *
	 * @param string $taxonomy Taxonomy to process.
	 * @param int    $thresold Thresold to consider terms as duplicates.
	 * @param array  $args     Additional arguments.
	 * @return array|bool|WP_Error
	 */
	public function get_similar_terms( string $taxonomy, int $thresold, array $args = [] ) {
		if ( class_exists( '\\ElasticPress\\Feature' ) && '1' === $this->get_settings( 'use_ep' ) ) {
			return $this->get_similar_terms_using_elasticpress( $taxonomy, $thresold, $args );
		}

		return $this->get_similar_terms_using_wpdb( $taxonomy, $thresold, $args );
	}

	/**
	 * Get similar terms using WPDB.
	 *
	 * This method is used to get similar terms using MySQL database.
	 * This method is slower than using ElasticPress but can be used
	 * when ElasticPress is not installed or not in use.
	 *
	 * @param string $taxonomy Taxonomy to process.
	 * @param int    $thresold Thresold to consider terms as duplicates.
	 * @param array  $args     Additional arguments.
	 * @return array|bool
	 */
	public function get_similar_terms_using_wpdb( string $taxonomy, int $thresold, array $args = [] ) {
		$processed = $args['processed'] ?? 0;
		$term_id   = $args['term_id'] ?? 0;
		$offset    = $args['offset'] ?? 0;
		$meta_key  = sanitize_text_field( $this->get_embeddings_meta_key() );

		if ( ! $term_id ) {
			$params = [
				'taxonomy'     => $taxonomy,
				'orderby'      => 'count',
				'order'        => 'DESC',
				'hide_empty'   => false,
				'fields'       => 'ids',
				'meta_key'     => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_compare' => 'EXISTS',
				'number'       => 1,
				'offset'       => $processed,
			];

			if ( is_taxonomy_hierarchical( $taxonomy ) ) {
				$params['parent'] = 0;
			}

			$terms = get_terms( $params );

			if ( is_wp_error( $terms ) || empty( $terms ) ) {
				return false;
			}

			$term_id         = $terms[0];
			$offset          = 0;
			$args['term_id'] = $term_id;
			$args['offset']  = $offset;
		}

		$meta_key       = sanitize_text_field( $this->get_embeddings_meta_key() );
		$term_embedding = get_term_meta( $term_id, $meta_key, true );

		if ( 1 === count( $term_embedding ) ) {
			$term_embedding = $term_embedding[0];
		}

		global $wpdb;
		$limit    = apply_filters( 'classifai_term_cleanup_compare_limit', 2000, $taxonomy );
		$meta_key = sanitize_text_field( $this->get_embeddings_meta_key() );

		// SQL query to retrieve term meta using joins
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Running a custom query to get 1k terms embeddings at a time.
		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT DISTINCT t.term_id, tm.meta_value, tt.count
				FROM {$wpdb->terms} AS t
				INNER JOIN {$wpdb->term_taxonomy} AS tt ON t.term_id = tt.term_id
				INNER JOIN {$wpdb->termmeta} AS tm ON t.term_id = tm.term_id
				WHERE tt.taxonomy = %s
				AND tm.meta_key = %s
				AND t.term_id != %d
				AND tt.parent = 0
				ORDER BY tt.count DESC
				LIMIT %d OFFSET %d",
				$taxonomy,
				$meta_key,
				$term_id,
				$limit,
				absint( $offset + $processed ) // Add the processed terms counts to the offset to skip already processed terms.
			)
		);
		$count   = count( $results );

		$calculations  = new EmbeddingCalculations();
		$similar_terms = [];

		foreach ( $results as $index => $result ) {
			// Skip if the term is the same as the term we are comparing.
			if ( $term_id === $result->term_id ) {
				continue;
			}

			$compare_term_id   = $result->term_id;
			$compare_embedding = maybe_unserialize( $result->meta_value );

			if ( 1 === count( $compare_embedding ) ) {
				$compare_embedding = $compare_embedding[0];
			}

			$similarity = $calculations->cosine_similarity( $term_embedding, $compare_embedding );
			if ( false !== $similarity && ( 1 - $similarity ) >= ( $thresold / 100 ) ) {
				$similar_terms[ $compare_term_id ] = 1 - $similarity;
			}
		}

		if ( ! empty( $similar_terms ) ) {
			$existing_similar_terms = get_term_meta( $term_id, 'classifai_similar_terms', true );

			if ( is_array( $existing_similar_terms ) ) {
				$similar_terms = $existing_similar_terms + $similar_terms;
			}

			update_term_meta( $term_id, 'classifai_similar_terms', $similar_terms );
		}

		if ( $count < $limit ) {
			$args['processed'] = $processed + 1;
			$args['term_id']   = 0;
			$args['offset']    = 0;
		} else {
			$args['offset'] = $offset + $limit;
		}

		return $args;
	}

	/**
	 * Get similar terms using Elasticsearch via ElasticPress.
	 *
	 * @param string $taxonomy Taxonomy to process.
	 * @param int    $thresold Thresold to consider terms as duplicates.
	 * @param array  $args     Additional arguments.
	 * @return array|bool|WP_Error
	 */
	public function get_similar_terms_using_elasticpress( string $taxonomy, int $thresold, array $args = [] ) {
		$processed = $args['processed'] ?? 0;
		$meta_key  = sanitize_text_field( $this->get_embeddings_meta_key() );

		$params = [
			'taxonomy'     => $taxonomy,
			'orderby'      => 'count',
			'order'        => 'DESC',
			'hide_empty'   => false,
			'fields'       => 'ids',
			'meta_key'     => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
			'meta_compare' => 'EXISTS',
			'number'       => 10,
			'offset'       => $processed,
		];

		if ( is_taxonomy_hierarchical( $taxonomy ) ) {
			$params['parent'] = 0;
		}

		$terms = get_terms( $params );

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return false;
		}

		if ( ! $this->ep_integration ) {
			$this->ep_integration = new TermCleanupEPIntegration( $this );
		}

		foreach ( $terms as $term_id ) {
			// Find similar terms for the term.
			$search_results = $this->ep_integration->exact_knn_search( $term_id, 'term', 500, $thresold );

			if ( is_wp_error( $search_results ) ) {
				return $search_results;
			}

			$similar_terms    = [];
			$filtered_results = array_filter(
				$search_results,
				function ( $result ) use ( $taxonomy ) {
					return $result['taxonomy'] === $taxonomy;
				}
			);

			foreach ( $filtered_results as $index => $result ) {
				$compare_term_id        = $result['term_id'];
				$existing_similar_terms = get_term_meta( $compare_term_id, 'classifai_similar_terms', true );

				// Skip if it is already present in the similar terms list of the term we are comparing.
				if ( ! empty( $existing_similar_terms ) && isset( $existing_similar_terms[ $term_id ] ) ) {
					continue;
				}

				$similar_terms[ $compare_term_id ] = $result['score'];
			}

			if ( ! empty( $similar_terms ) ) {
				$existing_similar_terms = get_term_meta( $term_id, 'classifai_similar_terms', true );

				if ( is_array( $existing_similar_terms ) ) {
					$similar_terms = $existing_similar_terms + $similar_terms;
				}

				update_term_meta( $term_id, 'classifai_similar_terms', $similar_terms );
			}

			$args['processed'] = $args['processed'] + 1;
		}

		$args['term_id'] = 0;

		return $args;
	}

	/**
	 * Get the background processing status.
	 *
	 * @param string $taxonomy Taxonomy to process.
	 * @return array
	 */
	public function get_background_processing_status( string $taxonomy ): array {
		if ( ! $this->background_process ) {
			return [];
		}

		$args = $this->background_process->get_args();

		if ( ! empty( $args ) ) {
			foreach ( $args as $arg ) {
				if ( 'term_cleanup' === $arg['action'] && $taxonomy === $arg['taxonomy'] ) {
					return $arg;
				}
			}
		}

		return [];
	}

	/**
	 * Render the processing status.
	 *
	 * @param string $taxonomy Taxonomy to process.
	 */
	public function render_background_processing_status( $taxonomy ) {
		$status = $this->get_background_processing_status( $taxonomy );

		if ( empty( $status ) ) {
			?>
			<p>
				<?php
				esc_html_e( 'Background process for finding similar terms is running for another taxonomy.', 'classifai' );
				?>
			<?php
			return;
		}

		$is_embeddings_generated = (bool) $status['embeddings_generated'];
		$processed               = $status['processed'] ?? 0;
		$args                    = array(
			'action'   => 'classifai_cancel_term_cleanup',
			'taxonomy' => $taxonomy,
		);
		$cancel_url              = add_query_arg( $args, wp_nonce_url( admin_url( 'admin-post.php' ), 'classifai_cancel_term_cleanup' ) );
		$label                   = strtolower( $this->get_taxonomy_label( $taxonomy, true ) );
		?>

		<div class="classifai-term-cleanup-process-status" data-taxonomy="<?php echo esc_attr( $taxonomy ); ?>">
			<h4 style="font-size: 1.1em;">
				<?php
				// translators: %s: Taxonomy name.
				printf( esc_html__( 'Finding similar %s...', 'classifai' ), esc_html( $label ) );
				?>
			</h4>

			<?php
			if ( $is_embeddings_generated ) {
				?>
				<p>
					<span class="dashicons dashicons-yes-alt"></span>
					<?php
					// translators: %1$s: Taxonomy name.
					printf( esc_html__( 'Embeddings are generated for %s.', 'classifai' ), esc_html( $label ) );
					?>
				</p>
				<p>
					<span class="spinner is-active" style="float:none; margin: 0px; vertical-align: bottom;"></span>
					<?php
					$page_url = add_query_arg( 'tax', $taxonomy, $this->setting_page_url );
					$refresh  = sprintf(
						// translators: %s: Refresh the page link.
						esc_html__( '%s to see these results.', 'classifai' ),
						'<a href="' . esc_url( $page_url ) . '">' . esc_html__( 'Refresh the page', 'classifai' ) . '</a>'
					);
					echo wp_kses_post(
						sprintf(
							/* translators: %1$s: Taxonomy name, %d: Number of terms processed */
							__( 'Finding similar %1$s, <strong>%2$d</strong> %1$s processed. %3$s', 'classifai' ),
							esc_html( $label ),
							absint( $processed ),
							( absint( $processed ) > 0 ) ? $refresh : ''
						)
					);
					?>
				</p>
				<?php
			} else {
				$meta_key  = sanitize_text_field( $this->get_embeddings_meta_key() );
				$generated = wp_count_terms(
					[
						'taxonomy'     => $taxonomy,
						'hide_empty'   => false,
						'meta_key'     => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
						'meta_compare' => 'EXISTS',
					]
				);
				?>
				<p>
					<span class="spinner is-active" style="float:none; margin: 0px; vertical-align: bottom;"></span>
					<?php
					echo wp_kses_post(
						sprintf(
							/* translators: %1$s: Taxonomy name, %d: Number of terms processed */
							__( 'Generating embeddings, <strong>%2$d</strong> %1$s processed.', 'classifai' ),
							esc_html( $label ),
							absint( $generated )
						)
					);
					?>
				</p>
				<?php
			}
			?>

			<a href="<?php echo esc_url( $cancel_url ); ?>" class="button button-link button-link-delete"><?php esc_html_e( 'Cancel', 'classifai' ); ?></a>
		</div>

		<?php
	}

	/**
	 * Render similar terms for the given taxonomy.
	 *
	 * @param string $taxonomy Taxonomy to display similar terms for.
	 */
	public function render_similar_terms( $taxonomy ) {
		$label = $this->get_taxonomy_label( $taxonomy, true );
		$count = wp_count_terms(
			[
				'taxonomy'     => $taxonomy,
				'hide_empty'   => false,
				'meta_key'     => 'classifai_similar_terms', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_compare' => 'EXISTS',
			]
		);

		if ( $count > 0 ) {
			?>
			<h3 style="margin-bottom: 0px;">
				<?php
				// translators: %s: Taxonomy name.
				printf( esc_html__( 'Similar %s', 'classifai' ), esc_html( $label ) );
				?>
			</h3>
			<form id="import-history" method="get">
				<input type="hidden" name="page" value="classifai-term-cleanup">
				<input type="hidden" name="tax" value="<?php echo esc_attr( $taxonomy ); ?>">
				<?php
				$list_table = new SimilarTermsListTable( $taxonomy );
				$list_table->prepare_items();
				$list_table->search_box( esc_html__( 'Search', 'classifai' ), 'search-term' );
				$list_table->display();
				?>
			</form>
			<?php
		}
	}

	/**
	 * Get taxonomy labels.
	 *
	 * @param string $taxonomy Taxonomy to get labels for.
	 * @param bool   $plural   Whether to get plural label.
	 * @return string
	 */
	public function get_taxonomy_label( $taxonomy, $plural = false ): string {
		$tax    = get_taxonomy( $taxonomy );
		$labels = get_taxonomy_labels( $tax );

		if ( $plural ) {
			$label = $labels->name ?? __( 'Terms', 'classifai' );
		} else {
			$label = $labels->singular_name ?? __( 'Term', 'classifai' );
		}

		return $label;
	}

	/**
	 * Ajax handler for refresh compare status.
	 */
	public function get_term_cleanup_status() {
		// Check the nonce for security
		check_ajax_referer( 'classifai-term-cleanup-status', 'nonce' );

		$data     = array(
			'is_running' => false,
			'status'     => '',
		);
		$taxonomy = isset( $_POST['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_POST['taxonomy'] ) ) : '';

		if ( empty( $taxonomy ) ) {
			$data['error'] = __( 'Taxonomy is required', 'classifai' );
			wp_send_json_error( $data );
		}

		if ( $this->background_process->in_progress() ) {
			$data['is_running'] = true;
			ob_start();
			$this->render_background_processing_status( $taxonomy );
			$data['status'] = ob_get_clean();
		}

		wp_send_json_success( $data );
	}

	/**
	 * Merge term.
	 */
	public function merge_term() {
		// Check the nonce for security
		if (
			empty( $_GET['_wpnonce'] ) ||
			! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'classifai_merge_term' )
		) {
			wp_die( esc_html__( 'You don\'t have permission to perform this operation.', 'classifai' ) );
		}

		$taxonomy  = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : '';
		$to        = isset( $_GET['to'] ) ? absint( wp_unslash( $_GET['to'] ) ) : 0;
		$from      = isset( $_GET['from'] ) ? absint( wp_unslash( $_GET['from'] ) ) : 0;
		$to_term   = get_term( $to, $taxonomy );
		$from_term = get_term( $from, $taxonomy );
		$args      = [
			'tax'   => $taxonomy,
			's'     => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : false,
			'paged' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : false,
		];
		$redirect  = add_query_arg( $args, $this->setting_page_url );

		if ( empty( $taxonomy ) || empty( $to ) || empty( $from ) ) {
			$this->add_notice(
				__( 'Invalid request.', 'classifai' ),
				'error'
			);

			// Redirect back to the settings page.
			wp_safe_redirect( $redirect );
			exit;
		}

		if ( $to === $from ) {
			$this->add_notice(
				__( 'Cannot merge term with itself.', 'classifai' ),
				'error'
			);

			// Redirect back to the settings page.
			wp_safe_redirect( $redirect );
			exit;
		}

		/**
		 * Fires before terms are merged together.
		 *
		 * @since x.x.x
		 * @hook classifai_feature_term_cleanup_pre_merge_term
		 *
		 * @param {int}    $from     Term ID being merged.
		 * @param {int}    $to       Term ID we're merging into.
		 * @param {string} $taxonomy Taxonomy of terms being merged.
		 */
		do_action( 'classifai_feature_term_cleanup_pre_merge_term', $from, $to, $taxonomy );

		$ret = wp_delete_term(
			$from,
			$taxonomy,
			array(
				'default'       => $to,
				'force_default' => true,
			)
		);

		/**
		 * Fires after terms are merged together.
		 *
		 * @since x.x.x
		 * @hook classifai_feature_term_cleanup_post_merge_term
		 *
		 * @param {int}               $from     Term ID being merged.
		 * @param {int}               $to       Term ID we're merging into.
		 * @param {string}            $taxonomy Taxonomy of terms being merged.
		 * @param {bool|int|WP_Error} $ret      Result of merge process.
		 */
		do_action( 'classifai_feature_term_cleanup_post_merge_term', $from, $to, $taxonomy, $ret );

		if ( is_wp_error( $ret ) ) {
			$this->add_notice(
				// translators: %s: Error message.
				sprintf( __( 'Error merging terms: %s.', 'classifai' ), $ret->get_error_message() ),
				'error'
			);
		}

		$this->add_notice(
			// translators: %1$s: From term name, %2$s: To term name.
			sprintf( __( 'Merged term "%1$s" into "%2$s".', 'classifai' ), $from_term->name, $to_term->name ),
			'success'
		);

		// Redirect back to the settings page.
		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Skip similar term.
	 */
	public function skip_similar_term() {
		// Check the nonce for security
		if (
			empty( $_GET['_wpnonce'] ) ||
			! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'classifai_skip_similar_term' )
		) {
			wp_die( esc_html__( 'You don\'t have permission to perform this operation.', 'classifai' ) );
		}

		$taxonomy     = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : '';
		$term         = isset( $_GET['term'] ) ? absint( wp_unslash( $_GET['term'] ) ) : 0;
		$similar_term = isset( $_GET['similar_term'] ) ? absint( wp_unslash( $_GET['similar_term'] ) ) : 0;
		$args         = [
			'tax'   => $taxonomy,
			's'     => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : false,
			'paged' => isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : false,
		];
		$redirect     = add_query_arg( $args, $this->setting_page_url );

		/**
		 * Fires before a term is skipped.
		 *
		 * @since x.x.x
		 * @hook classifai_feature_term_cleanup_pre_skip_term
		 *
		 * @param {int}    $term         Term ID being skipped.
		 * @param {int}    $similar_term Term ID that matched.
		 * @param {string} $taxonomy     Taxonomy of terms being merged.
		 */
		do_action( 'classifai_feature_term_cleanup_pre_skip_term', $term, $similar_term, $taxonomy );

		// SKip/Ignore the similar term.
		$term_meta = get_term_meta( $term, 'classifai_similar_terms', true );
		if ( is_array( $term_meta ) && isset( $term_meta[ $similar_term ] ) ) {
			unset( $term_meta[ $similar_term ] );
			if ( empty( $term_meta ) ) {
				delete_term_meta( $term, 'classifai_similar_terms' );
			} else {
				update_term_meta( $term, 'classifai_similar_terms', $term_meta );
			}
		}

		/**
		 * Fires after a term is skipped.
		 *
		 * @since x.x.x
		 * @hook classifai_feature_term_cleanup_post_skip_term
		 *
		 * @param {int}    $term         Term ID being skipped.
		 * @param {int}    $similar_term Term ID that matched.
		 * @param {string} $taxonomy     Taxonomy of terms being merged.
		 */
		do_action( 'classifai_feature_term_cleanup_post_skip_term', $term, $similar_term, $taxonomy );

		$this->add_notice(
			esc_html__( 'Skipped similar term.', 'classifai' ),
			'success'
		);

		// Redirect back to the settings page.
		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Add a notice to be displayed.
	 *
	 * @param string $message Message to display.
	 * @param string $type    Type of notice.
	 */
	public function add_notice( $message, $type = 'success' ) {
		$notices = get_transient( $this->notices_transient_key );

		if ( ! is_array( $notices ) ) {
			$notices = [];
		}

		$notices[] = array(
			'message' => $message,
			'type'    => $type,
		);

		set_transient( $this->notices_transient_key, $notices, 300 );
	}

	/**
	 * Render notices.
	 */
	public function render_notices() {
		$notices = get_transient( $this->notices_transient_key );

		if ( ! empty( $notices ) ) {
			foreach ( $notices as $notice ) {
				?>
				<div class="notice notice-<?php echo esc_attr( $notice['type'] ); ?> is-dismissible">
					<p>
						<?php echo wp_kses_post( $notice['message'] ); ?>
					</p>
				</div>
				<?php
			}
			delete_transient( $this->notices_transient_key );
		}
	}
}