<?php
namespace TeknoTok\AiTranslator\Queue;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use TeknoTok\AiTranslator\Database\JobRepository;
use TeknoTok\AiTranslator\Database\MemoryRepository;
use TeknoTok\AiTranslator\Providers\ProviderFactory;
use TeknoTok\AiTranslator\Utils\ErrorSanitizer;
use TeknoTok\AiTranslator\Utils\Logger;

/**
 * Class to manage background job queue processing.
 */
class QueueManager {
	/**
	 * Constructor. Registers cron action hook and AJAX action hooks.
	 */
	public function __construct() {
		add_filter( 'cron_schedules', array( $this, 'add_cron_schedules' ) );
		add_action( 'teknotok_ait_queue_worker', array( $this, 'process_queue' ) );

		// Schedule queue cron if not active
		if ( ! wp_next_scheduled( 'teknotok_ait_queue_worker' ) ) {
			wp_schedule_event( time(), 'every_minute', 'teknotok_ait_queue_worker' );
		}

		// Queue execution is intentionally limited to cron/Action Scheduler callbacks.
	}

	/**
	 * Register custom cron intervals.
	 *
	 * @param array $schedules Existing schedules.
	 * @return array
	 */
	public function add_cron_schedules( $schedules ) {
		$schedules['every_minute'] = array(
			'interval' => 60,
			'display'  => 'Every Minute',
		);
		return $schedules;
	}

	/**
	 * Check if SQLite database integration is active.
	 *
	 * @return bool True if SQLite is active, false otherwise.
	 */
	public static function is_sqlite_active() {
		global $wpdb;
		return (
			class_exists( 'WP_SQLite_DB' ) ||
			defined( 'SQLITE_DB_DIR' ) ||
			( isset( $wpdb ) && strpos( get_class( $wpdb ), 'SQLite' ) !== false )
		);
	}

	/**
	 * Trigger background queue processing asynchronously.
	 *
	 * @return void
	 */
	public static function trigger_async_queue() {
		$license = \TeknoTok\AiTranslator\Integrations\LicenseManager::get_license_data();
		$status  = $license['status'] ?? 'free';
		$is_free = ( 'free' === $status );

		// Prevent execution if not ready for translations (unconfigured)
		if ( ! \TeknoTok\AiTranslator\Plugin::is_ready_for_translations() ) {
			return;
		}

		if ( '1' === get_option( 'teknotok_ait_queue_paused', '0' ) ) {
			return;
		}

		// Prevent redundant processing requests if lock is already active
		$existing_lock = (int) get_option( 'teknotok_ait_queue_lock_expires', 0 );
		if ( $existing_lock > time() ) {
			return;
		}

		// 1. Try Action Scheduler (highly robust background library bundled with WooCommerce)
		if ( function_exists( 'as_enqueue_async_action' ) && ! self::is_sqlite_active() ) {
			as_enqueue_async_action( 'teknotok_ait_queue_worker', array(), 'teknotok-ait' );
			return;
		}

		$next_run = wp_next_scheduled( 'teknotok_ait_queue_worker' );

		if ( self::is_sqlite_active() ) {
			if ( ! $next_run || $next_run > time() + 65 ) {
				wp_schedule_single_event( time() + 60, 'teknotok_ait_queue_worker' );
			}
			return;
		}

		// 2. Fallback: Trigger standard WP Cron
		if ( ! $next_run || $next_run > time() + 5 ) {
			wp_schedule_single_event( time(), 'teknotok_ait_queue_worker' );
		}
		spawn_cron();
	}

	/**
	 * Run queue processing iteration.
	 *
	 * @return void
	 */
	public function process_queue() {
		// Prevent execution during WP-Cron requests on local/development single-threaded servers
		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			$host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
			if ( in_array( $host, array( 'localhost', '127.0.0.1' ), true ) ) {
				Logger::log( 'Queue execution skipped during DOING_CRON on local host to prevent single-threaded deadlocks.', 'info' );
				return;
			}
		}

		// Check if the queue is paused
		if ( '1' === get_option( 'teknotok_ait_queue_paused', '0' ) ) {
			return;
		}

		// Prevent parallel execution conflicts using a robust distributed lock verification
		$lock_session = wp_generate_password( 12, false );
		$now = time();
		$lock_expires = $now + 50; // Lock expires in 50 seconds

		$existing_lock = (int) get_option( 'teknotok_ait_queue_lock_expires', 0 );
		if ( $existing_lock > $now ) {
			return;
		}

		// Try to acquire the lock
		update_option( 'teknotok_ait_queue_lock_session', $lock_session );
		update_option( 'teknotok_ait_queue_lock_expires', $lock_expires );

