<?php

/**
 * Malcure Malware Scanner Class
 *
 * This file contains the main malware scanner class that handles file scanning,
 * threat detection, and security monitoring for the Malcure Security Suite plugin.
 *
 * @package MalcureSecuritySuite
 * @subpackage Classes
 * @since 1.0.0
 */

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

define( 'WPMR_CHECKSUMS', 'wpmr_checksums' );
define( 'WPMR_SCANNED_FILES', 'wpmr_scanned_files' );
define( 'WPMR_ISSUES', 'wpmr_issues' );
define( 'WPMR_LOGS', 'wpmr_logs' );
define( 'WPMR_EVENTS', 'wpmr_events' );

// Todo: elapsed time during each phase should be in human readable format.

/**
 * Main Malware Scanner Class
 *
 * Handles file scanning, threat detection, malware signature matching,
 * and security monitoring for WordPress installations.
 *
 * @since 1.0.0
 */
trait WPMR_Stateful_Scanner_Runtime_Orchestrator {

	/**
	 * Current scanner state data.
	 *
	 * @var mixed
	 */
	private $state = false;

	/**
	 * Database table name for scanner operations.
	 *
	 * @var string
	 */
	private $tablename = '';

	/**
	 * Original checksums table name.
	 *
	 * @var string|false
	 */
	private $wpmr_origin_cs = false;

	/**
	 * General checksums table name.
	 *
	 * @var string|false
	 */
	private $wpmr_gen_cs = false;

	/**
	 * Issues table name.
	 *
	 * @var string|false
	 */
	private $wpmr_issues = false;

	/**
	 * Checksums table name.
	 *
	 * @var string
	 */
	private $table_checksums = '';

	/**
	 * Scanned files table name.
	 *
	 * @var string
	 */
	private $table_scanned_files = '';

	/**
	 * Issues table name.
	 *
	 * @var string
	 */
	private $table_issues = '';

	/**
	 * Logs table name.
	 *
	 * @var string
	 */
	private $table_logs = '';

	/**
	 * Events table name.
	 *
	 * @var string
	 */
	private $table_events = '';

	/**
	 * Maximum execution time in seconds.
	 *
	 * @var int|false
	 */
	private $max_execution_time = false;

	/**
	 * Time buffer in seconds for operations.
	 *
	 * @var int
	 */
	private $time_buffer = 5;

	/**
	 * Sleep time in seconds between operations.
	 *
	 * @var int
	 */
	private $time_sleep = 2;

	/**
	 * Time slice for operations in seconds.
	 *
	 * Use smaller intervals so that you have enough remaining.
	 * 0.1 => 100,000 microseconds = 100 ms; 0.025 => 25,000 microseconds = 25 ms.
	 *
	 * @var float
	 */
	private $time_slice = 0.01;

	/**
	 * Total time slept count.
	 *
	 * @var float
	 */
	private $time_slept_count = 0.0;

	/**
	 * Malware definitions data.
	 *
	 * @var mixed
	 */
	private $definitions = false;

	/**
	 * Hook suffix for the stateful scanner page.
	 *
	 * @var string
	 */
	private $stateful_scanner_hook = '';

	/**
	 * Build a prefixed WPMR table name from a shared table constant.
	 *
	 * This keeps table-name construction in one place so traits stop duplicating
	 * prefix-based WPMR table literals throughout the codebase.
	 *
	 * @param string $table_constant Unprefixed table constant value.
	 * @return string Fully-prefixed table name.
	 */
	protected function get_wpmr_table_name( $table_constant ) {
		global $wpdb;

		return $wpdb->prefix . $table_constant;
	}

	/**
	 * Memory limit in MB (increased from 384MB to 512MB).
	 *
	 * @var int
	 */
	private $mem = 384;

	/**
	 * Maximum directory entries to process.
	 *
	 * @var int
	 */
	private $max_dir_entries = 10000;

	/**
	 * Initializes the malware scanner by setting up execution parameters, adjusting system limits,
	 * and registering various hooks and AJAX callbacks.
	 *
	 * This method handles:
	 * - Setting and constraining the maximum execution time based on PHP's ini settings.
	 * - Adjusting PCRE backtrack and recursion limits if current limits exceed preset thresholds.
	 * - Defining global variables for custom database table names using the WordPress database prefix.
	 * - Registering action hooks to:
	 *   - Add meta boxes for the security suite.
	 *   - Process AJAX requests for scan dispatching, file scanning, database scanning, and scan status.
	 *   - Handle plugin activation, database installation, and table upgrades.
	 *   - Trigger additional scan phases such as file malware scanning and database malware scanning.
	 *
	 *
	 * Better name: (current name is appropriate)
	 *
	 * @return void
	 */
	public function init_stateful_scanner() {
		// Get and set maximum execution time with constraints.

		$this->max_execution_time = ini_get( 'max_execution_time' );
		// Set execution time constraints based on server configuration.
		// Non-numeric or too low: default to 15 seconds.
		if ( ! is_numeric( $this->max_execution_time ) || $this->max_execution_time < 30 ) {
			$this->max_execution_time = 15;
		} else {
			// Otherwise, cap at 60 seconds.
			$this->max_execution_time = min( $this->max_execution_time, 60 );
		}

		$backtrack_limit = ini_get( 'pcre.backtrack_limit' );
		if ( is_numeric( $backtrack_limit ) ) {
			$backtrack_limit = (int) $backtrack_limit;
			if ( $backtrack_limit > 1000000 ) {
				$this->set_runtime_ini_value( 'pcre.backtrack_limit', 1000000 );
				$this->set_runtime_ini_value( 'pcre.recursion_limit', 1000000 );
			}
		}

		$this->table_checksums     = $GLOBALS['wpdb']->prefix . WPMR_CHECKSUMS;
		$this->table_scanned_files = $GLOBALS['wpdb']->prefix . WPMR_SCANNED_FILES;
		$this->table_issues        = $GLOBALS['wpdb']->prefix . WPMR_ISSUES;
		$this->table_logs          = $GLOBALS['wpdb']->prefix . WPMR_LOGS;
		$this->table_events        = $GLOBALS['wpdb']->prefix . WPMR_EVENTS;

		// User-triggered scan operation dispatcher.
		// Use a plugin-namespaced action to avoid collisions with other plugins/themes.
		add_action( 'wp_ajax_nopriv_wpmr_scanner_ajax_dispatcher', array( $this, 'user_ajax_dispatcher' ) );
		add_action( 'wp_ajax_wpmr_scanner_ajax_dispatcher', array( $this, 'user_ajax_dispatcher' ) );

		add_action( 'wp_ajax_nopriv_wpmr_stateful_scan_operation', array( $this, 'scan_operation_handler' ) );
		add_action( 'wp_ajax_wpmr_stateful_scan_operation', array( $this, 'scan_operation_handler' ) );

		add_action( 'wp_ajax_nopriv_wpmr_stateful_scan_file', array( $this, 'scan_file_callback' ) );
		add_action( 'wp_ajax_wpmr_stateful_scan_file', array( $this, 'scan_file_callback' ) );

		add_action( 'wp_ajax_nopriv_wpmr_stateful_scan_db', array( $this, 'scan_db_callback' ) );
		add_action( 'wp_ajax_wpmr_stateful_scan_db', array( $this, 'scan_db_callback' ) );

		add_action( 'wp_ajax_wpmr_stateful_scan_status', array( $this, 'scan_status_callback' ) );

		add_action( 'wpmr_scheduled_scan', array( $this, 'run_scheduled_scan' ) );

		// Plugin activation hook (fired by `register_activation_hook()` in `wpmr.php`).
		// Allows internal and third-party activation tasks (including DB install) to run.
		add_action( 'wpmr_plugin_activation', array( $this, 'upgrade_tables' ) );
		// Do NOT run schema upgrades during early bootstrap or updater verification requests.
		// Instead, defer to safer contexts.
		add_action( 'plugins_loaded', array( $this, 'maybe_schedule_tables_upgrade' ) );
		add_action( 'admin_init', array( $this, 'maybe_run_tables_upgrade_admin' ) );
		add_action( 'wpmr_run_schema_upgrade', array( $this, 'upgrade_tables' ) );
		// If this initializer runs after `plugins_loaded` already fired, the hook above will not
		// run for this request. In that case, evaluate and schedule (but do not run heavy work).
		if ( did_action( 'plugins_loaded' ) && ! doing_action( 'plugins_loaded' ) ) {
			$this->maybe_schedule_tables_upgrade();
		}

		add_action( 'wpmr_scan_phase_update_checksums', array( $this, 'phase_update_checksums' ) );
		add_action( 'wpmr_scan_phase_filemalwarescan', array( $this, 'phase_filemalwarescan' ) );
		add_action( 'wpmr_scan_phase_dbmalwarescan', array( $this, 'phase_dbmalwarescan' ) );
		add_action( 'wpmr_scan_phase_vulnerabilityscan', array( $this, 'phase_vulnerabilityscan' ) );
		add_action( 'wpmr_scan_phase_userscan', array( $this, 'phase_userscan' ) );

		add_filter( 'cron_schedules', array( $this, 'custom_cron_scan_intervals' ) );
		add_filter( 'cron_schedules', array( $this, 'monitoring_interval' ) );
		add_action( 'wpmr_scan_monitor_event', array( $this, 'monitor_scan' ) );

		// Self-heal monitor cron on any WordPress request when it was lost (e.g. server reboot).
		// Intentionally lightweight: only re-schedules the cron event, no recovery dispatch.
		add_action( 'init', array( $this, 'maybe_reschedule_monitor_cron' ) );
	}

	/**
	 * Adds the malware scanner meta box to the Security Suite admin page.
	 *
	 * This function registers a meta box for the DeepScan malware scanner in the
	 * Malcure Security Suite admin page. It uses the WordPress add_meta_box function
	 * to create the UI component, specifying the scanner_meta_box method as the
	 * callback to render its contents.
	 *
	 * Also registers the common Advanced Edition sidebar promo meta box
	 * (`wpmr_ad_common`) for consistent upsell/support messaging.
	 *
	 *
	 * Better name: (current name is appropriate)
	 *
	 * @return void
	 */
	public function setup_stateful_scanner_screen() {
		if ( ! function_exists( 'get_current_screen' ) ) {
			return;
		}
		$screen = get_current_screen();
		if ( empty( $screen ) || empty( $screen->id ) ) {
			return;
		}

		// Match the Admin UI pattern: enqueue postbox/metabox dependencies per-screen.
		add_action( 'admin_print_scripts-' . str_replace( '-network', '', $screen->id ), array( $this, 'wpmr_enqueue_js_dependencies' ) );
		if ( $this->is_advanced_edition() ) {
			add_meta_box( 'wpmr_scanner', 'DeepScan™ — Malcure Malware Scanner', array( $this, 'scanner_meta_box' ), $screen->id, 'main' );
		} else {
			add_meta_box( 'wpmr_scanner', 'DeepScan™ Status — Malcure Malware Scanner', array( $this, 'scanner_meta_box' ), $screen->id, 'main' );
		}

		add_meta_box( 'wpmr_scan_scheduler', 'Malware Monitor Scan', array( $this, 'scheduler_meta_box' ), $screen->id, 'main' );
		add_meta_box( 'wpmr_ad_common', 'Malcure Advanced Edition', array( $this, 'wpmr_ad_common' ), $screen->id, 'side', 'high' );
	}

	/**
	 * Render the Stateful Scanner admin page wrapper.
	 *
	 * This is the callback used by `add_submenu_page()`.
	 *
	 * Better name: render_scan_scheduler_page()
	 *
	 * @return void
	 */
	public function render_stateful_scanner_page() {
		$screen = get_current_screen();
		?>
		<div class="wrap wpmr">
			<h1>Malware Monitor</h1>
			<div id="poststuff">
				<div class="metabox-holder columns-2" id="post-body">
					<div class="postbox-container" id="post-body-content">
						<?php do_meta_boxes( $screen->id, 'main', null ); ?>
					</div>
					<!-- #postbox-container -->
					<div id="postbox-container-1" class="postbox-container">
						<?php do_meta_boxes( $screen->id, 'side', null ); ?>
					</div>
				</div>
			</div>
			
			<script type="text/javascript">
				jQuery( function( $ ) {
					// close postboxes that should be closed
					// $('.if-js-closed').removeClass('if-js-closed').addClass('closed');
					// postboxes setup
					postboxes.add_postbox_toggles('<?php echo esc_js( $screen->id ); ?>');
					// $('#main-sortables .postbox').addClass('closed');
				} );
			</script>
		</div>
		<?php
	}

