<?php
/**
 * WP Background Process
 *
 * @package WP-Background-Processing
 */

if ( ! class_exists( 'WPF_Background_Process' ) ) {

	/**
	 * WP Background Process class.
	 *
	 * @extends WPF_Async_Request
	 */
	class WPF_Background_Process extends WPF_Async_Request {

		/**
		 * Action
		 *
		 * (default value: 'background_process')
		 *
		 * @var string
		 * @access protected
		 */
		protected $action = 'background_process';

		/**
		 * Start time of current process.
		 *
		 * (default value: 0)
		 *
		 * @var int
		 * @access protected
		 */
		protected $start_time = 0;

		/**
		 * The number of records processed this cycle.
		 *
		 * @var int
		 * @access protected
		 */
		protected $items_this_cycle = 0;

		/**
		 * Cron_hook_identifier
		 *
		 * @var mixed
		 * @access protected
		 */
		protected $cron_hook_identifier;

		/**
		 * Cron_interval_identifier
		 *
		 * @var mixed
		 * @access protected
		 */
		protected $cron_interval_identifier;

		/**
		 * The status set when process is cancelling.
		 *
		 * @since 3.44.8
		 *
		 * @var int The status.
		 */
		const STATUS_CANCELLED = 1;

		/**
		 * Initiate new background process
		 */
		public function __construct() {
			parent::__construct();

			$this->cron_hook_identifier     = $this->identifier . '_cron';
			$this->cron_interval_identifier = $this->identifier . '_cron_interval';

			add_action( $this->cron_hook_identifier, array( $this, 'handle_cron_healthcheck' ) );
			add_filter( 'cron_schedules', array( $this, 'schedule_cron_healthcheck' ), 50 );

			if ( wpf_get_option( 'enable_cron' ) ) {
				$this->schedule_event();
			}
		}


		/**
		 * Dispatch
		 *
		 * @since  3.0.0
		 * @since  3.38.31 Will now return true if the process is already running.
		 *
		 * @return array Response from wp_remote_post
		 */
		public function dispatch() {

			// Clean up orphaned operations before starting new processes.
			$this->cleanup_orphaned_operations();

			if ( $this->is_process_running() ) {
				return true;
			}

			// Schedule the cron healthcheck.
			$this->schedule_event();

			// Perform remote post.
			return parent::dispatch();
		}


		/**
		 * Push to queue
		 *
		 * @param mixed $data Data.
		 *
		 * @return $this
		 */
		public function push_to_queue( $data ) {

			$this->data[] = $data;

			return $this;
		}

		/**
		 * Save queue
		 *
		 * @return $this
		 */
		public function save() {

			// Key is wpf_background_process_{random}

			$key = $this->generate_key();

			if ( count( $this->data ) > 10 ) {

				// Save status for health check. Don't track status on quick-add from Woo orders etc.

				$status = array(
					'key'        => $key,
					'total'      => count( $this->data ),
					'remaining'  => count( $this->data ),
					'started_at' => time(),
					'updated_at' => time(),
				);

				// Get max packet size

				global $wpdb;
				$max_packet_size = $wpdb->get_var( "SHOW VARIABLES LIKE 'max_allowed_packet'", 1 );

				$max_packet_size           = $max_packet_size / 1024 / 1024;
				$status['max_packet_size'] = $max_packet_size . 'MB';

				// Get data size

				if ( function_exists( 'mb_strlen' ) ) {

					$data_size           = mb_strlen( serialize( $this->data ), '8bit' );
					$data_size           = $data_size / 1024;
					$status['data_size'] = round( $data_size ) . 'KB';

				}

				// Get human readable max memory

				$max_memory = $this->get_memory_limit();
				$max_memory = $max_memory / 1024 / 1024;

				$status['max_memory'] = $max_memory . 'MB';

				update_site_option( 'wpfb_status_' . $key, $status );

			}

			if ( ! empty( $this->data ) ) {
				$result = update_site_option( $key, $this->data );
			}

			// Cases where the data is too big to be saved

			if ( false === $result && isset( $status ) ) {

				$status['saved'] = 'Failed to save';
				update_site_option( 'wpfb_status_' . $key, $status );

			}

			return $this;
		}

		/**
		 * Update queue
		 *
		 * @param string $key Key.
		 * @param array  $data Data.
		 *
		 * @return $this
		 */
		public function update( $key, $data ) {
			if ( ! empty( $data ) ) {
				update_site_option( $key, $data );
			}

			return $this;
		}

		/**
		 * Delete queue
		 *
		 * @param string $key Key.
		 *
		 * @return $this
		 */
		public function delete( $key ) {

			delete_site_option( $key );

			return $this;
		}

		/**
		 * Is queued?
		 *
		 * @since 3.44.8
		 *
		 * @return bool True if queued.
		 */
		public function is_queued() {
			return ! $this->is_queue_empty();
		}

		/**
		 * Is the tool currently active, e.g. starting, working, paused or cleaning up?
		 *
		 * @since 3.44.8
		 *
		 * @return bool True if active.
		 */
		public function is_active() {
			return $this->is_queued() || $this->is_process_running() || $this->is_cancelled();
		}

		/**
		 * Generate key
		 *
		 * Generates a unique key based on microtime. Queue items are
		 * given a unique key so that they can be merged upon save.
		 *
		 * @param int $length Length.
		 *
		 * @return string
		 */
		protected function generate_key( $length = 48 ) {
			$unique  = md5( microtime() . wp_rand() );
			$prepend = $this->identifier . '_';

			return substr( $prepend . $unique, 0, $length );
		}

		/**
		 * Get the status key.
		 *
		 * @since 3.44.8
		 *
		 * @return string The status key.
		 */
		public function get_status_key( $key ) {
			return str_replace( 'process', 'status', $key );
		}

		/**
		 * Maybe process queue
		 *
		 * Checks whether data exists within the queue and that
		 * the process is not already running.
		 */
		public function maybe_handle_ajax() {

			// Don't lock up other requests while processing
			session_write_close();

			if ( $this->is_process_running() ) {
				// Background process already running.
				wp_die();
			}

			if ( $this->is_queue_empty() ) {
				// No data to process.
				wp_die();
			}

			$this->handle();

			wp_die();
		}

		/**
		 * Get all the current batch keys
		 *
		 * @return array
		 */
		public function get_keys() {

			global $wpdb;

			// Change from options to sitemeta for multisite
			$table  = is_multisite() ? $wpdb->sitemeta : $wpdb->options;
			$column = is_multisite() ? 'meta_key' : 'option_name';

			$key = 'wpf_background_process_%';

			$results = $wpdb->get_col( $wpdb->prepare( "SELECT {$column} FROM {$table} WHERE {$column} LIKE %s", $key ) );

			return $results;
		}

		/**
		 * Get status
		 *
		 * @return bool
		 */
		public function get_status( $key = false ) {

			$status = get_site_option( 'wpfb_status_' . $key );

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

			return $status;
		}

		/**
		 * Is queue empty
		 *
		 * @return bool
		 */
		public function is_queue_empty() {

			global $wpdb;

			// Change from options to sitemeta for multisite
			$table  = is_multisite() ? $wpdb->sitemeta : $wpdb->options;
			$column = is_multisite() ? 'meta_key' : 'option_name';

			$key = $this->identifier . '_%';

			$count = $wpdb->get_var(
				$wpdb->prepare(
					"
			SELECT COUNT(*)
			FROM {$table}
			WHERE {$column} LIKE %s
			",
					$key
				)
			);

			return ( $count > 0 ) ? false : true;
		}

		/**
		 * Is process running
		 *
		 * Check whether the current process is already running
		 * in a background process.
		 */
		public function is_process_running() {
			if ( get_transient( $this->identifier . '_process_lock' ) ) {

				// Process already running.
				return true;

			}

			return false;
		}

		/**
		 * Lock process
		 *
		 * Lock the process so that multiple instances can't run simultaneously.
		 * Override if applicable, but the duration should be greater than that
		 * defined in the time_exceeded() method.
		 */
		protected function lock_process() {
			$this->start_time = time(); // Set start time of current process.

			$lock_duration = 60; // 1 minute.

			$max_time = ini_get( 'max_execution_time' );

			if ( $max_time > 30 ) {
				$lock_duration = $max_time + 30;
			}

			$lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration );

			set_transient( $this->identifier . '_process_lock', microtime(), $lock_duration );
		}

		/**
		 * Unlock process
		 *
		 * Unlock the process so that other instances can spawn.
		 *
		 * @return $this
		 */
		protected function unlock_process() {
			delete_transient( $this->identifier . '_process_lock' );
			return $this;
		}

		/**
		 * Get batch
		 *
		 * @return stdClass Return the first batch from the queue
		 */
		protected function get_batch() {

			global $wpdb;

			// Change from options to sitemeta for multisite
			$table        = is_multisite() ? $wpdb->sitemeta : $wpdb->options;
			$column       = is_multisite() ? 'meta_key' : 'option_name';
			$key_column   = is_multisite() ? 'meta_id' : 'option_id';
			$value_column = is_multisite() ? 'meta_value' : 'option_value';

			$key = $this->identifier . '_%';

			$query = $wpdb->get_row(
				$wpdb->prepare(
					"
					SELECT *
					FROM {$table}
					WHERE {$column} LIKE %s
					ORDER BY {$key_column} ASC
					LIMIT 1
				",
					$key
				)
			);

			$batch       = new stdClass();
			$batch->key  = $query->$column;
			$batch->data = maybe_unserialize( $query->$value_column );

			return $batch;
		}

		/**
		 * Update Status
		 *
		 * We'll keep track of what's going on in an option key for troubleshooting
		 */
		protected function update_status( $batch, $key, $starttime ) {

			$status = get_site_option( 'wpfb_status_' . $batch->key );

			if ( false !== $status && is_array( $status ) ) {

				$next_key = $key + 1;

				$status['remaining'] = count( $batch->data );
				$status['last_step'] = $batch->data[ $key ];

				if ( false !== $starttime ) {
					$status['time_last_step'] = round( ( microtime( true ) - $starttime ), 2 );
				}

				$status['items_last_step'] = $this->items_this_cycle;

				if ( isset( $batch->data[ $next_key ] ) ) {
					$status['next_step'] = $batch->data[ $next_key ];
				}

				$status['total_time']     = time() - $this->start_time;
				$status['memory_percent'] = ( memory_get_usage( true ) / $this->get_memory_limit() ) * 100 . '%';
				$status['updated_at']     = time();

				update_site_option( 'wpfb_status_' . $batch->key, $status );

			}
		}

		/**
		 * Handle
		 *
		 * Pass each queue item to the task handler, while remaining
		 * within server memory and time limit constraints.
		 */
		protected function handle() {

			$this->lock_process();

			$batch = $this->get_batch();

			foreach ( $batch->data as $key => $value ) {

				$starttime = microtime( true );

				// Update status before the task in case of timeout.
				$this->update_status( $batch, $key, false );

				$task = $this->task( $value );

				if ( false !== $task ) {
					$batch->data[ $key ] = $task;
				} else {

					// Update status after task.

					++$this->items_this_cycle;

					$this->update_status( $batch, $key, $starttime );

					unset( $batch->data[ $key ] );

				}

				// Keep the batch up to date while processing it.
				if ( ! empty( $batch->data ) ) {
					$this->update( $batch->key, $batch->data );
				}

				// Delete current batch if fully processed.
				if ( empty( $batch->data ) ) {
					$this->delete( $batch->key );
				}

				if ( $this->time_exceeded() || $this->memory_exceeded() || $this->is_cancelled( $batch->key ) ) {
					// Batch limits reached.
					break;
				}
			}

			$this->unlock_process();

			// Start next batch or complete process.
			if ( $this->is_cancelled( $batch->key ) ) {

				$this->cancel_process( $batch->key );

			} elseif ( ! $this->is_queue_empty() ) {

				$this->dispatch();

			} else {

				$this->complete( $batch->key );

			}
		}

		/**
		 * Memory exceeded
		 *
		 * Ensures the batch process never exceeds 90%
		 * of the maximum WordPress memory.
		 *
		 * @return bool
		 */
		protected function memory_exceeded() {

			// wpf_background_process_memory_utilization_percentage
			$memory_utilization_percentage = apply_filters( $this->identifier . '_memory_utilization_percentage', 0.80 ); // 80% of max memory

			$memory_limit   = $this->get_memory_limit() * $memory_utilization_percentage;
			$current_memory = memory_get_usage( true );
			$return         = false;

			if ( $current_memory >= $memory_limit ) {
				$return = true;
			}

			return apply_filters( $this->identifier . '_memory_exceeded', $return );
		}

		/**
		 * Get memory limit
		 *
		 * @return int
		 */
		public function get_memory_limit() {
			if ( function_exists( 'ini_get' ) ) {
				$memory_limit = ini_get( 'memory_limit' );
			} else {
				// Sensible default.
				$memory_limit = '128M';
			}

			if ( ! $memory_limit || -1 === absint( $memory_limit ) ) {
				// Unlimited, set to 512M.
				$memory_limit = '512M';
			}

			return absint( $memory_limit ) * 1024 * 1024;
		}

		/**
		 * Time exceeded.
		 *
		 * Ensures the batch never exceeds a sensible time limit.
		 * A timeout limit of 30s is common on shared hosting.
		 *
		 * @return bool
		 */
		protected function time_exceeded() {
			$finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 ); // 20 seconds
			$return = false;

			if ( time() >= $finish ) {
				$return = true;
			}

			// Identifier is wpf_batch

			return apply_filters( $this->identifier . '_time_exceeded', $return );
		}

		/**
		 * Clean up orphaned background operations.
		 *
		 * An operation is considered orphaned if:
		 * - No timestamp exists (legacy records from years ago), OR
		 * - Queue data doesn't exist but status does (completed but not cleaned), OR
		 * - Status hasn't been updated in 24+ hours AND no process lock exists
		 *
		 * @since 3.46.11
		 */
		public function cleanup_orphaned_operations() {

			// Only run cleanup if no process is currently running.
			if ( $this->is_process_running() ) {
				return;
			}

			global $wpdb;

			// Get all wpfb_status_ keys from both options and sitemeta tables.
			$table        = is_multisite() ? $wpdb->sitemeta : $wpdb->options;
			$column       = is_multisite() ? 'meta_key' : 'option_name';
			$value_column = is_multisite() ? 'meta_value' : 'option_value';

			$status_keys = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT {$column} as option_name, {$value_column} as option_value 
					FROM {$table} 
					WHERE {$column} LIKE %s",
					'wpfb_status_' . $this->identifier . '_%'
				)
			);

			if ( empty( $status_keys ) ) {
				return;
			}

			$max_age_hours = apply_filters( 'wpf_batch_max_age_hours', 24 );
			$cleaned_count = 0;

			foreach ( $status_keys as $status_row ) {
				$status_key = $status_row->option_name;
				$status     = maybe_unserialize( $status_row->option_value );

				// Skip if status data is invalid.
				if ( ! is_array( $status ) || empty( $status['key'] ) ) {
					delete_site_option( $status_key );
					++$cleaned_count;
					continue;
				}

				$queue_key   = $status['key'];
				$is_orphaned = false;
				$reason      = '';

				// Check if this operation is orphaned.
				if ( ! isset( $status['started_at'] ) ) {
					// Legacy record without timestamp - definitely orphaned.
					$is_orphaned = true;
					$reason      = 'no timestamp (legacy record)';
				} else {
					// Check if queue data still exists.
					$queue_data = get_site_option( $queue_key );

					if ( false === $queue_data ) {
						// Queue data is gone but status remains - orphaned.
						$is_orphaned = true;
						$reason      = 'queue data missing';
					} elseif ( empty( $queue_data ) ) {
						// Queue is empty but status exists - orphaned.
						$is_orphaned = true;
						$reason      = 'queue empty';
					} else {
						// Check age and process lock.
						$updated_at = isset( $status['updated_at'] ) ? $status['updated_at'] : $status['started_at'];
						$age_hours  = ( time() - $updated_at ) / HOUR_IN_SECONDS;

						if ( $age_hours > $max_age_hours ) {
							// Old operation - check if process is truly dead.
							$process_lock = get_transient( $this->identifier . '_process_lock' );
							if ( false === $process_lock ) {
								$is_orphaned = true;
								$reason      = sprintf( 'aged %.1f hours with no process lock', $age_hours );
							}
						}
					}
				}

				// Clean up orphaned operation.
				if ( $is_orphaned ) {
					delete_site_option( $status_key );
					delete_site_option( $queue_key ); // Clean queue data too.
					++$cleaned_count;

					wpf_log(
						'notice',
						0,
						sprintf( 'Cleaned up orphaned batch operation: %s (%s)', $queue_key, $reason ),
						array( 'source' => 'batch-cleanup' )
					);
				}
			}

			// Log summary if any operations were cleaned.
			if ( $cleaned_count > 0 ) {
				wpf_log(
					'info',
					0,
					sprintf( 'Batch cleanup completed: removed %d orphaned operations', $cleaned_count ),
					array( 'source' => 'batch-cleanup' )
				);
			}
		}

		/**
		 * Complete.
		 *
		 * Override if applicable, but ensure that the below actions are
		 * performed, or, call parent::complete().
		 */
		protected function complete( $key ) {

			// Unschedule the cron healthcheck.
			$this->clear_scheduled_event();

			$status = get_site_option( 'wpfb_status_' . $key );

			if ( ! empty( $status ) ) {

				wpf_log(
					'info',
					wpf_get_current_user_id(),
					sprintf(
						// translators: 1: total records processed.
						__( 'Batch operation %1$s completed on %2$d records.', 'wp-fusion-lite' ),
						'<code>' . $status['last_step'][0] . '</code>',
						intval( $status['total'] ),
					),
					array( 'source' => 'batch-process' )
				);

				// Delete counter variable
				delete_site_option( 'wpfb_status_' . $key );

			}
		}

		/**
		 * Schedule cron healthcheck
		 *
		 * @access public
		 *
		 * @param mixed $schedules Schedules.
		 *
		 * @return mixed
		 */
		public function schedule_cron_healthcheck( $schedules ) {

			$interval = apply_filters( $this->identifier . '_cron_interval', wpf_get_option( 'cron_interval', 5 ) );

			if ( property_exists( $this, 'cron_interval' ) ) {
				$interval = apply_filters( $this->identifier . '_cron_interval', $this->cron_interval_identifier );
			}

			// Adds every 5 minutes to the existing schedules.
			$schedules[ $this->identifier . '_cron_interval' ] = array(
				'interval' => MINUTE_IN_SECONDS * $interval,
				// translators: %d is the number of minutes.
				'display'  => sprintf( __( 'Every %d Minutes', 'wp-fusion-lite' ), $interval ),
			);

			return $schedules;
		}

		/**
		 * Handle cron healthcheck
		 *
		 * Restart the background process if not already running
		 * and data exists in the queue.
		 */
		public function handle_cron_healthcheck() {

			if ( ! wpf_get_option( 'connection_configured' ) ) {
				return; // the WPF settings have been reset.
			}

			// Clean up orphaned operations during healthcheck as backup.
			$this->cleanup_orphaned_operations();

			if ( $this->is_process_running() ) {

				// Background process already running.
				return;
			}

			if ( $this->is_queue_empty() ) {

				// No data to process.
				$this->clear_scheduled_event();
				return;
			}

			$this->handle();

			// Cron jobs must return, not die, so other processes can run.
		}

		/**
		 * Schedule event
		 */
		protected function schedule_event() {
			if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) {
				wp_schedule_event( time(), $this->cron_interval_identifier, $this->cron_hook_identifier );
			}
		}

		/**
		 * Clear scheduled event
		 */
		protected function clear_scheduled_event() {
			$timestamp = wp_next_scheduled( $this->cron_hook_identifier );

			if ( $timestamp && ! wpf_get_option( 'enable_cron' ) ) {
				// Only unschedule it if we're not doing the cron task all the time.
				wp_unschedule_event( $timestamp, $this->cron_hook_identifier );
			}
		}

		/**
		 * Cancel the current process.
		 *
		 * @since 3.44.8
		 *
		 * @param string $key The key.
		 */
		public function cancel( $key ) {

			$status_key = $this->get_status_key( $key );

			update_site_option( $status_key, self::STATUS_CANCELLED );

			// Just in case the job was paused at the time.
			$this->dispatch();
		}

		/**
		 * Has the process been cancelled?
		 *
		 * @since 3.44.8
		 *
		 * @param string $key The key.
		 * @return bool True if cancelled.
		 */
		public function is_cancelled( $key = false ) {

			$status_key = $this->get_status_key( $key );

			// Make sure the option isn't cached as non-existent, in case it's been updated
			// while the process was running.

			$notoptions = wp_cache_get( 'notoptions', 'options' );

			if ( isset( $notoptions[ $status_key ] ) ) {
				wp_cache_set( 'notoptions', $notoptions, 'options' );
			}

			$status = get_site_option( $status_key, 0 );

			return absint( $status ) === self::STATUS_CANCELLED;
		}

		/**
		 * Cancel Process
		 *
		 * Stop processing queue items, clear cronjob and delete batch.
		 */
		public function cancel_process( $key ) {

			if ( ! $this->is_queue_empty() ) {

				$this->delete( $key );
				$this->clear_scheduled_event();

				delete_site_option( 'wpfb_status_' . $key );
				delete_site_option( $this->get_status_key( $key ) );

				wpf_log( 'notice', 0, 'Batch operation cancelled', array( 'source' => 'batch-process' ) );

			}
		}

		/**
		 * Task
		 *
		 * Override this method to perform any actions required on each
		 * queue item. Return the modified item for further processing
		 * in the next pass through. Or, return false to remove the
		 * item from the queue.
		 *
		 * @param mixed $item Queue item to iterate over.
		 *
		 * @return mixed
		 */
		protected function task( $item ) {

			if ( ! defined( 'DOING_WPF_BATCH_TASK' ) ) {
				define( 'DOING_WPF_BATCH_TASK', $item[0] );
			}

			// Disable turbo for bulk processes.
			add_filter( 'wpf_use_api_queue', '__return_false' );

			// 0 is the action hook and 1 is the array of args.

			if ( has_action( 'wpf_batch_' . $item[0] ) ) {
				do_action_ref_array( 'wpf_batch_' . $item[0], $item[1] );
			} else {
				do_action_ref_array( $item[0], $item[1] );
			}

			$sleep = apply_filters( 'wpf_batch_sleep_time', 2 );

			if ( $sleep > 0 ) {
				sleep( $sleep );
			}

			return false;
		}
	}
}
