<?php

namespace Simply_Static;

/**
 * WP Background Process
 *
 * Copied from: https://github.com/deliciousbrains/wp-background-processing/tree/master
 * Copied version: 1.4.0
 *
 * @package WP-Background-Processing
 */


/**
 * Abstract Background_Process class.
 *
 * @abstract
 * @extends Async_Request
 */
abstract class Background_Process extends Async_Request {
	/**
	 * The default query arg name used for passing the chain ID to new processes.
	 */
	const CHAIN_ID_ARG_NAME = 'chain_id';

	/**
	 * Unique background process chain ID.
	 *
	 * @var string
	 */
	private $chain_id;

	/**
	 * Whether the background process is switched to process
	 * a batch from another blog in the multisite
	 *
	 * @var bool
	 */
	private $switched_to_blog = false;

	/**
	 * Current site ID for multisite support
	 *
	 * @var int|null
	 */
	private $current_site_id = null;

	/**
	 * 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;

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

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

	/**
	 * Restrict object instantiation when using unserialize.
	 *
	 * @var bool|array
	 */
	protected $allowed_batch_data_classes = true;

	/**
	 * Amount of seconds to sleep() between batches
	 *
	 * @var int
	 */
	protected $seconds_between_batches = 0;

	/**
	 * Whether this dispatch is currently running inline (not via loopback).
	 *
	 * @var bool
	 */
	protected $is_inline = false;

	/**
	 * Whether an inline shutdown continuation has already been registered.
	 *
	 * @var bool
	 */
	protected $inline_dispatch_pending = false;

	/**
	 * Value written to the current process lock by this PHP process.
	 *
	 * @var string|null
	 */
	protected $process_lock_value = null;

	/**
	 * Whether this PHP process has already attempted to release its lock.
	 *
	 * @var bool
	 */
	protected $process_lock_released = false;

	/**
	 * Whether the most recent queue batch was persisted successfully.
	 *
	 * @var bool
	 */
	protected $queue_save_succeeded = true;

	/**
	 * Whether this worker owns the database-level advisory lock.
	 *
	 * @var bool
	 */
	private $database_lock_acquired = false;

	/**
	 * Database advisory lock name for this site/process.
	 *
	 * @var string|null
	 */
	private $database_lock_name = null;

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

	/**
	 * The status set when process is paused or pausing.
	 *
	 * @var int;
	 */
	const STATUS_PAUSED = 2;

	/**
	 * Initiate new background process.
	 *
	 * @param bool|array $allowed_batch_data_classes Optional. Array of class names that can be unserialized. Default true (any class).
	 */
	public function __construct( $allowed_batch_data_classes = true ) {
		parent::__construct();
		$this->current_site_id = get_current_blog_id();

		if ( empty( $allowed_batch_data_classes ) && false !== $allowed_batch_data_classes ) {
			$allowed_batch_data_classes = true;
		}

		if ( ! is_bool( $allowed_batch_data_classes ) && ! is_array( $allowed_batch_data_classes ) ) {
			$allowed_batch_data_classes = true;
		}

		// If allowed_batch_data_classes property set in subclass,
		// only apply override if not allowing any class.
		if ( true === $this->allowed_batch_data_classes || true !== $allowed_batch_data_classes ) {
			$this->allowed_batch_data_classes = $allowed_batch_data_classes;
		}

		$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' ) );

		// Ensure dispatch query args included extra data.
		add_filter( $this->identifier . '_query_args', array( $this, 'filter_dispatch_query_args' ) );
	}

	/**
	 * Schedule the cron healthcheck and dispatch an async request to start processing the queue.
	 *
	 * If the server cannot reach itself via the loopback URL (a common issue on
	 * certain hosting environments), processing falls back to running inline
	 * via a shutdown function so the export still completes.
	 *
	 * @access public
	 * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted.
	 */
	public function dispatch() {
		// Always schedule the cron healthcheck first, so that even if the
		// dispatch is skipped (e.g. process lock still held), the cron can
		// recover and re-attempt processing later.
		$this->schedule_event();

		if ( $this->is_processing() ) {
			if ( $this->process_lock_released && $this->owns_process_lock() ) {
				Util::debug_log( 'Dispatch found a stale lock owned by the current process. Releasing it before dispatching.' );
				$this->unlock_process();
			}

			if ( $this->is_processing() ) {
				// Process already running.
				Util::debug_log( 'Dispatch skipped: background process is already running (lock held).' );
				return false;
			}
		}

		/**
		 * Filter fired before background process dispatches its next process.
		 *
		 * @param bool   $cancel   Should the dispatch be cancelled? Default false.
		 * @param string $chain_id The background process chain ID.
		 */
		$cancel = apply_filters( $this->identifier . '_pre_dispatch', false, $this->get_chain_id() );

		if ( $cancel ) {

			return false;
		}

		// If the loopback is known to be broken, skip the remote POST and
		// process the queue inline via a shutdown function.
		if ( ! $this->is_loopback_available() ) {
			return $this->dispatch_inline();
		}

		// Perform remote post.
		$response = parent::dispatch();

		// If the remote POST fails, mark loopback as unavailable and fall back
		// to inline processing so the current dispatch is not lost.
		if ( is_wp_error( $response ) ) {
			set_site_transient( $this->get_loopback_cache_key(), 'no', HOUR_IN_SECONDS );

			return $this->dispatch_inline();
		}

		return $response;
	}