	/**
	 * Renders the malware scanner user interface meta box.
	 *
	 * This function generates the UI components for the DeepScan malware scanner
	 * in the Security Suite admin page. It performs initialization checks and
	 * displays relevant controls and status information to the user.
	 *
	 * The interface includes:
	 * - Scanner status indicators
	 * - Scan progress visualization
	 * - Scan operation buttons (start/stop)
	 * - Performance metrics and statistics
	 * - Read-only whitelist view (managed on the Malware Scan page in advanced edition)
	 *
	 * Better name: render_scanner_hud_meta_box()
	 *
	 * @return void
	 */
	public function scanner_meta_box() {
		// Clear state during development and testing phase.
		if ( $this->needs_kill() ) {
			$this->term_scan_routines();
		}
		$operations      = array(
			'start',
			'stop',
		);
		$is_running      = (int) ! empty( $this->is_scan_running() );
		$valid_operation = $operations[ $is_running ];
		$wpmr_specs      = $this->get_plugin_data( WPMR_PLUGIN, false, false );
		// Match the stateless scanner's base-URL sourcing — respect MALCURE_API overrides
		// (white-label / test environments) instead of hardcoding the production host.
		$wpmr_web_ep = trailingslashit( MALCURE_API );
		// User-edit base for stateful scanner user-issue links (same-origin WP admin, no target="_blank").
		$wpmr_user_edit_base = admin_url( 'user-edit.php?user_id=' );
		?>
		<div id="wpmr_operation_overlay" style="display:none;">
			<div class="wpmr_overlay_content">
				<div id="wpmr_overlay_message"></div>
				<div class="wpmr_progress_bar"><div class="wpmr_progress_indicator"></div></div>
			</div>
		</div>
		<script type="text/javascript">
			wpmr_scrolled = false;
			wpmr_operations = <?php echo wp_json_encode( $operations ); ?>;
			wpmr_scan_running = <?php echo (int) $is_running; ?>;
			wpmr_valid_operation = '<?php echo esc_js( $valid_operation ); ?>';
		</script>
		<div id="wpmr_scan_hud">
			<div id="top-row" class="wpmr_row">
				<div class="left-col wpmr_col">
					<table id="wpmr-top-left">
						<tr>
							<td class="wpmr_label">WordPress:</td>
							<td class="wpmr_value"><?php echo esc_html( get_bloginfo( 'version' ) ); ?></td>
						</tr>
						<tr>
							<td class="wpmr_label">Plugin Version:</td>
							<td class="wpmr_value"><?php echo esc_html( $wpmr_specs['Version'] ); ?></td>
						</tr>
						<tr>
							<td class="wpmr_label">PHP:</td>
							<td class="wpmr_value"><?php echo esc_html( phpversion() ); ?></td>
						</tr>
						<tr>
							<td class="wpmr_label">Server:</td>
							<td class="wpmr_value"><?php echo esc_html( isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : 'Unknown' ); ?></td>
						</tr>
						<tr>
							<td class="wpmr_label">Memory Limit:</td>
							<td class="wpmr_value"><?php echo esc_html( ini_get( 'memory_limit' ) ? ini_get( 'memory_limit' ) : 'Unknown' ); ?></td>
						</tr>										
					</table>
				</div>
				<div class="middle-col wpmr_col">
					<div id="wpmr_screen" class="wpmr_status_<?php echo esc_attr( $valid_operation ); ?>">
						<div id="wpmr_progress" class="wpmr_progress scan_updates"></div>
					</div>
					<div id="scan_statistics"></div>
					<div id="dlog"></div>
					<?php
					if ( $this->is_advanced_edition() || $is_running ) {
						echo '<p><input class="button malcure-button-primary wpmr_action" value="' . esc_attr( $valid_operation ) . '" id="wpmr_scan_btn" type="button" /></p>';
					}
					?>
				</div>
			</div>
			<div id="middle-row" class="wpmr_row">
				<div class="left-col wpmr_col"></div>
				<div class="middle-col wpmr_col" id="middle-row-wpmr-col">
					<div id="wpmr_scan_results"></div>
					<div id="wpmr_scan_results_stats"></div>
					<div id="wpmr_whitelist_wrap">
						<?php
						if ( ! $this->is_advanced_edition() ) {
							echo '<p class="advanced_features wpmr_no_copy"><a href="' . esc_url( $this->wpmr_url( 'p=116', 'sidebar-cta', 'whitelist-notice' ) ) . '">Did you know, you can now whitelist files and database records in Malcure Advanced Edition&nbsp;&rarr;</a></p>';
						} else {
							$manage_whitelist_url = admin_url( 'admin.php?page=wpmr#wpmr_results_box' );
							$db_whitelist         = $this->get_db_record_whitelist();
							$file_whitelist       = $this->get_user_whitelist();
							?>
								<p class="wpmr_no_copy"><a class="malcure-button-primary" href="<?php echo esc_url( $manage_whitelist_url ); ?>">Manage whitelist on Malware Scan page&nbsp;&rarr;</a></p>
								<h4>&mdash; WHITELISTED DATABASE RECORDS &mdash;</h4>
								<div id="db_whitelist">
									<p id="db-whitelist-present-placeholder" <?php echo $db_whitelist ? '' : 'style="display:none;"'; ?>><strong>The following whitelisted database records will not be scanned&nbsp;&rarr;</strong></p>
									<p id="db-whitelist-absent-placeholder" <?php echo $db_whitelist ? 'style="display:none;"' : ''; ?>>No database records whitelisted.</p>
									<?php
									if ( $db_whitelist ) {
										$this->render_db_record_whitelist();
									}
									?>
								</div>
								<h4>&mdash; WHITELISTED FILES &mdash;</h4>
								<div id="whitelist">
									<p id="whitelist-present-placeholder" <?php echo $file_whitelist ? '' : 'style="display:none;"'; ?>><strong>The following whitelisted files will not be scanned&nbsp;&rarr;</strong></p>
									<p id="whitelist-absent-placeholder" <?php echo $file_whitelist ? 'style="display:none;"' : ''; ?>>No files whitelisted.</p>
									<?php
									if ( $file_whitelist ) {
										$this->render_whitelist();
									}
									?>
								</div>
							<?php
						}
						?>
					</div>
				</div>
				<?php
				if ( $this->is_advanced_edition() ) {
					echo '<button type="button" class="button" id="wpmr_copy_results" style="box-shadow: 1px 1px 0px 2px #000000">Copy results</button>';
				}
				?>

			</div>
		</div>

		<script type="text/javascript">
			(function () {
				function onReady(fn) {
					if (document.readyState !== "loading") {
						fn();
					} else {
						document.addEventListener("DOMContentLoaded", fn);
					}
				}

				onReady(function () {
					const trigger = document.getElementById("wpmr_copy_results");
					const target = document.getElementById("middle-row-wpmr-col");

					if (!trigger || !target) {
						console.warn("Copy trigger or target not found. Aborting.");
						return;
					}

					trigger.addEventListener("click", function () {
						try {
							const range = document.createRange();
							const selection = window.getSelection();

							range.selectNodeContents(target);
							selection.removeAllRanges();
							selection.addRange(range);

							const copied = document.execCommand("copy");
							selection.removeAllRanges();

							// Optional: check success
							if (!copied) {
								console.warn("Copy command may have failed.");
							}

							// Robust fade effect
							trigger.style.transition = "opacity 1s";
							trigger.style.opacity = "0";

							setTimeout(() => {
								trigger.style.opacity = "1";
							}, 1000);
						} catch (e) {
							console.error("Copy failed:", e);
						}
					});
				});
			})();

			// Shared date/time helpers (fallback if not already defined elsewhere).
			// These are used throughout the stateful scanner UI to display WP-Cron timestamps
			// in the *browser* timezone via `toLocaleString()`.
			if (typeof window.js_stamp_to_browser_time !== 'function') {
				window.js_stamp_to_browser_time = function(timestamp) {
					let ts = Number(timestamp);

					// Detect microtime format (e.g., seconds.microseconds)
					if (typeof timestamp === 'string' && timestamp.includes('.')) {
						ts = parseFloat(timestamp) * 1000;
					}
					// Assume numeric input as Unix timestamp in seconds if no decimal
					else if (ts < 1e12) {
						ts = ts * 1000;
					}

					const date = new Date(ts);
					const options = {
						year: 'numeric',
						month: 'long',
						day: 'numeric',
						hour: '2-digit',
						minute: '2-digit',
						hour12: false
					};

					return date.toLocaleString(undefined, options);
				};
			}

			if (typeof window.humanTimeDiffPhp !== 'function') {
				function wpmr_parsePhpTimestamp(ts) {
					if (typeof ts === 'number') {
						return ts < 1e12 ? ts * 1000 : ts;
					}
					if (typeof ts !== 'string') {
						return Date.now();
					}
					ts = ts.trim();
					if (!ts) {
						return Date.now();
					}
					if (ts.includes(' ')) {
						const parts = ts.split(' ');
						if (parts.length === 2) {
							const sec = parseInt(parts[0], 10) || 0;
							const micro = parseFloat(parts[1]) || 0;
							return (sec + micro) * 1000;
						}
					}
					const num = parseFloat(ts);
					return isNaN(num) ? Date.now() : (num < 1e12 ? num * 1000 : num);
				}

				window.humanTimeDiffPhp = function(from, to = undefined) {
					const fromMs = wpmr_parsePhpTimestamp(from);
					const toMs = (to == null) ? Date.now() : wpmr_parsePhpTimestamp(to);
					const diffSec = Math.abs(toMs - fromMs) / 1000;

					const MINUTE = 60;
					const HOUR = 60 * MINUTE;
					const DAY = 24 * HOUR;
					const WEEK = 7 * DAY;
					const MONTH = 30 * DAY;
					const YEAR = 365 * DAY;

					let count, unit;
					if (diffSec < MINUTE) {
						count = Math.max(1, Math.floor(diffSec));
						unit = 'Second';
					} else if (diffSec < HOUR) {
						count = Math.max(1, Math.round(diffSec / MINUTE));
						unit = 'Minute';
					} else if (diffSec < DAY) {
						count = Math.max(1, Math.round(diffSec / HOUR));
						unit = 'Hour';
					} else if (diffSec < WEEK) {
						count = Math.max(1, Math.round(diffSec / DAY));
						unit = 'Day';
					} else if (diffSec < MONTH) {
						count = Math.max(1, Math.round(diffSec / WEEK));
						unit = 'Week';
					} else if (diffSec < YEAR) {
						count = Math.max(1, Math.round(diffSec / MONTH));
						unit = 'Month';
					} else {
						count = Math.max(1, Math.round(diffSec / YEAR));
						unit = 'Year';
					}

					return `${count} ${unit}${count > 1 ? 's' : ''}`;
				};
			}

			wpmr_client_issues = new Set();
			wpmr_is_infected = false;
			wpmr_infection_cleared = false;
			var wpmr_offset = 0;
			var wpmr_scan_announced = false;
			if (!wpmr_scan_running) { wpmr_scan_announced = true; }

			var wpmr_results = {};
			var wpmr_status_updater = false;
			// fixme: on clicking stop, button should be disabled until wpmr_get_status confirms that the scan has indeed stopped.

			// Single boolean to track whether any operation is in progress
			var wpmr_action_initiated = false;

			// Add at the top with other variables
			let wpmrStatusRequest = null;
			let wpmrStatusTimeout = null;

			jQuery(document).ready(function ($) {
				wpmr_get_status(); // at least show something right away
				wpmr_valid_operation = wpmr_operations[wpmr_scan_running];
				console.log('wpmr_scan_running on Page Load ' + wpmr_scan_running);
				console.log('wpmr_valid_operation on Page Load ' + wpmr_valid_operation);

				wpmrOverlayTimeout = 0;

				// Functions to show and hide overlay
				function showOverlay(message) {
					if (!message) {
						message = '';
					}
					$('#wpmr_overlay_message').text(message);
					$('#wpmr_operation_overlay').fadeIn(200);
					
					// Start progress animation
					if (typeof wpmrOverlayTimeout !== 'undefined' && wpmrOverlayTimeout !== null && wpmrOverlayTimeout > 0) {
						clearTimeout(wpmrOverlayTimeout);
					} else {
						console.log('Error clearing timeout'+wpmrOverlayTimeout);
					}
					
					// If operation takes too long, add a timeout message
					wpmrOverlayTimeout = setTimeout(function() {
						$('#wpmr_overlay_message').html(message + '<br><small>(Taking longer than expected, please be patient...)</small>');
					}, 15000); // Show message after 15 seconds
				}
				
				function hideOverlay() {
					$('#wpmr_operation_overlay').fadeOut(200);
					if (wpmrOverlayTimeout) {
						clearTimeout(wpmrOverlayTimeout);
						wpmrOverlayTimeout = 0;
					}
				}

				$('#wpmr_scan_btn').click(function () {
					// Set the flag to true and disable the button while we send the request
					wpmr_action_initiated = true;
					$('#wpmr_scan_btn').attr('disabled', 'disabled');

					if (wpmr_valid_operation == 'start') {
						console.dir('wpmr_valid_operation is start. clearing issues.');
						wpmr_client_issues.clear();
						$('#wpmr_scan_results').html('');
						console.dir('issues ui cleared. Showing overlay at time ' + new Date().getTime());
						console.dir($('#wpmr_scan_results').html());
						showOverlay('Initializing scan, please wait …');
						jQuery('#wpmr_scan_btn').addClass('working');
						console.dir('#wpmr_scan_btn working.');
					} else {
						showOverlay('Cancelling scan, please wait …');
						jQuery('#wpmr_scan_btn').removeClass('working');
					}
					console.log('continuing');
					wpmr_user_operation = {
						wpmr_user_operation_nonce: '<?php echo esc_js( wp_create_nonce( 'wpmr_user_operation' ) ); ?>',
						action: "wpmr_scanner_ajax_dispatcher",
						operation: wpmr_valid_operation,
					};
					$.ajax({
						url: ajaxurl,
						dataType: "text",
						method: 'POST',
						timeout: 15000, // 15s
						data: wpmr_user_operation,
						success: function (data, textStatus, jqXHR) {
							console.dir('success Data Begins');
							console.dir('data');
							console.dir(data);
							console.dir('textStatus');
							console.dir(textStatus);
							console.dir('jqXHR');
							console.dir(jqXHR);
							console.dir('jqXHR');

							// Optimistically change UI if user clicked stop
							if (wpmr_valid_operation === 'stop') {
								wpmr_scan_running = false;
							}

							hideOverlay();
							update_scanner_ui();
							// Immediately fetch latest status
							wpmr_get_status();
						},
						error: function (jqXHR, textStatus, errorThrown) {
							console.dir('error Data Begins');
							console.dir('jqXHR');
							console.dir(jqXHR);
							console.dir('textStatus');
							console.dir(textStatus);
							console.dir('errorThrown');
							console.dir(errorThrown);
							// Show error message in overlay (include response body to help debug `0`/`-1` cases)
							var responseText = '';
							try {
								responseText = (jqXHR && jqXHR.responseText) ? String(jqXHR.responseText).trim() : '';
							} catch (e) {
								responseText = '';
							}
							var statusCode = (jqXHR && jqXHR.status) ? jqXHR.status : '';
							var errorMsg = errorThrown ? errorThrown : (textStatus ? textStatus : 'Request failed');
							if (responseText) {
								// Limit size so we don't dump huge HTML into the overlay.
								responseText = responseText.substring(0, 300);
								errorMsg += ' (HTTP ' + statusCode + '): ' + responseText;
							} else if (statusCode) {
								errorMsg += ' (HTTP ' + statusCode + ')';
							}
							$('#wpmr_overlay_message').html('Error: ' + errorMsg + '<br><small>Please try again</small>');
							
							// Hide overlay after error message display (3 seconds)
							setTimeout(hideOverlay, 3000);

							// Enable the button again
							wpmr_action_initiated = false;
							$('#wpmr_scan_btn').removeAttr('disabled');
							
						},
						complete: function (jqXHR, textStatus) {
							console.log('completion Data Begins')
							console.dir('jqXHR');
							console.dir(jqXHR);
							console.dir('textStatus');
							console.dir(textStatus);
						},
					}); // ajax end
				}); // click end
			});

			function update_scanner_ui() {
				wpmr_scan_running = Number(wpmr_scan_running);

				if (wpmr_scan_running) {
					wpmr_infection_cleared = false;
					// If the server indicates scanning is running
					// console.log('wpmr_scan_running is false');
					jQuery('#scan_statistics').show();
					// console.log('Dlog height: '+jQuery('#dlog').height());
					if(jQuery('#dlog').height() <= 0 || jQuery('#dlog').height() > 55){
						jQuery('#dlog').animate({
							height: '5.5em'
						}, 400);
					}
				} else {
					// console.log('wpmr_scan_running is false');
					if(jQuery('#dlog').height() > 0){
						jQuery('#dlog').animate({
							height: '0'
						}, 400);
					}
					// If the server indicates scanning is NOT running
					jQuery('#scan_statistics').html('');
					if (!wpmr_scrolled) {
						wpmr_scrolled = true;
						setTimeout(function () {
							jQuery('html,body').animate({
								scrollTop: jQuery('#wpmr_scan_results').offset().top
							}, 'slow');
						}, 1000);
					}
				}

				wpmr_valid_operation = wpmr_operations[wpmr_scan_running];
				jQuery('#wpmr_screen').attr('class', 'wpmr_status_' + wpmr_valid_operation);
				jQuery('#wpmr_scan_btn').val(wpmr_valid_operation + ' Scan');

				if (wpmr_valid_operation == 'start') {
					jQuery('#wpmr_scan_btn').removeClass('working');
				} else {
					jQuery('#wpmr_scan_btn').addClass('working');
				}

				// If no operation is in progress, allow the user to click again
				if (!wpmr_action_initiated) {
					jQuery('#wpmr_scan_btn').removeAttr('disabled');
				}
			}

			function wpmr_clear_infection_stats() {
				if (wpmr_infection_cleared) {
					return;
				}
				wpmr_infection_cleared = true;
				var wpmr_clear_infection_stats = {
					wpmr_clear_infection_stats_nonce: '<?php echo esc_js( wp_create_nonce( 'wpmr_clear_infection_stats' ) ); ?>',
					action: "wpmr_clear_infection_stats",
				};
				jQuery.ajax({
					url: ajaxurl,
					method: 'POST',
					data: wpmr_clear_infection_stats,
					complete: function () {
						jQuery("#wpmr-infected-alert").remove();
					},
				});
			}
			
			function wpmr_get_status() {
				wpmr_status_request_timeout = 2000;
				// Clear any existing timeout
				if (wpmrStatusTimeout) {
					clearTimeout(wpmrStatusTimeout);
					wpmrStatusTimeout = null;
				}
				
				// Abort any pending request
				if (wpmrStatusRequest && wpmrStatusRequest.state() === 'pending') {
					wpmrStatusRequest.abort();
				}

				wpmr_scan_status = {
					wpmr_scan_status_nonce: '<?php echo esc_js( wp_create_nonce( 'wpmr_stateful_scan_status' ) ); ?>',
					action: "wpmr_stateful_scan_status",
				};

				if(wpmr_offset > 0){
					wpmr_scan_status.offset = wpmr_offset;
				}
				wpmr_scan_running = false;
				wpmrStatusRequest = jQuery.ajax({
					url: ajaxurl,
					// timeout: wpmr_status_request_timeout,
					data: wpmr_scan_status,
					success: function (data, textStatus, jqXHR) {
						if (jqXHR.hasOwnProperty('responseJSON')) {
							wpmr_scan_status = jqXHR.responseJSON;
							if (wpmr_scan_status.hasOwnProperty('running') && wpmr_scan_status.running) {
								wpmr_scan_running = true;
								wpmr_scan_announced = false;
							}

							if(wpmr_scan_status.hasOwnProperty('dlog')){
								if(wpmr_scan_status.dlog.hasOwnProperty('total_count')){
									wpmr_offset = wpmr_scan_status.dlog.total_count;
								}

								if(wpmr_scan_status.dlog.hasOwnProperty('logs')){
									let logEntries = wpmr_scan_status.dlog.logs.map(function(log) {
										// console.dir(`[${log.severity}] ${log.msg}`);
										return `[${log.severity}] ${log.msg}`;
									}).join('\n');

									// Only keep a limited number of lines to prevent performance issues
									jQuery('#dlog').text(function(i, existingText) {
										let lines = existingText ? existingText.split('\n') : [];
										
										// Add new lines
										lines = lines.concat(logEntries.split('\n'));
										
										// Keep only the most recent 500 lines (adjust as needed)
										const maxLines = 50;
										if (lines.length > maxLines) {
											lines = lines.slice(lines.length - maxLines);
										}
										
										return lines.join('\n');
									});
									
									// Use requestAnimationFrame for smoother scrolling
									requestAnimationFrame(() => {
										jQuery("#dlog").scrollTop(jQuery("#dlog")[0].scrollHeight);
									});
								}
							}

							if (wpmr_scan_status.hasOwnProperty('issues') && wpmr_scan_status.issues.length) {
								console.dir('populating issues at time '+new Date().getTime());
								console.dir('wpmr_scan_status received:');
								console.dir(wpmr_scan_status);
								// console.dir('wpmr_scan_status has property issues');
								jQuery('#wpmr_scan_results .wpmr_scan_issue').each(function () {
									let existingId = jQuery(this).attr('data-issue-id');
									if (existingId) {
										wpmr_client_issues.add(existingId);
									}
								});
								let issues_html = '';
								wpmr_scan_status.issues.forEach(function (issue) {
									if (issue.hasOwnProperty('severity') && issue.hasOwnProperty('issue_id') && issue.hasOwnProperty('type')) {
										let unique_key = issue.issue_id;
										// Ensure the issue is not already listed
										if (!wpmr_client_issues.has(unique_key)) {
											wpmr_client_issues.add(unique_key); // Track displayed issues
											let location = issue.pointer;

											if (issue.type === 'database') {
												issues_html += `<div class="wpmr_scan_issue ${issue.severity}" data-issue-id="${issue.issue_id}" data-infection-id="${issue.infection_id}">
																							<a class="${issue.severity} infection_url" 
																								target="_blank" 
																								href="${window.wpmrUtm.buildIocUrl(issue.infection_id, 'database')}">
																									<span class="wpmr_severity_text">${issue.severity}</span>&nbsp;<span class="wpmr_sig_offset">${issue.infection_id}</span>
																								</a> <span class="pointer">Table <span class="table">${issue.pointer.table}</span> ID <span class="db_id">${issue.pointer.id}</span></span>
																						</div>`;
											} else if(issue.type === 'file'){ // File infections
												issues_html += `<div class="wpmr_scan_issue ${issue.severity}" data-issue-id="${issue.issue_id}" data-infection-id="${issue.infection_id}">
																							<a class="${issue.severity} infection_url" 
																								target="_blank" 
																								href="${window.wpmrUtm.buildIocUrl(issue.infection_id, 'file')}">
																									<span class="wpmr_severity_text">${issue.severity}</span>&nbsp;<span class="wpmr_sig_offset">${issue.infection_id}</span>
																								</a> <span class="pointer">${issue.pointer}</span>
																						</div>`;
											} else if (issue.type === 'user') {
										// Tooltip carries the canonical server-built reason string
										// (format_user_reasons()). Equality with WP-CLI and persisted
										// records is guaranteed because all three share one source.
										const userTitle = (issue.reason ? ('User ID ' + issue.pointer + ' — ' + issue.severity + ' (' + issue.reason + ')') : ('User ID ' + issue.pointer)).replace(/"/g, '&quot;');
											issues_html += `<div class="wpmr_scan_issue ${issue.severity}" data-issue-id="${issue.issue_id}" data-infection-id="${issue.infection_id}" title="${userTitle}">
																<a class="${issue.severity} infection_url" 
																	href="<?php echo esc_url( $wpmr_user_edit_base ); ?>${issue.pointer}">
																		<span class="wpmr_severity_text">${issue.severity}</span>&nbsp;<span class="wpmr_sig_offset">${issue.infection_id}</span>
																	</a> <span class="pointer">User ID ${issue.pointer}${issue.reason ? ' <span class="screen-reader-text">— ' + issue.reason + '</span>' : ''}</span>
															</div>`;
											} else {
												issues_html += `<div class="wpmr_scan_issue ${issue.severity}" data-issue-id="${issue.issue_id}" data-infection-id="${issue.infection_id}">
																	<a class="${issue.severity} infection_url" 
																		target="_blank" 
																		href="${window.wpmrUtm.buildIocUrl(issue.infection_id, 'other')}">
																			<span class="wpmr_severity_text">${issue.severity}</span>&nbsp;<span class="wpmr_sig_offset">${issue.infection_id}</span>
																		</a> <span class="pointer">${issue.pointer}</span>
																</div>`;
											}
										}
									} else {
										console.dir('Issue is missing severity, issue_id, or type');
									}
								});
								if (issues_html) {
									jQuery('#wpmr_scan_results').append(issues_html);
								}
							} else {
								jQuery('#wpmr_scan_results').empty();
								wpmr_client_issues.clear();
							}

							if (wpmr_scan_status.hasOwnProperty('status')) {
								if (wpmr_scan_status.status.hasOwnProperty('performance')) {
									// console.dir(wpmr_scan_status.status.performance);
								}

								if (wpmr_scan_status.status.hasOwnProperty('jobs')) {
									job = Object.keys(wpmr_scan_status.status.jobs).pop();
									html = '<strong>Progress :</strong> ' + wpmr_scan_status.status.jobs[job].message;
									if (wpmr_scan_status.status.jobs[job].hasOwnProperty('percentage')) {
										jQuery('#wpmr_screen #wpmr_progress').css('background', 'linear-gradient(to right, hsla(200, 100%, 50%, 1), #00ffff ' + wpmr_scan_status.status.jobs[job].percentage + '%, transparent ' + wpmr_scan_status.status.jobs[job].percentage + '%, transparent)');
									}
									else {
										console.log('job has no percentage');
										console.dir(wpmr_scan_status.status.jobs[job]);
										jQuery('#wpmr_screen #wpmr_progress').css('background', '');
									}
								}
								else {
									html = '';
								}

								jQuery('#scan_statistics').addClass('scan_update').delay(800).queue(function (next) {
									jQuery(this).html(html);
									next();
								}).delay(1200).queue(function (next) {
									jQuery(this).removeClass('scan_update');
									next();
								});	//$('#wpmr_progress').html(wpmr_scan_status.progress);
							} // if(wpmr_scan_status.hasOwnProperty('job_status')) end
							else {
								jQuery('#wpmr_screen #wpmr_progress').css('background', '');
							}
							
								
							
							if((wpmr_scan_status.hasOwnProperty('scan_initiated') || wpmr_scan_status.hasOwnProperty('scan_terminated') || wpmr_scan_status.hasOwnProperty('scan_completed'))){
								
								if ( ! wpmr_scan_running ) {
									if ( ! wpmr_scan_announced ) {
										wpmr_scan_announced = true;
										wpmr_notify_audio('scan-complete');
									}
									console.dir('wpmr_scan_status');
									console.dir(wpmr_scan_status);
									wpmr_cta_heading_text = '';
									wpmr_cta_description_text = '';
									wpmr_cta_timings = '';
									
									// Default state - no scan has ever been run
									if (!wpmr_scan_status.hasOwnProperty('scan_initiated') || !wpmr_scan_status.scan_initiated) {
										console.log('wpmr_scan_status.hasOwnProperty(scan_initiated)');
										wpmr_cta_heading_text = 'No Scan Yet — Awaiting First Scan';
										wpmr_cta_description_text = '<strong>System Status:</strong> No security scan has been performed yet. Click "Start Scan" to verify your WordPress installation.';
									} 
									// Scan was terminated before completion
									else if (wpmr_scan_status.hasOwnProperty('scan_terminated') && wpmr_scan_status.scan_terminated) {
										console.log('wpmr_scan_status.hasOwnProperty(scan_terminated)');
										wpmr_cta_heading_text = 'Scan User-Interrupted Before Completion';
										wpmr_cta_description_text = '<strong>System Status: </strong>Scan initiated on '+js_stamp_to_browser_time(wpmr_scan_status.scan_initiated)+' was interrupted after '+humanTimeDiffPhp(wpmr_scan_status.scan_initiated, wpmr_scan_status.scan_terminated)+'. Please run a new scan for complete results.';
									}
									// Scan completed successfully with no threats
									else if (wpmr_scan_status.hasOwnProperty('scan_completed') && wpmr_scan_status.scan_completed) {

										// console.log('wpmr_scan_status.hasOwnProperty(scan_completed)');

										if(wpmr_scan_status.hasOwnProperty('issues') && wpmr_scan_status.issues.length){
											let serious_issues = 0;
											wpmr_scan_status.issues.forEach(function(issue, index) {
												// console.log(`Issue #${index + 1}:`);
												// console.dir(issue);
												if(issue.hasOwnProperty('type') && issue.severity === 'severe' ||  issue.severity === 'high') {
													serious_issues++;
												}
											});
											// console.dir('serious_issues');
											// console.dir(serious_issues);
											if(serious_issues > 1) {
												wpmr_cta_heading_text = 'Scan Completed — '+serious_issues+' Infections Detected';
												wpmr_cta_description_text = '<strong>Alert:</strong> System integrity failed. '+wpmr_scan_status.issues.length+' Threats identified. Immediate action is required to neutralize threats and secure your system.';
											} else if(serious_issues == 1) {
												wpmr_cta_heading_text = 'Scan Completed — '+serious_issues+' Infections Detected';
												wpmr_cta_description_text = '<strong>Alert:</strong> System integrity failed. '+wpmr_scan_status.issues.length+' Threat identified. Immediate action is required to neutralize threats and secure your system.';
											} else {
												// No serious issues found, even though there might be non-serious issues
												wpmr_cta_heading_text = 'Scan Completed — No Infections Detected';
												wpmr_cta_description_text = '<strong>System Status:</strong> Attention required for other minor issues.';
											}
										}
										else {
											wpmr_cta_heading_text = 'Scan Completed — No Threats Detected';
											wpmr_cta_description_text = '<strong>System Status:</strong> Integrity confirmed. No threats or vulnerabilities detected. Your system is secure.';
											wpmr_clear_infection_stats();
										}
										
										// If we have both initiated and finished timestamps, display scan duration
										if (wpmr_scan_status.hasOwnProperty('scan_initiated') && wpmr_scan_status.scan_initiated && 
											wpmr_scan_status.hasOwnProperty('scan_completed') && wpmr_scan_status.scan_completed) {
											
											let initiated = wpmr_scan_status.scan_initiated;
											let finished = wpmr_scan_status.scan_completed;
											
											if (initiated && finished) {
												let duration = humanTimeDiffPhp(initiated, finished);
												
												wpmr_cta_description_text += '</p>';
											}
										}
									}

									if( wpmr_scan_status.hasOwnProperty('scan_initiated') && wpmr_scan_status.scan_initiated &&
										wpmr_scan_status.hasOwnProperty('scan_completed') && wpmr_scan_status.scan_completed ) {
											let over_test = 'Completed';
											let end_timestamp = wpmr_scan_status.scan_completed;
											
											if(wpmr_scan_status.hasOwnProperty('scan_terminated') && wpmr_scan_status.scan_terminated) {
												over_test = 'Terminated';
												end_timestamp = wpmr_scan_status.scan_terminated;
											}
											
											wpmr_cta_timings += '<table id="wpmr_scan_timings"><tr><td><small><strong>Scan Initiated:</strong></small></td><td><small>'+js_stamp_to_browser_time(wpmr_scan_status.scan_initiated) + '</small><td></tr>' +
											'<tr><td><small><strong>Scan '+over_test+'</strong>:</small></td><td><small>'+js_stamp_to_browser_time(end_timestamp)+'</small></td></tr>' +
											'<tr><td><small><strong>Scan Duration:</strong></td><td><small>' + humanTimeDiffPhp(wpmr_scan_status.scan_initiated, end_timestamp)+'</small></td></tr>' +
											'</table>';
									}

									wpmr_cta_html = '<h2 id="wpmr_scan_results_stats_head">' + wpmr_cta_heading_text + '</h2>' + '<p id="wpmr_scan_results_stats_txt">' + wpmr_cta_description_text + '</p>' + wpmr_cta_timings;

									jQuery('#wpmr_scan_results_stats').html(wpmr_cta_html);

									// console.dir('wpmr_scan_status.infected');
									// console.dir(wpmr_scan_status.infected);
								
									if(wpmr_scan_status.infected){
										wpmr_infection_cleared = false;
										if(! wpmr_is_infected ){ // toggle only once
											wpmr_is_infected = true;
										}
										jQuery('#wpmr_scan_results_stats').addClass('is_infected');
										// jQuery('#wpmr_scan_results_stats').empty();
									} else {
										jQuery('#wpmr_scan_results_stats').removeClass('is_infected');
										wpmr_clear_infection_stats();
									}
									// jQuery('#wpmr_scan_results').removeClass('is_infected');
									
								}
								
							} // if wpmr_scan_status.hasOwnProperty('infected')

							if(wpmr_scan_running) {
								jQuery('#wpmr_scan_results_stats').empty();
							}
							
						}
					},
					error: function (jqXHR, textStatus, errorThrown) {
					},
					complete: function(jqXHR, textStatus) {
						if (jqXHR.hasOwnProperty('responseJSON')) {
							// If the server confirms the new state, we can allow another click
							wpmr_action_initiated = false;
							update_scanner_ui();
							
						}
						// If still running, keep polling
						if (wpmr_scan_running) {
							wpmr_status_request_timeout = 1000;
							wpmrStatusTimeout = setTimeout(() => wpmr_get_status(), wpmr_status_request_timeout);
						} else {
							wpmr_status_request_timeout = 2000;
							wpmrStatusTimeout = setTimeout(() => wpmr_get_status(), wpmr_status_request_timeout);
						}
					}                                    
				});
			}

			// Add cleanup on page unload
			jQuery(window).on('unload', function() {
				if (wpmrStatusTimeout) {
					clearTimeout(wpmrStatusTimeout);
				}
				if (wpmrStatusRequest) {
					wpmrStatusRequest.abort();
				}
			});
		</script>

		<?php
	}

	/**
	 * Adds custom cron schedules for malware scanning.
	 *
	 * Better name: add_cron_scan_intervals()
	 *
	 * @param array $schedules Existing cron schedules.
	 * @return array Modified schedules array with custom intervals.
	 */
	public function custom_cron_scan_intervals( $schedules = array() ) {
		// Daily schedule: 86,400 seconds = 1 day.
		$schedules['wpmr_daily'] = array(
			'interval' => 86400,
			'display'  => 'Malcure Every Day',
		);

		// Weekly schedule: 604,800 seconds = 7 days.
		$schedules['wpmr_weekly'] = array(
			'interval' => 604800,
			'display'  => 'Malcure Every Week',
		);

		// Monthly schedule: 2,592,000 seconds = 30 days.
		$schedules['wpmr_monthly'] = array(
			'interval' => 2592000,
			'display'  => 'Malcure Every Month',
		);

		return $schedules;
	}

	/**
	 * Gets the monitoring interval in seconds.
	 *
	 * Better name: get_scan_monitor_interval_seconds()
	 *
	 * @return int Monitoring interval in seconds.
	 */
	public function get_monitoring_interval() {
		return 60; // 60 seconds
	}

	/**
	 * Gets the monitoring schedule.
	 *
	 * Better name: get_scan_monitor_schedule_key()
	 *
	 * @return string Monitoring schedule name.
	 */
	public function get_monitoring_schedule() {
		$return = 'wpmr_' . human_time_diff( time(), time() + $this->get_monitoring_interval() );
		$return = preg_replace( '/\s+/', '_', $return );

		return $return;
	}

	/**
	 * Adds monitoring interval to cron schedules.
	 *
	 * Better name: add_scan_monitor_schedule_interval()
	 *
	 * @param array $schedules Existing cron schedules.
	 * @return array Modified schedules array.
	 */
	public function monitoring_interval( $schedules = array() ) {

		$schedules[ $this->get_monitoring_schedule() ] = array(
			'interval' => $this->get_monitoring_interval(),
			'display'  => 'Malcure Monitoring ' . $this->get_monitoring_interval() . ' seconds',
		);

		return $schedules;
	}

	/**
	 * Starts the scan monitoring process.
	 *
	 * This method schedules a recurring event that checks scan progress every minute.
	 * It's called during scan initialization.
	 *
	 *
	 * Better name: start_scan_monitoring()
	 *
	 * @return void
	 */
	public function begin_monitoring() {
		if ( wp_next_scheduled( 'wpmr_scan_monitor_event' ) ) {
			return; // Already monitoring.
		}

		$this->flog( 'INFO: Starting scan monitoring' );
		$this->ss_update_setting( 'wpmr_recovery_attempts', 3 );
		wp_schedule_event( time(), $this->get_monitoring_schedule(), 'wpmr_scan_monitor_event' );
	}

	/**
	 * Self-heal the monitor cron when it was lost (e.g. server reboot, cron system failure).
	 *
	 * Registered on `init` so any WordPress request can restore the watchdog — no admin
	 * page or logged-in user required. Intentionally lightweight: only re-schedules the
	 * cron event. Does NOT dispatch recovery requests or decrement recovery counters.
	 *
	 * @return void
	 */
	public function maybe_reschedule_monitor_cron() {
		if ( $this->is_scan_running() && ! wp_next_scheduled( 'wpmr_scan_monitor_event' ) ) {
			$this->flog( 'INFO: Monitor cron missing for active scan — re-scheduling from init' );
			$this->begin_monitoring();
		}
	}

	/**
	 * Callback function for scan monitoring.
	 *
	 * This method runs every minute during an active scan and performs
	 * monitoring tasks like checking scan progress, logging statistics,
	 * and detecting potential issues with the scan process.
	 *
	 *
	 * Better name: monitor_scan_health()
	 *
	 * @return void
	 */
	public function monitor_scan() {
		if ( ! $this->is_scan_running() ) {
			$this->flog( 'WARNING: Monitoring detected scan is no longer running, ending monitoring' );
			$this->end_monitoring();
			return;
		}

		$backup_state = $this->ss_get_option( 'scanner_state_backup' );
		$current_time = time();

		// Check if a backup state exists and when it was last updated.
		if ( ! empty( $backup_state ) && ! empty( $backup_state['state_saved'] ) ) {
			$inactive_seconds = $current_time - $backup_state['state_saved'];
			// If scan state hasn't been updated in over 120 seconds, attempt recovery.
			if ( $inactive_seconds > ( $this->get_cycle_time() * 3 ) ) {
				$remaining_attempts = (int) $this->ss_get_setting( 'wpmr_recovery_attempts', 0 );

				$this->flog( 'ERROR: Scan appears to be stalled - last state update was ' . $inactive_seconds . ' seconds ago' );
				$this->dlog( 'Attempting to recover scan.', 2 );

				if ( $remaining_attempts <= 0 ) {
					$this->flog( 'ERROR: No recovery attempts remaining. Terminating scan.' );
					$this->dlog( 'No recovery attempts remaining. Terminating scan.', 3 );
					$this->set_kill(); // Force kill the scan.
					$this->term_scan_routines();
					return;
				}

				// Create a safe copy of the backup state.
				$recovery_state = $backup_state;

				// Trigger the scan to continue.

				$this->flog( 'RECOVERY: Restarting scan with token ' . $recovery_state['continue_token'] );
				$this->dlog( 'Restarting scan ID ' . $recovery_state['identifier'], 2 );
				// $url = admin_url( 'admin-ajax.php?action=wpmr_stateful_scan_operation&operation=continue&source=oops_recovery&token=' . $recovery_state['continue_token'] );
				$url = $this->get_continue_url( $recovery_state['continue_token'], 'oops_recovery', 1 );

				// Let's decrement remaining attempts before anything else happens.
				$this->ss_update_setting( 'wpmr_recovery_attempts', $remaining_attempts - 1 );

				$response = wp_remote_get(
					$url,
					array(
						'timeout'     => 5,
						'blocking'    => false,
						'compress'    => false,
						'httpversion' => '1.1',
						'sslverify'   => false,
						'headers'     => array(
							'wpmr_recovery' => '1',
							'wpmr_fork'     => '1',
							'connection'    => 'close',
						),
					)
				);

				// Log the recovery attempt
				$this->flog( $response );
				if ( is_wp_error( $response ) ) {
					$this->flog( 'ERROR: Failed to restart stalled scan - ' . $response->get_error_message() );
				} else {
					$this->flog( 'SUCCESS: Recovery request sent.' );
				}
			} else {
				$this->flog( 'INFO: Scan is active, last state update was ' . $inactive_seconds . ' seconds ago' );
			}
		} else {
			$this->flog( 'reattempt not required. Backup state: ' );
			$this->flog( $backup_state );
		}

		$this->flog();
		$this->flog( 'INFO: Monitoring...' . $current_time . ' Next monitoring at ' . ( $current_time + $this->get_monitoring_interval() ) . ' Recovery Attempts: ' . $this->ss_get_setting( 'wpmr_recovery_attempts' ) );
		$this->flog();
	}

	/**
	 * Build the admin-ajax URL used to continue a stateful scan.
	 *
	 * Used primarily by `monitor_scan()` to restart a stalled scan, and by any
	 * internal workflows that need to fork/continue an in-progress scan.
	 *
	 * When `$malcrumb` is truthy, this method appends a signature (`malcrumb`) that
	 * can be validated by `verify_scan_request_signature()`.
	 *
	 * Better name: build_continue_operation_url()
	 *
	 * @param string   $continue_token Secret per-scan token used for HMAC signing.
	 * @param string   $source         Human-readable source marker (e.g. 'continue', 'oops_recovery').
	 * @param bool|int $malcrumb       Whether to add a request signature query arg.
	 * @return string Fully-qualified admin URL.
	 */
	function get_continue_url( $continue_token, $source = 'continue', $malcrumb = false ) {
		$url = 'admin-ajax.php?action=wpmr_stateful_scan_operation&wpmr_fork=1&operation=continue&source=' . $source;

		if ( empty( $malcrumb ) ) {
			$url = admin_url( $url );
			return $url;
		} else {
			$url       = admin_url( $url );
			$signature = $this->sign_scan_request( $url, $continue_token );
			$url       = add_query_arg( array( 'malcrumb' => $signature ), $url );
			return $url;
		}
	}

	/**
	 * Sign a scan continuation payload.
	 *
	 * Produces a SHA-256 HMAC signature for the given `$payload` using the
	 * per-scan `$continue_token`.
	 *
	 * Better name: hmac_sign_scan_payload()
	 *
	 * @param string $payload        URL (or payload string) to sign.
	 * @param string $continue_token Per-scan secret used as the HMAC key.
	 * @return string Hex-encoded HMAC digest.
	 */
	function sign_scan_request( $payload, $continue_token ) {
		$hash = hash_hmac( 'sha256', $payload, $continue_token );
		return $hash;
	}

	/**
	 * Verify the `malcrumb` signature for a scan continuation request.
	 *
	 * Recomputes the expected signature for the canonical continue URL (without
	 * the `malcrumb` query arg) and compares it using `hash_equals()`.
	 *
	 * Better name: verify_continue_request_signature()
	 *
	 * @param string $continue_token Per-scan secret used as HMAC key.
	 * @param string $source         Source marker used to construct the continue URL.
	 * @param string $malcrumb       Signature provided by the client.
	 * @return bool True when signature matches; false otherwise.
	 */
	function verify_scan_request_signature( $continue_token, $source, $malcrumb ) {
		$url_continue = $this->get_continue_url( $continue_token, $source, false );
		$hash_now     = $this->sign_scan_request( $url_continue, $continue_token );
		$answer       = hash_equals( $malcrumb, $hash_now );
		return $answer;
	}

	/**
	 * Stops the scan monitoring process.
	 *
	 * This method unschedules the monitoring event when a scan completes or is terminated.
	 *
	 *
	 * Better name: stop_scan_monitoring()
	 *
	 * @return void
	 */
	public function end_monitoring() {
		$timestamp = wp_next_scheduled( 'wpmr_scan_monitor_event' );
		if ( false !== $timestamp ) {
			$this->flog( 'INFO: Stopping scan monitoring' );
			wp_unschedule_event( $timestamp, 'wpmr_scan_monitor_event' );
			// Clean up recovery attempts setting.
			$this->ss_delete_setting( 'wpmr_recovery_attempts' );
		}
	}

	/**
	 * Saves scan schedule via AJAX.
	 *
	 * Better name: ajax_save_stateful_scan_schedule()
	 *
	 * Scheduling timezone:
	 * - When provided by the UI, scheduling is computed in the browser timezone (WYSIWYG).
	 * - Accepted request fields:
	 *   - `browser_tz` (IANA timezone name, preferred)
	 *   - `browser_offset_minutes` (UTC offset in minutes east of UTC, fallback)
	 * - Falls back to WordPress site timezone when browser data is unavailable/invalid.
	 *
	 * @return void
	 */
	public function ajax_save_scan_schedule( $args = array() ) {
		// Browser timezone support: scheduling can be computed in the user's browser timezone
		// (sent from JS) and stored as a Unix timestamp for WP-Cron.
		// Let's validate the inputs.
		$enabled                = isset( $args['enabled'] ) && 'false' !== $args['enabled'];
		$interval               = isset( $args['interval'] ) ? sanitize_text_field( $args['interval'] ) : ( $enabled ? 'wpmr_monthly' : false );
		$hour                   = isset( $args['hour'] ) ? sanitize_text_field( $args['hour'] ) : '02';
		$minute                 = isset( $args['minute'] ) ? sanitize_text_field( $args['minute'] ) : '00';
		$day                    = isset( $args['day'] ) ? sanitize_text_field( $args['day'] ) : '1'; // Default to Monday
		$month_day              = isset( $args['month_day'] ) ? sanitize_text_field( $args['month_day'] ) : '1'; // Default to 1st
		$browser_tz             = isset( $args['browser_tz'] ) ? sanitize_text_field( $args['browser_tz'] ) : '';
		$browser_offset_minutes = isset( $args['browser_offset_minutes'] ) ? intval( $args['browser_offset_minutes'] ) : null; // Minutes east of UTC.

		// Validate hour, minute, day, and month_day
		$hour      = sprintf( '%02d', max( 0, min( 23, intval( $hour ) ) ) );
		$minute    = sprintf( '%02d', max( 0, min( 59, intval( $minute ) ) ) );
		$day       = max( 0, min( 6, intval( $day ) ) ); // 0-6 for Sunday-Saturday
		$month_day = max( 1, min( 31, intval( $month_day ) ) ); // 1-31 for day of month

		if ( $enabled ) {
			$log_msg = 'Scheduling scan to ' . $interval . ' at ' . $hour . ':' . $minute;
			if ( 'wpmr_weekly' === $interval ) {
				$log_msg .= ' on day ' . $day;
			} elseif ( 'wpmr_monthly' === $interval ) {
				$log_msg .= ' on day ' . $month_day . ' of month';
			}

			// Resolve schedule timezone.
			// Preference order:
			// 1) Valid browser IANA timezone name (handles DST)
			// 2) Browser UTC offset (minutes east of UTC)
			// 3) WordPress site timezone (fallback)
			$schedule_tz_string = '';
			if ( ! empty( $browser_tz ) && in_array( $browser_tz, timezone_identifiers_list(), true ) ) {
				$schedule_tz_string = $browser_tz;
			} elseif ( null !== $browser_offset_minutes && is_int( $browser_offset_minutes ) && $browser_offset_minutes >= -840 && $browser_offset_minutes <= 840 ) {
				$abs_minutes        = abs( $browser_offset_minutes );
				$hours_part         = (int) floor( $abs_minutes / 60 );
				$mins_part          = (int) ( $abs_minutes % 60 );
				$sign               = ( $browser_offset_minutes >= 0 ) ? '+' : '-';
				$schedule_tz_string = sprintf( '%s%02d:%02d', $sign, $hours_part, $mins_part );
			}
			if ( empty( $schedule_tz_string ) ) {
				$schedule_tz_string = wp_timezone_string();
			}

			$log_msg .= ' [tz:' . $schedule_tz_string . ']';
			$this->flog( $log_msg );

			// Calculate the next scheduled time based on the selected time.
			// Use the browser timezone when provided (WYSIWYG for the admin user).
			$timezone     = new DateTimeZone( $schedule_tz_string );
			$current_time = new DateTime( 'now', $timezone );

			if ( 'wpmr_weekly' === $interval ) {
				// For weekly schedules, find the next occurrence of the specified day
				$target_time = new DateTime( 'now', $timezone );
				$current_day = (int) $target_time->format( 'w' ); // 0=Sunday, 6=Saturday
				$target_day  = (int) $day;

				// Calculate days until target day
				$days_until_target = ( $target_day - $current_day + 7 ) % 7;

				// If it's the same day, check if the time has passed
				if ( $days_until_target === 0 ) {
					$today_at_time = new DateTime( $current_time->format( 'Y-m-d' ) . ' ' . $hour . ':' . $minute . ':00', $timezone );
					if ( $today_at_time <= $current_time ) {
						$days_until_target = 7; // Schedule for next week
					}
				}

				// Set the target date and time
				if ( $days_until_target > 0 ) {
					$target_time->add( new DateInterval( 'P' . $days_until_target . 'D' ) );
				}
				$target_time->setTime( (int) $hour, (int) $minute, 0 );
				$stamp = $target_time->getTimestamp();
			} elseif ( 'wpmr_monthly' === $interval ) {
				// For monthly schedules, find the next occurrence of the specified day of month
				$target_time = new DateTime( 'now', $timezone );
				$current_day = (int) $target_time->format( 'j' ); // Day of month (1-31)
				$target_day  = (int) $month_day;

				// Start with this month
				$target_time->setDate( $target_time->format( 'Y' ), $target_time->format( 'n' ), 1 ); // First day of current month
				$target_time->setTime( (int) $hour, (int) $minute, 0 );

				// Handle months that don't have the target day (e.g., 31st in February)
				$days_in_month = (int) $target_time->format( 't' );
				$actual_day    = min( $target_day, $days_in_month );
				$target_time->setDate( $target_time->format( 'Y' ), $target_time->format( 'n' ), $actual_day );

				// If the target day this month has already passed, or it's the same day but time has passed
				if ( $target_time <= $current_time ) {
					// Move to next month
					$target_time->add( new DateInterval( 'P1M' ) );
					$target_time->setDate( $target_time->format( 'Y' ), $target_time->format( 'n' ), 1 ); // First day of next month

					// Handle months that don't have the target day
					$days_in_month = (int) $target_time->format( 't' );
					$actual_day    = min( $target_day, $days_in_month );
					$target_time->setDate( $target_time->format( 'Y' ), $target_time->format( 'n' ), $actual_day );
					$target_time->setTime( (int) $hour, (int) $minute, 0 );
				}

				$stamp = $target_time->getTimestamp();
			} else {
				// For non-weekly/monthly schedules, use the existing logic
				// Create today's date at the specified time in WordPress timezone
				$today_at_time = new DateTime( $current_time->format( 'Y-m-d' ) . ' ' . $hour . ':' . $minute . ':00', $timezone );

				// If the time today has already passed, schedule for tomorrow
				if ( $today_at_time <= $current_time ) {
					$today_at_time->add( new DateInterval( 'P1D' ) ); // Add 1 day
				}

				$stamp = $today_at_time->getTimestamp();
			}

			// Schedule the scan.
			if ( ! wp_next_scheduled( 'wpmr_scheduled_scan' ) ) {
				$return = wp_schedule_event( $stamp, $interval, 'wpmr_scheduled_scan' );

			} else {
				// If the event is already scheduled, we can update it.
				$timestamp = wp_next_scheduled( 'wpmr_scheduled_scan' );
				$return    = wp_unschedule_event( $timestamp, 'wpmr_scheduled_scan' );

				$return = wp_schedule_event( $stamp, $interval, 'wpmr_scheduled_scan' );
			}
			$this->ss_update_setting( 'wpmr_scan_schedule_enabled', $enabled );
			$this->ss_update_setting( 'wpmr_scan_schedule_interval', $interval );
			$this->ss_update_setting( 'wpmr_scan_schedule_hour', $hour );
			$this->ss_update_setting( 'wpmr_scan_schedule_minute', $minute );
			$this->ss_update_setting( 'wpmr_scan_schedule_day', $day );
			$this->ss_update_setting( 'wpmr_scan_schedule_month_day', $month_day );
			$this->ss_update_setting( 'wpmr_scan_schedule_tz', $schedule_tz_string );
		} else {
			// Unschedule the scan but preserve settings for easy re-enabling.
			$return = wp_clear_scheduled_hook( 'wpmr_scheduled_scan' );

			// Only delete the enabled flag to preserve user's time/frequency settings for when they re-enable
			$this->ss_delete_setting( 'wpmr_scan_schedule_enabled' );
		}

		$response = array(
			'message' => 'Scan schedule updated successfully.',
			// 'next_scan' => date_i18n( 'F j, Y @ g:i a', time() ),
			// 'next_scan_stamp' => wp_next_scheduled( 'wpmr_scheduled_scan' ),
			// 'next_scan'       => date_i18n( 'F j, Y @ g:i a', wp_next_scheduled( 'wpmr_scheduled_scan' ) ),
		);
		$next_scan_timestamp = wp_next_scheduled( 'wpmr_scheduled_scan' );
		if ( $next_scan_timestamp ) {
			$response['next_scan_stamp'] = $next_scan_timestamp;
			// $response['next_scan']       = date_i18n( 'F j, Y @ g:i a', $next_scan_timestamp );
		} else {
			$response['next_scan_stamp'] = 0;
			// $response['next_scan']       = 'Not scheduled';
		}
		wp_send_json_success( $response );
	}

	/**
	 * Gets a persisted snapshot of the current stateful scan state.
	 *
	 * @param bool $include_internal Whether to include internal orchestration fields.
	 * @return array<string,mixed> Stateful scan status snapshot.
	 */
	public function get_stateful_scan_status_snapshot( $include_internal = false ) {
		$snapshot = array(
			'running'         => $this->is_scan_running(),
			'kill'            => $this->needs_kill(),
			'infected'        => (bool) $this->ss_get_setting( 'infected' ),
			'status'          => $this->ss_get_option( 'scanner_progress' ),
			'scan_initiated'  => $this->ss_get_setting( 'scan_initiated', false ),
			'scan_completed'  => $this->ss_get_setting( 'scan_completed', false ),
			'scan_terminated' => $this->ss_get_setting( 'scan_terminated', false ),
		);

		if ( $include_internal ) {
			$snapshot['scan_handshake_key'] = $this->ss_get_setting( 'scan_handshake_key', false );
			$snapshot['continue_socket']    = $this->ss_get_setting( 'continue_socket', false );
			$snapshot['monitor_next']       = wp_next_scheduled( 'wpmr_scan_monitor_event' );
			$snapshot['recovery_attempts']  = $this->ss_get_setting( 'wpmr_recovery_attempts', null );
		}

		return $snapshot;
	}

	/**
	 * Gets the reusable async scan status payload for AJAX and CLI consumers.
	 *
	 * @param array $args Payload options.
	 * @return array<string,mixed> Async scan status payload.
	 */
	public function get_stateful_scan_status_payload( $args = array() ) {
		$args = wp_parse_args(
			$args,
			array(
				'include_internal'  => false,
				'include_issues'    => true,
				'include_dlog'      => true,
				'dlog_offset'       => 0,
				'dlog_limit'        => null,
				'include_whitelist' => false,
			)
		);

		$payload = $this->get_stateful_scan_status_snapshot( ! empty( $args['include_internal'] ) );

		if ( ! empty( $args['include_issues'] ) ) {
			$payload['issues'] = $this->get_stateful_scan_issues_payload();
		}

		if ( ! empty( $args['include_dlog'] ) ) {
			$payload['dlog'] = $this->get_stateful_scan_dlog_payload( $args['dlog_offset'], $args['dlog_limit'] );
		}

		if ( ! empty( $args['include_whitelist'] ) ) {
			$payload['whitelist'] = $this->get_stateful_scan_whitelist_payload();
		}

		return $payload;
	}

	/**
	 * Normalize an issue pointer member to safe text.
	 *
	 * @param mixed $value Raw pointer member.
	 * @return string Sanitized text, or an empty string when the value is not scalar.
	 */
	private function normalize_stateful_issue_pointer_text( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		return sanitize_text_field( (string) $value );
	}

	/**
	 * Normalize an issue pointer member to a safe integer identifier.
	 *
	 * @param mixed $value Raw pointer id member.
	 * @return int Sanitized pointer id, or 0 when the value is not scalar.
	 */
	private function normalize_stateful_issue_pointer_id( $value ) {
		if ( ! is_scalar( $value ) ) {
			return 0;
		}

		return intval( $value );
	}

	/**
	 * Normalize a stored issue pointer for shared async status consumers.
	 *
	 * File and slug pointers stay scalar strings. Database pointers keep the existing
	 * `{ table, id }` shape, while malformed nested members are dropped instead of being
	 * forced through warning-prone string casts.
	 *
	 * @param mixed $pointer Raw stored pointer payload.
	 * @return string|array{table:string,id:int} Normalized pointer payload.
	 */
	private function normalize_stateful_issue_pointer( $pointer ) {
		if ( is_array( $pointer ) ) {
			return array(
				'table' => isset( $pointer['table'] ) ? $this->normalize_stateful_issue_pointer_text( $pointer['table'] ) : '',
				'id'    => isset( $pointer['id'] ) ? $this->normalize_stateful_issue_pointer_id( $pointer['id'] ) : 0,
			);
		}

		return $this->normalize_stateful_issue_pointer_text( $pointer );
	}

	/**
	 * Builds the normalized async scan issues payload.
	 *
	 * @return array<int,array<string,mixed>> Normalized issue rows.
	 */
	public function get_stateful_scan_issues_payload() {
		$issues_data = $this->get_issues();
		$issues      = array();

		foreach ( $issues_data as $issue ) {
			$details = json_decode( $issue['details'], true );

			if ( ! is_array( $details ) ) {
				$details = array();
			}

			$pointer = '';

			if ( isset( $details['pointer'] ) ) {
				$pointer = $this->normalize_stateful_issue_pointer( $details['pointer'] );
			}

			// Canonical reason string for user issues. The persisted `comment`
			// carries the full machine-readable factor state from scan_user();
			// format_user_reasons() in WPMR_Scanner is the single source of truth
			// shared with the WP-CLI summary and scan_user() persistence itself.
			// Surfacing it here lets the browser tooltip consume ONE string rather
			// than rebuilding a fourth copy from the factor booleans client-side.
			$reason = '';
			if ( 'user' === $issue['issue_type'] && isset( $details['comment'] ) && is_array( $details['comment'] ) ) {
				$comment_factors = $details['comment'];
				// comment['session_count']/['unique_ip_count'] are informational only;
				// session_anomaly is reconstructed from the persisted ip_spread_score
				// against the engine's own threshold to match scan-time decisions.
				$ip_score                           = isset( $comment_factors['ip_spread_score'] ) ? (float) $comment_factors['ip_spread_score'] : 0.0;
				$comment_factors['session_anomaly'] = $ip_score >= $this::IP_SPREAD_THRESHOLD;
				$reason                             = $this->format_user_reasons( $comment_factors );
			}

			$issues[] = array(
				'issue_id'     => intval( $issue['id'] ),
				'type'         => sanitize_text_field( $issue['issue_type'] ),
				'severity'     => sanitize_text_field( $issue['severity'] ),
				'infection_id' => isset( $details['infection_id'] ) ? sanitize_text_field( (string) $details['infection_id'] ) : '',
				'pointer'      => $pointer,
				'scan_id'      => sanitize_text_field( (string) $issue['scan_id'] ),
				'reason'       => sanitize_text_field( $reason ),
			);
		}

		return $issues;
	}

	/**
	 * Builds the normalized async scan dlog payload.
	 *
	 * @param int|null $offset Row offset for paginated reads. Null returns all rows unless $limit is provided.
	 * @param int|null $limit  Maximum number of most recent rows to return. When set, limit takes precedence over offset.
	 * @return array{logs:array<int,array<string,mixed>>,total_count:int,shown_count:int}
	 */
	public function get_stateful_scan_dlog_payload( $offset = 0, $limit = null ) {
		global $wpdb;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Stateful dlog payloads must reflect current persisted log truth for operators in a plugin-owned log table.
		$table = $this->table_logs;
		$limit = null === $limit ? null : absint( $limit );

		if ( null !== $limit ) {
			$result = array(
				'logs'        => array(),
				'total_count' => intval( $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ) ),
			);

			if ( $limit > 0 ) {
				$result['logs'] = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT * FROM (SELECT * FROM %i ORDER BY id DESC LIMIT %d) AS sub ORDER BY id ASC',
						$table,
						$limit
					),
					ARRAY_A
				);
			}
		} elseif ( null === $offset ) {
			$result = array(
				'logs'        => $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id ASC', $table ), ARRAY_A ),
				'total_count' => $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ),
			);
		} else {
			$result = $this->query_dlog( (int) $offset );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		$result['logs']        = $this->sanitize_stateful_scan_dlog_rows( empty( $result['logs'] ) ? array() : $result['logs'] );
		$result['total_count'] = isset( $result['total_count'] ) ? intval( $result['total_count'] ) : count( $result['logs'] );
		$result['shown_count'] = count( $result['logs'] );

		return $result;
	}

	/**
	 * Sanitizes dlog rows for transport-safe status output.
	 *
	 * @param array<int,array<string,mixed>> $logs Raw log rows.
	 * @return array<int,array<string,mixed>> Sanitized log rows.
	 */
	private function sanitize_stateful_scan_dlog_rows( $logs ) {
		if ( empty( $logs ) ) {
			return array();
		}

		foreach ( $logs as &$log ) {
			$log = array(
				'log_id'   => intval( $log['id'] ),
				'msg'      => wp_kses(
					$log['message'],
					array(
						'br'     => array(),
						'span'   => array( 'class' => array() ),
						'strong' => array(),
					)
				),
				'severity' => sanitize_text_field( $log['severity'] ),
				'comment'  => wp_kses(
					$log['comment'],
					array(
						'br'     => array(),
						'span'   => array( 'class' => array() ),
						'strong' => array(),
					)
				),
			);
		}

		return $logs;
	}

	/**
	 * Builds the whitelist payload exposed in async scan status responses.
	 *
	 * @return array{files:array<int,string>,db_records:array<int,array<string,mixed>>}
	 */
	public function get_stateful_scan_whitelist_payload() {
		$whitelist_files = array();
		$whitelist_db    = array();

		if ( ! $this->is_advanced_edition() ) {
			return array(
				'files'      => $whitelist_files,
				'db_records' => $whitelist_db,
			);
		}

		$whitelist = $this->get_user_whitelist();
		if ( is_array( $whitelist ) ) {
			foreach ( $whitelist as $file => $hash ) {
				$normalized = $this->normalise_path( $file );
				if ( @hash_file( 'sha256', $normalized ) === $hash ) {
					$whitelist_files[] = $normalized;
				}
			}
		}

		$db_whitelist = $this->get_db_record_whitelist();
		if ( is_array( $db_whitelist ) ) {
			foreach ( $db_whitelist as $table => $ids ) {
				if ( ! is_array( $ids ) ) {
					continue;
				}

				foreach ( $ids as $id => $flag ) {
					$id = intval( $id );
					if ( $id > 0 ) {
						$whitelist_db[] = array(
							'table' => (string) $table,
							'id'    => $id,
						);
					}
				}
			}
		}

		return array(
			'files'      => $whitelist_files,
			'db_records' => $whitelist_db,
		);
	}

	/**
	 * Dispatches the shared loopback request that starts the stateful scan.
	 *
	 * @param array $request_args Optional wp_remote_request() overrides.
	 * @return array|WP_Error HTTP response array, or WP_Error when the request cannot be dispatched.
	 */
	public function request_stateful_scan_start( $request_args = array() ) {
		if ( $this->is_scan_running() ) {
			return new WP_Error( 'wpmr_stateful_scan_running', 'Async scan already running.' );
		}

		$request_args = wp_parse_args(
			$request_args,
			array(
				'method'      => 'POST',
				'timeout'     => 5,
				'blocking'    => true,
				'compress'    => false,
				'httpversion' => '1.1',
				'sslverify'   => false,
				'cookies'     => array(),
				'body'        => array(),
			)
		);

		$request_args['body'] = wp_parse_args(
			(array) $request_args['body'],
			array(
				'action'     => 'wpmr_stateful_scan_operation',
				'operation'  => 'start',
				'wpmr_nonce' => $this->create_wpmr_nonce( 'wpmr_stateful_scan_operation' ),
			)
		);

		$url = admin_url( 'admin-ajax.php' );

		$this->flog( 'Initiating stateful scan ' . $url );
		$this->flog( wp_json_encode( $request_args, JSON_PRETTY_PRINT ) );

		return wp_remote_request( $url, $request_args );
	}

	/**
	 * Requests cancellation of the currently running stateful scan.
	 *
	 * @return array<string,mixed> Result containing cancel state, operator message, and status snapshot.
	 */
	public function request_stateful_scan_cancel() {
		$before = $this->get_stateful_scan_status_snapshot( true );

		if ( empty( $before['running'] ) ) {
			return array(
				'cancelled' => false,
				'noop'      => true,
				'message'   => 'No active async scan to cancel.',
				'status'    => $before,
			);
		}

		if ( ! empty( $before['kill'] ) ) {
			return array(
				'cancelled' => false,
				'noop'      => true,
				'message'   => 'Async scan cancel has already been requested.',
				'status'    => $before,
			);
		}

		$this->set_kill();

		return array(
			'cancelled' => true,
			'noop'      => false,
			'message'   => 'Async scan cancel requested.',
			'status'    => $this->get_stateful_scan_status_snapshot( true ),
		);
	}

	/**
	 * Runs a scheduled scan.
	 *
	 * Triggered by WP-Cron via the `wpmr_scheduled_scan` hook, and internally
	 * posts to `admin-ajax.php` to start a scan.
	 *
	 * Better name: run_cron_scheduled_scan()
	 *
	 * @return void
	 */
	public function run_scheduled_scan() {
		if ( ! wp_doing_cron() ) {
			// $this->flog( __FUNCTION__ . ' can only be run via WP-Cron.' );
			// wp_die(  __FUNCTION__ . ' can only be run via WP-Cron.' );
		}

		if ( $this->is_scan_running() ) {
			$this->flog( 'Scheduled scan already running, skipping schedule...' );
			return;
		}

		$response = $this->request_stateful_scan_start(
			array(
				'timeout'  => 5,
				'blocking' => true, // blocking requires true or false and not 1 or 0
				'body'     => array( 'wpmr_scan_source' => 'scheduled' ),
			)
		);

		if ( is_wp_error( $response ) ) {
			$this->flog( 'Scheduled scan failed to dispatch: ' . $response->get_error_message() );
			return;
		}

		do_action( 'wpmr_cron_scan_initiated' );
		$this->flog( wp_json_encode( $response, JSON_PRETTY_PRINT ) );
		$this->flog( $response );
		$this->flog( wp_remote_retrieve_response_code( $response ) );
	}

	/**
	 * Renders the scheduler meta box content.
	 *
	 * Better name: render_scheduler_meta_box()
	 *
	 * @return void
	 */
	public function scheduler_meta_box() {
		// Get existing schedule settings
		$enabled        = $this->ss_get_setting( 'wpmr_scan_schedule_enabled' );
		$interval       = $this->ss_get_setting( 'wpmr_scan_schedule_interval', 'wpmr_monthly' );
		$scan_hour      = $this->ss_get_setting( 'wpmr_scan_schedule_hour', '02' );
		$scan_minute    = $this->ss_get_setting( 'wpmr_scan_schedule_minute', '00' );
		$scan_day       = $this->ss_get_setting( 'wpmr_scan_schedule_day', '1' ); // Default to Monday
		$scan_month_day = $this->ss_get_setting( 'wpmr_scan_schedule_month_day', '1' ); // Default to 1st of month
		$schedule_tz    = $this->ss_get_setting( 'wpmr_scan_schedule_tz', '' );
		$next_scan      = wp_next_scheduled( 'wpmr_scheduled_scan' );

		$intervals = $this->custom_cron_scan_intervals();

		uasort(
			$intervals,
			function ( $a, $b ) {
				return $b['interval'] - $a['interval'];
			}
		);

		// $this->flog( $intervals );

		// Generate minute options (00, 15, 30, 45)
		$minutes = array();
		for ( $i = 0; $i < 60; $i++ ) {
			$minutes[] = str_pad( $i, 2, '0', STR_PAD_LEFT );
		}

		// Generate day-of-week options (0=Sunday, 1=Monday, etc.)
		$days = array(
			'1' => 'Monday',
			'2' => 'Tuesday',
			'3' => 'Wednesday',
			'4' => 'Thursday',
			'5' => 'Friday',
			'6' => 'Saturday',
			'0' => 'Sunday',
		);

		?>
		
		<div class="wpmr-schedule-settings">
			<p class="mssdescription">
				Configure automatic malware scans to run on a regular schedule to keep your site secure with minimal effort.
			</p>
			
			<p class="wpmr-schedule-row">
				<label for="wpmr_schedule_enabled">
					<input type="checkbox" id="wpmr_schedule_enabled" name="wpmr_schedule_enabled" <?php checked( $enabled ); ?>>
					Enable scheduled scans
				</label>
			</p>
			
			<div class="wpmr-schedule-row" id="wpmr_schedule_options" <?php echo ! $enabled ? 'style="display:none;"' : ''; ?>>
				
				<div class="wpmr-schedule-controls-row">
					<div class="wpmr-schedule-control-group">
						<label for="wpmr_schedule_interval">Scan Frequency:</label>
						<select id="wpmr_schedule_interval" name="wpmr_schedule_interval">
							<?php foreach ( $intervals as $key => $value ) : ?>
								<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $interval, $key ); ?>><?php echo esc_html( preg_replace( '/Malcure /', '', (string) $value['display'] ) ); ?></option>
							<?php endforeach; ?>
						</select>
					</div>
					
					<div title="If the selected day doesn't exist in a month (e.g., 31st in February), it will run on the last day of that month." class="wpmr-schedule-control-group" id="wpmr_schedule_month_day_group" style="<?php echo ( $interval !== 'wpmr_monthly' ) ? 'display:none;' : ''; ?>">
						<label for="wpmr_schedule_month_day">Day of Month:</label>
						<select id="wpmr_schedule_month_day" name="wpmr_schedule_month_day">
							<?php for ( $i = 1; $i <= 31; $i++ ) : ?>
								<option value="<?php echo esc_attr( $i ); ?>" <?php selected( $scan_month_day, (string) $i ); ?>><?php echo esc_html( $i ); ?></option>
							<?php endfor; ?>
						</select>
					</div>

					<div class="wpmr-schedule-control-group" id="wpmr_schedule_day_group" style="<?php echo ( $interval !== 'wpmr_weekly' ) ? 'display:none;' : ''; ?>">
						<label for="wpmr_schedule_day">Day of Week:</label>
						<select id="wpmr_schedule_day" name="wpmr_schedule_day">
							<?php foreach ( $days as $day_value => $day_name ) : ?>
								<option value="<?php echo esc_attr( $day_value ); ?>" <?php selected( $scan_day, $day_value ); ?>><?php echo esc_html( $day_name ); ?></option>
							<?php endforeach; ?>
						</select>
					</div>

					<div title="Time is based on your browser timezone (detected automatically)." class="wpmr-schedule-control-group">
						<label for="wpmr_schedule_time">Scan Time [24-hour format]:</label>
						<div class="wpmr-schedule-time-controls" id="wpmr_schedule_time">
							<select id="wpmr_schedule_hour" name="wpmr_schedule_hour">
								<?php for ( $i = 0; $i < 24; $i++ ) : ?>
									<?php $hour_value = sprintf( '%02d', $i ); ?>
									<option value="<?php echo esc_attr( $hour_value ); ?>" <?php selected( $scan_hour, $hour_value ); ?>><?php echo esc_html( $hour_value ); ?></option>
								<?php endfor; ?>
							</select><span>:</span>
							<select id="wpmr_schedule_minute" name="wpmr_schedule_minute">
								<?php foreach ( $minutes as $minute ) : ?>
									<option value="<?php echo esc_attr( $minute ); ?>" <?php selected( $scan_minute, $minute ); ?>><?php echo esc_html( $minute ); ?></option>
								<?php endforeach; ?>
							</select>
						</div>
					</div>
				</div>
				<?php
				if ( $next_scan ) {
					$tz_label      = ! empty( $schedule_tz ) ? $schedule_tz : wp_timezone_string();
					$next_scan_out = '<span id="wpmr_next_scan_countdown">Loading...</span> at: <span id="wpmr_next_scan_time" data-timestamp="' . $next_scan . '">Loading...</span> <span title="Timestamp"><small> [Timestamp:' . $next_scan . ' tz:' . esc_html( $tz_label ) . '] </small></span>';
				} else {
					$next_scan_out = 'Not Scheduled.';
				}
				if ( 'Not Scheduled.' === $next_scan_out ) {
					$next_scan_out = '<p class="wpmr_notice-error">' . $next_scan_out . '</p>';
				} else {
					$next_scan_out = '<p class="wpmr_notice-success">' . $next_scan_out . '</p>';
				}
				?>
			</div>
			
			<div class="wpmr-schedule-actions">
				<input type="submit" class="malcure-button-primary" id="wpmr_save_schedule" value="Save Schedule"/>
				<div id="wpmr_save_schedule_status" class="wpmr_status"><?php echo wp_kses_post( $next_scan_out ); ?></div>
			</div>
		</div>
		
		<script type="text/javascript">
		jQuery(document).ready(function($) {
			function getBrowserTimezoneName() {
				try {
					if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
						return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
					}
				} catch (e) {
					// No-op.
				}
				return '';
			}

			function getBrowserOffsetMinutesEast() {
				// JS getTimezoneOffset() returns minutes WEST of UTC.
				// Convert to minutes EAST of UTC (e.g., India +330).
				try {
					return -1 * (new Date()).getTimezoneOffset();
				} catch (e) {
					return null;
				}
			}

			// Convert timestamp to browser timezone and update countdown
			function updateNextScanTime() {
				const $timeElement = $('#wpmr_next_scan_time');
				const $countdownElement = $('#wpmr_next_scan_countdown');
				
				if ($timeElement.length) {
					const timestamp = $timeElement.data('timestamp');
					const localDateString = js_stamp_to_browser_time(timestamp);
					$timeElement.text(localDateString);
					
					// Update countdown
					updateCountdown(timestamp);
				}
			}
			
			// Function to calculate and display countdown
			function updateCountdown(timestamp) {
				const $countdownElement = $('#wpmr_next_scan_countdown');
				if (!$countdownElement.length) return;
				
				const now = Math.floor(Date.now() / 1000); // Current time in seconds
				const scanTime = parseInt(timestamp); // Scan time in seconds
				const timeDiff = scanTime - now;
				
				if (timeDiff <= 0) {
					$countdownElement.text('Next scan overdue');
					return;
				}
				
				const days = Math.floor(timeDiff / 86400);
				const hours = Math.floor((timeDiff % 86400) / 3600);
				const minutes = Math.floor((timeDiff % 3600) / 60);
				const seconds = timeDiff % 60;
				
				let countdownText = 'Next scan in ';
				const parts = [];
				
				if (days > 0) parts.push(days + (days === 1 ? ' day' : ' days'));
				if (hours > 0) parts.push(hours + (hours === 1 ? ' hour' : ' hours'));
				if (minutes > 0) parts.push(minutes + (minutes === 1 ? ' minute' : ' minutes'));
				if (seconds > 0 && days === 0 && hours === 0) parts.push(seconds + (seconds === 1 ? ' second' : ' seconds'));
				
				if (parts.length === 0) {
					countdownText += 'less than a minute';
				} else if (parts.length === 1) {
					countdownText += parts[0];
				} else if (parts.length === 2) {
					countdownText += parts[0] + ' and ' + parts[1];
				} else {
					countdownText += parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1];
				}
				
				$countdownElement.text(countdownText);
			}
			
			// Update countdown every second
			let countdownInterval;
			function startCountdownTimer() {
				const $timeElement = $('#wpmr_next_scan_time');
				if ($timeElement.length && $timeElement.data('timestamp')) {
					const timestamp = $timeElement.data('timestamp');
					countdownInterval = setInterval(function() {
						updateCountdown(timestamp);
					}, 1000);
				}
			}
			
			// Update on page load
			updateNextScanTime();
			startCountdownTimer();
			
			// Toggle schedule options visibility
			$('#wpmr_schedule_enabled').change(function() {
				if ($(this).is(':checked')) {
					$('#wpmr_schedule_options').slideDown(200);
				} else {
					$('#wpmr_schedule_options').slideUp(200);
				}
			});
			
			// Toggle day selection for weekly interval
			function toggleDaySelection() {
				const interval = $('#wpmr_schedule_interval').val();
				if (interval === 'wpmr_weekly') {
					$('#wpmr_schedule_day_group').slideDown(200);
				} else {
					$('#wpmr_schedule_day_group').slideUp(200);
				}
				
				if (interval === 'wpmr_monthly') {
					$('#wpmr_schedule_month_day_group').slideDown(200);
				} else {
					$('#wpmr_schedule_month_day_group').slideUp(200);
				}
			}
			
			// Handle interval change
			$('#wpmr_schedule_interval').change(function() {
				toggleDaySelection();
			});
			
			// Initialize day selection visibility on page load
			toggleDaySelection();
			
			// Save schedule settings
			$('#wpmr_save_schedule').click(function() {
				const enabled = $('#wpmr_schedule_enabled').is(':checked');
				const interval = $('#wpmr_schedule_interval').val();
				const hour = $('#wpmr_schedule_hour').val();
				const minute = $('#wpmr_schedule_minute').val();
				const day = $('#wpmr_schedule_day').val();
				const monthDay = $('#wpmr_schedule_month_day').val();
				const browserTz = getBrowserTimezoneName();
				const browserOffsetMinutes = getBrowserOffsetMinutesEast();
				const $message = $('#wpmr_save_schedule_status');
				
				$message.html('');
				
				wpmr_ajax_request(
					'ajax_save_scan_schedule',
					[{
						enabled: enabled,
						interval: interval,
						hour: hour,
						minute: minute,
						day: day,
						month_day: monthDay,
						browser_tz: browserTz,
						browser_offset_minutes: browserOffsetMinutes
					}]
				).done(function(response) {
					if (response.success) {
						$message.html('<p class="wpmr_notice-success">Schedule updated successfully!</p>');
						
						if (response.data && response.data.next_scan_stamp) {
							var localDateString = js_stamp_to_browser_time(response.data.next_scan_stamp);
							
							$message.html('<p class="wpmr_notice-success">Schedule updated successfully! Next scan at: ' + localDateString + '</p>');
							
							$('#wpmr_next_scan_time').data('timestamp', response.data.next_scan_stamp).text(localDateString);
							
							if (countdownInterval) {
								clearInterval(countdownInterval);
							}
							updateCountdown(response.data.next_scan_stamp);
							startCountdownTimer();
						}
					} else {
						$message.html('<p class="wpmr_notice-error">' + (response.data || 'Unknown error.') + '</p>');
					}
				}).fail(function(jqXHR, textStatus, errorThrown) {
					$message.html('<p class="wpmr_notice-error">Request failed: ' + (errorThrown || textStatus || 'Unknown error') + '</p>');
				});
			});
		});
		</script>
		<?php
	}

	/**
	 * Updates the progress of the malware scanner.
	 *
	 * This function checks if the state is not empty and updates the progress
	 * of the malware scanner. It saves the state if it has never been saved
	 * before or if the last save was more than 3 seconds ago. The status
	 * includes performance metrics such as CPU load and memory usage, and
	 * job statuses. The updated status is saved using the `$this->ss_update_option`
	 * method.
	 *
	 *
	 * Better name: persist_progress_snapshot()
	 *
	 * @return void
	 */
	public function update_progress() {
		if ( empty( $this->state ) ) {
			return;
		}

		$time = microtime( 1 );

		// if state has never been saved or last saved was more than 2 seconds ago, then save.
		if ( empty( $this->state['progress_saved'] ) || ( $time - $this->state['progress_saved'] > 3 ) ) {

			$saved = $time;

			$status = array(
				'performance' => array(
					'cpu' => sys_getloadavg(),
				),
			);

			if ( ! empty( $this->state['performance']['memory'] ) ) {
				$status['performance']['memory'] = $this->state['performance']['memory'];
			}

			$status['jobs'] = array_filter( $this->state['job_status'] );

			$this->ss_update_option( 'scanner_progress', $status );
			$this->state['progress_saved'] = $saved;
		}
	}

	/**
	 * Retrieves the current scan status and outputs it as a JSON response.
	 *
	 * This callback function gets the scanner state, fetches any issues found during the scan,
	 * and retrieves the scanner progress status. If the scanner is in a valid state, it may handle
	 * cases where a scan was interrupted (commented out in the code), and finally it sends a JSON
	 * response containing:
	 * - Whether a scan is currently running.
	 * - Any issues detected.
	 * - The current scanner progress.
	 * - Whitelisted files and database records (advanced edition only).
	 *
	 *
	 * Better name: ajax_get_scan_status()
	 *
	 * @return void This function outputs a JSON response using wp_send_json and does not return a value.
	 */
	public function scan_status_callback() {
		check_ajax_referer( 'wpmr_stateful_scan_status', 'wpmr_scan_status_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}

		$offset = filter_var(
			isset( $_REQUEST['offset'] ) ? wp_unslash( $_REQUEST['offset'] ) : 0,
			FILTER_VALIDATE_INT,
			array(
				'options' => array(
					'min_range' => 0,
					'default'   => 0,
				),
			)
		);

		wp_send_json(
			$this->get_stateful_scan_status_payload(
				array(
					'include_internal'  => false,
					'include_issues'    => true,
					'include_dlog'      => true,
					'dlog_offset'       => $offset,
					'include_whitelist' => true,
				)
			)
		);
	}

	/**
	 * Checks if a malware scan is currently running.
	 *
	 * This function determines whether a scan is underway by verifying the presence of a
	 * non-empty scan handshake key. It utilizes the $this->ss_get_setting method to fetch
	 * the key and returns true if a key is set, indicating that a scan is in progress.
	 *
	 *
	 * Better name: has_active_scan_handshake()
	 *
	 * @return bool True if the scan handshake key exists and is non-empty, false otherwise.
	 */
	public function is_scan_running() {
		return ! empty( $this->ss_get_setting( 'scan_handshake_key' ) );
	}

	/**
	 * User AJAX dispatcher for handling malware scan operations.
	 *
	 * This function is invoked through an AJAX call and performs several security checks:
	 *   - Verifies the provided nonce using check_ajax_referer().
	 *   - Confirms the current user has the appropriate privileges $this->cap.
	 *   - Validates the 'operation' parameter against allowed operations ('start', 'stop').
	 *
	 * The function then constructs a request to the site's own admin-ajax.php endpoint, setting the proper
	 * headers (including a custom Host header for SNI), and attaches the relevant parameters (including
	 * a newly created nonce for the scan operation). Depending on whether the request method is POST or GET,
	 * it either sets up the request body or appends the parameters to the URL.
	 *
	 * The request is executed using wp_remote_request(). On success, the response body is returned
	 * via wp_send_json_success(); on failure, it returns an error message with wp_send_json_error().
	 *
	 *
	 * Better name: ajax_dispatch_user_scan_operation()
	 *
	 * @return void Outputs a JSON response and terminates the script.
	 */
	public function user_ajax_dispatcher() {
		$this->flog( __FUNCTION__ );

		check_ajax_referer( 'wpmr_user_operation', 'wpmr_user_operation_nonce' );
		$this->flog( 'allowed' );
		// Check if the user has privileges.
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized request.' );
			exit;
		}

		// Check if the operation is valid.
		$allowed_operations = array( 'start', 'stop' );
		$operation          = isset( $_REQUEST['operation'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['operation'] ) ) : '';
		if ( ! in_array( $operation, $allowed_operations, true ) ) {
			$this->flog( 'Invalid operation ' . $operation . '.' );
			wp_send_json_error( 'Invalid operation ' . $operation . '.' );
			exit;
		}

		$this->dlog( 'Authorised operation: ' . $operation );
		// Get the path to admin-ajax.php using admin_url().
		$url = admin_url( 'admin-ajax.php' );

		// Set the URL to the server's own domain using SERVER_NAME.
		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';
		// Prepare the request arguments.
		$args = array(
			'method'      => $method,
			'timeout'     => 1,
			'blocking'    => true,
			'compress'    => false,
			'httpversion' => '1.1',
			'sslverify'   => false,
			'cookies'     => array(),
		);

		switch ( $operation ) {
			case 'start':
				do_action( 'wpmr_scan_initiated' );
				break;
			case 'stop':
				do_action( 'wpmr_scan_terminated' );
				break;
		}

		$action             = 'wpmr_stateful_scan_operation';
		$_REQUEST['action'] = $action;
		// Tag browser-initiated scans so send_scan_notification() can skip them.
		$_REQUEST['wpmr_scan_source'] = 'browser';
		$wpmr_nonce                   = $this->create_wpmr_nonce( $action );

		$_REQUEST['wpmr_nonce'] = $wpmr_nonce;
		if ( 'POST' === $method ) {
			$args['body'] = $_REQUEST;
		} else {
			// Append $_GET parameters to the URL for GET requests.
			$url = add_query_arg( $_REQUEST, $url );
		}

		$this->flog( 'INFO: Initiating user operation at ' . $url );
		$this->flog( $args );
		$response = wp_remote_request( $url, $args );

		// Return the response
		if ( is_wp_error( $response ) ) {
			wp_send_json_error( $response->get_error_message() );
		} else {
			wp_send_json_success( $response['body'] );
		}
	}

	/**
	 * Handles various malware scan operations based on the 'operation' parameter received via $_REQUEST.
	 *
	 * The function processes the following operations:
	 * - "start": Verifies the provided nonce, ensures no scan is already running, initializes scan routines,
	 *   triggers the malware scan job, terminates scan routines, and returns a JSON success message indicating the scan has started.
	 * - "continue": Checks for scan kill requests, validates the ongoing scan routines, pauses briefly (1 sec) if necessary,
	 *   triggers the scan job to continue, terminates scan routines, and returns a JSON success message upon completion.
	 * - "stop": Verifies the provided nonce, requests a kill for the running scan, and returns the current scan settings as a success response.
	 *
	 * Before processing these operations, the function:
	 * - Checks for an asynchronous handover acceptance.
	 * - Loads malware scan definitions (including version `v`) if not already set.
	 * - Enforces a 4-hour limit on running scans by checking saved state and forcing a kill request if exceeded.
	 *
	 * If an invalid or missing 'operation' is detected, or if the nonce verification fails,
	 * the function either terminates execution or returns an error response accordingly.
	 *
	 *
	 * Better name: ajax_handle_stateful_scan_operation()
	 *
	 * @return void
	 */
	public function scan_operation_handler() {
		// Stateful scan operations are authenticated below via the custom handshake nonce.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- WPCS does not follow the method-based verify_wpmr_nonce() contract used by this dispatcher.
		$operation  = isset( $_REQUEST['operation'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['operation'] ) ) : '';
		$action     = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '';
		$wpmr_nonce = isset( $_REQUEST['wpmr_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['wpmr_nonce'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		if ( '' === $operation ) {
			wp_die();
		}

		$this->accept_async_handover();

		if ( empty( $this->definitions ) ) {
			// Stateful scan workflows need the definitions version `v` for signature-version comparisons.
			$this->definitions = $this->get_stateless_definitions();
		}

		// FORCE KILL OVER 12 HOURS hard-limit
		$saved_state = $this->ss_get_option( 'scanner_state' );
		if ( ! empty( $saved_state ) && ! empty( $saved_state['identifier'] ) ) {
			$identifier = $saved_state['identifier'];
			$identifier = explode( '.', $identifier );
			if ( count( $identifier ) == 2 && ( time() - $identifier[0] > 43200 ) ) {
				$scan_started_at = gmdate( 'Y-m-d H:i:s', (int) $identifier[0] );
				$scan_now_utc    = gmdate( 'Y-m-d H:i:s' );
				$this->set_kill(); // required for checking manual scan cancellation.
				$this->term_scan_routines();
				$this->flog( 'ERROR: Forcing kill as scan over ' . human_time_diff( time(), $identifier[0] ) . ' old.' );
				$this->dlog( 'Forcing kill as scan over ' . human_time_diff( time(), $identifier[0] ) . ' old.', 4 );
				$this->dlog( 'Scan started at ' . $scan_started_at . ' and now is ' . $scan_now_utc );
			}
		}

		$this->flog( '' ); // every new iteration should start with a new line.
		$this->dlog( 'Initiating operation: ' . $operation );

		$this->raise_ajax_limits();

		switch ( $operation ) {
			case 'start':
				$this->flog( 'Starting scan operation.' );
				$this->verify_wpmr_nonce( $wpmr_nonce, $action );
				if ( $this->is_scan_running() ) {
					$this->dlog( 'Scan already running. Aborting.', 2 );
					$this->flog( 'Scan already running. Aborting.' );
					wp_send_json_error( 'Scan already running. Aborting.' );
				}
				$this->init_scan_routines();
				$this->trigger_job();
				$this->term_scan_routines();
				wp_send_json_success( 'Scan started.' );
				break;
			case 'continue':
				// Always validate before checking for kill
				$this->validate_scan_routines();
				$this->maybe_kill();
				$this->dlog( 'Continuing…' );
				$this->trigger_job();
				$this->term_scan_routines();
				wp_send_json_success( 'Scan completed.' );
				break;
			case 'stop':
				$this->verify_wpmr_nonce( $wpmr_nonce, $action );
				$this->set_kill();
				$this->flog( '' );
				$this->flog( 'INFO: Kill Requested: ' . $this->needs_kill() );
				$this->flog( '' );
				wp_send_json_success( get_option( 'WPMR' ) );
				break;
			default:
				$this->dlog( 'Invalid operation d: ' . $operation );
				$this->verify_wpmr_nonce( $wpmr_nonce, $action );
				break;
		}

		// test: what happens if all tasks complete without continue?
		// FIXME ERROR: We should never land here. Missing operation or broken switch.
		$this->verify_wpmr_nonce( $wpmr_nonce, $action );
		wp_send_json( 'Invalid Operation l' );
	}

	/**
	 * Raises the PHP memory limit if it is below the required threshold.
	 *
	 * This function checks if the "ini_set" function is allowed by inspecting the "disable_functions"
	 * configuration. If "ini_set" is available and the current memory limit is lower than the required
	 * value (specified by $this->mem), it increases the memory limit to this required value in megabytes.
	 *
	 * Key points:
	 * - Verifies that 'ini_set' is not disabled.
	 * - Uses memory_get_usage and ini_get to compare the current memory limit.
	 * - Adjusts the memory limit using ini_set if conditions are met.
	 *
	 *
	 * Better name: raise_runtime_limits_for_long_requests()
	 *
	 * @return void
	 */
	public function raise_ajax_limits() {
		if ( wp_doing_ajax() || wp_doing_cron() ) {
			if ( $this->can_adjust_php_runtime( 'ini_set' ) ) {
				// if ( function_exists( 'memory_get_usage' ) ) {
				$current_limit = $this->get_memory_limit_in_mb();
				if ( $current_limit < $this->mem ) {
					$this->set_runtime_ini_value( 'memory_limit', $this->mem . 'M' );

					// $this->flog( 'INFO: Increased memory limit from ' . $current_limit . 'M to ' . $this->mem . 'M' );
					// $this->dlog( 'Increased memory limit from ' . $current_limit . 'M to ' . $this->mem . 'M' );
				}
				// }
			} else {
				$this->dlog( 'Cannot modify memory limit as ini_set is disabled on this server.', 3 );
			}

			// Increase maximum execution time if possible.
			if ( $this->can_adjust_php_runtime( 'set_time_limit' ) ) {
				$this->extend_runtime_time_limit( max( 125, (int) ini_get( 'max_execution_time' ) ) ); // Try to ensure at least 2.5 minutes. phpcs:ignore WordPress.PHP.IniSet.Risky
				// $this->flog( max( 125, (int) ini_get( 'max_execution_time' ) ) . ' seconds execution time set.' );
			}
		}
	}

	/**
	 * Gets the current PHP memory limit in megabytes.
	 *
	 * This function converts the PHP memory_limit setting to megabytes,
	 * handling different formats like '128M', '1G', etc.
	 *
	 *
	 * Better name: get_php_memory_limit_mb()
	 *
	 * @return int Current memory limit in megabytes
	 */
	public function get_memory_limit_in_mb() {
		$memory_limit = ini_get( 'memory_limit' );
		if ( '-1' === $memory_limit ) {
			// No limit.
			return PHP_INT_MAX;
		}

		$unit  = strtolower( substr( $memory_limit, -1 ) );
		$value = (int) $memory_limit;

		switch ( $unit ) {
			case 'g':
				$value *= 1024;
				break;
			case 'k':
				$value /= 1024;
				break;
			case 'b':
				$value /= 1048576; // 1024 * 1024
				break;
		}

		return $value;
	}

	/**
	 * Initiates the malware scanning routines.
	 *
	 * This method performs the following operations:
	 * - Checks if the checksum origin table is empty; if so, updates web checksums.
	 * - Conditionally updates malware definitions.
	 * - Creates a job queue for scanning tasks.
	 * - If there are any jobs, counts and truncates the issues table in the database.
	 * - Initializes the scanning state using the job queue.
	 * - Stores the number of truncated issues (if any) in the state.
	 * - Updates the scan handshake key setting with the state's identifier.
	 *
	 *
	 * Better name: init_stateful_scan_session()
	 *
	 * @return void
	 */
	public function init_scan_routines() {

		$this->maybe_update_definitions();
		$jobs = $this->create_job_queue();

		if ( count( $jobs ) ) {
			global $wpdb;
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scan initialization must count and then truncate current issues in the live plugin-owned issues table.
			$row_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $this->table_issues ) );
			// Truncate the table.
			$result = $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $this->table_issues ) );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$this->flog( 'INFO: Truncated issues table. Row count before truncation: ' . $row_count . '. Result: ' . $result );
			$this->clear_dlog();

		}
		$this->initialize_state( $jobs );

		$this->ss_update_setting( 'continue_socket', $this->state['socket'] ); // Save the continue socket.
		$this->ss_update_setting( 'scan_initiated', $this->state['identifier'] ); // Save the scan identifier.
		$this->ss_delete_setting( 'scan_completed' ); // Clear any previous completion state.
		$this->ss_delete_setting( 'scan_terminated' ); // Clear any previous termination state.

		if ( ! is_null( $row_count ) && $row_count > 0 ) {
			$this->state['truncated_issues'] = $row_count;
		}
		$this->ss_update_setting( 'scan_handshake_key', $this->state['identifier'] );

		// Log scan initiation event.
		$this->log_scan_initiated();

		// Start monitoring the scan.
		$this->begin_monitoring();
	}

	/**
	 * Validates the continuation of a scan by verifying the provided continuation token.
	 *
	 * This function is called when a scan process attempts to continue a previously
	 * started scan session. It performs the following validations:
	 *
	 * 1. Checks if a continuation token was provided in the request
	 * 2. Verifies if a scan state exists in the database that can be restored
	 * 3. Confirms that the provided token matches the one stored in the state
	 * 4. Restores the scan state if all validations pass
	 *
	 * If any validation fails, the function logs an appropriate error message and
	 * returns a JSON error response, preventing unauthorized or invalid scan continuations.
	 *
	 *
	 * Better name: validate_and_restore_scan_state()
	 *
	 * @return void Outputs a JSON error response on failure or restores the scan state on success
	 */
	public function validate_scan_routines() {

		// Loopback continuation requests are authenticated via a signed `malcrumb` token, not a WordPress nonce.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- The signed continue/recovery contract is verified inside this function.
		if ( isset( $_REQUEST['malcrumb'] ) ) {

			$malcrumb = isset( $_REQUEST['malcrumb'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['malcrumb'] ) ) : '';
			$source   = isset( $_REQUEST['source'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['source'] ) ) : '';

			$this->flog( 'INFO: Checking state for ' . $this->ss_get_setting( 'continue_socket' ) );
			if ( $this->has_state() && 'continue' === $source ) { // state exists in database and has not been picked by a thread
				$state          = $this->ss_get_option( 'scanner_state' );
				$continue_token = isset( $state['continue_token'] ) ? $state['continue_token'] : false;

				if ( $this->verify_scan_request_signature( $continue_token, 'continue', $malcrumb ) ) {
					$this->restore_state();
				} else {
					$this->flog( 'ERROR: Continue token does not match. Aborting.' );
					wp_send_json_error( 'Continue token does not match. Aborting.' );
				}
			} elseif ( 'oops_recovery' === $source ) { // backup exists in database
				$r_state = $this->ss_get_option( 'scanner_state_backup' );
				if ( empty( $r_state ) ) {
					$this->dlog( 'No backup state found. Aborting.', 4 );
					$this->flog( 'ERROR: No backup state found. Aborting.' );
					wp_send_json_error( 'No backup state found. Aborting.' );
				} else {
					$this->dlog( 'Backup state found.' );
					$this->flog( 'Backup state found.' );
					$continue_token       = isset( $r_state['continue_token'] ) ? $r_state['continue_token'] : false;
					$r_state['start']     = time();
					$r_state['thread_id'] = 'recovery_' . time();

					if ( $this->verify_scan_request_signature( $continue_token, 'oops_recovery', $malcrumb ) ) {
						$this->dlog( 'Attempting to restore broken scan.' );
						$this->flog( 'WARNING: Attempting to restore broken scan.' );
						$this->restore_state( $r_state );
					} else {
						$this->dlog( 'No backup state found. Aborting.', 4 );
						wp_send_json_error( 'No backup state found. Aborting.' );
					}
				}
			} else {
				$this->flog( 'No state found. Returning.' );
				wp_send_json_error( 'No state found. Returning.' );
			}
		} else {
			$this->flog( 'Continue token not received. Returning.' );
			wp_send_json_error( 'Continue token not received. Returning.' );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Log a scan initiation event to the events table.
	 *
	 * Fires at the end of init_scan_routines() after initialize_state() has
	 * populated $this->state with the scan identifier, source, and start time.
	 *
	 * @return void
	 */
	private function log_scan_initiated() {
		if ( empty( $this->state ) || empty( $this->state['identifier'] ) ) {
			return;
		}

		$source = isset( $this->state['wpmr_scan_meta']['source'] )
			? sanitize_text_field( $this->state['wpmr_scan_meta']['source'] )
			: 'unknown';

		$this->log_event(
			'malcure_scan_initiated',
			array(
				'scan_id'         => (string) $this->state['identifier'],
				'scan_source'     => $source,
				'definitions_ver' => $this->get_definition_version(),
			)
		);
	}

	/**
	 * Log a scan completion event to the events table.
	 *
	 * Fires inside term_scan_routines() after notification dispatch and before
	 * clear_state(). Reads the same variables already computed by the notification
	 * block — no duplicate queries.
	 *
	 * Covers three outcomes: clean (0 issues), infected (>0 issues), terminated
	 * (user cancellation or hard kill).
	 *
	 * @return void
	 */
	private function log_scan_completed() {
		$is_terminated = $this->ss_get_setting( 'scan_terminated' );
		$issue_count   = $this->get_issue_count();
		$identifier    = isset( $this->state['identifier'] ) ? $this->state['identifier'] : '';
		$scan_start    = (int) explode( '.', (string) $identifier )[0];
		$diff_seconds  = time() - $scan_start;

		$source = isset( $this->state['wpmr_scan_meta']['source'] )
			? sanitize_text_field( $this->state['wpmr_scan_meta']['source'] )
			: 'unknown';

		$this->log_event(
			'malcure_scan_completed',
			array(
				'scan_id'         => (string) $identifier,
				'scan_source'     => $source,
				'outcome'         => $is_terminated ? 'terminated' : ( $issue_count > 0 ? 'infected' : 'clean' ),
				'issue_count'     => (int) $issue_count,
				'duration_sec'    => max( 0, $diff_seconds ),
				'definitions_ver' => $this->get_definition_version(),
			)
		);
	}

	/**
	 * Terminates the scan routines by clearing stored scanning states and removing related settings.
	 *
	 * This method performs cleanup operations required to reset the scanning environment:
	 * - Clears any current scanning state via the clear state() method.
	 * - Deletes the 'scan_handshake_key' setting used for establishing a scan session.
	 * - Deletes the 'kill requested' setting that indicates a scan termination request.
	 * - Removes the 'scanner_state_backup' option which holds backup state information.
	 * - Removes the 'scanner_progress' option which holds progress information.
	 *
	 *
	 * Better name: finalize_and_cleanup_scan()
	 *
	 * @return void
	 */
	function term_scan_routines() {
		$this->dlog( 'Terminating scan routines.' );
		$this->flog( 'Terminating scan routines. Scan termination status ' . $this->ss_get_setting( 'scan_terminated' ) );

		if ( ! $this->ss_get_setting( 'scan_terminated' ) ) {
			$this->ss_update_setting( 'scan_completed', microtime( 1 ) );
			do_action( 'wpmr_scan_completed' );
		}

		// Notification dispatch — runs before clear_state() so $this->state is intact.
		// State guard defends the hard-kill path where $this->state is null.
		if ( ! empty( $this->state ) && is_array( $this->state ) && ! empty( $this->state['identifier'] ) ) {
			$is_terminated = $this->ss_get_setting( 'scan_terminated' );
			$issue_count   = $this->get_issue_count();
			$identifier    = $this->state['identifier'];
			$scan_start    = (int) explode( '.', (string) $identifier )[0];
			$diff_seconds  = time() - $scan_start;
			$duration      = $diff_seconds < 1 ? 'less than a second' : $this->human_readable_time_diff( $scan_start, time() );

			if ( $is_terminated ) {
				$this->send_scan_notification( 'terminated', $issue_count, $duration );
			} elseif ( $issue_count > 0 ) {
				$this->send_scan_notification( 'infected', $issue_count, $duration );
			} else {
				$this->ss_delete_setting( 'infected' );
				$this->delete_setting( 'infected' );
				$this->send_scan_notification( 'clean', 0, $duration );
			}

			// Log scan completion event — reuses vars already computed above.
			$this->log_scan_completed();
			if ( 0 ) {

				// Persist user scan results to scan log transient so they appear on
				// page=wpmr_logs alongside db/files/vulnerabilities. Only type='user'
				// rows are needed — the other scan types already have their own
				// update_saved_records() calls in the stateless scanner path.
				$all_issues = $this->get_issues();
				$users      = array();
				foreach ( $all_issues as $issue ) {
					$details = isset( $issue['details'] ) ? json_decode( $issue['details'], true ) : null;
					if ( ! is_array( $details ) || empty( $details['type'] ) || 'user' !== $details['type'] ) {
						continue;
					}
					$comment = isset( $details['comment'] ) ? $details['comment'] : array();
					if ( ! empty( $comment['user_id'] ) ) {
						$users[] = array(
							'ID'                 => (int) $comment['user_id'],
							'severity'           => isset( $details['severity'] ) ? $details['severity'] : 'suspicious',
							'missing_email'      => ! empty( $comment['missing_email'] ),
							'missing_first_name' => ! empty( $comment['missing_first_name'] ),
							'missing_last_name'  => ! empty( $comment['missing_last_name'] ),
							'has_app_passwords'  => ! empty( $comment['has_app_passwords'] ),
							'app_password_count' => isset( $comment['app_password_count'] ) ? (int) $comment['app_password_count'] : 0,
							'has_elevated_caps'  => ! empty( $comment['has_elevated_caps'] ),
							'ip_spread_score'    => isset( $comment['ip_spread_score'] ) ? (float) $comment['ip_spread_score'] : 0,
						);
					}
				}
				if ( ! empty( $users ) ) {
					$this->update_saved_records( $identifier, array( 'users' => $users ) );
				}
			}
		}

		$socket = $this->ss_get_setting( 'continue_socket' );
		$delete = $this->delete_socket( $socket );
		$this->clear_state(); // clear state only after scan is not running.
		$this->ss_delete_setting( 'scan_handshake_key' );
		$this->ss_delete_setting( 'wpmr_ajax_handshake' );
		$this->ss_delete_setting( 'continue_socket' );
		$this->ss_delete_option( 'scanner_state_backup' );
		$this->ss_delete_option( 'scanner_progress' );
		// End monitoring
		$this->end_monitoring();
	}

	/**
	 * Initializes and creates a job queue for malware scanning tasks.
	 *
	 * This function sets up the necessary infrastructure to manage and process
	 * malware scanning jobs. It prepares the system by enqueuing tasks required
	 * to perform comprehensive malware detection and cleanup.
	 *
	 * Better name: build_scan_phase_queue()
	 *
	 * @return array<string,mixed> Map of scan phase keys to placeholder values.
	 */
	function create_job_queue() {
		$jobs = array();

		$jobs['vulnerabilityscan'] = '';
		$jobs['userscan']          = '';
		$jobs['dbmalwarescan']     = '';
		$jobs['update_checksums']  = '';
		$jobs['filemalwarescan']   = '';

		$filtered_jobs = array_filter(
			$jobs,
			function ( $jobName ) {
				return is_callable(
					array(
						$this,
						'phase_' . $jobName,
					)
				);
			},
			ARRAY_FILTER_USE_KEY
		);

		return $filtered_jobs;
	}

	/**
	 * Triggers all queued jobs by executing their associated scan phase actions.
	 *
	 * This method checks if the state contains any jobs. While there remain jobs in the state, the method:
	 * - Retrieves the first job in the state and executes a corresponding action hook ("wpmr_scan_phase_{job_key}").
	 * - Records the end time of the job using microtime.
	 * - Removes the job from the queue after processing.
	 *
	 * The function processes jobs until the queue is empty.
	 *
	 *
	 * Better name: run_scan_phase_queue()
	 *
	 * @return void
	 */
	function trigger_job() {
		if ( ! empty( $this->state['jobs'] ) && is_array( $this->state['jobs'] ) && count( $this->state['jobs'] ) ) {
			while ( ! empty( $this->state['jobs'] ) && is_array( $this->state['jobs'] ) && count( $this->state['jobs'] ) ) {
				$this->dlog( 'Job ' . array_keys( $this->state['jobs'] )[0] );
				do_action( 'wpmr_scan_phase_' . array_keys( $this->state['jobs'] )[0] );
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['end'] = microtime( 1 );
				unset( $this->state['jobs'][ array_keys( $this->state['jobs'] )[0] ] );
				$this->maybe_save_and_fork( 1 ); // ensure the new phase isn't strangled for time.
			}
		} else {
			$this->dlog( 'No jobs to process.' );
			$this->flog( 'No jobs to process.' );
			$this->flog( 'Scan completed.' );
			$this->dlog( 'Scan completed.' );
		}
	}

	/**
	 * Identifies components requiring checksum updates.
	 *
	 * Better name: get_components_requiring_checksum_refresh()
	 *
	 * This function checks WordPress core, plugins, and themes against the stored
	 * checksum versions in the database. It compares the currently installed versions
	 * with the versions recorded in the checksums table.
	 *
	 * @global wpdb $wpdb WordPress database abstraction object.
	 * @global string $wp_version Current WordPress version.
	 *
	 * @return array List of components (core, plugins, themes) that need their checksums updated.
	 *               Each item contains 'type', 'version', and optionally 'slug'.
	 */
	function populate_cs_data() {
		$required = array();

		global $wpdb, $wp_version;

		// Get table name
		$tableName = $this->table_checksums;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checksum refresh decisions must read current persisted component versions directly from a plugin-owned table.
		// Update WordPress core checksums if version changed
		$stored_version = $wpdb->get_var( $wpdb->prepare( 'SELECT version FROM %i WHERE type = %s', $tableName, 'core' ) );

		if ( $stored_version != $wp_version ) {
			$required[] = array(
				'type'    => 'core',
				'version' => $wp_version,
			);
		}

		// Update plugins checksums if version changed
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		$all_plugins = get_plugins();

		foreach ( $all_plugins as $plugin_path => $plugin_data ) {
			$plugin_slug = dirname( $plugin_path );
			if ( $plugin_slug == '.' ) {
				continue; // Skip plugins without a directory (drop-ins)
			}

			$plugin_path_like = '%/' . $wpdb->esc_like( $plugin_slug ) . '/%';
			$stored_version   = $wpdb->get_var( $wpdb->prepare( 'SELECT version FROM %i WHERE type = %s AND path LIKE %s', $tableName, 'plugin', $plugin_path_like ) );

			if ( $stored_version != $plugin_data['Version'] ) {
				// $this->flog( 'Plugin ' . $plugin_slug . ' version mismatch: stored ' . $stored_version . ', current ' . print_r( $plugin_data, 1 ) );
				$required[] = array(
					'type'    => 'plugin',
					'slug'    => $plugin_slug,
					'version' => $plugin_data['Version'],
				);
			}
		}

		$all_themes = wp_get_themes();

		foreach ( $all_themes as $theme_slug => $theme ) {
			$theme_version = $theme->get( 'Version' );

			$theme_path_like = '%/themes/' . $wpdb->esc_like( $theme_slug ) . '/%';
			$stored_version  = $wpdb->get_var( $wpdb->prepare( 'SELECT version FROM %i WHERE type = %s AND path LIKE %s', $tableName, 'theme', $theme_path_like ) );

			if ( $stored_version != $theme_version ) {
				// $this->flog( 'Theme ' . $theme_slug . ' version mismatch: stored ' . $stored_version . ', current ' . print_r( $theme, 1 ) );
				$required[] = array(
					'type'    => 'theme',
					'slug'    => $theme_slug,
					'version' => $theme_version,
				);
			}
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( ! $required ) {
			$this->flog( 'No updates required for checksums.' );
		}

		return $required;
	}

	/**
	 * Fetches and persists checksums for a single component using the existing checksum pipeline.
	 *
	 * Uses the trait's `refresh_component_checksums` helper to request fresh checksums from
	 * the SaaS endpoint and store them into `wpmr_checksums`, ensuring stateful scans rely on
	 * the same backend as the interactive scanner.
	 *
	 *
	 * Better name: refresh_component_checksums_for_type()
	 *
	 * @param string $type    Component type: core|plugin|theme.
	 * @param string $version Component version (for logging/trace only).
	 * @param string $slug    Plugin/theme slug when applicable.
	 * @return void
	 */
	private function update_component_checksums( $type, $version, $slug = '' ) {
		if ( 'plugin' === $type ) {
			if ( ! function_exists( 'get_plugins' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}

			$plugin_file = '';
			$plugins     = get_plugins();
			foreach ( $plugins as $path => $plugin_data ) {
				if ( dirname( $path ) === $slug ) {
					$plugin_file = $path;
					break;
				}
			}

			if ( empty( $plugin_file ) ) {
				$this->flog( 'Checksum update skipped: plugin not found for slug ' . $slug );
				return;
			}

			$this->refresh_component_checksums(
				null,
				array(
					'type'    => 'plugin',
					'plugins' => array( $plugin_file ),
				)
			);
			return;
		}

		if ( 'theme' === $type ) {
			if ( empty( $slug ) ) {
				$this->flog( 'Checksum update skipped: empty theme slug.' );
				return;
			}

			$theme = wp_get_theme( $slug );
			if ( ! $theme || ! $theme->exists() ) {
				$this->flog( 'Checksum update skipped: theme not found for slug ' . $slug );
				return;
			}

			$this->refresh_component_checksums(
				null,
				array(
					'type'   => 'theme',
					'themes' => array( $slug ),
				)
			);
			return;
		}

		if ( 'core' === $type ) {
			$this->refresh_component_checksums(
				null,
				array(
					'type' => 'core',
				)
			);
			return;
		}

		$this->flog( 'Checksum update skipped: unknown component type ' . $type );
	}

	/**
	 * Execute the checksum refresh scan phase.
	 *
	 * Builds a list of components (core/plugins/themes) whose versions have changed
	 * since the last recorded checksums, then refreshes checksums for each.
	 *
	 * This phase is designed to be resumable: it frequently calls
	 * `maybe_save_and_fork()` so long-running checksum refreshes can continue across
	 * multiple AJAX/cron invocations without timing out.
	 *
	 * Better name: phase_refresh_checksums()
	 *
	 * @return void
	 */
	function phase_update_checksums() {
		$cs_data = array();

		if ( ! isset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] ) ) { // if items not initialized
			$cs_data = $this->populate_cs_data();
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] = 0;
			if ( ! $cs_data ) {
				$this->flog( 'No checksums to update.' );
				return;
			} else {
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['cs_updates'] = $cs_data;
			}
		}

		$this->flog( 'Starting checksum updates for ' . count( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['cs_updates'] ) . ' items.' );
		$this->flog( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['cs_updates'] );

		while ( ! empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['cs_updates'] ) ) {

			$item = array_pop( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['cs_updates'] );

			$this->maybe_save_and_fork();

			++$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'];

			if ( is_array( $item ) ) {
				if ( count( $item ) == 3 && in_array( $item['type'], array( 'plugin', 'theme' ) ) ) {
					$this->flog( 'Updating checksum for ' . $item['type'] . ' ' . $item['slug'] . ' v' . $item['version'] );
					$this->dlog( 'Updating checksum for ' . $item['type'] . ' ' . $item['slug'] . ' v' . $item['version'] );
					// sleep( 10 );
					$this->update_component_checksums( $item['type'], $item['version'], $item['slug'] );
				} elseif ( $item['type'] == 'core' ) {
					$this->flog( 'Updating checksum for WordPress v' . $item['version'] );
					$this->dlog( 'Updating checksum for WordPress v' . $item['version'] );
					// sleep( 10 );
					$this->update_component_checksums( $item['type'], $item['version'] );
				} else {
					$this->flog( 'Unknown item type: ' . $item['type'] );
				}
			}
		}
	}

	/**
	 * Scans database content for malware threats.
	 *
	 * This function processes database records for malware detection by:
	 * - Examining specific database tables (posts, postmeta, options, comments)
	 * - Running each record against malware signature patterns
	 * - Performing security checks on content size to prevent resource exhaustion
	 * - Applying regex-based pattern matching for threat detection
	 * - Recording found threats for later reporting
	 *
	 * Better name: phase_database_malware_scan()
	 *
	 * @param array $passed Optional array of parameters for recursive/self-invoked calls.
	 * @return void
	 */
	public function phase_dbmalwarescan( $passed = array() ) {
		// Check if this is the first run by looking for initialized items counter
		if ( ! isset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] ) ) {
			// Define database tables to scan - posts, postmeta, options and comments
			$tables = array(
				'posts'    => array(),
				'postmeta' => array(),
				'options'  => array(),
				'comments' => array(),
			);

			// Allow other code to modify tables via filter
			$tables = apply_filters( 'wpmr_dbmalwarescan_args', $tables );

			// Initialize scanning metrics in job status
			$this->state['jobs']['dbmalwarescan'] = $tables;
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] = 0;  // Counter for processed items
			if ( ! isset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['start'] ) ) {
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['start'] = time(); // Start timestamp
			}
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['message'] = ''; // Progress message
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['task']    = 'Database Records'; // Description

			// Initialize iteration counter if not set
			if ( empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] ) ) {
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] = 0;
			}
		}

		// Get list of tables to process from state
		$tables = $this->state['jobs']['dbmalwarescan'];

		// Process each database table
		foreach ( $tables as $table => $val ) {
			// Scan the current table for malware patterns
			$this->scan_table( $table );
			// Remove processed table from queue
			unset( $this->state['jobs']['dbmalwarescan'][ $table ] );
		}

		// After first iteration (counting phase), prepare for detailed scan
		if ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] == 0 ) {

			// Store total number of Items found in first pass
			// fixme: length may not be defined initially

			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] = max(
				( ! empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] ) ? $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] : 0 ),
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items']
			);

			// Reset Items counter for second pass
			unset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] );

			// Increment iteration counter for next pass
			++$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'];

			// Recursively call self for second detailed scanning pass
			$this->{__FUNCTION__}(
				array(
					'recursion' => 'yes',
					'caller'    => 'iteration',
				)
			);
		}
	}

	/**
	 * Scans a specific database table for malware signatures.
	 *
	 * Runs in two modes depending on the current scan iteration:
	 * - Iteration 0: counts candidate rows that match coarse LIKE patterns.
	 * - Iteration 1: dispatches deep regex scanning per candidate row.
	 *
	 * Whitelisted database records (by table + ID) are excluded from both
	 * the candidate count and the deep scan queue.
	 *
	 * Better name: scan_db_table_for_malware_patterns()
	 *
	 * @param string $table Name of the WordPress table to scan
	 * @return bool|void Returns false if table is invalid or doesn't exist; otherwise returns void.
	 */
	function scan_table( $table ) {
		global $wpdb;

		// Get the malware definition patterns to check against
		$definitions = $this->get_malware_db_definitions();

		// Check if this table is in the scan queue
		if ( isset( $this->state['jobs']['dbmalwarescan'][ $table ] ) ) {
			// Get the last processed definition index or start from 0
			$last_definition_index = isset( $this->state['jobs']['dbmalwarescan'][ $table ]['definition_id'] )
			? $this->state['jobs']['dbmalwarescan'][ $table ]['definition_id']
			: 0;

			// Get reference to actual WordPress table
			$db_table = $wpdb->{$table};

			// Validate table exists
			if ( empty( $db_table ) ) {
				unset( $this->state['jobs']['dbmalwarescan'][ $table ] );
				return false;
			}

			// Iterate through malware definitions starting from last position
			for ( $i = $last_definition_index; $i < count( $definitions ); $i++ ) {
				// Check if we need to save state and fork process
				$this->maybe_save_and_fork();

				// Update current definition index in state
				$this->state['jobs']['dbmalwarescan'][ $table ]['definition_id'] = $i;

				// ====================

				if ( ! isset( $this->state['jobs']['dbmalwarescan'][ $table ]['results'] ) ) {

					// Decode the malware pattern to search for
					$where_clause_decoded = $this->decode( $definitions[ $i ]['query'] );

					// Build appropriate query based on table and definition
					$query = '';

					switch ( $table ) {
						case 'posts':
							$query = "SELECT ID FROM $db_table WHERE post_content LIKE '%s'";
							break;
						case 'postmeta':
							$query = "SELECT meta_id AS id FROM $db_table WHERE meta_value LIKE '%s'";
							break;
						case 'options':
							// Get plugin information for exclusions
							if ( ! function_exists( 'get_plugins' ) ) {
								require_once ABSPATH . 'wp-admin/includes/plugin.php';
							}
							$all_plugins       = get_plugins();
							$active_plugins    = get_option( 'active_plugins' );
							$exclusions_clause = '';

							if ( isset( $all_plugins['gotmls/index.php'] ) ) {
								$exclusions_clause .= " AND option_name != 'GOTMLS_definitions_array'";
							}
							if ( in_array( 'malcare-security/malcare.php', $active_plugins ) || in_array( 'blogvault-real-time-backup/blogvault.php', $active_plugins ) ) {
								$exclusions_clause .= " AND option_name != 'bvruleset'";
							}

							$query = "SELECT option_id AS id FROM $db_table WHERE option_value LIKE '%s'" . $exclusions_clause;
							break;
						case 'comments':
							$query = "SELECT comment_ID AS id FROM $db_table WHERE comment_content LIKE '%s' AND comment_approved = '1'";
							break;
						default:
							return false;
					}

					// Prepare and execute the search query
					$t = microtime( 1 );
					// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- DB malware scanning must query current row ids against live table content.
					// The table source is fixed by the switch above and any exclusions are constant option names.
					// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
					$query        = $wpdb->prepare( $query, $where_clause_decoded );
					$matching_ids = $wpdb->get_col( $query );
					// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
					// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
					if ( ! empty( $matching_ids ) ) {
						$matching_ids = array_values(
							array_filter(
								$matching_ids,
								function ( $id ) use ( $table ) {
									return ! $this->is_db_record_whitelisted( $table, intval( $id ) );
								}
							)
						);
					}
					// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' Results query executed in ' . ( microtime( 1 ) - $t ) . ' seconds. Iteration ' . $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] );
					$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] += count( $matching_ids );

					$this->dlog( 'Assessing table ' . $table . ' for malware signature ' . $i . '/' . count( $definitions ) );
					if ( count( $matching_ids ) ) {
					}
					$this->state['jobs']['dbmalwarescan'][ $table ]['results'] = $matching_ids;
				}
				// ====================

				$elapsed = ( time() - $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['start'] );

				if ( $elapsed && $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] == 0 ) {
					// Store elapsed time
					$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['elapsed'] = $elapsed;

					if ( ! isset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['total_tables'] ) ) {
						$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['total_tables'] = count( $this->state['jobs']['dbmalwarescan'] );
					}

					// Get current table index or set to 0 if not set
					if ( ! isset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['current_table'] ) ) {
						$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['current_table'] = 0;
					}

					// Calculate percentage if we know total length
					if ( 1 || ! empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] ) ) {
						// Calculate percentage based on:
						// (completed_tables * total_definitions + current_definition) / (total_tables * total_definitions) * 100
						$total_tables       = $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['total_tables'];
						$current_table      = $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['current_table'];
						$total_definitions  = count( $definitions );
						$current_definition = isset( $this->state['jobs']['dbmalwarescan'][ $table ]['definition_id'] )
						? $this->state['jobs']['dbmalwarescan'][ $table ]['definition_id']
						: 0;

						$percentage = ( ( $current_table * $total_definitions ) + $current_definition ) / ( $total_tables * $total_definitions ) * 100;
						$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['percentage'] = min( 100, $percentage );

					}

					// Update progress message
					$keyword = $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] ? 'potential' : 'suspicious';
					$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['message'] = $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] . ' ' . $keyword . ' database records in ' . $this->human_readable_time_diff( $elapsed ) . ' @ ' . ( round( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] / $elapsed ) ) . ' records per second…<br /><strong>Scanning :</strong> ' . strtoupper( $table ) . '…';
					// $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['percentage'] = ( $i / count( $definitions ) ) * count( $this->state['jobs']['dbmalwarescan'][ $tables ] ) * 100;
					$this->update_progress( $this->state['job_status'] );
				}

				// Send for actual scan only on second iteration.
				if ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] ) {

					// $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] = ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] ?? 0 ) + count( $this->state['jobs']['dbmalwarescan'][ $table ]['results'] );

					foreach ( $this->state['jobs']['dbmalwarescan'][ $table ]['results'] as $key => $id ) {

						$this->maybe_save_and_fork(); // Check if we need to save state and fork process

						if ( ! empty( $db_table ) ) { // who knows?
							$ts = microtime( 1 );
							$this->process_db_scan( $id, $i, $db_table );
							$this->dlog( 'Scanning ID ' . $id . ' against malware signature ' . $i . '/' . count( $definitions ) . ' in ' . $db_table );

							// Calculate percentage based on key position rather than incrementing items
							if ( ! empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] ) ) {
								$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['scanned'] = ( ! empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['scanned'] ) ? $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['scanned'] : 0 ) + 1;

								$percentage = ( ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['scanned'] ) / $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] ) * 100;
								$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['percentage'] = $percentage;

								$elapsed = ( time() - $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['start'] );
								if ( $elapsed ) {
									$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['message'] = $key + $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] . ' potential threats scanned in ' . $this->human_readable_time_diff( $elapsed ) . ' @ ' . round( $key + $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] / $elapsed ) . ' records per second...<br /><strong>Scanning :</strong> ' . strtoupper( $table ) . '...';
									$this->update_progress( $this->state['job_status'] );
								}
							}

							unset( $this->state['jobs']['dbmalwarescan'][ $table ]['results'][ $key ] );
						} else {
							$this->flog( 'ERROR: ' . $this->state['thread_id'] . ' Invalid table ' . $table . ' provided for scan_db_threats' );
							unset( $this->state['jobs']['dbmalwarescan'][ $table ] );
						}
					}
				} // second iteration

				// This query has been done and scanned.
				unset( $this->state['jobs']['dbmalwarescan'][ $table ]['results'] );
				// Move to next definition
				$this->state['jobs']['dbmalwarescan'][ $table ]['definition_id'] = $i + 1;
			}

			// Remove table from scan queue after processing all definitions
			unset( $this->state['jobs']['dbmalwarescan'][ $table ] );
		}
	}

	/**
	 * Processes a database record scanning request.
	 *
	 * This function serves as a wrapper for the enqueue_db_scan method,
	 * delegating the actual scanning process to that method. It initiates
	 * the scanning of a specific database record against a particular
	 * malware definition.
	 *
	 * Better name: dispatch_db_row_scan()
	 *
	 * @param int|string $postid The ID of the database record to scan
	 * @param int        $def_id The ID of the malware definition to check against
	 * @param string     $table  The database table name where the record is located
	 * @return void
	 */
	function process_db_scan( $postid, $def_id, $table ) {
		$this->enqueue_db_scan( $postid, $def_id, $table );
	}

	/**
	 * Enqueues a database scan request via WordPress AJAX.
	 *
	 * This function prepares the scan parameters by bundling the post ID, definition ID,
	 * and the table name, then encoding the data for transmission. It constructs the
	 * AJAX URL with the encoded parameters and makes a request to initiate the scan.
	 * If there's an error during the request, it logs the error message using the configured logging mechanism.
	 *
	 * Better name: enqueue_async_db_row_scan()
	 *
	 * @param int|string $postid The database row identifier.
	 * @param int        $def_id The malware definition index.
	 * @param string     $table  The WordPress table name (e.g. 'posts', 'options').
	 *
	 * @return void
	 */
	function enqueue_db_scan( $postid, $def_id, $table ) {

		// Direct scan works but may hang on preg_match
		$data = $this->exor(
			array(
				'id'     => $postid,
				'def_id' => $def_id,
				'table'  => $table,
			)
		);
		$data = $this->encode( $data );
		$url  = admin_url(
			'admin-ajax.php?action=wpmr_stateful_scan_db&marker=' . $data
		);
		$this->dlog( 'Sending ' . $table . ' ' . $postid . ' for DeepScan.' );
		$response = $this->scan_request( $url );
		if ( is_wp_error( $response ) ) {
			// Handle or skip?
		}
	}

	/**
	 * Callback function that handles asynchronous database scanning requests.
	 *
	 * This function is triggered via AJAX to scan specific database records for malware:
	 * 1. Performs a security handshake to validate the request
	 * 2. Checks if the scan has been requested to stop (kill signal)
	 * 3. Decodes the encrypted marker data containing scan parameters
	 * 4. Extracts database record ID, definition ID, and table name
	 * 5. Calls scan_db_threats() to perform the actual scanning
	 * 6. Terminates execution with wp_die() to ensure proper AJAX response
	 *
	 * The function uses secure XOR-based encryption/decryption to protect scan
	 * parameters during transmission between browser and server.
	 *
	 * Better name: ajax_scan_db_row_callback()
	 *
	 * @return void
	 */
	function scan_db_callback() {
		$this->raise_ajax_limits();
		// $this->accept_async_handover();

		$sec = $this->do_scan_handshake();
		if ( $this->needs_kill() ) {
			wp_die();
		}
		// Row-scan callbacks are authenticated by the active scan handshake key plus the encoded marker payload.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- This worker path does not use WordPress nonces.
		$marker = isset( $_REQUEST['marker'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['marker'] ) ) : '';
		$data   = $this->decode( $marker );
		$data   = $this->dxor( $data, $sec );

		if ( isset( $data['id'] ) && isset( $data['def_id'] ) && isset( $data['table'] ) ) {

			$this->scan_db_threats(
				$data['id'],
				$data['def_id'],
				$data['table'],
				$sec
			);
		} else {
			$this->flog( 'WARNING: something wrong with db data scan request: some data element(s) empty' . $this->debug_value_to_string( $data ) . ' ' . $sec . ' ' . $this->debug_value_to_string( wp_unslash( $_REQUEST ) ) );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		wp_die(); // this is required to terminate immediately and return a proper response
	}

	/**
	 * Scans a specific row in a database table for malware signatures.
	 *
	 * This function validates the provided parameters and constructs an SQL query based on
	 * the specified table (posts, post meta, options, or comments). It retrieves the content
	 * from the database, decodes the malware signature, and uses regular expression matching
	 * to detect potential infections. If a threat is found and its severity is labeled as "severe"
	 * or "high", the function flags the system as infected, logs the incident, and records
	 * detailed information about the infection.
	 *
	 * Whitelisted database records are skipped before any content is loaded.
	 *
	 * Better name: scan_db_row_for_threats()
	 *
	 * @param int|string $row_id    The unique identifier of the row in the database table.
	 * @param string     $def_index Index to retrieve the corresponding malware signature from the database.
	 * @param string     $table     The target database table (should be one of $wpdb->posts, $wpdb->postmeta, $wpdb->options, or $wpdb->comments).
	 * @param int|string $scan_id   The identifier for the current scan operation.
	 *
	 * @return bool Returns false if any required parameter is missing or if an invalid table is provided.
	 */
	function scan_db_threats( $row_id, $def_index, $table = '', $scan_id = '' ) {
		global $wpdb;

		// Ensure all required parameters are provided
		if ( empty( $row_id ) || ! is_int( $def_index ) || empty( $table ) || empty( $scan_id ) ) {
			$this->flog( 'ERROR: Empty parameters provided for scan_db_threats' );
			return false; // or handle this case as you see fit
		}

		$query = '';

		$table_key = '';
		switch ( $table ) {
			/**
			 * Match case for each $table and accordingly create a $query and a $column to fetch the content from
			 */
			case $wpdb->posts:
				$table_key = 'posts';
				$query     = $wpdb->prepare( 'SELECT post_content FROM %i WHERE ID = %d', $table, $row_id );
				break;
			case $wpdb->postmeta:
				$table_key = 'postmeta';
				$query     = $wpdb->prepare( 'SELECT meta_value FROM %i WHERE meta_id = %d', $table, $row_id );
				break;
			case $wpdb->options:
				$table_key = 'options';
				$query     = $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_id = %d', $table, $row_id );
				break;
			case $wpdb->comments:
				$table_key = 'comments';
				$query     = $wpdb->prepare( 'SELECT comment_content FROM %i WHERE comment_ID = %d', $table, $row_id );
				break;
			default:
				// ERROR: Invalid table $table provided for scan_db_threats
				return false;
			// or handle this case as you see fit
		}
		if ( $table_key && $this->is_db_record_whitelisted( $table_key, intval( $row_id ) ) ) {
			return true;
		}

		// Fetch the content from the specified table and column
		// The table source is fixed by the allowlisted switch above; only the row id is runtime data.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- DB threat scanning must read the current record content directly.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$content = $wpdb->get_var( $query );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
		$def = $this->get_db_sig_by_index( $def_index );

		$infection_details = array();
		if ( ! empty( $content ) && $this->str_size_bytes( $content ) < $this->maxsize ) { // could return string, empty or null
			$pattern = $this->decode( $def['signature'] );
			$result  = false;
			try {
				if ( @preg_match( $this->decode( $def['signature'] ), '' ) === false ) {
					throw new Exception( 'Invalid regular expression: ' . $def['id'] . ' Expression: ' . $this->decode( $def['signature'] ) . ' in ' . __FUNCTION__ );
				}
				$result = preg_match( $pattern, $content, $found );

			} catch ( Exception $e ) {
				$this->flog( 'WARNING: Faulty Signature: ' . $def['id'] );
				$this->flog( 'WARNING: Faulty Pattern: ' . $this->decode( $def['signature'] ) );
				$this->flog( 'WARNING: File: ' . $content );
				$this->flog( 'WARNING: message: ' . $e->getMessage() );
				$this->flog( 'WARNING: preg_last_error: ' . preg_last_error() );
				$this->flog( 'WARNING: preg_last_error_msg: ' . preg_last_error_msg() );
			}
			// fixme: should test for valid regex and throw Exception otherwise
			if ( $result >= 1 ) {

				// Save the status when malware is detected
				$this->save_db_status(
					$scan_id,
					$content,
					$def['id'],
					$def,
					$this->get_definition_version(),
					array(
						'table' => $table,
						'id'    => $row_id,
					)
				);
				$this->ss_update_setting( 'infected', true );
			} else {

				/*
				$this->flog( 'WARNING: Found Clean DB' );
				$this->flog( 'Calling save_db_status' );
				$this->flog( $scan_id );
				$this->flog( $content );

				$this->flog( '$this->get_definition_version()' );
				$this->flog( $this->get_definition_version() );
				$this->flog( 'others' );
				$this->flog(
					array(
						'table' => $table,
						'id'    => $row_id,
					)
				);

				$this->save_db_status(
					$scan_id,
					$content,
					'',     // no signature id for clean content
					array(), // no signature details for clean content
					$this->get_definition_version(),
					array(
						'table' => $table,
						'id'    => $row_id,
					)
				); */
			}
		} else {
			$error = 'ERROR: ';
			if ( empty( $content ) ) {
				$error .= 'Content empty';
			}
			if ( $this->str_size_bytes( $content ) >= $this->maxsize ) {
				$error .= 'Content exceeds maxsize';
			}
		}
	}

	/**
	 * Saves the scan status of a database record to the database checksums table.
	 *
	 * This function:
	 * - Calculates SHA256 checksum of the database content
	 * - Records infection details if a malware signature is matched
	 * - Inserts or updates the record in the database checksums table
	 *
	 * Note: at present this method returns early after inserting an issue for infected
	 * content and does not persist a scan-status row.
	 *
	 * Better name: record_db_scan_result()
	 *
	 * @param string $scan_id   The ID of the current scan.
	 * @param string $content   The database content being scanned.
	 * @param string $sid       The signature ID if malware was detected.
	 * @param array  $signature The signature details if malware was detected.
	 * @param string $sver      The signature version used for scanning.
	 * @param array  $record    The database record details (table, id, etc).
	 * @return void
	 */
	function save_db_status( $scan_id, $content, $sid, $signature, $sver, $record = array() ) {
		global $wpdb;

		// Table name
		// $table_name = $this->table_scanned_files;

		// Generate SHA256 hash of the content
		$checksum = hash( 'sha256', $content );
		if ( ! $checksum ) {
			$checksum = '';
			$this->flog( 'ERROR: Cannot generate checksum.' );
		}

		$severity = '';
		$comment  = '';
		$attrib   = array();
		if ( ! empty( $signature ) ) {
			// Record infection details
			$severity           = $signature['severity'];
			$attrib['sig_hash'] = hash( 'sha256', $signature['signature'] );

			$infection_details = array(
				'type'         => 'database',
				'severity'     => $signature['severity'],
				'infection_id' => $sid,
				'pointer'      => array(
					'table' => $record['table'],
					'id'    => $record['id'],
				),
				'comment'      => array(
					'message' => 'Database record in table <span class="db_table">' .
								$record['table'] . '</span> ID <span class="db_id">' .
								$record['id'] . '</span> has <span class="severity ' .
								$signature['severity'] . '">' . $signature['severity'] .
								'</span> infection.',
				),
			);

			$comment = json_encode( $infection_details['comment'] );

			// Insert issue for reporting
			$this->insert_issue(
				$scan_id,
				$infection_details
			);
		}

		return;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Legacy scanned-file lookups here must use current persisted scan truth if reached.
		$existing_record = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE path = %s',
				$this->table_scanned_files,
				$checksum
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching

		// If record exists and has same $sver version
		if ( $existing_record && $existing_record->signature_version === $sver & ! empty( $existing_record->severity ) ) {
			$this->flog( 'WARNING: Record exists and has same version' . $this->debug_value_to_string( $existing_record ) );
			return false;
		}

		// Prepare and execute the query
		$query = $wpdb->prepare(
			'INSERT INTO %i (path, signature_id, severity, signature_version, attributes, checksum) 
                VALUES (%s, %s, %s, %s, %s, %s)
                ON DUPLICATE KEY UPDATE 
                signature_id = VALUES(signature_id), 
                severity = VALUES(severity), 
                signature_version = VALUES(signature_version), 
                attributes = VALUES(attributes),
                checksum = VALUES(checksum)',
			$this->table_scanned_files,
			$checksum,
			$sid,
			$severity,
			$sver,
			json_encode( $attrib ),
			$checksum
		);
	}

	/**
	 * Recursively scan files for malware within the WordPress installation.
	 *
	 * This phase performs a two-pass traversal of the filesystem starting at ABSPATH
	 * (filterable via `wpmr_filemalwarescan_args`).
	 *
	 * - Iteration 0: indexes files and computes total length.
	 * - Iteration 1: dispatches deep scans for each file via `process_file()`.
	 *
	 * The traversal is resumable via a directory stack (`dstack`) and per-directory
	 * counters (`dcounter`), and cooperates with the time-sliced execution model by
	 * frequently calling `maybe_save_and_fork()`.
	 *
	 * @todo File entries should be unset as soon as processed to avoid memory exhaustion.
	 *
	 * Better name: phase_file_malware_scan()
	 *
	 * @param array $passed Optional array of additional parameters for recursive/self-invoked calls.
	 * @return void
	 */
	public function phase_filemalwarescan( $passed = array() ) {
		// fixme: file must be unset as soon as it is processed else it throws PHP Fatal error:  Allowed memory size of ... bytes exhausted
		if ( ! empty( $passed ) ) {
			// $this->flog( '$passed: ' . json_encode( $passed ) );
		}

		// If no items have been processed yet in the current job, initialize everything.
		if ( ! isset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] ) ) {
			// Get the main directory to begin scanning (defaulting to ABSPATH if not overridden).
			$entry_dir = apply_filters( 'wpmr_filemalwarescan_args', untrailingslashit( ABSPATH ) );

			// Push the entry directory onto the directory stack.
			$this->add_dir_to_stack( $entry_dir );

			// Initialize counters and status values for this job.
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items']   = 0;
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['start']   = time();
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['message'] = '';
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['task']    = 'Files';

			// If the iteration counter does not exist or is zero, set it to zero (only done once).
			if ( empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] ) ) {
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] = 0;
			}
		}

		// Retrieve the directory on top of the stack (the one we’re currently scanning).
		$dir = $this->state['dstack'][ count( $this->state['dstack'] ) - 1 ];
		// Get an array of all entries (files/directories) in this directory.
		$entries = $this->get_dir_entries( $dir );

		// Loop over all the entries in this directory until we've processed them all.
		for ( ; ( isset( $this->state['dcounter'][ $dir ] ) ) && $this->state['dcounter'][ $dir ] < count( $entries ); ) {
			// Possibly save state and fork the process if needed (helps avoid timeouts).
			$this->maybe_save_and_fork();

			// Build a full path for the current entry, based on the directory and its index.
			$index    = $this->state['dcounter'][ $dir ];
			$entry    = $entries[ $index ];
			$location = trailingslashit( $dir ) . $entry;

			// Increment the directory counter to mark that this entry is now being processed.
			++$this->state['dcounter'][ $dir ];

			// Check if this path is a valid file and process it if so.
			if ( $this->is_valid_file_m( $location ) ) {
				// Only call process_file if this scan iteration is active (non-zero).
				if ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] ) {
					$this->dlog( 'Scanning file ' . str_replace( trailingslashit( ABSPATH ), '', $location ) );
					$this->process_file( $location );
				} else {
					$this->dlog( 'Indexing file ' . str_replace( trailingslashit( ABSPATH ), '', $location ) );
				}

				// Update the total size counter for scanned files.
				// If 'size' is already set in state, add the file size; otherwise just set it.
				if ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] !== 0 ) {
					$this->state['size'] = array_key_exists( 'size', $this->state )
					? (int) $this->state['size'] + @filesize( $location )
					: @filesize( $location );
				} else {
					$this->state['size'] = 0;
				}

				// Increment the count of scanned items for this job.
				++$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'];

				// Calculate how much time has passed since this job started.
				$elapsed = ( time() - $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['start'] );

				if ( $elapsed ) {
					// Store the elapsed time in the job status.
					$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['elapsed'] = $elapsed;

					// If we know the total length (number of files) for this job, update the completion percentage.
					if ( ! empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] ) ) {
						$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['percentage'] =
						( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items']
							/ $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] )
							* 100;
					} else {
						// No length set yet, so we can't calculate a percentage.
					}

					// Update the progress message with details: total size scanned, file count, elapsed time, etc.
					$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['message'] =
					$this->human_readable_bytes( $this->state['size'] ) . ' in '
					. $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] . ' files in '
					. $this->human_readable_time_diff( $elapsed ) . ' @ '
					. ( round( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] / $elapsed ) )
					. ' files per second…<br /><strong>Scanning :</strong> '
					. str_replace( trailingslashit( ABSPATH ), '', $location ) . '…';
					$this->flog( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['message'] );

					// Save or display updated progress as needed.
					$this->update_progress( $this->state['job_status'] );
				}
				// DO NOT USE UNSET ELSE IT IMPACTS COUNT AND BREAKS FOR LOOP
				$entries[ $index ] = null; // unset immediately else array grows too large and throws: PHP Fatal error:  Allowed memory size of ... bytes exhausted

			} elseif ( $this->is_valid_dir_m( $location ) ) {   // Check if the current location is a valid directory that we need to recurse into.
				++$this->state['pad'];
				if ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] ) {
					$this->dlog( 'Scanning directory ' . str_replace( trailingslashit( ABSPATH ), '', $location ) );
				} else {
					$this->dlog( 'Indexing directory ' . str_replace( trailingslashit( ABSPATH ), '', $location ) );
				}
				// Add it to the directory stack so we scan inside that folder next.
				$this->add_dir_to_stack( $location );

				// Recursively call this function to process the newly added directory, then return.
				// The return ensures we don't continue in this loop after starting recursion.
				$this->{__FUNCTION__}(
					array(
						'recursion' => 'yes',
						'caller'    => 'loop',
						'location'  => $location,
					)
				);
				--$this->state['pad'];
			}
		}

		// If we've finished all entries in this directory, remove it from the directory stack.
		$this->remove_dir_from_stack( $dir, $passed );

		// Check if there are still directories in the stack that need scanning.
		// REASON: We may be resuming from a fork so in recursion we may loose track of the dcounter.
		if ( count( $this->state['dstack'] ) ) {

			// Just for logging, retrieve the new top of the stack and get its entries.
			$directory = $this->state['dstack'][ count( $this->state['dstack'] ) - 1 ];

			// Continue scanning in the newly uncovered directory on top of the stack.
			$this->{__FUNCTION__}(
				array(
					'recursion'     => 'yes',
					'caller'        => 'stack',
					'location_post' => $directory,
				)
			);
		} else {

			// If no more directories are left in the stack, we've scanned all items.
			// Run a second iteration for the real scan

			// If this is the first iteration, run another iteration if needed.
			if ( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] == 0 ) {

				// Set the 'length' key to the total number of items scanned.
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['length'] =
				$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'];

				// Remove 'items' so the function can re-initialize them if needed.
				unset( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['items'] );

				// Increment the iteration count.
				++$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'];

				// Call this function again, indicating we are now in a new iteration phase.
				$this->flog( 'Starting second iteration of file scan.' );
				$this->{__FUNCTION__}(
					array(
						'recursion' => 'yes',
						'caller'    => 'iteration',
					)
				);
			}
		}

		// todo: phase is complete. Should we call initially to just count and then self again to initiate scan?
	}

	function error_log( $message ) {
		// error_log( print_r( $this->mc_env_snapshot(), 1 ) );

		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, Squiz.PHP.DiscouragedFunctions.Discouraged -- This helper intentionally routes structured debug output to PHP's error log when explicitly requested.
		error_log( $this->debug_value_to_string( $message ) );
	}

	function mc_env_snapshot() {
		global $wpdb;

		$opcache = null;
		if ( function_exists( 'opcache_get_status' ) ) {
			$st = @opcache_get_status( false );
			if ( is_array( $st ) ) {
				$opcache = array(
					'enabled' => (bool) ( $st['opcache_enabled'] ?? false ),
					'jit'     => $st['jit'] ?? null,
					'memory'  => $st['memory_usage'] ?? null,
				);
			}
		}

		return array(
			'ts'     => gmdate( 'c' ),
			'php'    => array(
				'version'          => PHP_VERSION,
				'sapi'             => PHP_SAPI,
				'os'               => php_uname(),
				'ini'              => array(
					'memory_limit'        => ini_get( 'memory_limit' ),
					'max_execution_time'  => ini_get( 'max_execution_time' ),
					'opcache_enable'      => ini_get( 'opcache.enable' ),
					'opcache_enable_cli'  => ini_get( 'opcache.enable_cli' ),
					'pcre_jit'            => ini_get( 'pcre.jit' ),
					'realpath_cache_size' => ini_get( 'realpath_cache_size' ),
					'realpath_cache_ttl'  => ini_get( 'realpath_cache_ttl' ),
				),
				'extensions_count' => count( get_loaded_extensions() ),
				'opcache'          => $opcache,
			),
			'wp'     => array(
				'version'    => defined( 'WP_VERSION' ) ? WP_VERSION : null,
				'multisite'  => function_exists( 'is_multisite' ) ? is_multisite() : null,
				'mem_limits' => array(
					'WP_MEMORY_LIMIT'     => defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : null,
					'WP_MAX_MEMORY_LIMIT' => defined( 'WP_MAX_MEMORY_LIMIT' ) ? WP_MAX_MEMORY_LIMIT : null,
				),
				'theme'      => function_exists( 'wp_get_theme' ) ? wp_get_theme()->get( 'Name' ) . ' ' . wp_get_theme()->get( 'Version' ) : null,
			),
			'db'     => array(
				'server_version' => isset( $wpdb ) ? $wpdb->db_version() : null,
				'charset'        => defined( 'DB_CHARSET' ) ? DB_CHARSET : null,
				'collate'        => defined( 'DB_COLLATE' ) ? DB_COLLATE : null,
			),
			'memory' => array(
				'usage' => memory_get_usage( true ),
				'peak'  => memory_get_peak_usage( true ),
			),
		);
	}

	/**
	 * Return an indentation pad for nested scan logging.
	 *
	 * Uses `$this->state['pad']` as an indentation level and returns 8 spaces per level.
	 *
	 * Better name: get_log_indent()
	 *
	 * @return string
	 */
	function get_pad() {
		return str_repeat( ' ', $this->state['pad'] * 8 );
	}

	/**
	 * Calculate a dynamic batch size using throughput metrics and a time budget.
	 *
	 * This helper is context-aware and stores throughput history in scan state so
	 * batch sizes can adapt across requests. It does not execute any scanning.
	 *
	 * Better name: get_adaptive_batch_size()
	 *
	 * @param string $context Unique key for the scan context (e.g., 'db', 'files').
	 * @param array  $args    Optional overrides:
	 *                         - min_batch
	 *                         - max_batch
	 *                         - time_budget_sec
	 *                         - hard_timeout_sec
	 *                         - window_size
	 *                         - default_batch
	 *                         - last_batch_count
	 *                         - last_batch_duration
	 *
	 * @return int
	 */
	function get_dynamic_batch_size( $context, $args = array() ) {
		$defaults = array(
			'min_batch'           => 1,
			'max_batch'           => 111,
			'time_budget_sec'     => 8,
			'hard_timeout_sec'    => 12,
			'window_size'         => 5,
			'default_batch'       => 10,
			'last_batch_count'    => null,
			'last_batch_duration' => null,
		);

		$args = wp_parse_args( $args, $defaults );

		if ( empty( $context ) ) {
			$context = 'default';
		}

		if ( empty( $this->state['adaptive_batch'] ) || ! is_array( $this->state['adaptive_batch'] ) ) {
			$this->state['adaptive_batch'] = array();
		}

		if ( empty( $this->state['adaptive_batch'][ $context ] ) || ! is_array( $this->state['adaptive_batch'][ $context ] ) ) {
			$this->state['adaptive_batch'][ $context ] = array(
				'ips_window'          => array(),
				'batch_size'          => (int) $args['default_batch'],
				'last_batch_count'    => null,
				'last_batch_duration' => null,
			);
		}

		$bucket = &$this->state['adaptive_batch'][ $context ];

		$last_count    = $args['last_batch_count'];
		$last_duration = $args['last_batch_duration'];

		if ( is_numeric( $last_count ) && is_numeric( $last_duration ) ) {
			$duration = max( 0.001, (float) $last_duration );
			$count    = max( 0, (int) $last_count );
			$ips      = $count / $duration;

			if ( $ips > 0 ) {
				$bucket['ips_window'][] = $ips;
				if ( count( $bucket['ips_window'] ) > (int) $args['window_size'] ) {
					$bucket['ips_window'] = array_slice( $bucket['ips_window'], -1 * (int) $args['window_size'] );
				}
			}

			$bucket['last_batch_count']    = $count;
			$bucket['last_batch_duration'] = $duration;
		}

		$avg_ips = 0;
		if ( ! empty( $bucket['ips_window'] ) ) {
			$values = $bucket['ips_window'];
			sort( $values );
			$mid = (int) floor( count( $values ) / 2 );
			if ( count( $values ) % 2 ) {
				$avg_ips = $values[ $mid ];
			} else {
				$avg_ips = ( $values[ $mid - 1 ] + $values[ $mid ] ) / 2;
			}
		}

		$next_batch = ( $avg_ips > 0 )
		? (int) round( $avg_ips * (float) $args['time_budget_sec'] )
		: (int) $args['default_batch'];

		$next_batch = max( (int) $args['min_batch'], min( (int) $args['max_batch'], $next_batch ) );

		if ( is_numeric( $last_duration ) && (float) $last_duration > 0 ) {
			if ( (float) $last_duration > (float) $args['hard_timeout_sec'] ) {
				$next_batch = max( (int) $args['min_batch'], (int) floor( $next_batch * 0.7 ) );
			} elseif ( (float) $last_duration < ( (float) $args['time_budget_sec'] * 0.5 ) ) {
				$next_batch = min( (int) $args['max_batch'], (int) ceil( $next_batch * 1.2 ) );
			}
		}

		$bucket['batch_size'] = $next_batch;
		return $next_batch;
	}

	/**
	 * Retrieves all entries (files and directories) in a specified directory.
	 *
	 * This function opens the directory, reads its contents, and returns an array of
	 * entries excluding the special entries '.' and '..'. It also checks for a '.mcignore'
	 * file to determine if the directory should be ignored.
	 *
	 * Better name: list_directory_entries()
	 *
	 * @param string $dir The path to the directory to scan.
	 * @return array An array of entries in the directory.
	 */
	function get_dir_entries( $dir ) {

		// // Check if entries for this directory are already cached
		// if ( isset( $this->state['dir_entries_cache'][ $dir ] ) ) {
		// return $this->state['dir_entries_cache'][ $dir ];
		// }

		// AN ISSUE HAS BEEN NOTICED THAT WHEN DISK-BASED DATABASE CACHING IS ENABLED, DIRECTORIES AND FILES ARE CREATED AND VANISH WITHIN FEW MILLISECONDS.
		// THIS ADDRESSES IT.
		if ( ! file_exists( $dir ) ) {
			return array();
		}
		$handle = opendir( $dir );
		// $this->flog( '' );
		// $this->flog( 'Attempting to open directory: ' . $dir . ' Directory handle: ' . print_r( $handle, 1 ) );
		if ( ! $handle ) {

			if ( ! empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] ) ) {
				$this->insert_issue(
					$this->state['identifier'],
					array(
						'type'         => 'file',
						'severity'     => 'unreadable',
						'infection_id' => 'unreadable',
						'pointer'      => $dir,
						'comment'      => array( 'message' => 'Directory <span class="filename">' . $dir . '</span> is <span class="severity unreadable">unreadable</span>.' ),
					)
				);
			} else {
				$this->flog( 'WARNING: Directory ' . $dir . ' is unreadable. Iteration: ' );
				$this->flog( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['iteration'] );
			}
			return array();
		}
		$entries = array();
		if ( file_exists( trailingslashit( $dir ) . '.mcignore' ) ) {
			closedir( $handle );
			return $entries;
		}
		while ( false !== ( $entry = readdir( $handle ) ) ) {
			if ( $entry !== '.' && $entry !== '..' ) {
				// $this->flog( 'Found entry: ' . trailingslashit( $dir ) . $entry );

				// Currently disabled; uncomment if max entries limit required.
				if ( 0 && count( $entries ) > $this->max_dir_entries ) {
					break;
				}
				$entries[] = $entry;
			}
		}
		closedir( $handle );
		natcasesort( $entries );

		$entries = array_values( $entries ); // natcasesort preserves the array’s keys rather than re-indexing them, so we need this

		// Cache the entries before returning
		// $this->state['dir_entries_cache'][ $dir ] = $entries;
		return $entries;
	}

	/**
	 * Adds a directory to the scanning stack for processing.
	 *
	 * This function checks if the given directory location is already in the stack.
	 * If not, it adds the directory to the stack and initializes a counter for it.
	 * The directory stack uses indexed arrays to allow access to the last element
	 * using count($array) - 1 for iteration purposes.
	 *
	 * Better name: push_dir_stack()
	 *
	 * @param string $location The absolute path of the directory to add to the stack.
	 * @return void
	 */
	function add_dir_to_stack( $location ) {
		if ( ! in_array( $location, $this->state['dstack'] ) ) {
			// $this->flog( 'Adding directory to stack: ' . $location );
			array_push( $this->state['dstack'], $location ); // Need to have an indexed array so that we can access the last element using count( $array ) - 1 in the loop
			$this->state['dcounter'][ $location ] = 0;
		} else {
		}
	}

	/**
	 * Removes a directory from the scanning stack after processing is complete.
	 *
	 * This function performs two cleanup operations:
	 * 1. Removes the specified directory from the scanning stack (dstack) using array_filter.
	 * 2. Removes the counter associated with the directory from the counter array (dcounter).
	 *
	 * Better name: pop_dir_stack()
	 *
	 * @param string $dir    The absolute path of the directory to remove from the stack.
	 * @param mixed  $passed Optional parameter for additional context information.
	 * @return void
	 */
	function remove_dir_from_stack( $dir, $passed = '' ) {

		// $this->flog( 'Removing directory from stack: ' . $dir . ' from line ' . __LINE__ );

		// 1. Remove the directory from stack
		$this->state['dstack'] = array_filter(
			$this->state['dstack'],
			function ( $value ) use ( $dir ) {
				return $value !== $dir;
			}
		);

		// 2. Remove the directory from counter
		if ( ! isset( $this->state['dcounter'][ $dir ] ) ) {
			$this->flog( 'WARNING: Directory ' . $dir . ' not found in dcounter.' );
		}

		unset( $this->state['dcounter'][ $dir ] );
	}

	/**
	 * Processes a file during the malware scanning operation.
	 *
	 * This function handles the scanning of individual files by:
	 * 1. Checking if the file can be skipped based on previous scans or file attributes
	 * 2. If scanning is required, encoding the file information for secure transmission
	 * 3. Creating a request URL for the AJAX endpoint that performs the deep scanning
	 * 4. Initiating the scan request and logging appropriate information
	 *
	 * The function uses the exor() method to encrypt the file data for secure transmission
	 * and the scan_request() method to perform the actual AJAX request.
	 *
	 * Better name: enqueue_file_deep_scan()
	 *
	 * @param string $file The absolute path to the file that needs to be processed.
	 * @return void
	 */
	function process_file( $file ) {
		$file_can_be_skipped = $this->file_can_be_skipped( $file );
		if ( $file_can_be_skipped ) {
			// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' Processed file ' . $file );
			return;
		} else {
			// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' Processing file ' . $file );
		}

		$data = $this->exor(
			array(
				'type' => 'file',
				'path' => $file,
			)
		);

		$data = $this->encode( $data );

		$url = admin_url(
			'admin-ajax.php?action=wpmr_stateful_scan_file&marker=' . $data
		);
		$this->dlog( 'Sending file for DeepScan ' . ( str_replace( trailingslashit( ABSPATH ), '', $file ) ), 'w' );
		$response = $this->scan_request( $url );
	}

	/**
	 * Determines whether a file has been scanned against the current malware definitions.
	 *
	 * This function checks the file's scan history provided in $existing_file_info. If there is
	 * no existing scan record, it returns true. Otherwise, it retrieves the most recent scan
	 * record and compares its definitions version ('signature_version') with the current definitions version
	 * ($this->definitions['v']). If they match, the function logs an issue if the file was marked
	 * as infected and returns true. If they do not match, it returns false, indicating that the file
	 * has not been scanned with the current definitions.
	 *
	 * Better name: file_scanned_with_current_definitions()
	 *
	 * @param string $file               The path to the file being scanned.
	 * @param array  $existing_file_info An array containing previous scan records for the file.
	 *
	 * @return bool True if the file has already been scanned with the current definitions version;
	 *              false otherwise.
	 */
	function file_scanned_against_same_defs( $file, $existing_file_info ) {
		if ( empty( $existing_file_info ) ) { // no scan-history for file
			return true;
		} else {
			if ( count( $existing_file_info ) > 1 ) {
			}

			$existing_file_info = array_pop( $existing_file_info ); // first element of the array

			if ( $existing_file_info['signature_version'] == $this->definitions['v'] ) { // earliest return. If the file has been scanned against the same definitions
				if ( ! empty( $existing_file_info['severity'] ) ) { // file has an infection so log it into issues
					// log the infection for reporting
					$details = array(
						'type'         => 'file',
						'severity'     => $existing_file_info['severity'],
						'infection_id' => $existing_file_info['signature_id'],
						'pointer'      => $file,
						'comment'      => array( 'message' => 'File <span class="filename">' . $file . '</span> has <span class="severity ' . $existing_file_info['severity'] . '">' . $existing_file_info['severity'] . '</span> infection.' ),
					);

					$this->insert_issue(
						$this->state['identifier'],
						$details
					);
				}
				return true;
			} else {
				return false;
			}
		}
	}

	/**
	 * Checks if the signature of the provided file matches a known malware signature.
	 *
	 * This function validates whether the file's signature hash, derived from its infection metadata,
	 * matches the hash calculated using the malware definitions. It processes the first element of the
	 * provided file information array, decodes its attributes from JSON, and if a matching signature is found,
	 * it logs the infection details including type, severity, infection ID, file pointer, and a descriptive comment.
	 *
	 * Better name: infected_file_signature_is_still_current()
	 *
	 * @param string $file              The file path to be inspected.
	 * @param array  $existing_file_info An array containing infection metadata for the file.
	 *
	 * @return bool Returns true if a matching malware signature is detected and the infection is recorded;
	 *              otherwise, no return value is explicitly provided (implicitly false/null).
	 */
	function infected_file_sig_matches( $file, $existing_file_info ) {
		$existing_file_info = array_pop( $existing_file_info );
		$attrib             = ! empty( $existing_file_info['attributes'] ) ? json_decode( $existing_file_info['attributes'], 1 ) : false;

		if ( ! empty( $existing_file_info['severity'] ) &&
			! empty( $attrib['sig_hash'] ) &&
			isset( $this->definitions['definitions']['files'][ $existing_file_info['signature_id'] ] ) &&
			$attrib['sig_hash'] == hash( 'sha256', $this->definitions['definitions']['files'][ $existing_file_info['signature_id'] ]['signature'] )
		) {
			$details = array(
				'type'         => 'file',
				'severity'     => $existing_file_info['severity'],
				'infection_id' => $existing_file_info['signature_id'],
				'pointer'      => $file,
				'comment'      => array( 'message' => 'File <span class="filename">' . $file . '</span> has <span class="severity ' . $existing_file_info['severity'] . '">' . $existing_file_info['severity'] . '</span> infection.' ),
			);
			$this->insert_issue(
				$this->state['identifier'],
				$details
			);
			return true;
		}
	}

	/**
	 * Determine if a file can be skipped during the scanning process.
	 *
	 * The function checks whether the provided file meets any of the following conditions
	 * to safely skip further scanning:
	 *
	 * 1. The file is whitelisted and its hash matches the stored whitelist hash.
	 * 2. The file's SHA-256 checksum matches the known core checksum.
	 * 3. The file has no existing scan information (indicating it has never been scanned).
	 * 4. The file has been previously scanned against the same definitions.
	 * 5. In the case of an infected file, the infection signature remains unchanged.
	 *
	 * The method first resolves the real path of the file and calculates its SHA-256 checksum.
	 * If the checksum calculation fails, the function returns null implicitly.
	 * It then performs successive checks against the file's current state and historical scan data.
	 *
	 * Better name: file_is_safe_to_skip_scan()
	 *
	 * @param string $file The path to the file being evaluated.
	 * @return bool|null Returns true if the file can be skipped; returns null otherwise.
	 */
	function file_can_be_skipped( $file ) {

		$file = $this->realpath( $file );

		$checksum = @hash_file( 'sha256', $file );
		if ( ! $checksum ) {
			return;
		}

		// Any file whose checksum is known to the SaaS checksums table — core,
		// plugin, or theme — has authoritative validation. It must never be
		// whitelisted. The whitelist is a last-resort fallback for custom files
		// that have no known checksum of any kind.
		if ( $this->core_checksum_matches( $file, $checksum ) ) {
			return true;
		} elseif ( $this->is_core_file( $file ) ) {
		}

		// SaaS-served file whitelist: only reached for files with no SaaS-known
		// checksum. Available to all users (free and Advanced Edition).
		if ( ! $this->is_in_core_wp_dir( $file ) && ! $this->is_core_file( $file ) ) {

			$file_whitelist_map = $this->get_file_whitelist();
			if ( ! empty( $file_whitelist_map ) && isset( $file_whitelist_map[ $checksum ] ) ) {
				return true;
			}
		}

		if ( $this->is_advanced_edition() ) {
			$whitelist  = $this->get_user_whitelist();
			$normalized = $this->normalise_path( $file );
			if ( is_array( $whitelist ) && isset( $whitelist[ $normalized ] ) && $whitelist[ $normalized ] === $checksum ) {
				return true;
			}
		}

		$existing_file_info = $this->get_existing_file_info( $file, $checksum );

		if ( empty( $existing_file_info ) ) { // file never scanned
			return;
		} else {
		}

		$file_scanned_against_same_defs = $this->file_scanned_against_same_defs( $file, $existing_file_info );

		if ( $file_scanned_against_same_defs ) { // if file has been scanned against the same definitions
			// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' File ' . $file . ' has been scanned against the same definitions.' );
			return true;
		} else {

		}

		$infected_file_sig_matches = $this->infected_file_sig_matches( $file, $existing_file_info );
		if ( $infected_file_sig_matches ) { // if file is infected and infection signature hasn't changed
			// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' File ' . $file . ' is infected and signature matches.' );
			return true;
		}
	}

	/**
	 * Retrieves existing file information from the database based on the provided file path and checksum.
	 *
	 * This function uses the global $wpdb object to query the scanned-files table.
	 * It looks for a record that matches both the file path and the checksum. Note that
	 * the file path is included in the WHERE clause to avoid matching a different file with the same checksum.
	 *
	 * Better name: get_file_scan_history()
	 *
	 * @param string $file     The file path to check.
	 * @param string $checksum The checksum of the file. If the checksum is not provided, the function returns false.
	 *
	 * @return array|false An array of matching scan rows (typically one). Each row contains:
	 *                     'path', 'checksum', 'signature_id', 'severity', 'signature_version', 'attributes'.
	 *                     Returns false when checksum is empty.
	 */
	function get_existing_file_info( $file, $checksum ) {

		if ( ! $checksum ) {
			return false;
		}
		global $wpdb;
		$gencs_query = $wpdb->prepare(
			'SELECT 
                    path, 
                    checksum, 
					signature_id, 
					severity,
					signature_version,
					attributes 
                FROM %i 
                WHERE path = %s AND checksum = %s',
			$this->table_scanned_files,
			$file,
			$checksum
		); // pass path in conditional so that we don't get a match for a different file with the same checksum

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- File-scan skip decisions must read current persisted scanned-file truth.
		$existing_file_info = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT 
                    path, 
                    checksum, 
					signature_id, 
					severity,
					signature_version,
					attributes 
                FROM %i 
                WHERE path = %s AND checksum = %s',
				$this->table_scanned_files,
				$file,
				$checksum
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching

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

	/**
	 * Checks if a file's checksum matches any checksums in the origin checksum table.
	 *
	 * This function queries the origin checksum table to determine if the provided
	 * checksum matches any known core files. This is used to quickly identify whether
	 * a file is an unmodified core file that doesn't need to be scanned for malware.
	 *
	 * Better name: checksum_exists_in_core_table()
	 *
	 * @param string $file     The file path being checked (unused in the query but provided for context).
	 * @param string $checksum The SHA-256 checksum of the file to check against the database.
	 * @return bool  True if the checksum matches a known checksum in the origin table, false otherwise.
	 */
	function core_checksum_matches( $file, $checksum ) {
		global $wpdb;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Core checksum classification must read current persisted checksum truth.
		$checksum_matches = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT 
					id, 
                path, 
                checksum, 
                type, 
					version 
            FROM %i 
            WHERE checksum = %s',
				$this->table_checksums,
				$checksum
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( ! empty( $checksum_matches ) ) {
			return true;
		} else {
			// $this->flog( 'INFO: ' . $file . ' is not a core file with checksum ' . $checksum );
		}
	}

	/** UNUSED?
	 * Checks if a file is a core WordPress file.
	 *
	 * This function queries the database to determine if the provided file path
	 * corresponds to a known core file. It is used to identify files that are
	 * part of the WordPress core and do not require scanning for malware.
	 *
	 * Better name: is_core_wordpress_distribution_file()
	 *
	 * @param string $file The file path being checked.
	 * @return bool True if the file is a core WordPress file, false otherwise.
	 */
	function is_core_wp_distro_file( $file ) {
		global $wpdb;
		$cs_query = $wpdb->prepare(
			'SELECT * FROM %i WHERE `path` = %s AND `type` = %s',
			$this->table_checksums,
			$file,
			'core'
		);
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Core-path classification must read current checksum-table truth.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Prepared above and retained for existing debug logging.
		$results = $wpdb->get_results( $cs_query, ARRAY_A );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
		$this->flog( 'INFO: ' . $this->state['thread_id'] . ' is_core_wp_distro_file QUERY: ' . $cs_query );
		$this->flog( $results );
		return ! empty( $results );
	}

	/**
	 * Checks if a file is a core repository file.
	 *
	 * This function queries the database to determine if the provided file path
	 * corresponds to a known repository file. It is used to identify files that are
	 * part of the repository and may require scanning for malware.
	 *
	 * Better name: is_noncore_repo_file()
	 *
	 * @param string $file The file path being checked.
	 * @return bool True if the file is a core repository file, false otherwise.
	 */
	function is_core_repo_file( $file ) {
		global $wpdb;
		$cs_query = $wpdb->prepare(
			'SELECT * FROM %i WHERE `path` = %s AND `type` != %s',
			$this->table_checksums,
			$file,
			'core'
		);
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Repository-path classification must read current checksum-table truth.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Prepared above and retained for existing debug logging.
		$results = $wpdb->get_results( $cs_query, ARRAY_A );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
		$this->flog( 'INFO: ' . $this->state['thread_id'] . ' is_core_file QUERY: ' . $cs_query );
		$this->flog( $results );
		return ! empty( $results );
	}

	/**
	 * Checks if a file is a core WordPress file.
	 *
	 * This function queries the database to determine if the provided file path
	 * corresponds to a known core file. It is used to identify files that are
	 * part of the WordPress core and do not require scanning for malware.
	 *
	 * Better name: is_known_core_path()
	 *
	 * @param string $file The file path being checked.
	 * @return bool True if the file is a core WordPress file, false otherwise.
	 */
	function is_core_file( $file ) {
		global $wpdb;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Core-path classification must read current checksum-table truth.
		$results = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE `path` = %s',
				$this->table_checksums,
				$file
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
		return ! empty( $results );
	}

	/**
	 * Callback function to scan a file for threats.
	 *
	 * This function handles an asynchronous file scan request. It performs the following tasks:
	 * - Accepts an asynchronous handover of the scan process.
	 * - Initiates a handshake to obtain a security value.
	 * - Decodes the 'marker' parameter from the request and applies a de-obfuscation XOR operation using
	 *   the security value obtained from the handshake.
	 * - If the resulting data contains both a 'type' and a 'path', it triggers a threat scan on the specified file.
	 * - Terminates execution by calling wp_die() to ensure the proper response is returned.
	 *
	 * Better name: ajax_scan_file_callback()
	 *
	 * @return void
	 */
	function scan_file_callback() {
		$this->raise_ajax_limits();
		// $this->accept_async_handover();

		$sec = $this->do_scan_handshake();
		if ( $this->needs_kill() ) {
			wp_die();
		}
		// File-scan callbacks are authenticated by the active scan handshake key plus the encoded marker payload.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- This worker path does not use WordPress nonces.
		$marker = isset( $_REQUEST['marker'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['marker'] ) ) : '';
		$data   = $this->decode( $marker );
		$data   = $this->dxor( $data, $sec );
		if ( ! empty( $data['type'] ) && ! empty( $data['path'] ) ) {

			// AN ISSUE HAS BEEN NOTICED THAT WHEN DISK-BASED DATABASE CACHING IS ENABLED, DIRECTORIES AND FILES ARE CREATED AND VANISH WITHIN FEW MILLISECONDS.
			// THIS ADDRESSES IT.
			if ( file_exists( $data['path'] ) ) {
				$this->scan_file_threats( $data['path'], $sec );
			} else {
				$this->flog( 'WARNING: FILE gone AWOL ' . $data['path'] . ' during scan request.' );
				$this->dlog( 'FILE gone AWOL ' . $data['path'] . ' during scan request.', 'w' );
			}
		} else {
			$this->flog( 'ERROR: Invalid data received for file scan.' );
			$this->flog( 'Data: ' . $this->debug_value_to_string( $data ) );
			$this->flog( 'Sec: ' . $sec );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		wp_die(); // this is required to terminate immediately and return a proper response
	}

	/**
	 * Scans a file for malware threats by comparing its contents against known malware signatures.
	 *
	 * This function reads the contents of the specified file and checks them against a list of
	 * malware signatures defined in the system. It performs the following operations:
	 * - Reads the file contents
	 * - Gets the file extension
	 * - Retrieves malware definitions and the current definition version
	 * - Checks each definition against the file contents using regex pattern matching
	 * - Validates regex patterns before using them to avoid errors
	 * - Updates the system's infection status if severe or high threats are found
	 * - Saves the file's scan status regardless of whether threats were found
	 *
	 * Better name: scan_file_contents_for_threats()
	 *
	 * @param string $file    The path to the file to be scanned
	 * @param string $scan_id The identifier for the current scan session
	 * @return mixed Returns the result of save_file_status function
	 */
	function scan_file_threats( $file, $scan_id ) {
		$sver = $this->get_definition_version();
		if ( ! is_readable( $file ) ) {
			$this->flog( 'ERROR: File ' . $file . ' is unreadable.' );
			return $this->save_file_status(
				$scan_id,
				$file,
				'unreadable',
				array(
					'signature' => 'unreadable',
					'severity'  => 'unreadable',
				),
				$sver
			); // file, sigid, severity, dver

		}
		$file_contents = @file_get_contents( $file );
		$ext           = $this->get_fileext( $file );
		$tests         = array();

		$definitions = $this->get_malware_file_definitions();

		foreach ( $definitions as $definition => $signature ) {
			if ( $signature['class'] == 'htaccess' && $ext != 'htaccess' ) {
				continue;
			}

			$matches = false;
			if ( 0 && preg_match( '/error/i', $file ) ) {
				$this->flog( '$this->decode( $signature[signature] )' );
				$this->flog( $this->decode( $signature['signature'] ) );
				$tests = preg_match( $this->decode( $signature['signature'] ), $file_contents, $found_tests );
				$this->flog( 'tests for file ' . $file );
				$this->flog( $tests );
				$this->flog( $found_tests );

			}
			try {
				if ( @preg_match( $this->decode( $signature['signature'] ), '' ) === false ) {
					throw new Exception( 'Invalid regular expression: ' . $definition . ' Expression: ' . $this->decode( $signature['signature'] ) . ' in ' . __FUNCTION__ );
				}

				if ( in_array( $signature['severity'], array( 'severe', 'high' ) ) ) {
					$matches = preg_match( $this->decode( $signature['signature'] ), $file_contents, $found );
				}
			} catch ( Exception $e ) {
				$this->flog( 'WARNING: Faulty Signature: ' . $definition );
				$this->flog( 'WARNING: Faulty Pattern: ' . $this->decode( $signature['signature'] ) );
				$this->flog( 'WARNING: File: ' . $file );
				$this->flog( 'WARNING: message: ' . $e->getMessage() );
				$this->flog( 'WARNING: preg_last_error: ' . preg_last_error() );
				$this->flog( 'WARNING: preg_last_error_msg: ' . preg_last_error_msg() );
				continue;
			}
			if ( $matches >= 1 ) {
				if ( in_array( $signature['severity'], array( 'severe', 'high' ) ) ) {
					$this->ss_update_setting( 'infected', true );
				}
				return $this->save_file_status( $scan_id, $file, $definition, $signature, $sver ); // file, sigid, severity, dver
			}
		}
		return $this->save_file_status( $scan_id, $file, '', '', $sver );
	}

	/**
	 * Execute the vulnerability scan phase.
	 *
	 * This phase is invoked via the `wpmr_scan_phase_vulnerabilityscan` action and is
	 * designed to run in a fresh PHP execution context (fork) to reduce the chance of
	 * timeouts during plugin/theme/core vulnerability checks.
	 *
	 * Better name: phase_vulnerability_scan()
	 *
	 * @return void
	 */
	function phase_vulnerabilityscan() {

		if ( empty( $this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['forked'] ) ) {
			$this->state['job_status'][ array_key_first( $this->state['jobs'] ) ]['forked'] = true;
			$this->dlog( 'Forking for vulnerability scan.' );
			$this->flog( 'Forking for vulnerability scan.' );
			$this->maybe_save_and_fork( 1 );
		}

		$this->trigger_vulnerability_scan();
	}

	/**
	 * Run the vulnerability scan and persist any findings as issues.
	 *
	 * Calls `$this->vulnerability_scan()` (implemented elsewhere in the codebase) and
	 * converts the returned structure into entries in the issues table via `insert_issue()`.
	 * Uses `issue_exists_by_infection_id()` to avoid duplicate inserts for the same
	 * signature during a scan.
	 *
	 * Better name: run_vulnerability_scan_and_record_issues()
	 *
	 * @return array Detected issues grouped by component (core/plugins/themes).
	 */
	function trigger_vulnerability_scan() {
		$issues  = $this->vulnerability_scan();
		$scan_id = ! empty( $this->state['identifier'] ) ? $this->state['identifier'] : '';

		if ( empty( $issues ) ) {
			$this->flog( 'INFO: No vulnerabilities found.' );
			return array();
		}

		if ( ! empty( $issues['core'] ) ) {
			foreach ( $issues['core'] as $name => $issue ) {
				if ( ! empty( $scan_id ) && $this->issue_exists_by_infection_id( $scan_id, 'vulnerability', $issue['signature'] ) ) {
					continue;
				}
				$vuln = array(
					'type'         => 'vulnerability',
					'severity'     => $issue['severity'],
					'infection_id' => $issue['signature'],
					'pointer'      => $name,
					'comment'      => $issue['message'],
				);
				$this->flog( $vuln );
				$this->insert_issue(
					$this->state['identifier'],
					$vuln,
				);
			}
		}

		if ( ! empty( $issues['plugins'] ) ) {
			foreach ( $issues['plugins'] as $slug => $issue ) {
				if ( ! empty( $scan_id ) && $this->issue_exists_by_infection_id( $scan_id, 'vulnerability', $issue['signature'] ) ) {
					continue;
				}
				$vuln = array(
					'type'         => 'vulnerability',
					'severity'     => $issue['severity'],
					'infection_id' => $issue['signature'],
					'pointer'      => $slug,
					'comment'      => $issue['message'],
				);
				$this->flog( $vuln );
				$this->insert_issue(
					$this->state['identifier'],
					$vuln
				);
			}
		}

		if ( ! empty( $issues['themes'] ) ) {
			foreach ( $issues['themes'] as $slug => $issue ) {
				if ( ! empty( $scan_id ) && $this->issue_exists_by_infection_id( $scan_id, 'vulnerability', $issue['signature'] ) ) {
					continue;
				}
				$vuln = array(
					'type'         => 'vulnerability',
					'severity'     => $issue['severity'],
					'infection_id' => $issue['signature'],
					'pointer'      => $slug,
					'comment'      => $issue['message'],
				);
				$this->flog( $vuln );
				$this->insert_issue(
					$this->state['identifier'],
					$vuln,
				);
			}
		}

		return $issues;
	}

	/**
	 * Stateful user account audit phase.
	 *
	 * Single-pass per-user iterator. Counts users upfront via a cheap primary-key
	 * index query (safe on 500K+ user tables), then processes one user per iteration
	 * with per-user forking to survive PHP execution time limits on any hosting.
	 *
	 * Resumability: current_id advances BEFORE scan_user() runs, so a timeout mid-user
	 * saves the last COMPLETED user ID. On resume the loop picks up
	 * cleanly — no duplicate insert_issue() calls.
	 *
	 * Each user is evaluated by scan_user() which handles the 5-factor detection,
	 * severity resolution, and insert_issue() persistence — already wired and proven
	 * from the stateless scanner and CLI paths.
	 *
	 * Better name: run_stateful_user_account_audit()
	 *
	 * @return void
	 */
	public function phase_userscan() {
		global $wpdb;

		// The stateful scanner may run multiple phases (vuln → users → db → checksums → files).
		// Each phase reads the current job key from the top of the queue; `trigger_job()` fires
		// the action hook that invokes this method, so `jobs[userscan]` is guaranteed to exist.
		$job_key = array_key_first( $this->state['jobs'] );

		// ── INITIALISATION (runs once; skipped on fork-resume) ──
		if ( ! isset( $this->state['job_status'][ $job_key ]['items'] ) ) {

			// COUNT(*) on InnoDB uses the PRIMARY KEY index — O(log n), not a table scan.
			// Safe on 500K+ user tables (sub-millisecond on any modern MySQL/MariaDB).
			// MIN/MAX are similarly instantaneous via the index extremities.
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scanner reads live DB truth, not object cache.
			$stats = $wpdb->get_row(
				"SELECT MIN(ID) AS min_id, MAX(ID) AS max_id, COUNT(*) AS total FROM {$wpdb->users}"
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

			// Bail cleanly on sites with no users. Set items=0 so job_status carries
			// a clean zero-count entry for the progress snapshot instead of a zombie row
			// with only the 'end' microtime that trigger_job() stamps afterward.
			if ( empty( $stats ) || empty( $stats->total ) ) {
				$this->state['job_status'][ $job_key ]['items'] = 0;
				$this->flog( 'INFO: No users found. Skipping user account audit.' );
				return;
			}

			// Persist the scan cursor inside $this->state — saved to DB on every fork.
			// `current_id` starts one before min_id so the first `++current_id` in the loop
			// lands on the first real user ID.
			$this->state['userscan'] = array(
				'min_id'     => (int) $stats->min_id,
				'max_id'     => (int) $stats->max_id,
				'total'      => (int) $stats->total,
				'current_id' => (int) $stats->min_id - 1,
			);

			// Initialise job_status for progress reporting (consumed by update_progress()
			// and surfaced to the browser via get_stateful_scan_status_payload()).
			$this->state['job_status'][ $job_key ]['items']   = 0;
			$this->state['job_status'][ $job_key ]['start']   = time();
			$this->state['job_status'][ $job_key ]['length']  = (int) $stats->total;
			$this->state['job_status'][ $job_key ]['task']    = 'User Accounts';
			$this->state['job_status'][ $job_key ]['message'] = '';

			$this->flog(
				'INFO: User account audit starting — ' . $stats->total
				. ' users (ID range ' . $stats->min_id . '–' . $stats->max_id . ').'
			);
		}

		// ── PER-USER ITERATION (resumable across forks) ──
		while ( $this->state['userscan']['current_id'] < $this->state['userscan']['max_id'] ) {

			// ── FORK CHECK (after work; fork-resume picks up at next unprocessed ID) ──
			$this->maybe_save_and_fork();

			// ── ADVANCE (before work; load-bearing for resumability) ──
			++$this->state['userscan']['current_id'];

			// ── GUARD: SKIP NON-EXISTENT IDs (sparse ranges) ──
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scan-correctness path; must read live DB truth.
			$user_exists = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->users} WHERE ID = %d",
					$this->state['userscan']['current_id']
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

			// ── DETECTION (5-factor engine, wired to insert_issue()) ──
			if ( $user_exists ) {
				$this->scan_user( (int) $this->state['userscan']['current_id'], $this->state['identifier'] );
				// Only real users advance the counter — ghost IDs don't trigger progress writes.
				++$this->state['job_status'][ $job_key ]['items'];
			}

			// ── PROGRESS REPORTING (throttled to every 10 users) ──
			if ( 0 === $this->state['job_status'][ $job_key ]['items'] % 10 ) {
				$elapsed = time() - $this->state['job_status'][ $job_key ]['start'];
				if ( $elapsed && ! empty( $this->state['job_status'][ $job_key ]['length'] ) ) {
					$this->state['job_status'][ $job_key ]['elapsed'] = $elapsed;

					$this->state['job_status'][ $job_key ]['percentage'] =
						( $this->state['job_status'][ $job_key ]['items']
							/ $this->state['job_status'][ $job_key ]['length'] )
						* 100;
					$this->state['job_status'][ $job_key ]['message']    =
						$this->state['job_status'][ $job_key ]['items'] . ' of '
						. $this->state['job_status'][ $job_key ]['length'] . ' user accounts audited in '
						. $this->human_readable_time_diff( $elapsed ) . '…';
					$this->flog( $this->state['job_status'][ $job_key ]['message'] );
					// Persist the progress snapshot to wp_options for the browser poll to read.
					$this->update_progress( $this->state['job_status'] );
				}
			}
		}

		$this->flog(
			'INFO: User account audit complete — '
			. $this->state['job_status'][ $job_key ]['items'] . ' users processed.'
		);
	}

	/**
	 * Signals a scan termination request by removing the socket file.
	 *
	 * This function initiates the termination of a running scan by:
	 * 1. Logging the kill request to the debug log
	 * 2. Retrieving the path to the socket file from settings
	 * 3. Deleting the socket setting from the database
	 * 4. Physically deleting the socket file from the filesystem
	 *
	 * The deletion of the socket file is the mechanism that allows other
	 * parallel PHP processes to detect that the scan should be terminated,
	 * through their calls to the needs_kill() function.
	 *
	 * Better name: request_scan_cancel()
	 *
	 * @return void
	 */
	function set_kill() {
		$this->dlog( 'Setting kill.' );
		$socket = $this->ss_get_setting( 'continue_socket' );
		$delete = $this->delete_socket( $socket );

		$this->ss_delete_setting( 'continue_socket' );
		$this->flog( 'INFO: Delete Socket Operation returned: ' . $delete );
		$this->flog( 'INFO: Scan will be killed if the cycle is still running: ' . $delete );
	}

	/**
	 * Determines if the current scan operation should be terminated.
	 *
	 * This function checks if a kill condition exists for the current scan by:
	 * 1. Verifying if a socket file setting exists in the configuration
	 * 2. Checking if the socket file physically exists on the filesystem
	 *
	 * The socket file acts as a communication point between scan processes, and its
	 * deletion serves as a signal to terminate the scan. This mechanism allows for
	 * graceful cancellation of long-running scans across multiple PHP executions.
	 *
	 * Important: this method uses a filesystem socket marker because option reads may be
	 * cached inconsistently during long-running processes.
	 *
	 * Better name: scan_cancel_requested()
	 *
	 * @return bool True if the scan should be terminated (killed), false if it should continue
	 */
	function needs_kill() {
		// IMPORTANT: This function is required as querying database is tricky synchronously.
		$needs_kill_total_start = microtime( 1 );

		$segment_start   = microtime( 1 );
		$scan_is_running = $this->is_scan_running();
		// $this->error_log( 'WPMR|needs_kill|is_scan_running|' . number_format( microtime( 1 ) - $segment_start, 6, '.', '' ) );

		if ( ! $scan_is_running ) {
			// $this->flog( 'INFO: Scan is not running. No need to kill.' . __LINE__ );
			// $this->error_log( 'WPMR|needs_kill|total|' . number_format( microtime( 1 ) - $needs_kill_total_start, 6, '.', '' ) );
			return false;
		}

		$segment_start  = microtime( 1 );
		$socket_setting = $this->ss_get_setting( 'continue_socket' );
		// $this->error_log( 'WPMR|needs_kill|get_setting.continue_socket|' . number_format( microtime( 1 ) - $segment_start, 6, '.', '' ) );
		if ( ! $socket_setting ) { // if the setting doesn't exist
			$segment_start   = microtime( 1 );
			$scan_identifier = $this->ss_get_setting( 'scan_handshake_key' ); // attempt to get the socket via scan identifier
			// $this->error_log( 'WPMR|needs_kill|get_setting.scan_handshake_key|' . number_format( microtime( 1 ) - $segment_start, 6, '.', '' ) );

			$segment_start = microtime( 1 );
			$socket_path   = trailingslashit( $this->get_socket_path() ) . $scan_identifier;
			// $this->error_log( 'WPMR|needs_kill|get_socket_path+build|' . number_format( microtime( 1 ) - $segment_start, 6, '.', '' ) );

			$segment_start = microtime( 1 );
			$exists        = file_exists( $socket_path );
			// $this->error_log( 'WPMR|needs_kill|file_exists.scan_identifier_socket|' . number_format( microtime( 1 ) - $segment_start, 6, '.', '' ) );

			if ( $exists ) {
				$this->flog( 'WARNING!!! Returning TRUE. Socket setting missing but scan still running. Needs Kill!' . __LINE__ );
				// $this->error_log( 'WPMR|needs_kill|total|' . number_format( microtime( 1 ) - $needs_kill_total_start, 6, '.', '' ) );
				return true; // if the socket file exists then we need to kill
			} else {
				$this->flog( 'Returning true. Socket setting missing and file does not exist. ' . __LINE__ );
				// $this->error_log( 'WPMR|needs_kill|total|' . number_format( microtime( 1 ) - $needs_kill_total_start, 6, '.', '' ) );
				return true;
			}

			$this->flog( 'Botchya!!! Returning FALSE. Socket setting missing and scan still running. ' . __LINE__ );
			// $this->error_log( 'WPMR|needs_kill|total|' . number_format( microtime( 1 ) - $needs_kill_total_start, 6, '.', '' ) );
			return false;
		}

		// Socket setting exists but file doesn't exist
		$segment_start = microtime( 1 );
		$exists        = file_exists( $socket_setting );
		// $this->error_log( 'WPMR|needs_kill|file_exists.continue_socket|' . number_format( microtime( 1 ) - $segment_start, 6, '.', '' ) );
		if ( ! $exists ) { // if the socket doesn't exist then we don't need to kill
			// $this->dlog( 'Continue socket does not exist. Needs Kill!' );
			$this->flog( 'Returning TRUE. Continue socket does not exist. Needs Kill or scan already over!' . __LINE__ );
			// $this->error_log( 'WPMR|needs_kill|total|' . number_format( microtime( 1 ) - $needs_kill_total_start, 6, '.', '' ) );
			return true;
		}
		// Socket setting exists and file exists - no need to kill
		// $this->flog( 'Socket setting exists and file exists - no need to kill' );
		// $this->error_log( 'WPMR|needs_kill|total|' . number_format( microtime( 1 ) - $needs_kill_total_start, 6, '.', '' ) );
		return false;
	}

	/**
	 * Checks if a kill request is active and terminates the scan process if necessary.
	 *
	 * This function verifies if a kill request has been issued by calling needs_kill().
	 * If a kill request is detected, it logs the termination, cleans up any scan routines,
	 * and immediately exits the PHP process with a status code of 0 to indicate
	 * successful but early termination.
	 *
	 * Better name: exit_if_cancel_requested()
	 *
	 * @return void This function will either exit the PHP process or continue execution.
	 */
	function maybe_kill() {
		$needs_kill = $this->needs_kill();

		if ( $needs_kill ) {
			$this->flog( '' );
			$this->flog( 'INFO: Killing scan operation as requested.' );
			$this->dlog( 'Cancelling scan operation as requested.' );
			$this->flog( '' );

			$this->flog( 'Updating scan_terminated setting.' );
			$update_setting_start = microtime( 1 );
			$this->ss_update_setting( 'scan_terminated', microtime( 1 ) );
			$this->error_log(
				'WPMR|maybe_kill|update_setting.scan_terminated|'
				. number_format( microtime( 1 ) - $update_setting_start, 6, '.', '' )
			);
			$get_setting_start = microtime( 1 );
			$this->flog( 'Result scan_terminated: ' . $this->debug_value_to_string( $this->ss_get_setting( 'scan_terminated' ) ) );
			$this->error_log(
				'WPMR|maybe_kill|get_setting.scan_terminated|'
				. number_format( microtime( 1 ) - $get_setting_start, 6, '.', '' )
			);

			$term_scan_routines_start = microtime( 1 );
			$this->term_scan_routines();
			$this->error_log(
				'WPMR|maybe_kill|term_scan_routines|'
				. number_format( microtime( 1 ) - $term_scan_routines_start, 6, '.', '' )
			);
			exit( 0 );
		}
	}

	/**
	 * Manages execution time limits by checking if scanner needs to save state and fork to a new process.
	 *
	 * This function prevents PHP timeout issues during long-running scans by:
	 * 1. Checking if a kill request is active and terminating if so
	 * 2. Calculating remaining execution time based on PHP's max_execution_time
	 * 3. Periodically sleeping to avoid server overload (every 10 cycles)
	 * 4. When remaining time is too low, saving the current state, generating a new token,
	 *    and forking the process to continue execution in a fresh PHP context
	 *
	 * The function handles the graceful transition between process iterations, ensuring
	 * continuity of the scanning operation across multiple execution cycles.
	 *
	 * Reference: https://www.phpinternalsbook.com/php7/extensions_design/php_lifecycle.html
	 *
	 * Better name: maybe_fork_scan_cycle()
	 *
	 * @param bool $force Whether to force a fork regardless of remaining time.
	 * @return void
	 */
	public function maybe_save_and_fork( $force = false ) {
		$this->maybe_kill(); // this never works due to db query caching / thus we need to check the socket file
		$this->time_sliced_sleep( $this->time_sleep ); // take a break using time-sliced approach
		$max_execution_time = $this->max_execution_time;
		$start              = $this->state['start'];
		$now                = time();
		$elapsed            = $now - $start;
		$remaining          = $this->get_cycle_remaining( $start );

		// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' ' . __FUNCTION__ . ' Max Time: ' . $max_execution_time . ' Now: ' . $now . ' since ' . $this->state['start'] . ' Elapsed: ' . $elapsed . ' Remaining time: ' . $remaining . ' Sleep: ' . $this->time_sleep . ' and buffer ' . $this->time_buffer );

		$memory_usage = memory_get_usage( true ) / 1024 / 1024; // MB
		if ( $memory_usage > ( $this->mem * 0.7 ) && gc_enabled() ) { // If using >70% of allowed memory
			$collected = gc_collect_cycles();
			$after     = memory_get_usage( true ) / 1024 / 1024; // MB
			$this->dlog( "Memory usage high: {$memory_usage} After GC: {$after} Collected: {$collected}", 2 );
			$this->flog( "WARNING: Memory usage high: {$memory_usage}- After GC: {$after}" );
		}

		if ( ! empty( $this->state['cycles'] ) && is_int( $this->state['cycles'] ) ) {
			if ( $this->state['cycles'] % 10 == 0 && ( $remaining - $this->time_buffer ) > 0 ) {
				$this->flog( '' );
				$this->dlog( 'Pausing for ' . ( $remaining - $this->time_buffer ) . ' seconds to reduce server load' );
				$this->flog( 'INFO: ' . $this->state['thread_id'] . ' I never get enough sleep… Sleeping for ' . ( $remaining - $this->time_buffer ) . ' seconds. Remaining ' . $remaining . ' Buffer ' . $this->time_buffer );
				sleep( $remaining - $this->time_buffer ); // sleep a decent bit every 10 cycles
				$this->flog( 'INFO: ' . $this->state['thread_id'] . ' Good Morning!' );
				$this->flog( '' );
			}
		} else {
			$this->state['cycles'] = 0;
		}

		if ( $remaining <= 0 || $force ) {
			$this->update_memory_usage();
			if ( preg_match( '/recovery_/', $this->state['thread_id'] ) ) {
				$this->flog( 'INFO: Resetting recovery attempts.' );
				$this->dlog( 'Resetting recovery attempts.' );
				// apparently we have recovered the scan and are good to fork and continue so reset the recovery attempts
				$this->ss_update_setting( 'wpmr_recovery_attempts', 3 );
			}
			++$this->state['cycles'];
			$continue_token                = number_format( microtime( 1 ), 6, '.', '' ); // float to string conversion is not reliable in PHP
			$this->state['continue_token'] = $continue_token;
			$old_thread_id                 = $this->state['thread_id'];
			$this->state['thread_id']      = explode( '.', $continue_token )[0];
			$this->save_state();
			$url = $this->get_continue_url( $continue_token, 'continue', 1 );
			// $url = admin_url( $url );
			// $this->flog( $this->state );
			$this->flog( 'INFO: ' . $old_thread_id . ' Forking into thread id: ' . $this->state['thread_id'] );
			$this->dlog( 'Will continue…' );
			$state = $this->state;
			wp_recursive_ksort( $state ); // ensure the state is sorted for consistency
			$this->flog( 'Forking state ' . json_encode( $state ) );
			// ensure that fork always works.
			$fork = @wp_remote_get(
				$url,
				array(
					// 'timeout'   => $this->time_buffer - 1,
					// 'timeout'     => 1, // take as long as needed but must
					'timeout'     => $this->max_execution_time,
					'blocking'    => false,
					'compress'    => false,
					'httpversion' => '1.1',
					'sslverify'   => false,
					'headers'     => array(
						'wpmr_fork'  => '1',
						'connection' => 'close',
					),
				)
			);

			$this->flog( 'INFO: ' . $old_thread_id . ' See you in the fork ' . $this->state['thread_id'] . ' 😇 This message typically shows after the new thread is already in progress 😇' );
			exit( 0 );
		} // remaining time check complete
		else {
		}

		// no-op
	}

	/**
	 * Installs the necessary database tables for the malware scanner plugin.
	 *
	 * This function creates three tables if they do not already exist:
	 *   - The origin checksum table: Stores original file paths, checksums, types, and versions.
	 *   - The scanned-files status table: Stores file paths, checksums, signature details, and JSON attributes.
	 *   - The issues table: Records issues found during scans with details about the issue.
	 *
	 * It retrieves the plugin version from the plugin data and falls back to a default version if none is found.
	 * The function applies the appropriate character set and collation settings from the WordPress database connection.
	 *
	 * The SQL definitions enforce a specific formatting requirement by ensuring that the PRIMARY KEY keyword
	 * contains exactly two spaces before the column definition.
	 *
	 * The WordPress core function dbDelta is used to safely create or update the tables.
	 *
	 * Better name: install_scanner_tables()
	 *
	 * Note: reads plugin version using the internal helper with translation disabled to avoid
	 * WordPress 6.7+ just-in-time textdomain loading notices during activation/upgrade windows.
	 *
	 * @global wpdb $wpdb The WordPress database abstraction object.
	 * @return void
	 */
	public function db_install() {
		global $wpdb;

		$charset_collate = $wpdb->get_charset_collate();

		// 1. Checksums (Origin)
		$sql_checksums = "CREATE TABLE IF NOT EXISTS {$this->table_checksums} (
			id INT(11) NOT NULL AUTO_INCREMENT,
			path VARCHAR(191) NOT NULL,
			checksum LONGTEXT NOT NULL,
			type LONGTEXT NOT NULL,
			version LONGTEXT NOT NULL,
			UNIQUE (path),
			PRIMARY KEY  (id)
		) $charset_collate;";

		// 2. Scanned Files Status (Generated)
		$sql_scanned_files = "CREATE TABLE IF NOT EXISTS {$this->table_scanned_files} (
			id INT(11) NOT NULL AUTO_INCREMENT,
			path VARCHAR(191) NOT NULL,
			checksum LONGTEXT NOT NULL,
			signature_id VARCHAR(32) NOT NULL,
			severity VARCHAR(32) NOT NULL,
			signature_version VARCHAR(32) NOT NULL,
			attributes LONGTEXT,
			UNIQUE (path),
			PRIMARY KEY  (id)
		) $charset_collate;";

		// 3. Issues
		$sql_issues = "CREATE TABLE IF NOT EXISTS {$this->table_issues} (
			id INT(11) NOT NULL AUTO_INCREMENT,
			scan_id LONGTEXT NOT NULL,
			issue_type LONGTEXT NOT NULL,
			severity LONGTEXT NOT NULL,
			details LONGTEXT NOT NULL,
			comment LONGTEXT NOT NULL,
			PRIMARY KEY  (id)
		) $charset_collate;";

		// 4. Logs
		$sql_logs = "CREATE TABLE IF NOT EXISTS {$this->table_logs} (
			id INT(11) NOT NULL AUTO_INCREMENT,
			message LONGTEXT NOT NULL,
			severity LONGTEXT NOT NULL,
			comment LONGTEXT NOT NULL,
			PRIMARY KEY  (id)
		) $charset_collate;";

		// 5. Events
		$sql_events = "CREATE TABLE IF NOT EXISTS {$this->table_events} (
			id INT(11) NOT NULL AUTO_INCREMENT,
			event_name VARCHAR(255) NOT NULL,
			event_data TEXT,
			user_id INT(11),
			ip_address VARCHAR(45),
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY event_name (event_name),
			KEY created_at (created_at),
			KEY user_id (user_id)
		) $charset_collate;";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql_checksums );
		dbDelta( $sql_scanned_files );
		dbDelta( $sql_issues );
		dbDelta( $sql_logs );
		dbDelta( $sql_events );

		$plugin_data = $this->get_plugin_data( WPMR_PLUGIN, false, false );
		$this->ss_update_setting( 'wpmr_db_version', $plugin_data['Version'] );
	}


	/**
	 * Upgrades the database tables if needed.
	 *
	 * This method retrieves the current database version and the plugin version,
	 * then compares them using version_compare().
	 *
	 * Note: current logic triggers an install when versions differ (not only when the
	 * DB version is older).
	 *
	 * Better name: maybe_upgrade_scanner_tables()
	 *
	 * Note: uses translation-disabled plugin header parsing to avoid WordPress 6.7+ JIT
	 * textdomain notices in early lifecycle phases.
	 *
	 * @return void
	 */
	function upgrade_tables( $source = '' ) {
		// WordPress auto-updater performs a post-update "scrape" request to verify the site loads.
		// Any fatal during that request triggers an automatic rollback. Never run `dbDelta()` here.
		if ( $this->is_wp_updater_scrape_request() ) {
			return;
		}

		$source = is_string( $source ) ? $source : '';
		if ( '' === $source ) {
			if ( defined( 'WP_CLI' ) && WP_CLI ) {
				$source = 'wp_cli';
			} elseif ( defined( 'DOING_CRON' ) && DOING_CRON ) {
				$source = 'cron';
			} elseif ( is_admin() ) {
				$source = 'admin';
			} else {
				$source = 'unknown';
			}
		}

		$db_version = $this->ss_get_setting( 'wpmr_db_version' );

		$plugin_version = $this->get_plugin_data( WPMR_PLUGIN, false, false );

		if ( ! empty( $plugin_version['Version'] ) ) {
			$plugin_version = $plugin_version['Version'];
		}

		if ( ! $db_version || version_compare( $db_version, $plugin_version, '<>' ) ) {
			$this->db_install();
		}

		// Record successful schema upgrade attempt for diagnostics/verification.
		$this->ss_update_setting(
			'wpmr_schema_upgrade_last_run',
			array(
				'time'       => function_exists( 'current_time' ) ? current_time( 'mysql' ) : gmdate( 'Y-m-d H:i:s' ),
				'source'     => $source,
				'is_admin'   => is_admin(),
				'doing_ajax' => function_exists( 'wp_doing_ajax' ) ? wp_doing_ajax() : false,
			)
		);
	}

	/**
	 * Check whether DB tables need upgrading and schedule the upgrade in a safe context.
	 *
	 * This is intentionally light-weight and safe to run on `plugins_loaded`.
	 *
	 * @return void
	 */
	public function maybe_schedule_tables_upgrade() {
		if ( $this->is_wp_updater_scrape_request() ) {
			return;
		}

		// WP-CLI is a safe context to run upgrades immediately.
		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			$this->upgrade_tables( 'wp_cli' );
			return;
		}

		$db_version = $this->ss_get_setting( 'wpmr_db_version' );
		$plugin     = $this->get_plugin_data( WPMR_PLUGIN, false, false );
		$version    = ( ! empty( $plugin['Version'] ) ) ? $plugin['Version'] : '';

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

		if ( ! $db_version || version_compare( $db_version, $version, '<>' ) ) {
			// Avoid heavy schema work on frontend. Schedule a single upgrade run.
			if ( ! is_admin() ) {
				$this->schedule_tables_upgrade_event();
			}
		}
	}

	/**
	 * Run table upgrades in wp-admin for privileged users.
	 *
	 * @return void
	 */
	public function maybe_run_tables_upgrade_admin() {
		if ( $this->is_wp_updater_scrape_request() ) {
			return;
		}
		if ( ! is_admin() ) {
			return;
		}
		if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
			return;
		}
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}

		$this->upgrade_tables( 'admin_init' );
	}

	/**
	 * Determine whether the current request is the WordPress updater verification "scrape".
	 *
	 * @return bool
	 */
	private function is_wp_updater_scrape_request() {
		// Updater scrape requests typically include these markers.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- This helper only detects core updater scrape markers and does not process submitted form data.
		return isset( $_GET['wp_scrape_key'] ) || isset( $_GET['wp_scrape_nonce'] );
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Schedule a one-off schema upgrade event.
	 *
	 * @return void
	 */
	private function schedule_tables_upgrade_event() {
		if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) {
			return;
		}
		if ( wp_next_scheduled( 'wpmr_run_schema_upgrade' ) ) {
			return;
		}

		$this->ss_update_setting(
			'wpmr_schema_upgrade_last_scheduled',
			array(
				'time'  => function_exists( 'current_time' ) ? current_time( 'mysql' ) : gmdate( 'Y-m-d H:i:s' ),
				'eta'   => function_exists( 'current_time' ) ? gmdate( 'Y-m-d H:i:s', time() + 60 ) : gmdate( 'Y-m-d H:i:s', time() + 60 ),
				'delay' => 60,
			)
		);

		wp_schedule_single_event( time() + 60, 'wpmr_run_schema_upgrade' );
	}

	/**
	 * Retrieves all malware issues detected during scans.
	 *
	 * This function queries the issues table and returns all records as an array.
	 * Each record contains information about a detected threat including its type,
	 * severity, and other details.
	 *
	 * Better name: list_scan_issues()
	 *
	 * @param int $limit Optional row limit. 0 = unlimited. When > 0, fetches $limit + 1 rows
	 *                   so callers can detect overflow (e.g. for threat summaries).
	 * @return array An array of issues, each represented as an associative array or empty array
	 */
	function get_issues( $limit = 0 ) {
		global $wpdb;

		$query = $wpdb->prepare( 'SELECT * FROM %i', $this->table_issues );

		if ( $limit > 0 ) {
			$query .= $wpdb->prepare( ' LIMIT %d', (int) ( $limit + 1 ) );
		}

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $query is assembled via $wpdb->prepare() above; issue views must read current persisted scan findings directly.
		$issues = $wpdb->get_results( $query, ARRAY_A );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return is_array( $issues ) ? $issues : array();
	}

	/**
	 * Count issues in the issues table.
	 *
	 * Used by notification email builders to determine whether a scan produced findings.
	 *
	 * @return int Number of issue rows, 0 when empty.
	 */
	function get_issue_count() {
		global $wpdb;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scan-correctness path must read current persisted issue truth.
		return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $this->table_issues ) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Check whether an issue already exists for a scan/type/infection ID triple.
	 *
	 * This prevents duplicate vulnerability records when multiple code paths
	 * attempt to persist the same issue during a single scan.
	 *
	 * Better name: issue_exists_for_scan()
	 *
	 * @param string|int $scan_id      Scan identifier (timestamp or state id).
	 * @param string     $issue_type   Issue type (e.g., vulnerability).
	 * @param string     $infection_id Signature or infection identifier.
	 * @return bool True when a matching issue already exists.
	 */
	function issue_exists_by_infection_id( $scan_id, $issue_type, $infection_id ) {
		global $wpdb;

		if ( empty( $scan_id ) || empty( $issue_type ) || empty( $infection_id ) ) {
			return false;
		}

		$needle = '"infection_id":"' . sanitize_text_field( $infection_id ) . '"';
		$like   = '%' . $wpdb->esc_like( $needle ) . '%';
		// The issues table name is plugin-owned internal state; runtime values are still prepared.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		$query = $wpdb->prepare(
			'SELECT COUNT(*) FROM ' . $this->table_issues . ' WHERE scan_id = %s AND issue_type = %s AND details LIKE %s',
			$scan_id,
			$issue_type,
			$like
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Issue existence checks must read current persisted issue truth.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Prepared above; only the plugin-owned issues table is interpolated.
		$result = ( (int) $wpdb->get_var( $query ) ) > 0;
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return $result;
	}

	/**
	 * Inserts a malware issue record into the issues table.
	 *
	 * Single persistence surface for every scan type — vulnerabilities, database
	 * infections, file infections, and user-account audit findings all funnel
	 * through this method. The issues table is the canonical record a scan
	 * leaves behind: the stateful UI, the email builder, and forensic review
	 * all read from it via get_issues() / get_stateful_scan_issues_payload().
	 *
	 * Better name: record_issue()
	 *
	 * ## Table schema — {$wpdb->prefix}wpmr_issues
	 *
	 * Five columns. Four are pulled directly out of $details by key; the fifth
	 * (id) is the auto-increment primary key consumed downstream as issue_id.
	 *
	 *   id          bigint    Primary key (PK), auto-increment. Surfaced as
	 *                        `issue_id` (intval) by
	 *                        get_stateful_scan_issues_payload(). Each row
	 *                        gets a unique numeric id that no other row
	 *                        shares — the database equivalent of a ticket
	 *                        number.
	 *   scan_id     varchar   Scan identifier. Stateless scans pass the
	 *                        $GLOBALS['WPMR']['timestamp'] string; stateful
	 *                        scans pass $this->state['identifier']. Used to
	 *                        scope a scan's findings and as the duplicate-key
	 *                        component in issue_exists_by_infection_id().
	 *   issue_type  varchar   Lowercase machine type tag. Stored from
	 *                        $details['type']. Controls which renderer branch
	 *                        the UI selects. Current values: 'vulnerability',
	 *                        'database', 'file', 'user'.
	 *   severity    varchar   Machine severity tag from $details['severity'].
	 *                        Doubles as the CSS class used on the rendered
	 *                        badge. Current values: 'severe', 'suspicious',
	 *                        'skipped' (user only), plus legacy 'vulnerable'
	 *                        (vulnerabilities) and 'unreadable' (files).
	 *   details     text      json_encode( $details ) — the WHOLE $details
	 *                        array, including keys this method does not pull
	 *                        out individually. Round-tripped by
	 *                        get_stateful_scan_issues_payload(), which decodes
	 *                        it and re-extracts the render-time keys below.
	 *   comment     text      json_encode( $details['comment'] ). Forensics-
	 *                        only payload, never read by the render path;
	 *                        intended for email/forensic review surfaces.
	 *
	 * ## $details contract — required keys
	 *
	 *   type         string  Required. See issue_type above.
	 *   severity     string  Required. See severity above.
	 *   infection_id string  Required. Stable identifier for this issue used
	 *                        for duplicate detection (issue_exists_by_infection_id
	 *                        CONCATENATES '"infection_id":"<value>"' and LIKE-
	 *                        matches inside the details JSON). Meaning is type-
	 *                        dependent — see per-type map below.
	 *   pointer      mixed   Required. CANONICAL LOCATOR FOR THE SOURCE OF THE
	 *                        ISSUE — the on-site coordinate an operator would
	 *                        act on to clean or resolve the finding. Shape is
	 *                        type-dependent: scalar string for file paths and
	 *                        component slugs, array{table,id} for database
	 *                        rows, integer user ID for user-account audit.
	 *                        See per-type map below. This field exists so any
	 *                        future UI or remediation action has one
	 *                        consistent field to reach for — "go to the thing
	 *                        that is wrong."
	 *   comment      array   Required. Forensic context. The 'message' subkey
	 *                        carries an HTML-safe one-liner describing the
	 *                        finding (sanitised downstream with wp_kses).
	 *
	 * ## $details contract — per-type field semantics
	 *
	 * The schema fields are type-agnostic, but what each one MEANS is type-
	 * specific. This is the contract — every call site must conform:
	 *
	 *   type = 'vulnerability'
	 *     infection_id  signature ID from the SaaS vuln database
	 *                  (e.g. '100VAP', 'wfFSAP'). Used in the malcure.com
	 *                  IOC lookup URL as ssig=<value>.
	 *     pointer      scalar string — component slug for plugins/themes
	 *                  (e.g. 'google-sitemap-generator') or core name (e.g.
	 *                  'WordPress'). Rendered as trailing context text.
	 *     comment      array with 'message' subkey (HTML safe).
	 *
	 *   type = 'database'
	 *     infection_id  DB malware signature ID (e.g. 'Ov3A1L', '5P456O').
	 *                  Used as ssig in the malcure.com IOC lookup URL.
	 *     pointer      array{ table: prefixed_table_label (string),
	 *                   id:    record_id (int) }. Rendered as
	 *                  "Table <x> ID <y>". NEVER a scalar — the array shape
	 *                  is what distinguishes database issues from other types
	 *                  inside normalize_stateful_issue_pointer() and the UI.
	 *     comment      array with 'message' subkey (HTML safe).
	 *
	 *   type = 'file'
	 *     infection_id  file malware signature ID (e.g. '5P456O'), or a
	 *                  heuristic sentinel.
	 *     pointer      scalar string — absolute file path, or directory
	 *                  path for the unreadable case. Rendered as trailing
	 *                  context text.
	 *     comment      array with 'message' subkey (HTML safe).
	 *
	 *   type = 'user'  (added in v19.9.4)
	 *     infection_id  synthetic key 'user_<ID>' (e.g. 'user_61'). Used
	 *                  ONLY for duplicate detection. MUST be a string —
	 *                  see DUPLICATE-DETECTION TRAP below.
	 *     pointer      integer user ID. Rendered as "User ID <n>" with a
	 *                  link to user-edit.php?user_id=<n>. NOTE: after
	 *                  normalize_stateful_issue_pointer() this survives as
	 *                  a string (e.g. '61'); the renderer parseInt's or
	 *                  template-strings it. Multisite support is deferred
	 *                  — single-site user-edit URL only for v19.9.4.
	 *     comment      array with 'message' subkey (HTML safe) plus the
	 *                  full machine-readable factor state (missing_email,
	 *                  has_app_passwords, app_password_count,
	 *                  has_elevated_caps, session_count, unique_ip_count,
	 *                  ip_spread_score). Same reason string the CLI
	 *                  already builds.
	 *
	 * ## Heuristic (non-signature) values
	 *
	 * These are the only non-signature values
	 * the server resolves to meaningful descriptions; anything else falls
	 * through to "Unknown Signature. Please update signatures."
	 *
	 *
	 *
	 *   mismatch   → "File checksum mismatch."
	 *   unknown    → "Unknown file found in WordPress core."
	 *   nochecksum → "No checksum found for file. Please register plugin
	 *                and update checksums."
	 *   unreadable → "Unreadable file found."
	 *   failure    → "Failure while scanning file."
	 *   '' (empty) → falls through to "Unknown Signature"
	 *
	 *
	 * ## DUPLICATE-DETECTION TRAP — infection_id MUST be a string
	 *
	 * issue_exists_by_infection_id() builds the literal needle
	 * `'"infection_id":"<value>"'` (note the quotes) and runs a LIKE
	 * match against the JSON in the details column. Whether the value
	 * ends up quoted in the JSON depends on its PHP type at json_encode()
	 * time:
	 *
	 *   'user_61'         → "infection_id":"user_61"   (matches, correct)
	 *   '61' (string)     → "infection_id":"61"        (matches)
	 *   61   (integer)    → "infection_id":61          (NO match — breaks)
	 *
	 * If anyone changes scan_user() to pass the bare integer user ID
	 * instead of the 'user_<ID>' string, every re-scan within the same
	 * run silently re-inserts duplicate user rows because the quoted LIKE
	 * needle never matches the unquoted integer in the encoded JSON. The
	 * 'user_' prefix is load-bearing for two reasons: it keeps the value
	 * string-typed (preserving the quoted match), and it namespaces user
	 * issues from any signature ID that happens to look numeric.
	 *
	 * ## Extraction for UI rendering
	 *
	 * The render path never touches the table directly. It reads via
	 * get_stateful_scan_issues_payload(), which decodes `details` and
	 * re-extracts FIVE keys into the JSON sent to the browser:
	 *
	 *   issue_id     ← id              (intval)
	 *   type         ← issue_type      (sanitize_text_field)
	 *   severity     ← severity        (sanitize_text_field)
	 *   infection_id ← details.infection_id (sanitize_text_field)
	 *   pointer      ← details.pointer      (normalize_stateful_issue_pointer)
	 *   scan_id      ← scan_id         (sanitize_text_field)
	 *
	 * normalize_stateful_issue_pointer() routes arrays → {table,id} and
	 * scalars → sanitize_text_field((string) value). User IDs inherit the
	 * scalar path; the function is type-agnostic and does not branch on
	 * issue_type.
	 *
	 * Everything else in $details — including the entire `comment` array —
	 * is discarded before transport. `comment` MUST NOT be relied upon by
	 * the UI render path; it is forensics-only.
	 *
	 * ## Duplicate detection
	 *
	 * issue_exists_by_infection_id( $scan_id, $issue_type, $infection_id )
	 * builds the literal needle `'"infection_id":"<sanitised>"'` and runs a
	 * LIKE match against the details column scoped by scan_id + issue_type.
	 * Every caller is responsible for calling it before insert_issue() to
	 * avoid duplicate rows for the same finding within a scan.
	 *
	 * @param mixed $scan_id The scan instance identifier (stateless timestamp
	 *                       string or stateful state identifier).
	 * @param array $details Associative array per the contract above. Must
	 *                       contain type, severity, infection_id, pointer,
	 *                       and comment keys.
	 * @return void
	 */
	function insert_issue( $scan_id, $details ) {
		global $wpdb;
		$table_name = $this->table_issues;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- Write path on plugin-owned issues table; table name is internal state.
		$inserts = $wpdb->insert(
			$table_name,
			array(
				'scan_id'    => $scan_id,
				'issue_type' => $details['type'],
				'severity'   => $details['severity'],
				'details'    => json_encode( $details ),
				'comment'    => json_encode( $details['comment'] ),
			),
			array( '%s', '%s', '%s', '%s', '%s' )
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
	}

	/**
	 * Updates the recorded peak memory usage for performance tracking.
	 *
	 * This method retrieves the current peak memory usage (in MB) using the PHP memory_get_peak_usage()
	 * function. If the current memory usage exceeds the previously stored memory usage in the state,
	 * it updates the state accordingly. Otherwise, if no memory usage is set, it initializes
	 * the value with the current memory usage.
	 *
	 * Better name: update_peak_memory_usage()
	 *
	 * @return void
	 */
	function update_memory_usage() {

		if ( empty( $this->state['performance']['memory'] ) ) {
			$this->state['performance']['memory'] = memory_get_peak_usage( 1 ) / 1024 / 1024;
		} else {
			$current = memory_get_peak_usage( 1 ) / 1024 / 1024;
			if ( $current > $this->state['performance']['memory'] ) {
				$this->state['performance']['memory'] = $current;
			}
		}
	}

	/**
	 * Saves the scan status of a file to the generated checksums table.
	 *
	 * This function records the results of a file scan, including:
	 * - Calculating the file's SHA-256 checksum
	 * - Recording infection details if malware was detected
	 * - Adding the issue to the issues table for reporting
	 * - Inserting or updating the record in the scanned-files status table
	 *
	 * If a malware signature is matched, the function saves details about the
	 * severity, signature ID, and additional attributes (JSON) including a hash of
	 * the signature for future verification.
	 *
	 * Better name: record_file_scan_result()
	 *
	 * @param string $scan_id  The ID of the current scan session
	 * @param string $file     The path to the scanned file
	 * @param string $sid      The signature ID if malware was detected
	 * @param array  $signature The signature details if malware was detected
	 * @param string $sver     The signature version used for scanning
	 * @param array  $attrib   Additional attributes to store with the record
	 * @return int|false Number of rows affected or false on error
	 */
	function save_file_status( $scan_id, $file, $sid, $signature, $sver, $attrib = array() ) {
		global $wpdb;

		// Table name
		$table_name = $this->table_scanned_files;

		$checksum = @hash_file( 'sha256', $file );
		if ( ! $checksum ) {
			$checksum = '';
		}
		$severity = '';
		if ( ! empty( $signature ) ) {
			$attrib['sig_hash'] = hash( 'sha256', $signature['signature'] );
			$severity           = $signature['severity'];

			$infection_details = array();

			$infection_details['type']     = 'file';
			$infection_details['severity'] = $signature['severity'];

			$infection_details['infection_id'] = $sid;
			$infection_details['pointer']      = $file;
			$infection_details['comment']      = array( 'message' => 'File <span class="filename">' . $file . '</span> has <span class="severity ' . $signature['severity'] . '">' . $signature['severity'] . '</span> infection.' );

			unset( $infection_details['signature'] );
			unset( $infection_details['class'] );

			$this->insert_issue(
				$scan_id,
				$infection_details
			);
		}

		// The scanned-files table name is plugin-owned internal state; runtime values are still prepared.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		$query = $wpdb->prepare( // Prepare the query
			'INSERT INTO %i (path, checksum, signature_id, severity, signature_version, attributes) VALUES (%s, %s, %s, %s, %s, %s)
				ON DUPLICATE KEY UPDATE checksum = VALUES(checksum), signature_id = VALUES(signature_id), severity = VALUES(severity), signature_version = VALUES(signature_version), attributes = VALUES(attributes)',
			$table_name,
			$file,
			$checksum,
			$sid,
			$severity,
			$sver,
			wp_json_encode( $attrib )
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persisting scanned-file status is a write path where caching is inapplicable.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Prepared above; only the plugin-owned scanned-files table is interpolated.
		$result = $wpdb->query( $query );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return $result;
	}

	/**
	 * Checks whether malware definitions require an update and performs the update if needed.
	 *
	 * This method uses $this->definition_updates_available() to verify if new definitions are available.
	 * If updates are available, it calls $this->update_definitions() to update them and returns the result.
	 * Otherwise, no action is taken.
	 *
	 * Better name: maybe_auto_update_definitions()
	 *
	 * @return mixed The result of the definition update if performed; otherwise, null.
	 */
	function maybe_update_definitions() {
		// $this->dlog( 'Attempting to update definitions.' );
		if ( $this->is_scan_running() ) {
			$this->dlog( 'Aborting definition update as scan is running.', 2 );
		}
		if ( $this->definition_updates_available() && $this->ss_get_setting( 'def_auto_update_enabled' ) ) {
			$this->dlog( 'New definitions available. Syncing.' );
			return $this->update_definitions_cli( true );
		} else {
		}
	}

	/**
	 * Retrieves malware database definitions.
	 *
	 * This function obtains the definitions payload via $this->get_definitions_data() and extracts
	 * the database definitions if available. It then iterates over each definition, appending the original
	 * key as an 'id' field within each sub-array, and collects these into an indexed array.
	 *
	 * Better name: get_db_malware_definitions()
	 *
	 * @return array An array of malware definitions with each definition augmented by an 'id' corresponding to
	 *               its original key. Returns an empty array if no database definitions are found.
	 */
	function get_malware_db_definitions() {
		$defs = $this->get_definitions_data();
		if ( ! empty( $defs['definitions']['db'] ) ) {
			$defs         = $defs['definitions']['db'];
			$indexed_defs = array();
			foreach ( $defs as $key => $value ) {
				$value['id']    = $key; // add the key as 'id' into the sub-array
				$indexed_defs[] = $value;
			}
			return $indexed_defs;
		} else {
			return array();
		}
	}

	/**
	 * Retrieves malware file definitions.
	 *
	 * This function calls the get_definitions_data() method to fetch the definitions payload.
	 * If the 'files' element exists and is non-empty within the 'definitions' key, it returns that array.
	 * Otherwise, it returns an empty array.
	 *
	 * Better name: get_file_malware_definitions()
	 *
	 * @return array The malware file definitions if available, or an empty array otherwise.
	 */
	function get_malware_file_definitions() {
		$defs = $this->get_definitions_data();
		if ( ! empty( $defs['definitions']['files'] ) ) {
			return $defs['definitions']['files'];
		} else {
			return array();
		}
	}

	/**
	 * Retrieves the definitions data for the malware scanner.
	 *
	 * This function obtains the definitions by calling the internal get_stateless_definitions() method.
	 * If the definitions are available in the expected array structure, it returns that payload.
	 * Otherwise, it returns an empty array.
	 *
	 * Better name: get_raw_definitions_payload()
	 *
	 * @return array The definitions array if available, otherwise an empty array.
	 */
	function get_definitions_data() {
		$definitions = $this->get_stateless_definitions();

		if ( ! empty( $definitions['definitions'] ) ) {
			return $definitions;
		} else {
			return array();
		}
	}

	/**
	 * Retrieves the malware signature defined in the malware database based on the provided index.
	 *
	 * This method fetches the complete list of malware database definitions and then returns
	 * the signature definition corresponding to the specified index. If a definition with the
	 * provided index exists in the database, it is returned; otherwise, the function returns null.
	 *
	 * Better name: get_db_definition_by_index()
	 *
	 * @param mixed $id The index or identifier used to look up the malware signature in the database.
	 *                  This can be an integer or a string depending on how signatures are indexed.
	 *
	 * @return mixed Returns the signature definition if found, or null if the index does not exist.
	 */
	function get_db_sig_by_index( $id ) {
		$defs = $this->get_malware_db_definitions();
		if ( ! empty( $defs[ $id ] ) ) {
			return $defs[ $id ];
		} else {
		}
	}

	/**
	 * Initialize the state required for the malware scanning process.
	 *
	 * This method sets up an initial state array containing configuration and runtime
	 * information needed during the scanning process, such as:
	 * - Job details (provided via the $jobs parameter).
	 * - Various counters and stacks used for directory scanning.
	 * - A unique identifier created using the current microtime.
	 * - Performance metrics (e.g., PHP memory limit).
	 * - The start time of the execution, based on the server's request time.
	 *
	 * Additionally, it conditionally assigns the host value based on whether the local host is supported.
	 *
	 * Better name: initialize_scan_state()
	 *
	 * @param array $jobs An associative array of job configurations to be processed.
	 *
	 * @return void
	 */
	function initialize_state( $jobs ) {
		$state = array(
			'jobs'        => array(),
			'total'       => 0,
			'cycles'      => 0,
			'dstack'      => array(),
			'dcounter'    => array(),
			'identifier'  => microtime( 1 ),
			'pad'         => 0,
			'recurring'   => 0,
			'start'       => isset( $_SERVER['REQUEST_TIME'] ) ? (int) sanitize_text_field( wp_unslash( $_SERVER['REQUEST_TIME'] ) ) : time(), // used to track max_execution_time
			'performance' => array(
				'memory_limit' => @ini_get( 'memory_limit' ),
			),
		);

		$state['socket']    = $this->create_socket( $state['identifier'] );
		$state['thread_id'] = $state['start']; // get the thread id from the continue token

		$this->flog( '$state socket' );
		$this->flog( $state['socket'] );

		$state['jobs']       = $jobs;
		$state['job_status'] = array_fill_keys( array_keys( $jobs ), array() );
		$this->state         = $state;
		$this->state         = apply_filters( 'wpmr_scanner_state', $this->state ); // Allow other plugins to modify the state

		// Store scan initiation source for notification dispatch gating.
		// Survives forks via state persistence; cleaned up atomically by clear_state().
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Caller (scan_operation_handler) verifies wpmr_nonce handshake before reaching initialize_state(); $_REQUEST['wpmr_scan_source'] is set by cron loopback body, not direct user input.
		$source = isset( $_REQUEST['wpmr_scan_source'] )
			? sanitize_text_field( wp_unslash( $_REQUEST['wpmr_scan_source'] ) )
			: 'browser';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		$this->state['wpmr_scan_meta'] = array(
			'source' => $source,
		);

		$this->flog( 'Initialized state to ' );
		$this->flog( $state );
		$this->backup_state();
	}

	/**
	 * Saves the current scanner state to persistent storage.
	 *
	 * This method saves the current state of the malware scanner to the database
	 * using the WordPress options API. The state is only saved if there is no
	 * kill request active, which helps prevent saving corrupted or incomplete state.
	 * If a kill request is active, the function logs this information but does not
	 * save the state.
	 *
	 * Better name: persist_scan_state()
	 *
	 * @return void
	 */
	private function save_state() {
		if ( ! $this->needs_kill() ) {
			$this->ss_update_option( 'scanner_state', $this->state );
		} else {
			$this->dlog( 'Kill set. Not saving state.' );
		}
	}

	/**
	 * Restore scanner state for a continued/forked scan.
	 *
	 * 1. Retrieves the current scanner state from persistent storage (unless `$state` provided).
	 * 2. Updates the state with the current request time.
	 * 3. Backs up the updated state to allow recovery if the scan stalls.
	 * 4. Clears the primary state to prevent repeated restoration by subsequent requests.
	 *
	 * Note: The order of these operations is crucial. The state must be backed up
	 * before it is cleared.
	 *
	 * Better name: restore_scan_state()
	 *
	 * @param array $state Optional state payload to restore instead of reading from storage.
	 * @return void
	 */
	function restore_state( $state = array() ) {
		if ( empty( $state ) ) {
			$state = $this->ss_get_option( 'scanner_state' );
		} else {
			$this->dlog( 'WARNING: Marking forced restoration!', 2 );
			$this->flog( 'WARNING: Marking forced restoration!' );
			$this->flog( $state );
		}

		$state['start'] = isset( $_SERVER['REQUEST_TIME'] ) ? (int) sanitize_text_field( wp_unslash( $_SERVER['REQUEST_TIME'] ) ) : time();
		// $state['thread_id'] = explode( '.', $state['continue_token'] )[0]; // get the thread id from the continue token
		// This sequence is important else backup will save an empty state.
		$this->state = $state; // Restore the state.
		$this->backup_state(); // Back it up for attempting continue just in case.
		$this->clear_state(); // clear the state so that no other request can restore it again
	}

	/**
	 * Determines whether a scanner state is present.
	 *
	 * This method checks if the scanner state, retrieved via the '$this->ss_get_option' method with the key 'scanner_state',
	 * is not empty. A non-empty state indicates that a scanner state exists, and thus the function returns true.
	 *
	 * Better name: has_persisted_scan_state()
	 *
	 * @return bool True if a scanner state exists, false otherwise.
	 */
	function has_state() {
		return ! empty( $this->ss_get_option( 'scanner_state' ) );
	}

	/**
	 * Get (and create if needed) the directory used for scan socket marker files.
	 *
	 * Socket marker files live under `wp_upload_dir()['basedir']/malcure/` and are used
	 * as a cross-request, cross-process coordination mechanism (continue/cancel signals).
	 *
	 * Better name: get_scan_socket_directory()
	 *
	 * @return string|false Absolute directory path on success; false on failure.
	 */
	function get_socket_path() {
		// Get WordPress uploads directory
		$uploads    = wp_upload_dir();
		$upload_dir = $uploads['basedir'];

		// Define the malcure directory path
		$malcure_dir = trailingslashit( trailingslashit( $upload_dir ) . 'malcure' );

		// Create malcure directory if it doesn't exist
		if ( ! file_exists( $malcure_dir ) ) {
			$dir_created = wp_mkdir_p( $malcure_dir );
			if ( ! $dir_created ) {
				$this->dlog( 'Failed to create malcure directory', 4 );
				return false;
			}
		}
		return $malcure_dir;
	}

	/**
	 * Creates a socket file for ongoing scan communication.
	 *
	 * This function creates an empty file in the WordPress uploads directory that serves
	 * as a socket or communication point for the scanning process. The file is used to:
	 * - Indicate that a scan is currently running
	 * - Provide a persistent marker that can be checked by different processes
	 * - Function as a kill switch mechanism when deleted
	 *
	 * The function creates a 'malcure' directory in the uploads folder if it doesn't exist,
	 * then creates an empty file named after the provided identifier.
	 *
	 * Better name: create_scan_socket_file()
	 *
	 * @param string $name A unique identifier (typically from microtime) to use as the filename
	 * @return string|bool The full path to the created socket file, or false if creation failed
	 */
	function create_socket( $name ) {
		$name = (string) $name;

		// Create a unique filename based on the provided name
		$filename    = sanitize_file_name( $name );
		$malcure_dir = $this->get_socket_path();
		$file_path   = trailingslashit( $malcure_dir ) . $filename;

		// Write the file
		$result = file_put_contents( $file_path, '' );

		if ( $result === false ) {
			$this->dlog( 'Failed to create socket file: ' . $file_path, 4 );
			return false;
		}

		// Return the full path to the file
		return $file_path;
	}

	/**
	 * Delete a scan socket marker file.
	 *
	 * Validates that the provided `$socket_path` is within the Malcure socket directory
	 * (as returned by `get_socket_path()`) before deleting.
	 *
	 * Better name: delete_scan_socket_file()
	 *
	 * @param string $socket_path Absolute path to the socket marker file.
	 * @return bool True when deleted; false when not deleted/invalid.
	 */
	function delete_socket( $socket_path = '' ) {
		$malcure_dir = $this->get_socket_path();

		// check if the socket_path is inside the malcure directory
		if ( empty( $socket_path ) ) {
			$this->dlog( 'No socket path provided.', 'w' );
			return false;
		}

		if ( strpos( $socket_path, $malcure_dir ) !== 0 ) {
			$this->dlog( 'Invalid socket path: ' . $socket_path, 'w' );
			return false;
		}

		// Delete the socket file
		if ( file_exists( $socket_path ) ) {
			return wp_delete_file( $socket_path );
		}
	}

	/**
	 * Backs up the scanner state to a persistent storage.
	 *
	 * This function saves the current scanner state as a backup that can be used
	 * for recovery purposes if needed. If a kill is requested, it logs a message
	 * and does not save the backup.
	 *
	 * Better name: persist_scan_state_backup()
	 *
	 * @return void
	 */
	private function backup_state() {
		if ( empty( $this->state ) ) {
			return;
		}
		if ( ! $this->needs_kill() ) {
			$this->state['state_saved'] = microtime( 1 ); // add a timestamp to the state
			if ( empty( $this->state['continue_token'] ) ) {
				$this->flog( 'WARNING: No continue token set. Need to set.' );
				$this->state['continue_token'] = number_format( microtime( 1 ), 6, '.', '' );
			}
			$this->ss_update_option( 'scanner_state_backup', $this->state );
		} else {
			$this->dlog( 'Kill set. Not saving state-backup.' );
		}
	}

	/**
	 * Clear scanner state and clean up stateful artifacts.
	 *
	 * State cleanup and email notification are now separate concerns.
	 * Email dispatch lives in send_scan_notification() (Step 7).
	 *
	 * @return void
	 */
	private function clear_state() {
		$identifier = false;
		if ( ! empty( $this->state['identifier'] ) ) {
			$identifier = $this->state['identifier']; // save the identifier for later
		}
		$this->ss_delete_option( 'scanner_state' );
		if ( ! $identifier ) {
			$this->dlog( 'No identifier to clear.' );
			$this->flog( 'WARNING: No identifier.', '', true );
			return;
		}
	}

	/**
	 * Performs a security handshake to authenticate scan operations.
	 *
	 * This function retrieves the scan handshake key from the system settings,
	 * which is a unique identifier generated during scan initialization. The key
	 * serves as a security token that validates legitimate scan requests and prevents
	 * unauthorized access to scanning functions.
	 *
	 * If no handshake key is found in the settings (indicating no active scan),
	 * the function terminates execution immediately with wp_die().
	 *
	 * Better name: require_scan_handshake_key()
	 *
	 * @return string The scan handshake key if it exists
	 */
	function do_scan_handshake() {
		$scan_handshake_key = $this->ss_get_setting( 'scan_handshake_key' );
		if ( empty( $scan_handshake_key ) ) {
			wp_die();
		}
		return $scan_handshake_key;
	}

	/**
	 * Prepares the environment for asynchronous operation during AJAX requests.
	 *
	 * This function sets up the necessary conditions for non-blocking AJAX processing:
	 * - Verifies the current request is an AJAX request, terminating if not
	 * - Prevents user aborts from stopping script execution
	 * - Sets appropriate headers to close the connection with the client
	 * - Disables search engine indexing of the response
	 *
	 * By closing the connection early while allowing script execution to continue,
	 * this function enables long-running scan operations to proceed in the background
	 * without causing timeouts for the user's browser.
	 *
	 * Better name: detach_ajax_response_and_continue()
	 *
	 * @return void
	 */
	function accept_async_handover() {
		if ( ! wp_doing_ajax() ) {
			wp_die();
		}

		ignore_user_abort( true );

		if ( session_id() ) {
			session_write_close();
		}

		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}

		if ( ! headers_sent() ) {
			// Ensure proper protocol/version is used
			if ( function_exists( 'php_sapi_name' ) && php_sapi_name() !== 'cgi-fcgi' ) {
				header( 'HTTP/1.1 204 No Content', true, 204 );
			} else {
				status_header( 204 );
			}
			header( 'X-Robots-Tag: noindex' );
			header( 'Content-Length: 0' );
			header( 'Connection: close' );
			header( 'Content-Type: text/html; charset=UTF-8' );
		}

		// todo: need to check if this content is allowed in a 204 response.
		// echo "\r\n\r\n"; // Ensures \r\n\r\n is properly formed
		// This guarantees proper header/body separation and ends output
		if ( function_exists( 'fastcgi_finish_request' ) ) {
			// $this->flog( 'Using fastcgi_finish_request() to close the connection.' );
			fastcgi_finish_request();
		} else {
			// $this->flog( 'Using flush() to close the connection.' );
			// Last resort: send empty body with proper termination

			// echo "\r\n\r\n"; // Ensures \r\n\r\n is properly formed
			flush();
		}

		// Background logic continues here
	}

	/**
	 * Calculates the size of a string in bytes.
	 *
	 * This function accurately determines the byte count of a string using wordwrap
	 * to break the string into individual bytes, then counts the number of breaks plus one
	 * to get the total byte count. This is more reliable than functions like strlen()
	 * which may not correctly count multibyte characters.
	 *
	 * Better name: get_string_size_bytes()
	 *
	 * @param string $str The string to measure
	 * @return int The size of the string in bytes
	 */
	function str_size_bytes( $str ) {
		// Use wordwrap to break the string into bytes
		$bytesArray = wordwrap( $str, 1, "\n", true );
		// Count the bytes
		$byteCount = substr_count( $bytesArray, "\n" ) + 1;
		return $byteCount;
	}

	/**
	 * Creates a secure nonce for AJAX operations with improved security.
	 *
	 * This function generates a cryptographically secure nonce by:
	 * 1. Getting the current timestamp for freshness
	 * 2. Generating a random 16-byte value as entropy
	 * 3. Building a secret using WordPress salts (preferring NONCE_SALT and NONCE_KEY)
	 * 4. Combining the action, timestamp, and random string
	 * 5. Creating the nonce using HMAC with SHA-256
	 *
	 * The generated nonce and its metadata are stored in the database for later
	 * verification, enabling secure AJAX communications in the scanner.
	 *
	 * Better name: create_scan_operation_nonce()
	 *
	 * @param string $action An optional action name to associate with this nonce
	 * @return string The generated nonce value
	 */
	function create_wpmr_nonce( $action = '' ) {
		// Get the current timestamp.
		$timestamp = microtime( true );

		// Generate a secure random value (32 hex characters = 16 bytes).
		$random = bin2hex( random_bytes( 16 ) );

		// Build the secret using WordPress salts.
		// Prefer NONCE_SALT and NONCE_KEY if defined.
		$secret = '';
		if ( defined( 'NONCE_SALT' ) ) {
			$secret .= NONCE_SALT;
		}
		if ( defined( 'NONCE_KEY' ) ) {
			$secret .= NONCE_KEY;
		}
		// Fallback to AUTH_KEY if no nonce salts are defined.
		if ( empty( $secret ) ) {
			$secret = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'default_secret';
		}

		// Combine the action, timestamp, and random string.
		$data = $action . '|' . $timestamp . '|' . $random;

		// Create the nonce using HMAC with SHA-256.
		$nonce = hash_hmac( 'sha256', $data, $secret );

		// Store the nonce details for later verification (including timestamp for potential expiry checks).
		$value = array(
			'action'    => $action,
			'timestamp' => $timestamp,
			'random'    => $random,
			'nonce'     => $nonce,
		);
		$this->ss_update_setting( 'wpmr_ajax_handshake', $value );

		return $nonce;
	}

	/**
	 * Verifies the validity of a Malcure Security Suite nonce.
	 *
	 * This function checks the provided nonce against a saved handshake value to authenticate
	 * AJAX requests. The verification process:
	 * 1. Retrieves the saved handshake details from settings
	 * 2. Validates that the saved handshake contains required data
	 * 3. Confirms the requested action matches the saved action
	 * 4. Recomputes the expected nonce using HMAC with SHA-256
	 * 5. Compares the provided nonce with the expected value using hash_equals()
	 * 6. Ensures the nonce hasn't expired (within 30 seconds)
	 *
	 * If verification succeeds, the handshake record is deleted and the function returns true.
	 *
	 * Better name: verify_scan_operation_nonce()
	 *
	 * @param string $nonce  The nonce to verify
	 * @param string $action The action associated with this nonce
	 * @return bool True if the nonce is valid, false otherwise
	 */
	function verify_wpmr_nonce( $nonce, $action = '' ) {
		// Retrieve the saved wpmr_ajax_handshake value
		$saved_handshake = $this->ss_get_setting( 'wpmr_ajax_handshake' );

		// Check if the saved wpmr_ajax_handshake is valid
		if ( ! isset( $saved_handshake['action'] ) ||
		! isset( $saved_handshake['timestamp'] ) ||
		! isset( $saved_handshake['nonce'] ) ||
		! isset( $saved_handshake['random'] )
		) {
			$this->flog( 'Invalid handshake saved. Verification failed.' );
			wp_die( -1, 418 ); // Return a 418 I'm a teapot error
			return false;
		}

		// Check if the action matches
		if ( $saved_handshake['action'] !== $action ) {
			$this->flog( 'Invalid Action. Verification failed.' . $this->debug_value_to_string( $saved_handshake ) . ' requested action ' . $action );
			wp_die( -1, 418 ); // Return a 418 I'm a teapot error
		}

		$secret = '';
		if ( defined( 'NONCE_SALT' ) ) {
			$secret .= NONCE_SALT;
		}
		if ( defined( 'NONCE_KEY' ) ) {
			$secret .= NONCE_KEY;
		}
		if ( empty( $secret ) ) {
			$secret = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'default_secret';
		}

		$data = $saved_handshake['action'] . '|' . $saved_handshake['timestamp'] . '|' . $saved_handshake['random'];

		// Recompute the nonce
		$expected_nonce = hash_hmac( 'sha256', $data, $secret );

		// Compare the provided nonce to the expected nonce
		if ( hash_equals( $expected_nonce, $nonce ) && ( microtime( 1 ) - $saved_handshake['timestamp'] ) < 30 ) {
			$this->ss_delete_setting( 'wpmr_ajax_handshake' );
			return true;
		} else {
			$this->flog( 'Nonce verification failed. Nonce does not match or has expired.' );
			wp_die( -1, 418 ); // Return a 418 I'm a teapot error
		}
		wp_die( -1, 418 ); // Return a 418 I'm a teapot error
	}

	/**
	 * Sets the mail content type for HTML emails.
	 *
	 * This function is intended to be used as a callback for WordPress filters,
	 * ensuring that the email content is set to 'text/html' so that HTML formatting
	 * is rendered correctly in email messages.
	 *
	 * Better name: get_html_mail_content_type()
	 *
	 * @return string The mail content type, which is 'text/html'.
	 */
	function set_mail_content_type() {
		return 'text/html';
	}

	/**
	 * Checks if a database table contains no records.
	 *
	 * This function queries the specified database table and determines if it's empty
	 * by counting the number of rows. It sanitizes the table name before executing the query
	 * to prevent SQL injection attacks.
	 *
	 * Better name: table_is_empty()
	 *
	 * @param string $table_name The name of the database table to check
	 * @return bool True if the table is empty (has no records), false otherwise
	 */
	function is_table_empty( $table_name ) {
		global $wpdb;
		$table_name = sanitize_text_field( $table_name ); // Sanitize the table name to ensure it's a valid SQL identifier
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Empty-table checks must read current row counts directly.
		$count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table_name ) ); // Query to count the number of rows in the table
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return $count == 0; // Check if the count is zero
	}

	/**
	 * Deliberately consumes CPU time by performing iterative arithmetic operations.
	 *
	 * This method updates an internal number using a formula involving the square root of 5 and the golden ratio,
	 * effectively simulating a CPU-intensive task without performing any meaningful computation.
	 * The loop runs until the remaining execution time is reduced to either the specified number of seconds or
	 * a defined time buffer, ensuring that the function respects the maximum execution time constraints.
	 *
	 * Better name: burn_cpu_until_time_remaining()
	 *
	 * @param int $seconds The threshold, in seconds, to determine when to terminate the loop based on remaining execution time.
	 * @return void
	 */
	function waste_time( $seconds ) {
		$start_time = time();

		$this->number = 1.61803398874989484820458683436563811772030917980576286213544862270526046281890;
		while ( true ) {
			$elapsed_time   = time() - $start_time;
			$remaining_time = $this->max_execution_time - $elapsed_time;
			$remaining_time = $this->max_execution_time - $elapsed_time;
			if ( $remaining_time <= $seconds || $remaining_time <= $this->time_buffer ) {
				break;
			}
			$this->number = $this->number * ( 1 + sqrt( 5 ) ) / 2; // Get the prime factors of the current number
		}
	}

	/**
	 * Encrypt data using XOR operation with a JSON-encoded string.
	 *
	 * This method first checks if the object's state contains a non-empty 'identifier' which is used as
	 * the key for the XOR operation. If the 'identifier' is empty, the method returns false.
	 *
	 * The data passed to this method is JSON encoded, then each character of the encoded string is XORed
	 * with the corresponding character in the key, cycling through the key as needed. After the XOR
	 * operation, the resulting output is first base64 encoded and then URL encoded.
	 *
	 * Better name: xor_obfuscate_marker_payload()
	 *
	 * @param mixed $data The data to be encrypted. It will be JSON encoded before applying the XOR operation.
	 * @return string|false Returns the URL-encoded string of the base64 encoded result, or false if the 'identifier' key in the object's state is empty.
	 */
	function exor( $data ) {
		if ( empty( $this->state['identifier'] ) ) {
			return false;
		}
		$key    = (string) $this->state['identifier'];
		$data   = json_encode( $data );
		$output = '';
		for ( $i = 0; $i <
		strlen( $data ); $i++ ) {
			$output .= $data[ $i ] ^
			$key[ $i %
				strlen( $key ) ];
		}
		return urlencode( base64_encode( $output ) );
	}

	/**
	 * Decodes and decrypts the provided encoded data.
	 *
	 * This function takes a URL-encoded, base64-encoded string and decodes it first.
	 * If decoding is successful, it then performs a bitwise XOR operation on each character
	 * using the provided key to decrypt the data. The decrypted string is then JSON-decoded.
	 *
	 * Better name: xor_deobfuscate_marker_payload()
	 *
	 * @param string $data The URL-encoded, base64-encoded string that needs to be decoded and decrypted.
	 * @param mixed  $key  The key used for the XOR decryption. It will be cast to a string.
	 *
	 * @return array|false Returns an associative array if decoding and decryption are successful,
	 *                     or false if any decoding step (base64 or JSON) fails.
	 */
	function dxor( $data, $key ) {
		$key  = (string) $key;
		$data = base64_decode( urldecode( $data ) );
		if ( empty( $data ) ) {
			return false; // base64_decode returns false in case of failure
		}
		$output = '';
		for ( $i = 0; $i < strlen( $data ); $i++ ) {
			$output .= $data[ $i ] ^
			$key[ $i %
				strlen( $key ) ];
		}
		$data = json_decode( $output, true );
		if ( empty( $data ) ) {
			return false; // json_decode returns null in case of failure
		}
		return $data;
	}

	/**
	 * Validates if the provided directory meets the scanning criteria.
	 *
	 * This method checks whether the given directory exists, is not a symbolic link,
	 * is readable, and optionally does not contain a ".mcignore" file (unless it's the site's root directory).
	 *
	 * The directory is considered valid if:
	 * - It is an actual directory (not a link) and is readable.
	 * - Either there is no ".mcignore" file present in the directory or the directory is the WordPress root (ABSPATH).
	 *
	 * Better name: is_scannable_directory()
	 *
	 * @param string $dir The directory path to validate.
	 * @return bool|null True if the directory is valid for scanning; otherwise null.
	 */
	function is_valid_dir_m( $dir ) {

		if ( ( is_dir( $dir ) && ! is_link( $dir ) ) && ( ! file_exists( trailingslashit( $dir ) . '.mcignore' ) || $dir == untrailingslashit( ABSPATH ) ) ) {
			return true;
		} else {
			// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' Dir: ' . $dir . ' is not a valid directory for scan.' );
		}
	}

	/**
	 * Validates whether a given file meets the required criteria.
	 *
	 * This method checks that the file:
	 * - Exists and is a regular file (not a directory).
	 * - Is not a symbolic link.
	 * - Is readable.
	 * - Has a non-zero size.
	 * - Does not exceed the maximum allowed file size specified by $this->maxsize.
	 *
	 * Better name: is_scannable_file()
	 *
	 * @param string $file The full path to the file to validate.
	 * @return bool|null Returns true if the file passes all checks; otherwise null.
	 */
	function is_valid_file_m( $file ) {
		$reasons = array();

		if ( ! is_file( $file ) ) {
			$reasons[] = 'not a regular file';
		}

		if ( is_link( $file ) ) {
			$reasons[] = 'symlink';
		}

		$file_size = null;

		if ( empty( $reasons ) ) {
			$file_size = filesize( $file );

			if ( false === $file_size ) {
				$reasons[] = 'filesize() failed';
			} elseif ( 0 === $file_size ) {
				$reasons[] = 'empty file';
			} elseif ( $file_size >= $this->maxsize ) {
				$reasons[] = 'file size ' . $file_size . ' exceeds or equals max size ' . $this->maxsize;
			}
		}

		if ( empty( $reasons ) ) {
			return true;
		}

		// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' File: ' . $file . ' is not a valid file for scan. Reasons: ' . implode( '; ', $reasons ) );
	}

	/**
	 * Determines if the cycle time has been exceeded since the provided timestamp.
	 *
	 * This function calculates the elapsed time from the given start time and compares
	 * it to the allowed cycle duration obtained via the get_cycle_time() method.
	 *
	 * Better name: has_cycle_time_exceeded_since()
	 *
	 * @param int $time The start time as a Unix timestamp.
	 * @return bool Returns true if the elapsed time exceeds the cycle time, false otherwise.
	 */
	function cycle_exceeded_since( $time ) {
		$now        = time();
		$elapsed    = $now - $time;
		$cycle_time = $this->get_cycle_time();
		if ( $elapsed > $cycle_time ) {
			return true;
		}
		return false;
	}

	/**
	 * Get remaining wall-clock seconds for the current execution cycle.
	 *
	 * Better name: get_cycle_seconds_remaining()
	 *
	 * @param int $since Unix timestamp representing cycle start.
	 * @return int Remaining seconds (may be negative).
	 */
	function get_cycle_remaining( $since ) {
		$now        = time();
		$elapsed    = $now - $since;
		$cycle_time = $this->get_cycle_time(); // ( $this->max_execution_time - $elapsed );
		$remaining  = $cycle_time - $elapsed;
		return $remaining;
	}

	/**
	 * Get the maximum wall-clock seconds allowed for a single scan cycle.
	 *
	 * Computed as `$this->max_execution_time - $this->time_buffer - $this->time_sleep`.
	 *
	 * Better name: get_cycle_time_seconds()
	 *
	 * @return int
	 */
	function get_cycle_time() {
		// $this->flog( 'INFO: Max execution time: ' . $this->max_execution_time );
		return $this->max_execution_time - $this->time_buffer - $this->time_sleep;
	}

	/**
	 * Implements a time-sliced sleep that behaves more like a background process
	 *
	 * This method breaks down a longer sleep period into multiple smaller sleeps
	 * which allows the process to "yield" regularly using usleep() for microsecond precision,
	 * helps the scanner behave more like a background process.
	 *
	 * Better name: sleep_in_time_slices()
	 *
	 * @param int $time_sleep The total sleep budget in seconds for the current PHP cycle
	 * @return void
	 */
	function time_sliced_sleep( $time_sleep ) {
		// Only sleep if we haven't already reached the total budget
		if ( $this->time_slept_count < $time_sleep ) {
			$remaining = $time_sleep - $this->time_slept_count;
			// Sleep for either one full slice or whatever remains
			$slice = min( $this->time_slice, $remaining );

			// Convert to microseconds
			usleep( (int) ( $slice * 1000000 ) );

			// Update how much we've slept so far
			$this->time_slept_count += $slice;
			// $this->flog( 'INFO: ' . $this->state['thread_id'] . ' Slept for ' . $slice . ' seconds. Total slept count: ' . $this->time_slept_count . ' seconds.' );
		}
	}

	/**
	 * Sends an HTTP request to scan a specific URL for malware threats.
	 *
	 * This function makes an HTTP request to the specified URL, which typically
	 * points to an AJAX endpoint responsible for scanning a specific file or database record.
	 * The request is configured with:
	 * - Appropriate timeout value based on the scanner's time buffer
	 * - HTTP headers to identify the request as part of the malware scanning process
	 * - WordPress's built-in wp_remote_get() function for making HTTP requests
	 *
	 * The function also tracks the time taken to complete the request for performance analysis.
	 *
	 * Better name: http_get_scan_request()
	 *
	 * @param string $url The URL to send the request to
	 * @return array|WP_Error The response from the request or WP_Error on failure
	 */
	function scan_request( $url ) {
		$startTime = microtime( 1 );
		$remaining = min( $this->time_buffer - 1, $this->get_cycle_remaining( $this->state['start'] ) - 1 );
		$response  = wp_remote_get(
			$url,
			array(
				'timeout'     => $this->time_buffer - 1, // That's what the buffer is for
				'blocking'    => true, // We need this to prevent bombarding the server with requests. Slow and steady wins the race. Handle the error if required.
				'compress'    => false,
				'httpversion' => '1.1',
				'sslverify'   => false,
				'headers'     => array(
					'wpmr_scan' => '1',
				),
			)
		);

		$endTime     = microtime( 1 );
		$elapsedTime = $endTime - $startTime;
		return $response;
	}

	/**
	 * Test function for demonstrating malware signature detection in a sample file.
	 *
	 * This function serves as a test or demonstration tool to show how malware signatures
	 * are matched against file contents. It reads a specific file, retrieves a malware
	 * definition, decodes the signature pattern, and tests whether the file content
	 * matches the signature using regular expression matching.
	 *
	 * The results of the match operation, including any found content, are logged for inspection.
	 * This is primarily intended for development and testing purposes.
	 *
	 * Better name: dev_custom_signature_match_test()
	 *
	 * @return void
	 */
	function custom_match() {
		$file          = '/_extvol_data/html/dev/plugindev/wp/h.txt';
		$file_contents = file_get_contents( $file );
		$definition    = $this->get_definitions_data();
		$definition    = $definition['definitions']['files']['ZOA6CL'];
		$signature     = $this->decode( $definition['signature'] );
		$this->llog( $signature );
		$matches = preg_match( $signature, $file_contents, $found );

		$this->llog( $matches );
		$this->llog( $found );
	}

	/**
	 * Development-only placeholder for local testing.
	 *
	 * Better name: dev_tests_placeholder() (or remove in production builds)
	 *
	 * @return void
	 */
	function tests() {
	}
}
