<?php
/**
 * SEO Clustering Handler
 *
 * Handles all AJAX requests related to keyword deduplication, cluster
 * management, cannibalization prevention, and editorial calendar entry
 * operations. Extracted from SEO_Intelligence_Real for single-responsibility
 * organisation.
 *
 * @package Wisewand
 * @since 2.8.2
 * @license GPL-2.0-or-later
 */

namespace Wisewand\Admin\SEO;

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

use Wisewand\Admin\Traits\AJAX_Security;

// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- error_log() used within WP_DEBUG conditionals; all AJAX handlers verify nonce before processing $_POST data; direct database calls required for custom plugin table operations

/**
 * Handles SEO keyword clustering, deduplication, and calendar AJAX requests
 *
 * Manages keyword cluster scanning (AI or fuzzy), cluster CRUD operations,
 * cannibalization checks, and editorial calendar entry removal. Depends on
 * SEO_Handler_Context for shared services.
 *
 * @package Wisewand
 * @since 2.8.2
 */
class SEO_Clustering_Handler {

	use AJAX_Security;

	/**
	 * Shared handler context (dependencies)
	 *
	 * @since 2.8.2
	 * @var SEO_Handler_Context
	 */
	private $context;

	/**
	 * Create a new clustering handler
	 *
	 * @since 2.8.2
	 *
	 * @param SEO_Handler_Context $context Shared dependencies DTO.
	 */
	public function __construct( SEO_Handler_Context $context ) {
		$this->context = $context;
	}

	// =========================================================================
	// PUBLIC AJAX HANDLERS
	// =========================================================================

	/**
	 * Remove a single keyword entry from the editorial calendar
	 *
	 * @since 2.8.2
	 */
	public function ajax_remove_from_calendar() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		$calendar_id = absint( $_POST['calendar_id'] ?? 0 );

		if ( ! $calendar_id ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid calendar ID', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
		}