	/**
	 * Check whether the server can reach itself via the loopback URL.
	 *
	 * The result is cached in a site transient for one hour. If the loopback
	 * has not been tested yet, a quick blocking request is made to admin-ajax.php
	 * and the result is stored for future dispatches.
	 *
	 * @return bool True if the loopback works, false otherwise.
	 */
	protected function is_loopback_available() {
		/**
		 * Filter to override the loopback availability check.
		 *
		 * Return true to force async dispatch, false to force inline processing.
		 * Return null to use the automatic detection (default).
		 *
		 * @param null|bool $available Null for auto-detect, bool to override.
		 */
		$override = apply_filters( $this->identifier . '_loopback_available', null );

		if ( is_bool( $override ) ) {
			return $override;
		}

		// Known hosting environments where loopback requests are unreliable.
		if ( $this->is_restricted_hosting() ) {
			Util::debug_log( 'Restricted hosting environment detected. Falling back to inline processing.' );

			return false;
		}

		$transient_key = $this->get_loopback_cache_key();
		$cached        = get_site_transient( $transient_key );

		if ( 'yes' === $cached ) {
			return true;
		}

		if ( 'no' === $cached ) {
			return false;
		}

		// Not cached yet — probe this exact worker action. A generic Heartbeat
		// request can pass even when a WAF blocks the background route itself.
		$url                         = add_query_arg( $this->get_query_args(), $this->get_query_url() );
		if ( ! $this->is_dispatch_url_allowed( $url ) ) {
			set_site_transient( $transient_key, 'no', HOUR_IN_SECONDS );
			return false;
		}
		$args                        = $this->get_post_args();
		$args['blocking']            = true;
		$args['body']['simply_static_probe'] = '1';
		$authorization = Util::get_basic_auth_header_for_url( $url );
		if ( $authorization ) {
			$args['headers'] = array( 'Authorization' => $authorization );
		}

		$response = wp_remote_post( esc_url_raw( $url ), $args );

		if ( is_wp_error( $response ) ) {
			Util::debug_log( 'Loopback test failed: ' . $response->get_error_message() . '. Falling back to inline processing.' );
			set_site_transient( $transient_key, 'no', HOUR_IN_SECONDS );

			return false;
		}

		// Check HTTP status code — a non-200 response means the loopback
		// endpoint is not reachable properly (e.g. proxy/WAF blocking).
		$status_code = wp_remote_retrieve_response_code( $response );

		if ( $status_code < 200 || $status_code >= 300 ) {
			Util::debug_log( 'Loopback test returned HTTP ' . $status_code . '. Falling back to inline processing.' );
			set_site_transient( $transient_key, 'no', HOUR_IN_SECONDS );

			return false;
		}

		set_site_transient( $transient_key, 'yes', HOUR_IN_SECONDS );

		return true;
	}

	/**
	 * Get the site-scoped loopback capability cache key.
	 *
	 * @return string
	 */
	private function get_loopback_cache_key() {
		$key = $this->identifier . '_loopback_available';
		if ( is_multisite() ) {
			$site_id = null !== $this->current_site_id ? $this->current_site_id : get_current_blog_id();
			$key    .= '_site_' . absint( $site_id );
		}

		return $key;
	}