		// Verify we won the race (wait 50ms to let concurrent updates settle)
		usleep( 50000 );
		if ( get_option( 'teknotok_ait_queue_lock_session' ) !== $lock_session ) {
			return; // Lost the race!
		}

		// Prevent execution if not ready for translations (unlicensed or unconfigured)
		$license = \TeknoTok\AiTranslator\Integrations\LicenseManager::get_license_data();
		$status  = $license['status'] ?? 'free';
		$is_free = ( 'free' === $status );

		if ( ! \TeknoTok\AiTranslator\Plugin::is_ready_for_translations() ) {
			delete_option( 'teknotok_ait_queue_lock_expires' );
			delete_option( 'teknotok_ait_queue_lock_session' );
			return;
		}

		$start_time = time();
		
		// Safely detect PHP execution time limit
		$server_limit = (int) ini_get( 'max_execution_time' );
		if ( $server_limit > 0 ) {
			$time_limit = max( 15, $server_limit - 5 );
		} else {
			$time_limit = 25; // Safe default
		}
		$processed_any = false;

		try {
			// Recover/fail any stuck jobs first to keep the queue clean and reliable
			JobRepository::reset_stuck_jobs();
			$quota_failed_count = JobRepository::get_quota_failed_count();
			if ( $quota_failed_count > 0 ) {
				self::pause_for_provider_limit( ErrorSanitizer::quota_exceeded_message() );
				Logger::log( sprintf( 'Queue paused because %d job(s) previously failed after the translation word quota was reached. Add words or confirm quota, then click Resume Translation.', $quota_failed_count ), 'warning' );
				delete_option( 'teknotok_ait_queue_lock_expires' );
				delete_option( 'teknotok_ait_queue_lock_session' );
				return;
			}

			$settings    = get_option( 'teknotok_ait_settings', array() );
			$targets     = isset( $settings['target_languages'] ) && is_array( $settings['target_languages'] ) ? $settings['target_languages'] : array();
			$cancelled_unconfigured = JobRepository::cancel_jobs_for_unconfigured_targets( $targets );
			if ( $cancelled_unconfigured > 0 ) {
				Logger::log( sprintf( 'Cancelled %d queue job(s) for target languages no longer configured before processing.', $cancelled_unconfigured ), 'info' );
			}

			$source_lang = isset( $settings['source_language'] ) ? sanitize_key( $settings['source_language'] ) : 'en';
			$batch_size = isset( $settings['queue_batch_size'] ) ? (int) $settings['queue_batch_size'] : 10;

			$warmup_processed  = $this->process_frontend_warmup_stage( $start_time, $time_limit, $batch_size );
			$warmup_due        = JobRepository::get_pending_jobs_by_type( 'frontend_warmup', 1 );
			$warmup_active     = JobRepository::get_active_job_count_by_type( 'frontend_warmup' );
			if ( $warmup_processed || $warmup_active > 0 || ! empty( $warmup_due ) ) {
				if ( ! $warmup_processed && $warmup_active > 0 ) {
					Logger::log( sprintf( 'Provider translation is waiting for %d active frontend warmup job(s) to finish discovery first.', $warmup_active ), 'info' );
				}

				delete_option( 'teknotok_ait_queue_lock_expires' );
				delete_option( 'teknotok_ait_queue_lock_session' );

				if ( $warmup_processed || ! empty( $warmup_due ) || $warmup_active > 0 ) {
					self::trigger_async_queue();
				}
				return;
			}

			$provider = ProviderFactory::get_provider();

			while ( time() - $start_time < $time_limit ) {
				$iteration_processed = false;

				// 1. Process content jobs after all frontend warmups have discovered visible strings.
				$heavy_jobs = JobRepository::get_pending_jobs_by_type( 'string', $batch_size, true );
				$heavy_jobs = array_values(
					array_filter(
						$heavy_jobs,
						function( $job ) {
							return isset( $job->object_type ) && 'frontend_warmup' !== $job->object_type;
						}
					)
				);
				if ( ! empty( $heavy_jobs ) ) {
					foreach ( $heavy_jobs as $job ) {
						if ( time() - $start_time >= ( $time_limit - 3 ) ) {
							break;
						}

						JobRepository::update_status( $job->id, 'processing' );

						$provider_name = $settings['api_provider'] ?? 'teknotok';
						Logger::log( sprintf( 'Starting Job #%d (type="%s", ID=%d, target="%s", provider="%s")', $job->id, $job->object_type, $job->object_id, $job->target_lang, $provider_name ), 'info' );

						try {
							$this->process_single_job( $job, $provider, $source_lang );
							JobRepository::update_status( $job->id, 'completed' );
							Logger::log( sprintf( 'Job #%d (type="%s", ID=%d, target="%s") completed successfully.', $job->id, $job->object_type, $job->object_id, $job->target_lang ), 'info' );
							
							if ( 'post' === $job->object_type ) {
								\TeknoTok\AiTranslator\Utils\CacheManager::purge_post_cache( $job->object_id );
							}
						} catch ( \Exception $e ) {
							$safe_error = ErrorSanitizer::sanitize_message( $e->getMessage() );
							Logger::log( sprintf( 'Job #%d (type="%s", ID=%d, target="%s") failed: %s', $job->id, $job->object_type, $job->object_id, $job->target_lang, $safe_error ), 'error' );

							if ( self::is_provider_limit_error( $e ) ) {
								JobRepository::release_jobs_to_pending( array( $job->id ) );
								self::pause_for_provider_limit( $safe_error );
								delete_option( 'teknotok_ait_queue_lock_expires' );
								delete_option( 'teknotok_ait_queue_lock_session' );
								return; // Stop queue processing immediately
							}

							JobRepository::update_status( $job->id, 'failed', $safe_error );
						}
					}
					$processed_any = true;
					$iteration_processed = true;
				}

				// 2. Process a small string batch so quota/provider stops do not affect many jobs.
				$string_batch_size = max( 1, min( 20, $batch_size * 2 ) );
				$string_jobs       = JobRepository::get_pending_jobs_by_type( 'string', $string_batch_size );
				if ( ! empty( $string_jobs ) ) {
					$this->process_string_jobs_batch( $string_jobs, $provider, $source_lang );
					$processed_any = true;
					$iteration_processed = true;
				}

				if ( ! $iteration_processed ) {
					break;
				}
			}

			if ( ! $processed_any ) {
				delete_option( 'teknotok_ait_queue_lock_expires' );
				delete_option( 'teknotok_ait_queue_lock_session' );
				return;
			}
		} catch ( \Exception $e ) {
			$safe_error = ErrorSanitizer::sanitize_message( $e->getMessage() );
			if ( ErrorSanitizer::generic_message() !== $safe_error ) {
				Logger::log( 'Queue worker exception: ' . $safe_error, 'error' );
			}
			if ( self::is_provider_limit_error( $e ) ) {
				self::pause_for_provider_limit( $safe_error );
				delete_option( 'teknotok_ait_queue_lock_expires' );
				delete_option( 'teknotok_ait_queue_lock_session' );
				return;
			}
		}