		try {
			$result = $this->context->calendar_repository->delete_entry( $calendar_id );

			if ( false === $result ) {
				throw new \Exception( 'Database error: ' . $this->context->calendar_repository->get_last_error() );
			}

			$this->context->logger->info( 'Keyword removed from calendar', array( 'calendar_id' => $calendar_id ) );

			wp_send_json_success( array( 'message' => 'Removed from calendar' ) );

		} catch ( \Exception $e ) {
			$this->context->logger->error( 'Failed to remove from calendar', array( 'error' => $e->getMessage() ) );
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Clear all non-published entries from the editorial calendar
	 *
	 * Requires manage_options capability because this is a destructive
	 * bulk operation that deletes all scheduled (non-published) calendar rows.
	 *
	 * @since 2.8.2
	 */
	public function ajax_clear_all_calendar() {
		$this->verify_ajax_request( 'wisewand_seo_real', 'manage_options' );

		try {
			$result = $this->context->calendar_repository->clear_all_except_published();

			if ( false === $result ) {
				throw new \Exception( 'Database error: ' . $this->context->calendar_repository->get_last_error() );
			}

			$this->context->logger->info(
				'Calendar cleared completely',
				array(
					'entries_deleted' => $result,
					'kept_published'  => $this->context->calendar_repository->get_published_count(),
				)
			);

			wp_send_json_success(
				array(
					'message'       => "Cleared {$result} calendar entries. Published posts were kept.",
					'deleted_count' => $result,
				)
			);

		} catch ( \Exception $e ) {
			$this->context->logger->error( 'Failed to clear calendar', array( 'error' => $e->getMessage() ) );
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Prepare for a duplicate-keyword scan
	 *
	 * Checks how many keywords need enrichment before clustering can begin and
	 * returns the IDs for client-side batched enrichment. Clears existing
	 * cluster data to ensure a fresh scan.
	 *
	 * @since 2.8.2
	 */
	public function ajax_scan_duplicates() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		try {
			$saved_manager   = $this->context->get_saved_keywords_manager();
			$cluster_manager = $this->context->get_cluster_manager();

			$all_keywords = $saved_manager->get_all_saved_keywords();

			if ( empty( $all_keywords ) ) {
				wp_send_json_error( array( 'message' => esc_html__( 'No keywords in library', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
				return;
			}

			$unenriched = array_filter(
				$all_keywords,
				function ( $kw ) {
					return empty( $kw['is_enriched'] ) || null === $kw['search_volume'];
				}
			);

			$unenriched_ids   = array_column( $unenriched, 'id' );
			$enrichment_count = count( $unenriched_ids );

			// Clear existing clusters for a fresh scan.
			$cluster_manager->clear_all_clusters();

			wp_send_json_success(
				array(
					'needs_enrichment' => $enrichment_count > 0,
					'unenriched_ids'   => $unenriched_ids,
					'unenriched_count' => $enrichment_count,
					'total_keywords'   => count( $all_keywords ),
					'message'          => $enrichment_count > 0
						/* translators: %d: Number of unenriched keywords that need data */
						? sprintf( esc_html__( 'Found %d unenriched keywords. Enriching first...', 'wisewand-ai-content-writer-seo-content-generator' ), $enrichment_count )
						: __( 'Ready to scan', 'wisewand-ai-content-writer-seo-content-generator' ),
				)
			);

		} catch ( \Exception $e ) {
			$this->context->logger->error( 'Scan preparation failed', array( 'error' => $e->getMessage() ) );
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Run the actual keyword clustering scan (after enrichment)
	 *
	 * Selects AI-based semantic deduplication when OpenAI is configured,
	 * falling back to multi-phase fuzzy matching otherwise. Persists clusters
	 * to the database via Keyword_Cluster_Manager.
	 *
	 * @since 2.8.2
	 */
	public function ajax_run_clustering_scan() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		$threshold_semantic_duplicate = isset( $_POST['threshold_semantic_duplicate'] )
			? (float) sanitize_text_field( wp_unslash( $_POST['threshold_semantic_duplicate'] ) )
			: 0.85;

		$threshold_semantic_related = isset( $_POST['threshold_semantic_related'] )
			? (float) sanitize_text_field( wp_unslash( $_POST['threshold_semantic_related'] ) )
			: 0.85;

		$threshold_fuzzy = isset( $_POST['threshold_fuzzy'] )
			? (float) sanitize_text_field( wp_unslash( $_POST['threshold_fuzzy'] ) )
			: 0.85;

		// Clamp to safe operational range.
		$threshold_semantic_duplicate = max( 0.70, min( 0.95, $threshold_semantic_duplicate ) );
		$threshold_semantic_related   = max( 0.70, min( 0.95, $threshold_semantic_related ) );
		$threshold_fuzzy              = max( 0.70, min( 0.95, $threshold_fuzzy ) );

		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log(
				sprintf(
					'Wisewand: Deduplication thresholds - Semantic Duplicate: %.2f, Semantic Related: %.2f, Fuzzy: %.2f',
					$threshold_semantic_duplicate,
					$threshold_semantic_related,
					$threshold_fuzzy
				)
			);
		}

		try {
			if ( ! class_exists( 'Wisewand\\Utils\\OpenAI_Client' ) ) {
				require_once WISEWAND_PLUGIN_DIR . 'includes/Utils/OpenAI_Client.php';
			}

			$use_ai_engine = \Wisewand\Utils\OpenAI_Client::is_configured();

			$saved_manager   = $this->context->get_saved_keywords_manager();
			$cluster_manager = $this->context->get_cluster_manager();

			$all_keywords = $saved_manager->get_all_saved_keywords();

			if ( empty( $all_keywords ) ) {
				wp_send_json_error( array( 'message' => esc_html__( 'No keywords in library', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
				return;
			}

			$result      = null;
			$engine_used = 'fuzzy_matching';
			$engine_name = 'Multi-phase Fuzzy Matching (Token, Levenshtein, Jaro-Winkler, Phonetic)';

			if ( $use_ai_engine ) {
				try {
					if ( ! class_exists( 'Wisewand\\Utils\\Semantic_Deduplication_Engine' ) ) {
						require_once WISEWAND_PLUGIN_DIR . 'includes/Utils/Semantic_Deduplication_Engine.php';
					}

					$openai_key    = \Wisewand\Utils\OpenAI_Client::get_stored_api_key();
					$openai_client = new \Wisewand\Utils\OpenAI_Client( $openai_key, $this->context->logger );

					$this->context->keyword_repository->ensure_embedding_column();

					$semantic_engine = new \Wisewand\Utils\Semantic_Deduplication_Engine(
						$this->context->logger,
						$openai_client,
						$this->context->keyword_repository,
						$threshold_semantic_duplicate,
						$threshold_semantic_related
					);

					$embedding_stats = $semantic_engine->generate_missing_embeddings( $all_keywords );
					$result          = $semantic_engine->detect_semantic_clusters( $all_keywords );

					$result['stats']['embeddings_generated'] = $embedding_stats['generated'];
					$result['stats']['embeddings_missing']   = $embedding_stats['missing'];

					$engine_used = 'ai_embeddings';
					$engine_name = 'OpenAI Embeddings (text-embedding-3-small) + Cosine Similarity';

					$this->context->logger->info( 'AI Semantic Deduplication completed successfully' );
				} catch ( \Exception $e ) {
					$this->context->logger->error( 'AI Deduplication failed, falling back to fuzzy matching', array( 'error' => $e->getMessage() ) );
					$result = null;
				} catch ( \Error $e ) {
					$this->context->logger->error( 'AI Deduplication error, falling back to fuzzy matching', array( 'error' => $e->getMessage() ) );
					$result = null;
				}
			}

			// Fallback to fuzzy matching if AI did not run or failed.
			if ( null === $result ) {
				if ( ! class_exists( 'Wisewand\\Utils\\Keyword_Deduplication_Engine' ) ) {
					require_once WISEWAND_PLUGIN_DIR . 'includes/Utils/Keyword_Deduplication_Engine.php';
				}
				if ( ! class_exists( 'Wisewand\\Utils\\Similarity_Calculator' ) ) {
					require_once WISEWAND_PLUGIN_DIR . 'includes/Utils/Similarity_Calculator.php';
				}

				$dedup_engine = new \Wisewand\Utils\Keyword_Deduplication_Engine( $this->context->logger, $threshold_fuzzy, true );

				$result = $dedup_engine->detect_clusters(
					$all_keywords,
					function ( $progress ) {
						// Progress tracking handled client-side.
					}
				);

				$engine_used = 'fuzzy_matching';
				$engine_name = 'Multi-phase Fuzzy Matching (Token, Levenshtein, Jaro-Winkler, Phonetic)';
			}

			$save_result = $cluster_manager->save_clusters( $result['clusters'], true );

			if ( ! empty( $save_result['errors'] ) ) {
				wp_send_json_error(
					array(
						'message'    => $save_result['errors'][0],
						'all_errors' => $save_result['errors'],
					)
				);
				return;
			}

			wp_send_json_success(
				array(
					'stats'            => $result['stats'],
					'clusters_saved'   => $save_result['saved'],
					'clusters_updated' => $save_result['updated'],
					'engine_used'      => $engine_used,
					'engine_name'      => $engine_name,
					'message'          => sprintf(
						/* translators: 1: Number of keyword clusters found, 2: Number of keyword variants, 3: Number of unique keywords */
						__( 'Found %1$d clusters with %2$d variants. %3$d unique keywords.', 'wisewand-ai-content-writer-seo-content-generator' ),
						$result['stats']['clusters'],
						$result['stats']['variants'],
						$result['stats']['unique']
					),
				)
			);

		} catch ( \Exception $e ) {
			$this->context->logger->error( 'Duplicate scan failed', array( 'error' => $e->getMessage() ) );
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Retrieve all keyword clusters with optional filters
	 *
	 * @since 2.8.2
	 */
	public function ajax_get_clusters() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		try {
			$cluster_manager = $this->context->get_cluster_manager();

			$filters = array();

			if ( ! empty( $_POST['min_similarity'] ) ) {
				$filters['min_similarity'] = floatval( $_POST['min_similarity'] );
			}

			if ( ! empty( $_POST['min_size'] ) ) {
				$filters['min_size'] = intval( $_POST['min_size'] );
			}

			if ( ! empty( $_POST['language_code'] ) ) {
				$filters['language_code'] = sanitize_text_field( $_POST['language_code'] );
			}

			$clusters = $cluster_manager->get_clusters( $filters );
			$stats    = $cluster_manager->get_statistics();

			wp_send_json_success(
				array(
					'clusters' => $clusters,
					'stats'    => $stats,
				)
			);

		} catch ( \Exception $e ) {
			$this->context->logger->error( 'Failed to get clusters', array( 'error' => $e->getMessage() ) );
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Change which keyword is the primary representative of a cluster
	 *
	 * @since 2.8.2
	 */
	public function ajax_set_cluster_primary() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		$cluster_id     = absint( $_POST['cluster_id'] ?? 0 );
		$new_primary_id = absint( $_POST['new_primary_id'] ?? 0 );

		if ( ! $cluster_id || ! $new_primary_id ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid parameters', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			return;
		}

		try {
			$cluster_manager = $this->context->get_cluster_manager();
			$success         = $cluster_manager->set_cluster_primary( $cluster_id, $new_primary_id );

			if ( $success ) {
				wp_send_json_success( array( 'message' => esc_html__( 'Primary keyword updated', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			} else {
				wp_send_json_error( array( 'message' => esc_html__( 'Failed to update primary', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			}
		} catch ( \Exception $e ) {
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Dissolve a cluster, marking all its members as not duplicates
	 *
	 * @since 2.8.2
	 */
	public function ajax_ungroup_cluster() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		$cluster_id = absint( $_POST['cluster_id'] ?? 0 );

		if ( ! $cluster_id ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid cluster ID', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			return;
		}

		try {
			$cluster_manager = $this->context->get_cluster_manager();
			$success         = $cluster_manager->ungroup_cluster( $cluster_id );

			if ( $success ) {
				wp_send_json_success( array( 'message' => esc_html__( 'Cluster ungrouped successfully', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			} else {
				wp_send_json_error( array( 'message' => esc_html__( 'Failed to ungroup cluster', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			}
		} catch ( \Exception $e ) {
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Delete all variant keywords in a cluster, keeping only the primary
	 *
	 * @since 2.8.2
	 */
	public function ajax_delete_cluster_variants() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		$cluster_id = absint( $_POST['cluster_id'] ?? 0 );

		if ( ! $cluster_id ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid cluster ID', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			return;
		}

		try {
			$cluster_manager = $this->context->get_cluster_manager();
			$result          = $cluster_manager->delete_variants( $cluster_id );

			if ( $result['success'] ) {
				wp_send_json_success(
					array(
						/* translators: %d: Number of keyword variants deleted from cluster */
						'message' => sprintf( esc_html__( '%d variant(s) deleted', 'wisewand-ai-content-writer-seo-content-generator' ), $result['deleted'] ),
						'deleted' => $result['deleted'],
					)
				);
			} else {
				wp_send_json_error( array( 'message' => $result['message'] ) );
			}
		} catch ( \Exception $e ) {
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Mark a cluster as approved (user has reviewed and accepted it)
	 *
	 * @since 2.8.2
	 */
	public function ajax_approve_cluster() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		$cluster_id = absint( $_POST['cluster_id'] ?? 0 );

		if ( ! $cluster_id ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Invalid cluster ID', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			return;
		}

		try {
			$cluster_manager = $this->context->get_cluster_manager();
			$success         = $cluster_manager->approve_cluster( $cluster_id );

			if ( $success ) {
				wp_send_json_success( array( 'message' => esc_html__( 'Cluster approved', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			} else {
				wp_send_json_error( array( 'message' => esc_html__( 'Failed to approve cluster', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			}
		} catch ( \Exception $e ) {
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	/**
	 * Check whether an existing keyword would cannibalize a new one
	 *
	 * Looks for matches in the calendar, published posts, and existing
	 * clusters, then returns a risk level alongside the detailed warnings.
	 *
	 * @since 2.8.2
	 */
	public function ajax_check_cannibalization() {
		$this->verify_ajax_request( 'wisewand_seo_real' );

		$keyword    = sanitize_text_field( $_POST['keyword'] ?? '' );
		$keyword_id = absint( $_POST['keyword_id'] ?? 0 );

		if ( empty( $keyword ) ) {
			wp_send_json_error( array( 'message' => esc_html__( 'Keyword required', 'wisewand-ai-content-writer-seo-content-generator' ) ) );
			return;
		}

		try {
			if ( ! class_exists( 'Wisewand\\Utils\\Keyword_Deduplication_Engine' ) ) {
				require_once WISEWAND_PLUGIN_DIR . 'includes/Utils/Keyword_Deduplication_Engine.php';
			}

			$dedup_engine = new \Wisewand\Utils\Keyword_Deduplication_Engine( $this->context->logger, 0.75, true );
			$warnings     = $dedup_engine->check_cannibalization( $keyword, $keyword_id );
			$has_warnings = ! empty( $warnings['calendar'] ) || ! empty( $warnings['posts'] ) || ! empty( $warnings['clusters'] );

			wp_send_json_success(
				array(
					'warnings'     => $warnings,
					'has_warnings' => $has_warnings,
					'risk_level'   => $this->calculate_risk_level( $warnings ),
				)
			);

		} catch ( \Exception $e ) {
			$this->context->logger->error( 'Cannibalization check failed', array( 'error' => $e->getMessage() ) );
			wp_send_json_error( array( 'message' => esc_html( $e->getMessage() ) ) );
		}
	}

	// =========================================================================
	// PRIVATE HELPERS
	// =========================================================================

	/**
	 * Derive a risk level string from a cannibalization warnings array
	 *
	 * @since 2.8.2
	 *
	 * @param array $warnings Warnings from Keyword_Deduplication_Engine::check_cannibalization().
	 * @return string One of: none|low|medium|high.
	 */
	private function calculate_risk_level( $warnings ) {
		$calendar_count = count( $warnings['calendar'] ?? array() );
		$posts_count    = count( $warnings['posts'] ?? array() );
		$clusters_count = count( $warnings['clusters'] ?? array() );

		$total = $calendar_count + $posts_count + $clusters_count;

		if ( 0 === $total ) {
			return 'none';
		} elseif ( 1 === $total ) {
			return 'low';
		} elseif ( $total <= 3 ) {
			return 'medium';
		} else {
			return 'high';
		}
	}

	/**
	 * Ensure the editorial calendar CRON event is scheduled
	 *
	 * Schedules a daily CRON event at 2 AM if one is not already registered.
	 * Called automatically when operations add entries to the calendar (e.g.
	 * after roadmap generation) so that the calendar auto-publish logic runs.
	 *
	 * @since 2.8.2
	 */
	private function ensure_calendar_cron_scheduled() {
		if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_event' ) ) {
			return;
		}

		if ( wp_next_scheduled( 'wisewand_editorial_calendar_cron' ) ) {
			$this->context->logger->debug(
				'CRON already scheduled',
				array(
					'next_run' => gmdate( 'Y-m-d H:i:s', wp_next_scheduled( 'wisewand_editorial_calendar_cron' ) ),
				)
			);
			return;
		}

		$tomorrow_2am = strtotime( 'tomorrow 2:00 AM' );
		$scheduled    = wp_schedule_event( $tomorrow_2am, 'daily', 'wisewand_editorial_calendar_cron' );

		if ( false !== $scheduled ) {
			$this->context->logger->info(
				'Editorial calendar CRON auto-scheduled after roadmap generation',
				array(
					'first_run' => gmdate( 'Y-m-d H:i:s', $tomorrow_2am ),
				)
			);
		} else {
			$this->context->logger->error( 'Failed to auto-schedule CRON after roadmap generation' );
		}
	}
}