	/**
	 * Detect hosting environments known to block or interfere with loopback requests.
	 *
	 * These hosts typically use reverse proxies, aggressive security layers, or
	 * non-standard PHP-FPM configurations that cause non-blocking self-requests
	 * to silently fail even when a blocking test appears to succeed.
	 *
	 * @return bool True if running on a restricted host, false otherwise.
	 */
	protected function is_restricted_hosting() {
		/**
		 * Filter to override restricted hosting detection.
		 *
		 * Return true to force inline processing, false to skip hosting detection.
		 * Return null to use automatic detection (default).
		 *
		 * @param null|bool $is_restricted Null for auto-detect, bool to override.
		 */
		$override = apply_filters( $this->identifier . '_is_restricted_hosting', null );

		if ( is_bool( $override ) ) {
			return $override;
		}

		// WP Engine: uses reverse proxy and security layers that can silently
		// drop non-blocking loopback requests.
		if ( defined( 'WPE_APIKEY' ) || ( function_exists( 'is_wpe' ) && is_wpe() ) ) {
			return true;
		}

		// Flywheel: shares infrastructure with WP Engine.
		if ( defined( 'FLYWHEEL_CONFIG_DIR' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Process the queue inline via a shutdown function.
	 *
	 * This is a fallback for environments where the loopback wp_remote_post()
	 * to admin-ajax.php fails (DNS, reverse-proxy, firewall, or hosting issues).
	 * The queue is processed in the current PHP process after the response has
	 * been sent, keeping the UI responsive.
	 *
	 * @return bool True to indicate inline dispatch was scheduled.
	 */
	protected function dispatch_inline() {
		// Multiple callers can request a continuation before shutdown begins.
		// Keep a single pending callback, while allowing the callback itself to
		// schedule the next bounded worker window when queue work remains.
		if ( $this->inline_dispatch_pending ) {
			return true;
		}

		$this->is_inline               = true;
		$this->inline_dispatch_pending = true;

		// Prevent wp_die() from terminating the shutdown function.
		// handle() calls maybe_wp_die() at the end, which would otherwise
		// kill execution before chained batches can run.
		add_filter( $this->identifier . '_wp_die', '__return_false' );

		$process        = $this;
		$target_site_id = $this->current_site_id;

		$this->register_inline_shutdown_handler( function () use ( $process, $target_site_id ) {
			// Release the pending slot before handling. If handle() reaches its
			// time or memory boundary with work left, dispatch_inline() can now
			// register exactly one immediate continuation.
			$process->inline_dispatch_pending = false;

			// A paused queue must remain dormant until resume() explicitly
			// dispatches it again. Continuing inline here would spin through
			// shutdown callbacks without doing useful work.
			if ( $process->is_paused() ) {
				return;
			}

			$switched = is_multisite()
				&& ! empty( $target_site_id )
				&& absint( $target_site_id ) !== get_current_blog_id();

			try {
				if ( $switched ) {
					switch_to_blog( $target_site_id );
					$process->set_current_site_id( get_current_blog_id() );
					if ( method_exists( $process, 'set_options' ) ) {
						$process->set_options( Options::reinstance() );
					}
				}

				// Don't run if another process already holds the target site's lock.
				if ( $process->is_processing() ) {
					return;
				}

				// Access the protected handle() method via Closure binding.
				$handler = \Closure::bind( function () {
					$this->handle();
				}, $process, get_class( $process ) );

				if ( $handler ) {
					$handler();
				}
			} finally {
				if ( $switched ) {
					restore_current_blog();
					$process->set_current_site_id( get_current_blog_id() );
					if ( method_exists( $process, 'set_options' ) ) {
						$process->set_options( Options::reinstance() );
					}
				}
			}
		} );

		return true;
	}

	/**
	 * Register an inline worker callback for PHP shutdown.
	 *
	 * Kept as a method so continuation scheduling can be covered without
	 * executing real shutdown callbacks in the unit-test process.
	 *
	 * @param callable $handler Inline worker callback.
	 * @return void
	 */
	protected function register_inline_shutdown_handler( $handler ) {
		register_shutdown_function( $handler );
	}

	/**
	 * Push to the queue.
	 *
	 * Note, save must be called in order to persist queued items to a batch for processing.
	 *
	 * @param mixed $data Data.
	 *
	 * @return $this
	 */
	public function push_to_queue( $data ) {
		$this->data[] = $data;

		return $this;
	}

	/**
	 * Save the queued items for future processing.
	 *
	 * @return $this
	 */
	public function save() {
		$key = $this->generate_key();
		$this->queue_save_succeeded = true;

		if ( ! empty( $this->data ) ) {
			Util::debug_log( "Saving to key: $key. Data: " . print_r( $this->data, true ));
			$this->queue_save_succeeded = (bool) update_option( $key, $this->data );
			if ( ! $this->queue_save_succeeded ) {
				Util::debug_log( 'Unable to persist background queue batch: ' . $key );
			}
		}

		// Clean out data so that new data isn't prepended with closed session's data.
		$this->data = array();

		return $this;
	}

	/**
	 * Whether save() persisted the most recent queue batch.
	 *
	 * @return bool
	 */
	public function was_last_queue_save_successful() {
		return $this->queue_save_succeeded;
	}

	/**
	 * Update a batch's queued items.
	 *
	 * @param string $key  Key.
	 * @param array  $data Data.
	 *
	 * @return $this
	 */
	public function update( $key, $data ) {
		if ( ! empty( $data ) ) {
			update_option( $key, $data );
		}

		return $this;
	}

	/**
	 * Delete a batch of queued items.
	 *
	 * @param string $key Key.
	 *
	 * @return $this
	 */
	public function delete( $key ) {
		delete_option( $key );

		return $this;
	}

	/**
	 * Delete entire job queue.
	 */
	public function delete_all() {
		$batches = $this->get_batches();

		foreach ( $batches as $batch ) {
			$this->delete( $batch->key );
		}

		delete_option( $this->get_status_key() );

		$this->cancelled();
	}

	/**
	 * Cancel job on next batch.
	 */
	public function cancel() {
		update_option( $this->get_status_key(), self::STATUS_CANCELLED );

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

	/**
	 * Has the process been cancelled?
	 *
	 * @return bool
	 */
	public function is_cancelled() {
		return $this->get_status() === self::STATUS_CANCELLED;
	}

	/**
	 * Called when background process has been cancelled.
	 */
	protected function cancelled() {
		do_action( $this->identifier . '_cancelled', $this->get_chain_id() );
	}

	/**
	 * Pause job on next batch.
	 */
	public function pause() {
		update_option( $this->get_status_key(), self::STATUS_PAUSED );
	}

	/**
	 * Has the process been paused?
	 *
	 * @return bool
	 */
	public function is_paused() {
		return $this->get_status() === self::STATUS_PAUSED;
	}

	/**
	 * Called when background process has been paused.
	 */
	protected function paused() {
		do_action( $this->identifier . '_paused', $this->get_chain_id() );
	}

	/**
	 * Resume job.
	 */
	public function resume() {
		delete_option( $this->get_status_key() );

		$this->schedule_event();
		$this->dispatch();
		$this->resumed();
	}

	/**
	 * Called when background process has been resumed.
	 */
	protected function resumed() {
		do_action( $this->identifier . '_resumed', $this->get_chain_id() );
	}

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

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

	/**
	 * Generate key for a batch.
	 *
	 * 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 Optional max length to trim key to, defaults to 64 characters.
	 * @param string $key    Optional string to append to identifier before hash, defaults to "batch".
	 *
	 * @return string
	 */
	protected function generate_key( $length = 64, $key = 'batch' ) {
		$unique  = md5( microtime() . wp_rand() );
		$prepend = $this->identifier . '_' . $key;

		if( is_multisite() ){
			$site_id = get_current_blog_id();
			$prepend .= '_' . $site_id;
		}

		$prepend .= '_';

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

	/**
	 * Get the status key.
	 *
	 * @return string
	 */
	protected function get_status_key() {
		return $this->identifier . '_status';
	}

	/**
	 * Get the status value for the process.
	 *
	 * @return int
	 */
	protected function get_status() {
		global $wpdb;

		/*
		 * Still using status per site within their options to allow multiple exports.
		 **
		if ( is_multisite() ) {
			$status = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT meta_value FROM $wpdb->sitemeta WHERE meta_key = %s AND site_id = %d LIMIT 1",
					$this->get_status_key(),
					get_current_network_id()
				)
			);
		} else {
			$status = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1",
					$this->get_status_key()
				)
			);
		}/**/

		$status = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1",
				$this->get_status_key()
			)
		);

		return absint( $status );
	}

	/**
	 * Maybe process a batch of queued items.
	 *
	 * Checks whether data exists within the queue and that
	 * the process is not already running.
	 */
	public function maybe_handle() {
		// Don't lock up other requests while processing.
		if ( function_exists( 'session_write_close' ) ) {
			session_write_close();
		}

		Util::debug_log( "Maybe handling: Site ID: " . get_current_blog_id() );
		$this->set_current_site_id( get_current_blog_id() );

		check_ajax_referer( $this->identifier, 'nonce' );

		if ( ! empty( $_REQUEST['simply_static_probe'] ) ) {
			return $this->maybe_wp_die();
		}

		// Background process already running.
		if ( $this->is_processing() ) {
			return $this->maybe_wp_die();
		}

		// Cancel requested.
		if ( $this->is_cancelled() ) {
			$this->clear_scheduled_event();
			$this->delete_all();

			return $this->maybe_wp_die();
		}

		// Pause requested.
		if ( $this->is_paused() ) {
			$this->clear_scheduled_event();
			$this->paused();

			return $this->maybe_wp_die();
		}

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

		$this->handle();

		return $this->maybe_wp_die();
	}

	/**
	 * Is queue empty?
	 *
	 * @return bool
	 */
	protected function is_queue_empty() {
		return empty( $this->get_batch() );
	}

	/**
	 * Is process running?
	 *
	 * Check whether the current process is already running
	 * in a background process.
	 *
	 * @return bool
	 *
	 * @deprecated 1.1.0 Superseded.
	 * @see        is_processing()
	 */
	protected function is_process_running() {
		return $this->is_processing();
	}

	/**
	 * Is the background process currently running?
	 *
	 * @return bool
	 */
	public function is_processing() {
		if ( get_site_transient( $this->get_lock_key() ) ) {
			// Process already running.
			return true;
		}

		return $this->is_database_lock_in_use();
	}

	/**
	 * Determine whether the process lock belongs to a worker that may still run.
	 *
	 * Invalid legacy lock values are not treated as active so an administrator
	 * can recover them through the queue-reset utility. Valid locks remain active
	 * only for the configured lock duration.
	 *
	 * @return bool
	 */
	public function has_active_process_lock() {
		if ( $this->is_database_lock_in_use() ) {
			return true;
		}

		$lock_value = get_site_transient( $this->get_lock_key() );
		if ( ! is_string( $lock_value ) || '' === $lock_value ) {
			return false;
		}

		if ( null === $this->get_process_lock_timestamp( $lock_value ) ) {
			return false;
		}

		return ! $this->is_process_lock_stale( $lock_value );
	}

	/**
	 * 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.
	 *
	 * @param bool $reset_start_time Optional, default true.
	 */
	public function lock_process( $reset_start_time = true ) {
		if ( $reset_start_time ) {
			$this->start_time = time(); // Set start time of current process.

			$database_lock = $this->acquire_database_lock();
			if ( false === $database_lock ) {
				do_action(
					$this->identifier . '_process_locked',
					false,
					'',
					0,
					$this->get_chain_id()
				);

				return false;
			}
		}

		$lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60; // 1 minute
		$lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration );

		$microtime                   = microtime();
		$this->process_lock_value    = $microtime . ':' . uniqid( '', true );
		$this->process_lock_released = false;
		$locked                      = set_site_transient( $this->get_lock_key(), $this->process_lock_value, $lock_duration );

		/**
		 * Action to note whether the background process managed to create its lock.
		 *
		 * The lock is used to signify that a process is running a task and no other
		 * process should be allowed to run the same task until the lock is released.
		 *
		 * @param bool   $locked        Whether the lock was successfully created.
		 * @param string $microtime     Microtime string value used for the lock.
		 * @param int    $lock_duration Max number of seconds that the lock will live for.
		 * @param string $chain_id      Current background process chain ID.
		 */
		do_action(
			$this->identifier . '_process_locked',
			$locked,
			$microtime,
			$lock_duration,
			$this->get_chain_id()
		);

		if ( ! $locked && $reset_start_time ) {
			$this->release_database_lock();
		}

		return (bool) $locked;
	}

	/**
	 * Unlock process.
	 *
	 * Unlock the process so that other instances can spawn.
	 *
	 * @return $this
	 */
	protected function unlock_process() {
		$lock_key   = $this->get_lock_key();
		$lock_value = get_site_transient( $lock_key );

		if ( $lock_value && ! $this->owns_process_lock() && ! $this->is_process_lock_stale( $lock_value ) ) {
			Util::debug_log( 'Skipping process unlock because the lock is owned by another process.' );
			$this->release_database_lock();
			return $this;
		}

		$unlocked                     = delete_site_transient( $lock_key );
		$this->process_lock_released = true;

		/**
		 * Action to note whether the background process managed to release its lock.
		 *
		 * The lock is used to signify that a process is running a task and no other
		 * process should be allowed to run the same task until the lock is released.
		 *
		 * @param bool   $unlocked Whether the lock was released.
		 * @param string $chain_id Current background process chain ID.
		 */
		do_action( $this->identifier . '_process_unlocked', $unlocked, $this->get_chain_id() );
		$this->release_database_lock();

		return $this;
	}

	/**
	 * Acquire an atomic MySQL advisory lock for this worker when supported.
	 *
	 * A null result means the database does not expose advisory locks and the
	 * existing transient guard remains the compatibility fallback.
	 *
	 * @return bool|null True when acquired, false when another worker owns it,
	 *                   null when unavailable/disabled.
	 */
	private function acquire_database_lock() {
		global $wpdb;

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

		if ( ! apply_filters( $this->identifier . '_use_database_lock', true ) ) {
			return null;
		}

		if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_var' ) ) {
			return null;
		}

		$this->database_lock_name = $this->get_database_lock_name();
		$result = $wpdb->get_var(
			$wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $this->database_lock_name )
		);

		if ( '1' === (string) $result ) {
			$this->database_lock_acquired = true;
			return true;
		}

		if ( '0' === (string) $result ) {
			return false;
		}

		Util::debug_log( 'Database advisory locks are unavailable; using the transient process lock fallback.' );
		return null;
	}