		delete_option( 'teknotok_ait_queue_lock_expires' );
		delete_option( 'teknotok_ait_queue_lock_session' );

		// If more pending jobs exist, self-spawn the queue worker to process the next batch asynchronously
		$more_jobs = JobRepository::get_pending_jobs( 1 );
		if ( ! empty( $more_jobs ) ) {
			self::trigger_async_queue();
		}
	}

	/**
	 * Process frontend warmup jobs before any provider-backed translation work begins.
	 *
	 * @param int $start_time Worker start timestamp.
	 * @param int $time_limit Safe worker time limit in seconds.
	 * @param int $batch_size Configured queue batch size.
	 * @return bool True when at least one warmup job was processed.
	 */
	private function process_frontend_warmup_stage( $start_time, $time_limit, $batch_size ) {
		$warmup_batch_size = 1;
		$warmup_jobs       = JobRepository::get_pending_jobs_by_type( 'frontend_warmup', $warmup_batch_size );
		$processed_any     = false;

		if ( empty( $warmup_jobs ) ) {
			return false;
		}

		foreach ( $warmup_jobs as $job ) {
			if ( time() - $start_time >= ( $time_limit - 5 ) ) {
				break;
			}

			JobRepository::update_status( $job->id, 'processing' );
			Logger::log( sprintf( 'Warming frontend URL Job #%d (target="%s").', $job->id, $job->target_lang ), 'info' );

			try {
				$this->process_frontend_warmup_job( $job );
				JobRepository::update_status( $job->id, 'completed' );
				Logger::log( sprintf( 'Frontend warmup Job #%d completed successfully.', $job->id ), 'info' );
			} catch ( \Exception $e ) {
				$safe_error = ErrorSanitizer::sanitize_message( $e->getMessage() );
				$will_exhaust_retries = isset( $job->retry_count ) && (int) $job->retry_count >= 2;
				Logger::log( sprintf( 'Frontend warmup Job #%d failed: %s', $job->id, $safe_error ), 'error' );
				JobRepository::update_status( $job->id, 'failed', $safe_error );

				if ( $will_exhaust_retries ) {
					Logger::log( 'Frontend warmup discovery skipped after repeated failures. Provider translation can continue; retry failed warmup jobs after fixing the frontend response.', 'warning' );
					break;
				}
			}

			$processed_any = true;
		}

		return $processed_any;
	}

	/**
	 * Process frontend/theme string jobs in batches grouped by target language.
	 *
	 * @param array  $jobs        String job rows.
	 * @param object $provider    Active AI translation provider instance.
	 * @param string $source_lang Source language locale code.
	 * @return void
	 * @throws \Exception When a batch fails.
	 */
	private function process_string_jobs_batch( $jobs, $provider, $source_lang ) {
		$groups = array();
		foreach ( $jobs as $job ) {
			$groups[ $job->target_lang ][] = $job;
		}

		$settings = get_option( 'teknotok_ait_settings', array() );
		$provider_name = $settings['api_provider'] ?? 'teknotok';

		foreach ( $groups as $target_lang => $group_jobs ) {
			$payload = array();
			$cached_skipped = 0;
			foreach ( $group_jobs as $job ) {
				JobRepository::update_status( $job->id, 'processing' );
				$source_text = isset( $job->source_text ) ? trim( (string) $job->source_text ) : '';
				if ( empty( $source_text ) ) {
					JobRepository::update_status( $job->id, 'completed' );
					continue;
				}
				
				$cached = MemoryRepository::get_translation( $source_text, $target_lang );
				if ( false !== $cached ) {
					JobRepository::update_status( $job->id, 'completed' );
					$cached_skipped++;
					continue;
				}
				$payload[ (string) $job->id ] = $source_text;
			}

			if ( $cached_skipped > 0 ) {
				Logger::log( sprintf( '  [Cache Hit] Skipped %d strings in batch because translations were found in cache memory.', $cached_skipped ), 'info' );
			}

			if ( empty( $payload ) ) {
				continue;
			}

			Logger::log( sprintf( 'Processing batch of %d string translation job(s) for target="%s" using provider "%s"', count( $payload ), $target_lang, $provider_name ), 'info' );
			
			$preview_strings = array();
			foreach ( array_slice( $payload, 0, 5 ) as $str_text ) {
				$preview_strings[] = '"' . ( strlen( $str_text ) > 35 ? substr( $str_text, 0, 35 ) . '...' : $str_text ) . '"';
			}
			Logger::log( '  Batch payload preview: ' . implode( ', ', $preview_strings ) . ( count( $payload ) > 5 ? '...' : '' ), 'info' );

			try {
				$translations = $provider->translate( $payload, $source_lang, $target_lang );
				if ( ! is_array( $translations ) ) {
					throw new \Exception( __( 'Provider returned an invalid string translation batch.', 'teknotok-ai-translator' ) );
				}

				$success_count = 0;
				foreach ( $payload as $job_id => $source_text ) {
					if ( isset( $translations[ $job_id ] ) && is_string( $translations[ $job_id ] ) && '' !== trim( $translations[ $job_id ] ) ) {
						MemoryRepository::save_translation( $source_text, $translations[ $job_id ], $source_lang, $target_lang );
						JobRepository::update_status( (int) $job_id, 'completed' );
						$success_count++;
					} else {
						JobRepository::update_status( (int) $job_id, 'failed', __( 'Missing translated string in provider response.', 'teknotok-ai-translator' ) );
						Logger::log( sprintf( '  [Error] Failed to translate batch string (Job #%d): "%s"', $job_id, strlen($source_text) > 60 ? substr($source_text, 0, 60) . '...' : $source_text ), 'error' );
					}
				}
				Logger::log( sprintf( 'Completed string translation batch for target="%s". Successfully translated: %d/%d strings.', $target_lang, $success_count, count( $payload ) ), 'info' );
			} catch ( \Exception $e ) {
				$safe_error = ErrorSanitizer::sanitize_message( $e->getMessage() );
				if ( self::is_provider_limit_error( $e ) ) {
					JobRepository::release_jobs_to_pending( array_keys( $payload ) );
				} else {
					foreach ( array_keys( $payload ) as $job_id ) {
						JobRepository::update_status( (int) $job_id, 'failed', $safe_error );
					}
				}
				Logger::log( sprintf( 'Failed string translation batch for target="%s": %s', $target_lang, $safe_error ), 'error' );
				throw $e;
			}
		}
	}

	/**
	 * Determine whether an exception is a quota/license/provider limit failure.
	 *
	 * @param \Exception $exception Queue exception.
	 * @return bool True when the queue should pause.
	 */
	public static function is_provider_limit_error( $exception ) {
		$message = $exception->getMessage();

		return (
			402 === (int) $exception->getCode()
			|| ErrorSanitizer::is_limit_message( $message )
		);
	}

	/**
	 * Pause queue execution after a provider quota/license limit is reached.
	 *
	 * @param string $reason Pause reason.
	 * @return void
	 */
	public static function pause_for_provider_limit( $reason ) {
		update_option( 'teknotok_ait_queue_paused', '1', false );
		update_option( 'teknotok_ait_queue_pause_reason', sanitize_text_field( ErrorSanitizer::sanitize_message( $reason ) ), false );
		update_option( 'teknotok_ait_queue_paused_at', current_time( 'mysql', true ), false );
	}

	/**
	 * Pause queue execution when frontend discovery cannot finish safely.
	 *
	 * @param string $reason Warmup failure reason.
	 * @return void
	 */
	public static function pause_for_warmup_failure( $reason ) {
		$message = sprintf(
			/* translators: %s: warmup failure reason */
			__( 'Frontend warmup could not finish after several attempts, so translation has not started. Fix the site response or retry warmup, then click Resume Translation. Last error: %s', 'teknotok-ai-translator' ),
			ErrorSanitizer::sanitize_message( $reason )
		);

		update_option( 'teknotok_ait_queue_paused', '1', false );
		update_option( 'teknotok_ait_queue_pause_reason', sanitize_text_field( $message ), false );
		update_option( 'teknotok_ait_queue_paused_at', current_time( 'mysql', true ), false );
	}

	/**
	 * Resume queue execution after license/quota has been fixed.
	 *
	 * @return int Number of quota failed jobs reset to pending.
	 */
	public static function resume_after_provider_limit() {
		$reset_jobs = JobRepository::reset_quota_failed_jobs();
		$reset_jobs += JobRepository::reset_warmup_failed_jobs();
		delete_option( 'teknotok_ait_queue_paused' );
		delete_option( 'teknotok_ait_queue_pause_reason' );
		delete_option( 'teknotok_ait_queue_paused_at' );
		delete_option( 'teknotok_ait_queue_lock_expires' );
		delete_option( 'teknotok_ait_queue_lock_session' );

		return (int) $reset_jobs;
	}

	/**
	 * Retry failed jobs that were not caused by quota or license limits.
	 *
	 * @return int Number of reset jobs.
	 */
	public static function retry_failed_jobs() {
		$reset_jobs = JobRepository::reset_retryable_failed_jobs();
		delete_option( 'teknotok_ait_queue_lock_expires' );
		delete_option( 'teknotok_ait_queue_lock_session' );

		return (int) $reset_jobs;
	}

	/**
	 * Cancel active jobs for removed target languages.
	 *
	 * @param array $languages Target language codes.
	 * @return int Number of canceled jobs.
	 */
	public static function cancel_jobs_for_languages( $languages ) {
		$cancelled_jobs = JobRepository::cancel_jobs_for_languages( $languages );
		delete_option( 'teknotok_ait_queue_lock_expires' );
		delete_option( 'teknotok_ait_queue_lock_session' );

		if ( $cancelled_jobs > 0 ) {
			Logger::log( sprintf( 'Cancelled %d queue job(s) for removed target language(s).', $cancelled_jobs ), 'info' );
		}

		return (int) $cancelled_jobs;
	}

	/**
	 * Cancel jobs for target languages no longer present in settings.
	 *
	 * @return int Number of canceled jobs.
	 */
	public static function cancel_unconfigured_language_jobs() {
		$settings = get_option( 'teknotok_ait_settings', array() );
		$targets  = isset( $settings['target_languages'] ) && is_array( $settings['target_languages'] ) ? $settings['target_languages'] : array();

		$cancelled_jobs = JobRepository::cancel_jobs_for_unconfigured_targets( $targets );
		delete_option( 'teknotok_ait_queue_lock_expires' );
		delete_option( 'teknotok_ait_queue_lock_session' );

		if ( $cancelled_jobs > 0 ) {
			Logger::log( sprintf( 'Cancelled %d queue job(s) for target languages no longer configured.', $cancelled_jobs ), 'info' );
		}

		return (int) $cancelled_jobs;
	}

	/**
	 * Cancel all waiting, processing, and failed queue rows.
	 *
	 * @return int Number of canceled jobs.
	 */
	public static function cancel_waiting_jobs() {
		$cancelled_jobs = JobRepository::cancel_waiting_jobs();
		delete_option( 'teknotok_ait_queue_paused' );
		delete_option( 'teknotok_ait_queue_pause_reason' );
		delete_option( 'teknotok_ait_queue_paused_at' );
		delete_option( 'teknotok_ait_queue_lock_expires' );
		delete_option( 'teknotok_ait_queue_lock_session' );

		if ( $cancelled_jobs > 0 ) {
			Logger::log( sprintf( 'Cancelled %d waiting queue job(s) from Translation Status.', $cancelled_jobs ), 'info' );
		}

		return (int) $cancelled_jobs;
	}

	/**
	 * Process a single job based on its object type.
	 *
	 * @param object $job             Job row details.
	 * @param object $provider        Active AI translation provider instance.
	 * @param string $source_lang     Source language locale code.
	 * @return void
	 * @throws \Exception
	 */
	private function process_single_job( $job, $provider, $source_lang ) {
		$target_lang = $job->target_lang;

		if ( 'frontend_warmup' === $job->object_type ) {
			$this->process_frontend_warmup_job( $job );
			return;
		}

		if ( 'post' === $job->object_type ) {
			$post = get_post( $job->object_id );
			if ( ! $post || 'trash' === $post->post_status ) {
				Logger::log( sprintf( 'Skipped Job #%d: Post #%d not found or in trash.', $job->id, $job->object_id ), 'warning' );
				return;
			}

			$edit_link = get_edit_post_link( $post->ID );
			$permalink = get_permalink( $post->ID );
			$label = 'post' === $post->post_type ? 'post' : ( 'page' === $post->post_type ? 'page' : ( 'product' === $post->post_type ? 'product' : $post->post_type ) );
			
			Logger::log( sprintf( 'Translating %s #%d ("%s") to target="%s". Edit Link: "%s" | URL: "%s"', $label, $post->ID, $post->post_title, $target_lang, $edit_link, $permalink ), 'info' );

			// Gather fields that need translating (plain text fields)
			$payload = array();
			$fields = array(
				'title'   => $post->post_title,
				'excerpt' => $post->post_excerpt,
			);

			foreach ( $fields as $key => $value ) {
				if ( empty( $value ) ) {
					continue;
				}
				$cached = MemoryRepository::get_translation( $value, $target_lang );
				if ( false !== $cached ) {
					Logger::log( sprintf( '  [Cache Hit] Field "%s" already translated. Value: "%s"', $key, strlen($cached) > 50 ? substr($cached, 0, 50) . '...' : $cached ), 'info' );
				} else {
					Logger::log( sprintf( '  [Cache Miss] Field "%s" needs translation. Original: "%s"', $key, strlen($value) > 50 ? substr($value, 0, 50) . '...' : $value ), 'info' );
					$payload[ $key ] = $value;
				}
			}

			if ( ! empty( $payload ) ) {
				Logger::log( sprintf( '  Translating fields (%s) via provider...', implode(', ', array_keys($payload)) ), 'info' );
				$translations = $provider->translate( $payload, $source_lang, $target_lang );
				foreach ( $payload as $key => $original_value ) {
					if ( isset( $translations[ $key ] ) && ! empty( $translations[ $key ] ) ) {
						MemoryRepository::save_translation( $original_value, $translations[ $key ], $source_lang, $target_lang );
						Logger::log( sprintf( '  Saved translation for field "%s" to database.', $key ), 'info' );
					} else {
						Logger::log( sprintf( '  [Warning] Field "%s" translation missing in provider response.', $key ), 'warning' );
					}
				}
			}

			// Parse and translate HTML content block-by-block
			if ( ! empty( $post->post_content ) ) {
				$cached = MemoryRepository::get_translation( $post->post_content, $target_lang );
				if ( false !== $cached ) {
					Logger::log( sprintf( '  [Cache Hit] HTML body content already translated in database (%d chars)', strlen($post->post_content) ), 'info' );
				} else {
					Logger::log( sprintf( '  [Cache Miss] HTML body content needs translation (%d chars). Parsing blocks...', strlen($post->post_content) ), 'info' );
					$translated_content = \TeknoTok\AiTranslator\Utils\HtmlParser::translate_html( $post->post_content, $source_lang, $target_lang, $provider );
					MemoryRepository::save_translation( $post->post_content, $translated_content, $source_lang, $target_lang );
					Logger::log( '  HTML content translated block-by-block and saved to database.', 'info' );
				}
			}

			// Post metadata translation
			$seo_whitelist = array(
				'_yoast_wpseo_title',
				'_yoast_wpseo_metadesc',
				'_metaseo_description',
				'rank_math_title',
				'rank_math_description',
				'rank_math_focus_keyword',
			);

			$all_meta = get_post_meta( $job->object_id );
			$meta_strings = array();

			if ( is_array( $all_meta ) ) {
				foreach ( $all_meta as $meta_key => $values ) {
					// Skip keys starting with _ unless whitelisted
					if ( strpos( $meta_key, '_' ) === 0 ) {
						if ( ! in_array( $meta_key, $seo_whitelist, true ) ) {
							continue;
						}
					}

					// Use single meta helper to get deserialized value
					$meta_val = get_post_meta( $job->object_id, $meta_key, true );

					if ( is_array( $meta_val ) ) {
						// Check if single-level (flat) array
						$is_flat = true;
						foreach ( $meta_val as $sub_val ) {
							if ( is_array( $sub_val ) || is_object( $sub_val ) ) {
								$is_flat = false;
								break;
							}
						}
						if ( $is_flat ) {
							foreach ( $meta_val as $sub_val ) {
								if ( is_string( $sub_val ) && ! empty( trim( $sub_val ) ) && ! is_numeric( $sub_val ) ) {
									$meta_strings[] = $sub_val;
								}
							}
						}
					} elseif ( is_string( $meta_val ) && ! empty( trim( $meta_val ) ) && ! is_numeric( $meta_val ) ) {
						$meta_strings[] = $meta_val;
					}
				}
			}

			$meta_strings = array_unique( $meta_strings );
			$meta_payload = array();
			$idx = 0;

			foreach ( $meta_strings as $str ) {
				$cached = MemoryRepository::get_translation( $str, $target_lang );
				if ( false !== $cached ) {
					// Silent cache hit to avoid excessive clutter for meta fields
				} else {
					$meta_payload[ 'meta_' . $idx++ ] = $str;
				}
			}

			$total_meta = count( $meta_strings );
			$miss_meta = count( $meta_payload );
			$hit_meta = $total_meta - $miss_meta;
			if ( $total_meta > 0 ) {
				Logger::log( sprintf( '  [Meta Fields] Found %d translatable meta values. Cache Hits: %d, Cache Misses: %d', $total_meta, $hit_meta, $miss_meta ), 'info' );
			}

			if ( ! empty( $meta_payload ) ) {
				Logger::log( sprintf( '  Translating %d custom fields/meta values to target="%s"', count($meta_payload), $target_lang ), 'info' );
				$meta_translations = $provider->translate( $meta_payload, $source_lang, $target_lang );
				if ( is_array( $meta_translations ) ) {
					$saved_meta = 0;
					foreach ( $meta_payload as $key => $original_value ) {
						if ( isset( $meta_translations[ $key ] ) && ! empty( $meta_translations[ $key ] ) ) {
							MemoryRepository::save_translation( $original_value, $meta_translations[ $key ], $source_lang, $target_lang );
							$saved_meta++;
						}
					}
					Logger::log( sprintf( '  Saved %d custom fields/meta translations to database.', $saved_meta ), 'info' );
				}
			}
		}

		if ( 'taxonomy' === $job->object_type ) {
			$term = get_term( $job->object_id );
			if ( ! $term || is_wp_error( $term ) ) {
				Logger::log( sprintf( 'Skipped Job #%d: Term #%d not found or invalid.', $job->id, $job->object_id ), 'warning' );
				return;
			}

			$edit_link = get_edit_term_link( $term->term_id, $term->taxonomy );
			Logger::log( sprintf( 'Translating taxonomy term #%d ("%s", taxonomy="%s") to target="%s". Edit Link: "%s"', $term->term_id, $term->name, $term->taxonomy, $target_lang, $edit_link ), 'info' );

			$payload = array();
			$fields = array(
				'name'        => $term->name,
				'description' => $term->description,
			);

			foreach ( $fields as $key => $value ) {
				if ( empty( $value ) ) {
					continue;
				}
				$cached = MemoryRepository::get_translation( $value, $target_lang );
				if ( false !== $cached ) {
					Logger::log( sprintf( '  [Cache Hit] Term Field "%s" already translated. Value: "%s"', $key, strlen($cached) > 50 ? substr($cached, 0, 50) . '...' : $cached ), 'info' );
				} else {
					Logger::log( sprintf( '  [Cache Miss] Term Field "%s" needs translation. Original: "%s"', $key, strlen($value) > 50 ? substr($value, 0, 50) . '...' : $value ), 'info' );
					$payload[ $key ] = $value;
				}
			}

			if ( ! empty( $payload ) ) {
				Logger::log( sprintf( '  Translating term fields (%s) via provider...', implode(', ', array_keys($payload)) ), 'info' );
				$translations = $provider->translate( $payload, $source_lang, $target_lang );
				foreach ( $payload as $key => $original_value ) {
					if ( isset( $translations[ $key ] ) && ! empty( $translations[ $key ] ) ) {
						MemoryRepository::save_translation( $original_value, $translations[ $key ], $source_lang, $target_lang );
						Logger::log( sprintf( '  Saved translation for term field "%s".', $key ), 'info' );
					}
				}
			}
		}

		if ( 'string' === $job->object_type ) {
			$source_text = isset( $job->source_text ) ? trim( (string) $job->source_text ) : '';
			if ( empty( $source_text ) ) {
				return;
			}
			
			$cached = MemoryRepository::get_translation( $source_text, $target_lang );
			if ( false !== $cached ) {
				Logger::log( sprintf( '  [Cache Hit] Individual string already translated. Value: "%s"', strlen($cached) > 50 ? substr($cached, 0, 50) . '...' : $cached ), 'info' );
				return;
			}

			Logger::log( sprintf( 'Translating individual string ("%s") to target="%s"', strlen($source_text) > 50 ? substr($source_text, 0, 50) . '...' : $source_text, $target_lang ), 'info' );

			$translated = $provider->translate( $source_text, $source_lang, $target_lang );
			if ( is_string( $translated ) && '' !== trim( $translated ) ) {
				MemoryRepository::save_translation( $source_text, $translated, $source_lang, $target_lang );
				Logger::log( '  Saved string translation to database.', 'info' );
			} else {
				Logger::log( '  [Warning] String translation returned empty value from provider.', 'warning' );
			}
		}
	}

	/**
	 * Render a translated frontend URL in the background so output-buffer parsing can queue theme strings.
	 *
	 * @param object $job Job row details.
	 * @return void
	 * @throws \Exception When the warmup request fails.
	 */
	private function process_frontend_warmup_job( $job ) {
		$source_url  = isset( $job->source_text ) ? trim( (string) $job->source_text ) : '';
		$target_lang = isset( $job->target_lang ) ? sanitize_key( $job->target_lang ) : '';

		if ( empty( $source_url ) || empty( $target_lang ) ) {
			return;
		}

		if ( ! \TeknoTok\AiTranslator\Utils\UrlGuard::is_frontend_url( $source_url ) ) {
			Logger::log( sprintf( 'Skipped warmup URL because it is not a public frontend URL: "%s"', $source_url ), 'info' );
			return;
		}

		$target_url = \TeknoTok\AiTranslator\Plugin::get_translated_url_for_source( $source_url, $target_lang );
		$target_url = add_query_arg(
			array(
				'teknotok_ait_warmup'     => '1',
				'teknotok_ait_warmup_job' => isset( $job->id ) ? (int) $job->id : time(),
			),
			$target_url
		);

		$host = wp_parse_url( $target_url, PHP_URL_HOST );
		if ( in_array( $host, array( 'localhost', '127.0.0.1' ), true ) ) {
			update_option( 'teknotok_ait_local_warmup_skipped_at', current_time( 'mysql', true ), false );
			update_option(
				'teknotok_ait_local_warmup_skipped_message',
				__( 'Frontend warmup URL discovery was skipped on localhost to prevent the local single-threaded server from deadlocking. Post, page, product, and shared menu strings can still be queued, but page-only theme strings may be discovered when the translated URL is opened or when the site runs on staging/production.', 'teknotok-ai-translator' ),
				false
			);
			Logger::log( sprintf( 'Skipped warmup URL "%s" on local host to prevent loopback deadlock. Page-only frontend strings may be discovered when the translated URL is opened or on staging/production.', $target_url ), 'warning' );
			return;
		}

		Logger::log( sprintf( 'Processing warmup URL for language "%s": URL="%s"', $target_lang, $target_url ), 'info' );

		$response = wp_remote_get(
			$target_url,
			array(
				'timeout'     => 20,
				'redirection' => 3,
				'blocking'    => true,
				'headers'     => array(
					'X-TeknoTok-Warmup' => '1',
					'Cache-Control'     => 'no-cache, no-store, must-revalidate',
					'Pragma'            => 'no-cache',
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			throw new \Exception( $response->get_error_message() );
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		if ( $status >= 400 ) {
			throw new \Exception( sprintf( 'Frontend warmup failed with HTTP %d for %s.', $status, esc_url_raw( $target_url ) ) );
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( false !== stripos( $body, '<html' ) ) {
			$settings    = get_option( 'teknotok_ait_settings', array() );
			$source_lang = isset( $settings['source_language'] ) ? sanitize_key( $settings['source_language'] ) : 'en';
			\TeknoTok\AiTranslator\Utils\HtmlParser::translate_html( $body, $source_lang, $target_lang, null, false, true, false, false );
		}

		Logger::log( sprintf( 'Warmup URL "%s" loaded successfully (HTTP %d).', $target_url, $status ), 'info' );
	}
}