	/**
	 * Determine whether any database connection owns this process lock.
	 *
	 * @return bool
	 */
	private function is_database_lock_in_use() {
		global $wpdb;

		if ( ! apply_filters( $this->identifier . '_use_database_lock', true ) ) {
			return false;
		}

		if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'prepare' ) || ! method_exists( $wpdb, 'get_var' ) ) {
			return false;
		}

		$result = $wpdb->get_var(
			$wpdb->prepare( 'SELECT IS_USED_LOCK(%s)', $this->get_database_lock_name() )
		);

		return null !== $result && false !== $result && '' !== (string) $result;
	}

	/**
	 * Build a server-global advisory lock name scoped to this WordPress install.
	 *
	 * @return string
	 */
	private function get_database_lock_name() {
		global $wpdb;

		if ( ! empty( $this->database_lock_name ) ) {
			return $this->database_lock_name;
		}

		$scope = array(
			isset( $wpdb->dbname ) ? (string) $wpdb->dbname : '',
			isset( $wpdb->base_prefix ) ? (string) $wpdb->base_prefix : ( isset( $wpdb->prefix ) ? (string) $wpdb->prefix : '' ),
			function_exists( 'get_current_network_id' ) ? (string) get_current_network_id() : '1',
			(string) ( null !== $this->current_site_id ? $this->current_site_id : get_current_blog_id() ),
			$this->get_lock_key(),
		);
		$salt = function_exists( 'wp_salt' )
			? wp_salt( 'nonce' )
			: ( defined( 'AUTH_SALT' ) ? AUTH_SALT : ABSPATH );
		$digest = hash_hmac( 'sha256', implode( '|', $scope ), (string) $salt );

		return 'simply_static_' . substr( $digest, 0, 48 );
	}

	/**
	 * Release this worker's database advisory lock.
	 *
	 * @return void
	 */
	private function release_database_lock() {
		global $wpdb;

		if ( ! $this->database_lock_acquired || empty( $this->database_lock_name ) ) {
			return;
		}

		if ( is_object( $wpdb ) && method_exists( $wpdb, 'prepare' ) && method_exists( $wpdb, 'get_var' ) ) {
			$wpdb->get_var(
				$wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $this->database_lock_name )
			);
		}

		$this->database_lock_acquired = false;
		$this->database_lock_name     = null;
	}

	/**
	 * Check whether the current PHP process owns the active lock.
	 *
	 * @return bool
	 */
	protected function owns_process_lock() {
		if ( empty( $this->process_lock_value ) ) {
			return false;
		}

		$lock_value = get_site_transient( $this->get_lock_key() );

		return is_string( $lock_value ) && hash_equals( $this->process_lock_value, $lock_value );
	}

	/**
	 * Check whether a process lock is older than the configured lock duration.
	 *
	 * @param string $lock_value Current lock transient value.
	 *
	 * @return bool
	 */
	protected function is_process_lock_stale( $lock_value ) {
		$lock_timestamp = $this->get_process_lock_timestamp( $lock_value );

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

		$lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60; // 1 minute
		$lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration );

		return ( microtime( true ) - $lock_timestamp ) > $lock_duration;
	}

	/**
	 * Extract a Unix timestamp from a process lock value.
	 *
	 * @param string $lock_value Current lock transient value.
	 *
	 * @return float|null
	 */
	protected function get_process_lock_timestamp( $lock_value ) {
		$microtime = explode( ':', $lock_value, 2 )[0];
		$parts     = explode( ' ', $microtime );

		if ( count( $parts ) !== 2 || ! is_numeric( $parts[0] ) || ! is_numeric( $parts[1] ) ) {
			return null;
		}

		return (float) $parts[1] + (float) $parts[0];
	}

	/**
	 * Get batch.
	 *
	 * @param int $for_site_id Get batch for a specific Site ID (multisite)
	 * @return \stdClass Return the first batch of queued items.
	 */
	protected function get_batch($for_site_id = null) {
		return array_reduce(
			$this->get_batches( 1, $for_site_id ),
			static function ( $carry, $batch ) {
				return $batch;
			},
			array()
		);
	}

	/**
	 * Get batches.
	 *
	 * @param int $limit Number of batches to return, defaults to all.
	 * @param int $for_site_id Get batches for a specific Site ID (multisite)
	 * @return array of stdClass
	 */
	public function get_batches( $limit = 0, $for_site_id = null ) {
		global $wpdb;

		if ( empty( $limit ) || ! is_int( $limit ) ) {
			$limit = 0;
		}

		$table        = $wpdb->options;
		$column       = 'option_name';
		$key_column   = 'option_id';
		$value_column = 'option_value';


		/*
		 * Still using batches on site level.
		*if ( is_multisite() ) {
			$table        = $wpdb->sitemeta;
			$column       = 'meta_key';
			$key_column   = 'meta_id';
			$value_column = 'meta_value';
		}
		/**/

		if( !is_null( $for_site_id ) ) {
			$key = $wpdb->esc_like( $this->identifier . '_batch_' . $for_site_id ) . '%';
		} else {
			$key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%';
		}

		$sql = '
			SELECT *
			FROM ' . $table . '
			WHERE ' . $column . ' LIKE %s
			ORDER BY ' . $key_column . ' ASC
			';

		$args = array( $key );

		if ( ! empty( $limit ) ) {
			$sql .= ' LIMIT %d';

			$args[] = $limit;
		}

		$items = $wpdb->get_results(
			$wpdb->prepare(
				$sql, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$args
			)
		);

		$batches = array();

		if ( ! empty( $items ) ) {
			$allowed_classes = $this->allowed_batch_data_classes;

			$batches = array_map(
				static function ( $item ) use ( $column, $value_column, $allowed_classes ) {
					$batch          = new \stdClass();
					$batch->key     = $item->{$column};

					if( is_multisite() ) {
						$batch->site_id = static::extract_site_id_from_column_name($item->{$column});
					}

					$batch->data    = static::maybe_unserialize( $item->{$value_column}, $allowed_classes );

					return $batch;
				},
				$items
			);
		}

		return $batches;
	}

	/**
	 * Extract the site ID from the database column name when in a multisite environment
	 *
	 * @param $column_name
	 * @return int|null
	 */
	protected static function extract_site_id_from_column_name($column_name ) {
		if ( preg_match( '/_batch_(\d+)_/', $column_name, $matches ) ) {
			return intval( $matches[1] );
		}

		return null;
	}

	/**
	 * Handle a dispatched request.
	 *
	 * Pass each queue item to the task handler, while remaining
	 * within server memory and time limit constraints.
	 */
	protected function handle() {
		if ( false === $this->lock_process() ) {
			return $this->maybe_wp_die();
		}

		if ( $this->is_cancelled() ) {
			Util::debug_log( 'Background process cancelled before handling batch. Clearing queue.' );
			$this->clear_scheduled_event();
			$this->unlock_process();
			$this->delete_all();

			return $this->maybe_wp_die();
		}

		/**
		 * Number of seconds to sleep between batches. Defaults to 0 seconds, minimum 0.
		 *
		 * @param int $seconds
		 */
		$throttle_seconds = max(
			$this->seconds_between_batches,
			apply_filters(
				$this->identifier . '_seconds_between_batches',
				apply_filters(
					$this->prefix . '_seconds_between_batches',
					0
				)
			)
		);

		do {
			$batch = $this->get_batch();
			if ( ! is_object( $batch ) || empty( $batch->data ) || ! is_array( $batch->data ) ) {
				break;
			}

			if( is_multisite() && isset( $batch->site_id ) && !is_null( $batch->site_id ) && $batch->site_id !== get_current_blog_id() ) {

				//Do not try to run a batch for a site that doesn't exist (anymore)
				if ( ! get_site( $batch->site_id ) ) {
					$this->delete( $batch->key );
					continue;
				}

				switch_to_blog( $batch->site_id );
				$this->switched_to_blog = true;
			}

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

				// Refresh the process lock after each task so that long-running
				// perform() calls (e.g. minify, github_commit) do not cause the
				// transient lock to expire while processing is still active.
				// Without this, the cron healthcheck sees no lock and dispatches
				// a second concurrent worker, leading to race conditions.
				$this->lock_process( false );

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

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

				// Let the server breathe a little.
				sleep( $throttle_seconds );

				// Batch limits reached, or pause or cancel requested.
				if ( ! $this->should_continue() ) {
					break;
				}
			}

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

			if( $this->switched_to_blog ) {
				restore_current_blog();
			}

			// Let the server breathe a little.
			sleep( $throttle_seconds );
		} while ( ! $this->is_queue_empty() && $this->should_continue() );

		$this->unlock_process();

		if ( $this->is_cancelled() ) {
			Util::debug_log( 'Background process cancelled after handling batch. Clearing queue.' );
			$this->clear_scheduled_event();
			$this->delete_all();

			return $this->maybe_wp_die();
		}

		// Start next batch or complete process.
		if ( ! $this->is_queue_empty() ) {
			$this->dispatch();
		} else {
			$this->complete();
		}

		return $this->maybe_wp_die();
	}

	/**
	 * Memory exceeded?
	 *
	 * Ensures the batch process never exceeds 90%
	 * of the maximum WordPress memory.
	 *
	 * @return bool
	 */
	protected function memory_exceeded() {
		$memory_limit   = $this->get_memory_limit() * 0.9; // 90% of max memory
		$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 in bytes.
	 *
	 * @return int
	 */
	protected 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 === intval( $memory_limit ) ) {
			// Unlimited, set to 32GB.
			$memory_limit = '32000M';
		}

		return wp_convert_hr_to_bytes( $memory_limit );
	}

	/**
	 * Time limit 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;
		}

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

	/**
	 * Complete processing.
	 *
	 * Override if applicable, but ensure that the below actions are
	 * performed, or, call parent::complete().
	 */
	protected function complete() {
		delete_option( $this->get_status_key() );

		// Remove the cron healthcheck job from the cron schedule.
		$this->clear_scheduled_event();

		$this->completed();
	}

	/**
	 * Called when background process has completed.
	 */
	protected function completed() {
		do_action( $this->identifier . '_completed', $this->get_chain_id() );
	}

	/**
	 * Get the cron healthcheck interval in minutes.
	 *
	 * Default is 5 minutes, minimum is 1 minute.
	 *
	 * @return int
	 */
	public function get_cron_interval() {
		$interval = 5;

		if ( property_exists( $this, 'cron_interval' ) ) {
			$interval = $this->cron_interval;
		}

		$interval = apply_filters( $this->cron_interval_identifier, $interval );

		return is_int( $interval ) && 0 < $interval ? $interval : 5;
	}

	/**
	 * Schedule the cron healthcheck job.
	 *
	 * @access public
	 *
	 * @param mixed $schedules Schedules.
	 *
	 * @return mixed
	 */
	public function schedule_cron_healthcheck( $schedules ) {
		$interval = $this->get_cron_interval();

		if ( 1 === $interval ) {
			$display = __( 'Every Minute' );
		} else {
			$display = sprintf( __( 'Every %d Minutes' ), $interval );
		}

		// Adds an "Every NNN Minute(s)" schedule to the existing cron schedules.
		$schedules[ $this->cron_interval_identifier ] = array(
			'interval' => MINUTE_IN_SECONDS * $interval,
			'display'  => $display,
		);

		return $schedules;
	}

	/**
	 * Handle cron healthcheck event.
	 *
	 * Restart the background process if not already running
	 * and data exists in the queue.
	 */
	public function handle_cron_healthcheck() {
		if ( $this->is_processing() ) {
			// Background process already running.
			return;
		}

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

		$this->dispatch();
	}

	/**
	 * Schedule the cron healthcheck event.
	 */
	protected function schedule_event() {
		if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) {
			wp_schedule_event(
				time() + ( $this->get_cron_interval() * MINUTE_IN_SECONDS ),
				$this->cron_interval_identifier,
				$this->cron_hook_identifier
			);
		}
	}

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

		if ( $timestamp ) {
			wp_unschedule_event( $timestamp, $this->cron_hook_identifier );
		}
	}

	/**
	 * Cancel the background process.
	 *
	 * Stop processing queue items, clear cron job and delete batch.
	 *
	 * @deprecated 1.1.0 Superseded.
	 * @see        cancel()
	 */
	public function cancel_process() {
		$this->cancel();
	}

	/**
	 * Perform task with queued item.
	 *
	 * 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
	 */
	abstract protected function task( $item );

	/**
	 * Maybe unserialize data, but not if an object.
	 *
	 * @param mixed      $data            Data to be unserialized.
	 * @param bool|array $allowed_classes Array of class names that can be unserialized.
	 *
	 * @return mixed
	 */
	protected static function maybe_unserialize( $data, $allowed_classes ) {
		if ( is_serialized( $data ) ) {
			$options = array();
			if ( is_bool( $allowed_classes ) || is_array( $allowed_classes ) ) {
				$options['allowed_classes'] = $allowed_classes;
			}

			return @unserialize( $data, $options ); // @phpcs:ignore
		}

		return $data;
	}

	/**
	 * Set the current site ID for multisite processing
	 *
	 * @param int|null $site_id
	 */
	public function set_current_site_id( $site_id ) {
		$this->current_site_id = $site_id;
	}

	/**
	 * Get the current site ID for multisite processing
	 *
	 * @return int|null
	 */
	public function get_current_site_id() {
		return $this->current_site_id;
	}

	/**
	 * Get site-specific lock key
	 *
	 * @return string
	 */
	private function get_lock_key() {
		$lock_key = $this->identifier . '_process_lock';
		
		// In multisite, add site ID to make lock site-specific
		if ( is_multisite() && !is_null( $this->current_site_id ) ) {
			$lock_key .= '_site_' . $this->current_site_id;
		}
		
		return $lock_key;
	}

	/**
	 * Should any processing continue?
	 *
	 * @return bool
	 */
	public function should_continue() {
		/**
		 * Filter whether the current background process should continue running the task
		 * if there is data to be processed.
		 *
		 * If the processing time or memory limits have been exceeded, the value will be false.
		 * If pause or cancel have been requested, the value will be false.
		 *
		 * It is very unlikely that you would want to override a false value with true.
		 *
		 * If false is returned here, it does not necessarily mean background processing is
		 * complete. If there is batch data still to be processed and pause or cancel have not
		 * been requested, it simply means this background process should spawn a new process
		 * for the chain to continue processing and then close itself down.
		 *
		 * @param bool   $continue Should the current process continue processing the task?
		 * @param string $chain_id The current background process chain's ID.
		 *
		 * @return bool
		 */
		return apply_filters(
			$this->identifier . '_should_continue',
			! ( $this->time_exceeded() || $this->memory_exceeded() || $this->is_paused() || $this->is_cancelled() ),
			$this->get_chain_id()
		);
	}

	/**
	 * Get the string used to identify this type of background process.
	 *
	 * @return string
	 */
	public function get_identifier() {
		return $this->identifier;
	}

	/**
	 * Return the current background process chain's ID.
	 *
	 * If the chain's ID hasn't been set before this function is first used,
	 * and hasn't been passed as a query arg during dispatch,
	 * the chain ID will be generated before being returned.
	 *
	 * @return string
	 */
	public function get_chain_id() {
		if ( empty( $this->chain_id )
		     && wp_doing_ajax()
		     && isset( $_REQUEST['action'] )
		     && $_REQUEST['action'] === $this->identifier ) {
			check_ajax_referer( $this->identifier, 'nonce' );

			if ( ! empty( $_GET[ $this->get_chain_id_arg_name() ] ) ) {
				$chain_id = sanitize_key( $_GET[ $this->get_chain_id_arg_name() ] );

				if ( wp_is_uuid( $chain_id ) ) {
					$this->chain_id = $chain_id;

					return $this->chain_id;
				}
			}
		}

		if ( empty( $this->chain_id ) ) {
			$this->chain_id = wp_generate_uuid4();
		}

		return $this->chain_id;
	}

	/**
	 * Filters the query arguments used during an async request.
	 *
	 * @param array $args Current query args.
	 *
	 * @return array
	 */
	public function filter_dispatch_query_args( $args ) {
		$args[ $this->get_chain_id_arg_name() ] = $this->get_chain_id();

		return $args;
	}

	/**
	 * Get the query arg name used for passing the chain ID to new processes.
	 *
	 * @return string
	 */
	private function get_chain_id_arg_name() {
		static $chain_id_arg_name;

		if ( ! empty( $chain_id_arg_name ) ) {
			return $chain_id_arg_name;
		}

		/**
		 * Filter the query arg name used for passing the chain ID to new processes.
		 *
		 * If you encounter problems with using the default query arg name, you can
		 * change it with this filter.
		 *
		 * @param string $chain_id_arg_name Default "chain_id".
		 *
		 * @return string
		 */
		$chain_id_arg_name = apply_filters( $this->identifier . '_chain_id_arg_name', self::CHAIN_ID_ARG_NAME );

		if ( ! is_string( $chain_id_arg_name ) || empty( $chain_id_arg_name ) ) {
			$chain_id_arg_name = self::CHAIN_ID_ARG_NAME;
		}

		return $chain_id_arg_name;
	}
}
