<?php

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

trait WPMR_Scanner {

	/**
	 * Wilson score threshold for session IP-spread anomaly detection.
	 *
	 * `scan_user()` scores each user's session IP spread as the Wilson score interval
	 * lower bound of (unique_buckets - 1, sessions - 1). The first session/bucket is
	 * discounted — a single-session user can never trigger an anomaly. Scores at or
	 * above this threshold indicate "most sessions originate from new networks" at 95%
	 * confidence and flag the user as live-compromise severe.
	 *
	 * Hardcoded, not configurable. Ship the answer. See non-versioned/plans/user-scan.md
	 * `## Session IP spread scoring` for the full rationale and the worked-examples table.
	 *
	 * PHP 8.2+ supports const in traits; resolved as self::IP_SPREAD_THRESHOLD within the
	 * trait and $this::IP_SPREAD_THRESHOLD / self::IP_SPREAD_THRESHOLD from the consuming
	 * class since all traits resolve to the same class at runtime.
	 */
	const IP_SPREAD_THRESHOLD = 0.5;

	/**
	 * Caps and role slugs that are safe below the threat surface.
	 *
	 * Factor 4 is exclusion-based: anything NOT in this list, when present and truthy
	 * in the {prefix}capabilities usermeta, flags the user as exceeding the Contributor
	 * baseline. This intentionally catches bundled-plugin caps (manage_woocommerce,
	 * elementor editing, membership access, etc.) without us having to enumerate them.
	 *
	 * Contents (derived from populate_roles_160/210 in wp-admin/includes/schema.php):
	 *   - 'subscriber', 'contributor': role slugs whose inheritance trees stay below publish.
	 *     WordPress stores roles in usermeta as a single slug=>true entry, not expanded caps.
	 *   - 'read': Subscriber and Contributor cap.
	 *   - 'edit_posts', 'delete_posts': Contributor caps (write/manage own drafts, delete own drafts).
	 *   - 'level_0' .. 'level_10': deprecated user-level system, vestigial on every account.
	 *
	 * Note: anything beyond this (publish_posts, upload_files, manage_options, edit_themes,
	 * and every custom/bundled-plugin cap) flags. That is the intent. A wider net is cheaper
	 * to maintain than chasing every plugin-specific capability, and rule 8 of the severity
	 * chain (which hides caps-only users with complete identity) prevents false positives
	 * on clean accounts.
	 */
	const SAFE_CAPS = array(
		// Stock role slugs at/below Contributor.
		'subscriber',
		'contributor',
		// Contributor + Subscriber exact cap set.
		'read',
		'edit_posts',
		'delete_posts',
		// Deprecated user_level system — still attached to every account by WP core.
		'level_0',
		'level_1',
		'level_2',
		'level_3',
		'level_4',
		'level_5',
		'level_6',
		'level_7',
		'level_8',
		'level_9',
		'level_10',
	);

	/**
	 * Normalize and persist scan runtime arguments into the global WPMR scan context.
	 *
	 * Builds/overwrites $GLOBALS['WPMR'] with scan flags and parameters, sourced from:
	 * - WP-CLI args when running under WP-CLI
	 * - Request parameters (typically via authenticated AJAX) in web context
	 *
	 * Forces suspicious-mode scanning when the site is not registered.
	 *
	 * Better name: init_scan_context()
	 *
	 * @param array $args Optional scan arguments (primarily for WP-CLI). Expected keys include:
	 *                    do_vuln_scan, do_db_scan, do_file_scan, do_redirect_scan,
	 *                    mcsuspicious, mcskipdirs, mcregex, mcdbquery, mcdbregex,
	 *                    mcfiles, mcscanonlydirs, timestamp, mcdebug.
	 * @return array<string, mixed> The populated $GLOBALS['WPMR'] scan context.
	 */
	function set_args( $args = array() ) {
		$cli             = $this->wpmr_iscli();
		$GLOBALS['WPMR'] = array();

		// Non-CLI callers enter this shared helper only through nonce-verified scan endpoints.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- WPCS cannot follow the verified caller contract into this shared request-normalization helper.
		$GLOBALS['WPMR'] = wp_parse_args(
			$GLOBALS['WPMR'],
			array(
				'do_vuln_scan'        => $cli ? $args['do_vuln_scan'] : ( ! empty( $_REQUEST['do_vuln_scan'] ) ? $this->mc_get_bool( sanitize_text_field( wp_unslash( $_REQUEST['do_vuln_scan'] ) ) ) : false ),
				'do_db_scan'          => $cli ? $args['do_db_scan'] : ( ! empty( $_REQUEST['do_db_scan'] ) ? $this->mc_get_bool( sanitize_text_field( wp_unslash( $_REQUEST['do_db_scan'] ) ) ) : false ),
				'do_file_scan'        => $cli ? $args['do_file_scan'] : ( ! empty( $_REQUEST['do_file_scan'] ) ? $this->mc_get_bool( sanitize_text_field( wp_unslash( $_REQUEST['do_file_scan'] ) ) ) : false ),
				'do_redirect_scan'    => $cli ? $args['do_redirect_scan'] : ( ! empty( $_REQUEST['do_redirect_scan'] ) ? $this->mc_get_bool( sanitize_text_field( wp_unslash( $_REQUEST['do_redirect_scan'] ) ) ) : false ),
				'do_user_scan'        => $cli ? ( isset( $args['do_user_scan'] ) ? $args['do_user_scan'] : true ) : ( ! empty( $_REQUEST['do_user_scan'] ) ? $this->mc_get_bool( sanitize_text_field( wp_unslash( $_REQUEST['do_user_scan'] ) ) ) : true ),
				'suspicious'          => $cli ? $args['mcsuspicious'] : ( ! empty( $_REQUEST['suspicious'] ) ? $this->mc_get_bool( sanitize_text_field( wp_unslash( $_REQUEST['suspicious'] ) ) ) : false ),
				'skipdirs'            => $cli ? ( ! empty( $args['mcskipdirs'] ) ? $args['mcskipdirs'] : null ) : ( ! empty( $_REQUEST['skipdirs'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['skipdirs'] ) ) : null ),
				'regex'               => $cli ? ( ! empty( $args['mcregex'] ) ? $this->encode( $args['mcregex'] ) : null ) : ( ! empty( $_REQUEST['regex'] ) ? $this->process_regex_input( sanitize_text_field( wp_unslash( $_REQUEST['regex'] ) ) ) : false ),
				'wpmr_extra_db_query' => $cli ? ( ! empty( $args['mcdbquery'] ) ? $this->encode( $args['mcdbquery'] ) : null ) : ( ! empty( $_REQUEST['wpmr_extra_db_query'] ) ? $this->process_base64_input( sanitize_text_field( wp_unslash( $_REQUEST['wpmr_extra_db_query'] ) ) ) : false ),
				'wpmr_extra_db_regex' => $cli ? $this->process_cli_regex_argument( $args, 'mcdbregex', '--mcdbregex' ) : ( ! empty( $_REQUEST['wpmr_extra_db_regex'] ) ? $this->process_regex_input( sanitize_text_field( wp_unslash( $_REQUEST['wpmr_extra_db_regex'] ) ) ) : false ),
				// Targeted file scans intentionally accept operator-supplied paths, including
				// paths outside the WordPress tree, so advanced troubleshooting can inspect
				// suspected malware that may live above or beside the install root.
				'files'               => $cli ?
						( ! empty( $args['mcfiles'] ) ? $args['mcfiles'] : null )
					: ( ! empty( $_REQUEST['files'] ) ? array_map(
						array( $this, 'decode_filename' ),
						array_map( 'sanitize_text_field', wp_unslash( $_REQUEST['files'] ) )
					) : null ),
				'only_scan_dirs'      => $cli ? ( ! empty( $args['mcscanonlydirs'] ) ? $args['mcscanonlydirs'] : null ) : ( ! empty( $_REQUEST['wpmr_scan_only_dirs'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['wpmr_scan_only_dirs'] ) ) : null ),

				'timestamp'           => $cli ? $args['timestamp'] : ( ! empty( $_REQUEST['timestamp'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['timestamp'] ) ) : false ),
				'debug'               => $cli ? ( ! empty( $args['mcdebug'] ) && ( $args['mcdebug'] == 'true' ) ? true : false ) : ( isset( $_REQUEST['debug'] ) ? true : false ),
			)
		);
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		if ( ! $this->is_registered() ) {
			$GLOBALS['WPMR']['suspicious'] = true;
		}

		$GLOBALS['WPMR']['home_dir'] = $this->get_home_dir();
		return $GLOBALS['WPMR'];
	}

	/**
	 * Determine whether scan instrumentation should run for this request.
	 *
	 * Performance tracing is debug-only. Skip the bookkeeping on normal scans.
	 *
	 * @return bool True when scan instrumentation is enabled.
	 */
	private function should_log_scan_instrumentation() {
		return;
		return defined( 'WP_DEBUG' ) && WP_DEBUG;
	}

	/**
	 * Write a compact, timestamped performance entry for scan pipeline tracing.
	 *
	 * Used to correlate browser-driven scan phases and batch timings in `log.log`
	 * when debug instrumentation is enabled, without changing the execution model.
	 *
	 * @param string $caller  Function name, typically `__FUNCTION__`.
	 * @param string $stage   Stage label such as `start` or `end`.
	 * @param array  $metrics Structured metrics to include in the log entry.
	 * @return void
	 */
	private function log_scan_perf( $caller, $stage, $metrics = array() ) {
		if ( ! $this->should_log_scan_instrumentation() ) {
			return;
		}

		$payload = array_merge(
			array(
				'stage'   => $stage,
				'context' => $this->wpmr_iscli() ? 'cli' : ( wp_doing_ajax() ? 'ajax' : 'web' ),
			),
			$metrics
		);

		array_walk_recursive(
			$payload,
			function ( &$value ) {
				if ( is_float( $value ) ) {
					$value = round( $value, 4 );
				}
			}
		);

		$this->flog( $caller . ' ' . wp_json_encode( $payload ), false, true );
	}

	/**
	 * Build the scan "bootstrap" payload (file list + metadata) and optionally kick off sub-scans.
	 *
	 * This is used by the UI init flow (AJAX) and by WP-CLI. It:
	 * - Raises resource limits
	 * - Initializes scan context ($GLOBALS['WPMR'])
	 * - Builds a file list across core + custom content/plugin/theme/upload dirs
	 * - Applies prioritisation and optional directory allow-listing
	 * - Optionally triggers DB / redirect / vuln scans when init nonce is present or in WP-CLI
	 * - Fires `wpmr_scan_init` action with the scan context
	 *
	 * Better name: build_scan_bootstrap_payload()
	 *
	 * @param array $args Optional scan arguments (primarily for WP-CLI).
	 * @return array<string, mixed> Bootstrap response payload.
	 */
	function bootstrap( $args = array() ) {
		$this->raise_limits_conditionally();
		$this->set_args( $args );
		$bootstrap_start = microtime( true );
		// Bootstrap runs only through verified scan/stat AJAX endpoints or via WP-CLI.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- These request reads are gated by verified callers; the init nonce below only controls optional sub-scans.
		$has_init_nonce = ! empty( $_REQUEST['wpmr_init_nonce'] );
		$scan_type      = isset( $_REQUEST['scan_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['scan_type'] ) ) : '';
		$batch_number   = isset( $_REQUEST['batch'] ) ? intval( $_REQUEST['batch'] ) : 0;
		$scan_mode      = isset( $_REQUEST['scan_mode'] ) ? sanitize_key( wp_unslash( $_REQUEST['scan_mode'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $GLOBALS['WPMR']['skipdirs'] ) ) {
			$skipdirs                    = array_map( 'trim', explode( ',', $GLOBALS['WPMR']['skipdirs'] ) );
			$GLOBALS['WPMR']['skipdirs'] = $skipdirs;
		} else {
			$GLOBALS['WPMR']['skipdirs'] = array();
		}
		if ( ! empty( $GLOBALS['WPMR']['only_scan_dirs'] ) ) {
			$only_scan_dirs                    = array_map( 'trim', explode( ',', $GLOBALS['WPMR']['only_scan_dirs'] ) );
			$only_scan_dirs                    = array_map( 'untrailingslashit', $only_scan_dirs );
			$only_scan_dirs                    = array_map( array( $this, 'unleadingslashit' ), $only_scan_dirs );
			$only_scan_dirs                    = array_map(
				function ( $k ) {
					return $this->normalise_path( untrailingslashit( ABSPATH ) . DIRECTORY_SEPARATOR . $k );
				},
				$only_scan_dirs
			);
			$GLOBALS['WPMR']['only_scan_dirs'] = $only_scan_dirs;
		} else {
			$GLOBALS['WPMR']['only_scan_dirs'] = array();
		}

		$log_perf = $this->should_log_scan_instrumentation() && ( $has_init_nonce || $this->wpmr_iscli() );
		if ( $log_perf ) {
			$this->log_scan_perf(
				__FUNCTION__,
				'start',
				array(
					'scan_timestamp'       => ! empty( $GLOBALS['WPMR']['timestamp'] ) ? (string) $GLOBALS['WPMR']['timestamp'] : '',
					'do_vuln_scan'         => ! empty( $GLOBALS['WPMR']['do_vuln_scan'] ),
					'do_db_scan'           => ! empty( $GLOBALS['WPMR']['do_db_scan'] ),
					'do_file_scan'         => ! empty( $GLOBALS['WPMR']['do_file_scan'] ),
					'do_redirect_scan'     => ! empty( $GLOBALS['WPMR']['do_redirect_scan'] ),
					'skipdirs_count'       => count( $GLOBALS['WPMR']['skipdirs'] ),
					'only_scan_dirs_count' => count( $GLOBALS['WPMR']['only_scan_dirs'] ),
				)
			);
		}

		$file_discovery_start = microtime( true );
		$all_files            = $this->return_all_files();

		// Check if there's a custom WP_CONTENT_DIR
		if ( ! file_exists( trailingslashit( $this->normalise_path( ABSPATH ) ) . 'wp-content' ) ) {
			$wp_content = $this->return_all_files( WP_CONTENT_DIR );
			if ( ! is_array( $wp_content ) ) { // in case WP_CONTENT_DIR is .ignored
				$wp_content = array();
			}
			$all_files = array_merge( $all_files, $wp_content );

		}

		// Check if there's a custom plugin directory
		if ( ! file_exists( trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'plugins' ) ) {
			$wp_content_plugins = $this->return_all_files( WP_PLUGIN_DIR );
			if ( ! is_array( $wp_content_plugins ) ) { // in case WP_PLUGIN_DIR is .ignored
				$wp_content_plugins = array();
			}
			$all_files = array_merge( $all_files, $wp_content_plugins );
		}

		// check if there's a custom themes directory
		if ( ! file_exists( trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'themes' ) ) {
			$wp_content_themes = $this->return_all_files( get_theme_root() );
			if ( ! is_array( $wp_content_themes ) ) {
				$wp_content_themes = array();
			}
			$all_files = array_merge( $all_files, $wp_content_themes );
		}

		// Check if there's a custom upload directory
		if ( ! file_exists( $this->default_uploads_path() ) ) {
			$uploads = wp_get_upload_dir();
			if ( ! empty( $uploads['basedir'] ) ) {
				$wp_content_uploads = $this->return_all_files( $uploads['basedir'] );
				if ( ! is_array( $wp_content_uploads ) ) { // in case $uploads['basedir'] is .ignored
					$wp_content_uploads = array();
				}
				$all_files = array_merge( $all_files, $wp_content_uploads );
			}
		}
		$file_discovery_elapsed = microtime( true ) - $file_discovery_start;

		$post_process_start   = microtime( true );
		$all_files            = $this->prioritise_core_files( $all_files );
		$files                = $this->process_only_scan_dirs( $all_files['files'] );
		$files                = array_values( $files );
		$post_process_elapsed = microtime( true ) - $post_process_start;

		$db_stats_initial_start    = microtime( true );
		$db_stats_initial          = $this->db_stats();
		$db_stats_initial_elapsed  = microtime( true ) - $db_stats_initial_start;
		$response                  = array(
			'files'     => $files,
			'timestamp' => $GLOBALS['WPMR']['timestamp'],
			'count'     => count( $files ),
			'debug'     => array( // Only include specific, sanitized debug information
				'scan_type'      => $scan_type,
				'batch_number'   => $batch_number,
				'scan_mode'      => $scan_mode,
				'memory_peak_mb' => round( memory_get_peak_usage( true ) / 1024 / 1024, 2 ),
			),
		);
		$db_stats_prefetch_elapsed = 0.0;
		$db_stats_response_elapsed = 0.0;
		$title_hack_elapsed        = 0.0;
		$redirect_elapsed          = 0.0;
		$vulnerability_elapsed     = 0.0;

		if ( $has_init_nonce || $this->wpmr_iscli() ) {

			$response['checksums'] = count( $this->get_db_checksums() ) . '+' . count( $this->get_all_checksums() );
			$response['db_stats']  = $db_stats_initial;

			if ( ! $GLOBALS['WPMR']['do_file_scan'] ) {
				$response['files'] = array();
			}

			if ( $GLOBALS['WPMR']['do_db_scan'] ) {
				$db_stats_prefetch_start   = microtime( true );
				$db_stats                  = @$this->db_stats();
				$db_stats_prefetch_elapsed = microtime( true ) - $db_stats_prefetch_start;
				if ( $this->wpmr_iscli() ) {
					unset( $response['db_stats'] ); // force remove for now else this triggers batch scan
					$response['db_scan'] = @$this->db_scan_cli();
				} else {
					$db_stats_response_start   = microtime( true );
					$response['db_stats']      = @$this->db_stats();
					$db_stats_response_elapsed = microtime( true ) - $db_stats_response_start;
				}
			} else {
				unset( $response['db_stats'] );
			}
			$title_hack_start       = microtime( true );
			$response['title_hack'] = (bool) $this->title_hack();
			$title_hack_elapsed     = microtime( true ) - $title_hack_start;

			if ( $GLOBALS['WPMR']['do_redirect_scan'] ) {
				$redirect_start              = microtime( true );
				$response['redirect_hijack'] = (bool) @$this->redirect_hijack();
				$redirect_elapsed            = microtime( true ) - $redirect_start;
			} else {
				$response['redirect_hijack'] = false;
			}

			if ( $GLOBALS['WPMR']['do_vuln_scan'] ) {
				$vulnerability_start              = microtime( true );
				$response['vulnerabilities_scan'] = @$this->vulnerability_scan();
				$vulnerability_elapsed            = microtime( true ) - $vulnerability_start;
			} else {
				$response['vulnerabilities_scan'] = false;
			}

			// User stats — surfaced when user scan is enabled so the JS can batch-scan users.
			if ( ! empty( $GLOBALS['WPMR']['do_user_scan'] ) ) {
				global $wpdb;
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scanner reads live DB truth, not object cache.
				$response['user_stats'] = $wpdb->get_row(
					"SELECT COUNT(*) AS count, MIN(ID) AS min_id, MAX(ID) AS max_id FROM {$wpdb->users}"
				);
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			}

			$response['definition_count']   = $this->get_definition_count();
			$response['definition_version'] = $this->get_definition_version();
			$response['last_updated']       = $this->get_last_updated_ago();
			do_action( 'wpmr_scan_init', $GLOBALS['WPMR'] );
		}

		// Persist count for bootstrap_stats() magnitude display (Step 3).
		// set_transient( 'wpmr_file_count_cache', count( $files ), 5 * MINUTE_IN_SECONDS );

		if ( $log_perf ) {
			$this->log_scan_perf(
				__FUNCTION__,
				'end',
				array(
					'elapsed'                   => microtime( true ) - $bootstrap_start,
					'discovered_files'          => ( ! empty( $all_files['files'] ) && is_array( $all_files['files'] ) ) ? count( $all_files['files'] ) : 0,
					'queued_files'              => count( $response['files'] ),
					'file_discovery_elapsed'    => $file_discovery_elapsed,
					'post_process_elapsed'      => $post_process_elapsed,
					'db_stats_initial_elapsed'  => $db_stats_initial_elapsed,
					'db_stats_prefetch_elapsed' => $db_stats_prefetch_elapsed,
					'db_stats_response_elapsed' => $db_stats_response_elapsed,
					'title_hack_elapsed'        => $title_hack_elapsed,
					'redirect_elapsed'          => $redirect_elapsed,
					'vulnerability_elapsed'     => $vulnerability_elapsed,
					'db_tables'                 => ( ! empty( $response['db_stats'] ) && is_array( $response['db_stats'] ) ) ? count( $response['db_stats'] ) : 0,
					'definition_version'        => ! empty( $response['definition_version'] ) ? $response['definition_version'] : '',
				)
			);
		}

		return $response;
	}

	/**
	 * Return lightweight dashboard stats without file discovery or SaaS fetch.
	 *
	 * Used by wpmr_get_stats('bootstrap') to populate the dashboard magnitude stats
	 * with cached counts and table sizes. Does NOT call get_all_checksums(),
	 * return_all_files(), or any method that triggers a SaaS request.
	 *
	 * Checksum count uses a path-cardinality UNION COUNT mirroring get_all_checksums()
	 * precision (version-scoped core rows + signature-version-scoped cached rows,
	 * de-duped by path). See Checksum Lifecycle Context in 184-dependency-audit.md.
	 *
	 * File count is a short-TTL transient (minutes) refreshed by the first dashboard
	 * load after expiry. Scan-init (Step 5) writes the live count. A stale-by-minutes
	 * count is honest for the magnitude signal (do I need CLI?); scan-init refetches
	 * a fresh, uncached count for accurate progress tracking.
	 *
	 * Both checksum tables are guarded with SHOW TABLES LIKE checks (mirroring
	 * get_cached_checksums() at wpmr_checksums.php:344) so a fresh-activated
	 * install with no tables yet returns 0+0 quietly instead of emitting DB errors.
	 *
	 * @global \wpdb $wpdb
	 * @return array{count:int, checksums:string, db_stats:array}
	 */
	function bootstrap_stats() {
		global $wpdb, $wp_version;

		$checksums_table = $this->get_wpmr_table_name( WPMR_CHECKSUMS );
		$scanned_table   = $this->get_wpmr_table_name( WPMR_SCANNED_FILES );
		$sig_version     = (string) $this->get_definition_version();

		// SHOW TABLES LIKE guard — mirrors get_cached_checksums() fresh-install safety.
		// On a brand-new install (composer/manual upload before activation, table damage),
		// COUNT against a missing table is noisy. Fall through to 0+0 instead.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table-existence guard for scan init; scanner reads current schema truth.
		$checksums_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $checksums_table ) ) === $checksums_table;
		$scanned_exists   = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $scanned_table ) ) === $scanned_table;
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( ! $checksums_exists || ! $scanned_exists ) {
			return array(
				'count'     => 0,
				'checksums' => count( $this->get_db_checksums() ) . '+0',
				'db_stats'  => $this->db_stats(),
			);
		}

		// Path-cardinality UNION COUNT — matches count(get_all_checksums()) precision.
		// UNION (not UNION ALL) de-dupes overlapping paths, mirroring array_merge key-overwrite.
		// checksum <> '' guard mirrors get_cached_checksums() skip of empty-checksum rows.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Dashboard magnitude stat; uncached DB truth is correct for operator decision input.
		$effective = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM (
					SELECT path FROM %i WHERE type <> %s OR (type = %s AND version = %s)
					UNION
					SELECT path FROM %i
					WHERE signature_id = '' AND severity = '' AND signature_version = %s AND checksum <> ''
				) merged",
				$checksums_table,
				'core',
				'core',
				(string) $wp_version,
				$scanned_table,
				$sig_version
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		// File count: short-TTL transient. Scan-init (Step 5) writes the live value
		// via set_transient() in bootstrap(). On cold cache, replicate the full file
		// discovery logic from bootstrap() — same custom-dir checks, same home_dir.
		// Heavy, but correct. Optimise later.
		$file_count = (int) get_transient( 'wpmr_file_count_cache' );
		if ( ! $file_count ) {
			$saved_home_dir              = isset( $GLOBALS['WPMR']['home_dir'] ) ? $GLOBALS['WPMR']['home_dir'] : null;
			$GLOBALS['WPMR']['home_dir'] = $this->get_home_dir();

			$all_files = $this->return_all_files();

			if ( ! file_exists( trailingslashit( $this->normalise_path( ABSPATH ) ) . 'wp-content' ) ) {
				$extra = $this->return_all_files( WP_CONTENT_DIR );
				if ( is_array( $extra ) ) {
					$all_files = array_merge( $all_files, $extra );
				}
			}
			if ( ! file_exists( trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'plugins' ) ) {
				$extra = $this->return_all_files( WP_PLUGIN_DIR );
				if ( is_array( $extra ) ) {
					$all_files = array_merge( $all_files, $extra );
				}
			}
			if ( ! file_exists( trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'themes' ) ) {
				$extra = $this->return_all_files( get_theme_root() );
				if ( is_array( $extra ) ) {
					$all_files = array_merge( $all_files, $extra );
				}
			}
			if ( ! file_exists( $this->default_uploads_path() ) ) {
				$uploads = wp_get_upload_dir();
				if ( ! empty( $uploads['basedir'] ) ) {
					$extra = $this->return_all_files( $uploads['basedir'] );
					if ( is_array( $extra ) ) {
						$all_files = array_merge( $all_files, $extra );
					}
				}
			}

			if ( null !== $saved_home_dir ) {
				$GLOBALS['WPMR']['home_dir'] = $saved_home_dir;
			} else {
				unset( $GLOBALS['WPMR']['home_dir'] );
			}

			$file_count = count( $all_files );
			// set_transient( 'wpmr_file_count_cache', $file_count, 5 * MINUTE_IN_SECONDS );
		}

		return array(
			'count'     => $file_count,
			'checksums' => count( $this->get_db_checksums() ) . '+' . $effective,
			'db_stats'  => $this->db_stats(),
		);
	}

	/**
	 * Scan files for malware signatures and checksum anomalies.
	 *
	 * Registered on `wp_ajax_wpmr_scan_files` for admin UI scanning.
	 * In web context, validates nonce and capability, then returns JSON.
	 * In WP-CLI, returns the result array.
	 *
	 * Better name: scan_files_action()
	 *
	 * @param array $args Optional scan arguments (primarily for WP-CLI).
	 * @return array<string, mixed>|void Result array in WP-CLI; otherwise outputs JSON response.
	 */
	function wpmr_scan_files( $args = array() ) {
		if ( ! $this->wpmr_iscli() ) {

			// Check for nonce if not using WP CLI
			check_ajax_referer( 'wpmr_scan_files', 'wpmr_scan_files_nonce' );
			// Check if the user has the required capability
			if ( ! current_user_can( $this->cap ) ) {
				wp_send_json_error( 'Unauthorized access' );
				return;
			}
		}

		$start = microtime( true );
		$this->raise_limits_conditionally();
		$this->set_args( $args );
		$files = $GLOBALS['WPMR']['files'];
		if ( ! is_array( $files ) ) {
			$files = array();
		}
		$log_perf             = $this->should_log_scan_instrumentation();
		$requested_iteration  = ( $log_perf && isset( $_REQUEST['iteration'] ) ) ? intval( sanitize_text_field( wp_unslash( $_REQUEST['iteration'] ) ) ) : 0;
		$requested_files      = count( $files );
		$scannable_files      = 0;
		$invalid_files        = 0;
		$checksum_failures    = 0;
		$checksum_clean_skips = 0;
		$content_scans        = 0;

		if ( defined( 'WP_CLI' ) && WP_CLI ) {

		} else {
			$this->set_runtime_ini_value( 'max_execution_time', min( 90, count( $files ) + 1 ) );
		}
		// @ini_set( 'max_execution_time', max( (int) ini_get( 'max_execution_time' ), min( 90, count( $files ) ) ) );

		$affected_files = array();
		$registered     = $this->is_registered();
		$definitions    = $this->get_definitions()['definitions']['files'];
		if ( ! empty( $GLOBALS['WPMR']['regex'] ) ) {
			$definitions = array_merge(
				array(
					'DWPMR' => array(
						'severity'  => 'severe',
						'signature' => $GLOBALS['WPMR']['regex'],
						'class'     => 'scripting',
					),
				),
				$definitions
			);
		}
		$GLOBALS['WPMR']['definitions'] = $definitions;

		// Load checksums for batch processing
		$GLOBALS['WPMR']['checksums']      = $this->get_checksums_values(); // Hash map for non-core files (O(1) lookups) - includes all official + cached
		$GLOBALS['WPMR']['core_checksums'] = $this->get_core_checksums();    // Path-based map for core file validation - includes all official

		foreach ( $files as $file ) {
			// Scan scope is intentionally broader than mutate-file safety. This path may
			// inspect operator-requested files outside WordPress for malware triage, while
			// read/write style operations rely on is_safe_file_path() separately.
			// $this->flog( 'Scanning file: ' . $file );
			if ( is_link( $file ) || ! $this->is_scannable_file( $file ) ) { // skip if this is a directory symlink
				if ( $log_perf ) {
					++$invalid_files;
				}
				continue;
			}
			if ( $log_perf ) {
				++$scannable_files;
			}

			$checksum_failure = $this->fails_checksum( $file );
			// $this->flog( 'Checksum failure status for file ' . $file . ': ' . print_r( $checksum_failure, true ) );
			if ( $checksum_failure ) {
				// SaaS-served file whitelist: skip known-good non-core files whose
				// checksums fail validation but are confirmed false positives. Core
				// files are excluded — their checksums are authoritative and must not
				// be overridden by the whitelist.
				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 ) ) {
						$whitelist_hash = @hash_file( 'sha256', $file );
						if ( $whitelist_hash && isset( $file_whitelist_map[ $whitelist_hash ] ) ) {
							if ( $log_perf ) {
								++$checksum_clean_skips;
							}
							continue;
						}
					}
				}
				if ( $log_perf ) {
					++$checksum_failures;
				}
				$threat = 0;
				if ( $log_perf ) {
					++$content_scans;
				}
				$threat = $this->wpmr_scan_file_threats( $file );
				// $this->flog('$threat: ' . $file);
				// $this->flog($threat);
				if ( $threat ) {
					$affected_files[ $file ] = $threat;
				} elseif ( ( $checksum_failure == 'missing' ) && ( ! $registered || $GLOBALS['WPMR']['suspicious'] ) ) {
					// THIS SHOULD NEVER BE THE CASE. PLUGIN REQUIRES REGISTRATION ELSE IT CAN'T BE USED
					// If the scan is running without API registration
					$affected_files[ $file ] = array(
						'id'       => 'mismatch',
						'severity' => 'suspicious',
						'info'     => 'Mismatch',
					);
				} elseif ( $checksum_failure == 'missing' && $this->is_core_wp_file( $file ) ) {
					// unknown file in core directory
					$affected_files[ $file ] = array(
						'id'       => 'unknown',
						'severity' => 'suspicious',
						'info'     => 'Suspicious',
					);
				} elseif ( $GLOBALS['WPMR']['debug'] ) {
				}
			} else {
				if ( $log_perf ) {
					++$checksum_clean_skips;
				}
				if ( $GLOBALS['WPMR']['debug'] ) {

				}
			}
		}

		$clean_files = array_diff( array_values( $files ), array_keys( $affected_files ) );
		$this->update_cached_checksums( $clean_files ); // This will not save checksum if the file is suspicious either
		$affected_files = $this->may_be_filter_suspicious( $affected_files );
		if ( $affected_files ) {
			if ( ! empty( $GLOBALS['WPMR']['timestamp'] ) ) {
				$this->update_saved_records( $GLOBALS['WPMR']['timestamp'], array( 'files' => $affected_files ) );
			}
		}
		foreach ( $affected_files as $f => $report ) {
			$affected_files[ $f ] = $this->set_status( $report['severity'], $report['info'], $report['id'] );
		}
		if ( $affected_files ) {
			$scan_id = ! empty( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : 'unknown';
			foreach ( $affected_files as $f => $report ) {
				$this->insert_issue(
					$scan_id,
					array(
						'type'         => 'file',
						'severity'     => $report['severity'],
						'infection_id' => $report['signature'],
						'pointer'      => $f,
						'comment'      => array( 'message' => $report['message'] ),
					)
				);
			}
		}
		if ( empty( $GLOBALS['WPMR']['response_debug'] ) ) {
			$GLOBALS['WPMR']['response_debug'] = array();
		}
		$cpu = $this->get_server_load();
		if ( empty( $cpu ) ) {
			$cpu = array( 0, 0, 0 );
		}

		$cpu = array_map(
			function ( $num ) {
				return number_format( $num, 2, '.', '' );
			},
			$cpu
		);

		$memory_limit                                    = @ini_get( 'memory_limit' );
		$GLOBALS['WPMR']['response_debug']['time_taken'] = ( microtime( true ) - $start );
		$result = array(
			'report'       => $affected_files,
			'memory'       => ( memory_get_peak_usage( true ) / 1024 / 1024 ),
			'memory_limit' => $memory_limit,
			'cpu'          => $cpu,
			'debug'        => $GLOBALS['WPMR']['response_debug'],
		);
		if ( $log_perf ) {
			$elapsed = microtime( true ) - $start;
			$this->log_scan_perf(
				__FUNCTION__,
				'end',
				array(
					'requested_iteration'   => $requested_iteration,
					'requested_files'       => $requested_files,
					'scannable_files'       => $scannable_files,
					'invalid_files'         => $invalid_files,
					'checksum_failures'     => $checksum_failures,
					'checksum_clean_skips'  => $checksum_clean_skips,
					'content_scans'         => $content_scans,
					'findings'              => count( $affected_files ),
					'clean_candidates'      => count( $clean_files ),
					'elapsed'               => $elapsed,
					'requested_files_per_s' => $elapsed > 0 ? $requested_files / $elapsed : 0,
					'content_scans_per_s'   => $elapsed > 0 ? $content_scans / $elapsed : 0,
					'memory_peak_mb'        => ( memory_get_peak_usage( true ) / 1024 / 1024 ),
				)
			);
		}
		if ( $this->wpmr_iscli() ) {
			return $result;
		} else {
			wp_send_json( $result );
		}
	}

	/**
	 * Scan a single file's contents against the loaded signature set.
	 *
	 * Called from wpmr_scan_files() after checksum failure detection.
	 * Returns a threat descriptor array for the first matching signature.
	 *
	 * Better name: scan_file_for_signatures()
	 *
	 * @param string $file Absolute file path.
	 * @return array<string, string>|null Threat array with keys id/severity/info, or null when no threat.
	 */
	function wpmr_scan_file_threats( $file ) {
		if ( $this->is_invalid_file( $file ) ) {
			if ( ! is_readable( $file ) ) { // return early so that error is displayed
				return array(
					'id'       => 'unreadable',
					'severity' => 'skipped',
					'info'     => 'Error Reading File',
				);
			}
			return;
		}
		$ext   = $this->get_fileext( $file );
		$tests = array();

		if ( $this->is_invalid_file( $file ) ) {
			return;
		}

		$GLOBALS['WPMR']['tmp']['file_contents'] = @file_get_contents( $file );

		// failure to read file contents
		if ( $GLOBALS['WPMR']['tmp']['file_contents'] === false ) {
			return array(
				'id'       => 'unreadable',
				'severity' => 'skipped',
				'info'     => 'Error Reading File',
			);
		}

		// File contents read but file is empty
		if ( empty( $GLOBALS['WPMR']['tmp']['file_contents'] ) ) {
			// Flag empty files in core directories as suspicious
			if ( $this->is_core_wp_file( $file ) ) {
				return array(
					'id'       => 'unknown',
					'severity' => 'suspicious',
					'info'     => 'Suspicious',
				);
			}

			// not core file, skip scanning
			return;
		}

		$definitions = $GLOBALS['WPMR']['definitions'];
		$this->set_runtime_ini_value( 'pcre.backtrack_limit', 1000000 );

		// Set execution time limit per pattern to prevent hangs
		$max_pattern_time  = 5; // seconds per pattern
		$log_slow_patterns = $this->should_log_scan_instrumentation();

		foreach ( $definitions as $definition => $signature ) {
			if ( $signature['class'] == 'htaccess' && $ext != 'htaccess' ) {
				continue;
			}
			try {
				// $this->flog( 'Testing signature ' . $definition . ' ' . $this->decode( $signature['signature'] ) . ' on file ' . $file );
				if ( @preg_match( $this->decode( $signature['signature'] ), '' ) === false ) {
					throw new Exception( 'Invalid regular expression ' . $definition . ' in ' . __FUNCTION__ );
				}
				// $this->flog( 'Pattern ' . $this->decode( $signature['signature'] ) . ' is valid.' );
				// FOR DEBUGGING
				if ( false && preg_match( '/footer/isS', $file ) ) {
					$this->flog( 'Scanning file ' . $file . ' for ' . $this->decode( $signature['signature'] ) );
				}

				// Execute preg_match with timeout protection
				$start_time = microtime( true );
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Scanner timeout protection needs a temporary handler to convert regex warnings into controlled failures.
				set_error_handler(
					function () use ( $start_time, $max_pattern_time ) {
						if ( ( microtime( true ) - $start_time ) > $max_pattern_time ) {
							$this->flog( 'Pattern execution timeout reached.' );
							throw new Exception( 'Pattern execution timeout' );
						}
					}
				);

				$matches = @preg_match( $this->decode( $signature['signature'] ), $GLOBALS['WPMR']['tmp']['file_contents'], $found );
				restore_error_handler();

				if ( $log_slow_patterns ) {
					$execution_time = microtime( true ) - $start_time;
					if ( $execution_time > 0.050 ) { // 0.020 is 20ms, which is a long time for a regex match in a file scan - log if it exceeds this threshold
						$this->flog( 'WARNING: Slow pattern execution (' . number_format( $execution_time, 2 ) . 's) for ' . $definition . ' on file ' . $file );
					}
				}

				// $this->flog( 'Scanning : ' . $definition . ' ' . $this->decode( $signature['signature'] ) . ' complete on file ' . $file . print_r( $found, true ) . ' Matches: ' . $matches );
			} catch ( Exception $e ) {
				restore_error_handler();
				$this->flog( 'Faulty Signature: ' . $definition, false, false, true );
				$this->flog( 'Faulty Pattern: ' . $this->decode( $signature['signature'] ), false, false, true );
				$this->flog( 'File: ' . $file, false, false, true );
				$this->flog( $e->getMessage(), false, false, true );
				continue;
			}
			if ( $matches >= 1 ) {
				if ( in_array( $signature['severity'], array( 'severe', 'high' ) ) ) {
					$this->update_setting( 'infected', true );
				}
				return array(
					'id'       => $definition,
					'severity' => $signature['severity'],
					'info'     => $signature['severity'],
					// 'sig_hash' => hash( 'sha256', $signature['signature'] ),
				);
			}
		}
	}

	/**
	 * Perform a SaaS-powered vulnerability scan of core/plugins/themes.
	 *
	 * Collects installed component versions and sends them to the control plane.
	 * Saves results into the scan log record when a timestamp is present.
	 * When issue persistence is available, inserts unique vulnerability issues
	 * for the current scan, keyed by infection ID.
	 *
	 * Better name: scan_vulnerabilities()
	 *
	 * @return array<string, mixed> Vulnerability issues grouped by component type, or empty array.
	 */
	function vulnerability_scan() {
		// Build the components array with WordPress, plugins, and themes information
		$components = array();
		$home_path  = untrailingslashit( realpath( get_home_path() ) ) . DIRECTORY_SEPARATOR;
		$issues     = array();
		// Add WordPress core version
		$components['core'] = array(
			'version' => get_bloginfo( 'version' ),
			// 'version' => '6.0.2,'
		);

		// Add plugins information
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		$all_plugins           = get_plugins();
		$components['plugins'] = array();

		foreach ( $all_plugins as $plugin_file => $plugin_data ) {

			if ( ! empty( $plugin_data['Name'] ) && ! empty( $plugin_data['Version'] ) ) {
				$plugin_name = $plugin_data['Name'];

				$plugin_dir  = dirname( $plugin_file );
				$readme_path = WP_PLUGIN_DIR . "/{$plugin_dir}/readme.txt";

				if ( is_readable( $readme_path ) ) {
					$contents = file_get_contents( $readme_path );
					if ( preg_match( '/^===\s*(.+?)\s*===\s*$/m', $contents, $m ) ) {
						// override if we actually found a readme header
						$plugin_name = trim( $m[1] );
					}
				}

				// Use the absolute path to the plugin file as the key
				$plugin_path = wp_normalize_path( WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $plugin_file );

				$components['plugins'][ $plugin_path ] = array(
					'name'    => $plugin_name,
					'version' => $plugin_data['Version'],
				);
			}
		}
		ksort( $components['plugins'] );

		// Add themes information
		$all_themes           = wp_get_themes();
		$components['themes'] = array();
		foreach ( $all_themes as $theme_key => $theme_obj ) {
			$theme_name = $theme_obj->get( 'Name' );

			$readme = $theme_obj->get_stylesheet_directory() . '/readme.txt';
			if ( is_readable( $readme ) ) {
				$contents = file_get_contents( $readme );
				if ( preg_match( '/^===\s*(.+?)\s*===/m', $contents, $matches ) ) {
					$theme_name = trim( $matches[1] );
				}
			}

			// Use the absolute path to the theme directory as the key
			$theme_path = $theme_obj->get_stylesheet_directory();

			$components['themes'][ wp_normalize_path( $theme_path . DIRECTORY_SEPARATOR . 'style.css' ) ] = array(
				'name'    => $theme_name,
				'version' => $theme_obj->get( 'Version' ),
			);
		}

		ksort( $components['themes'] );

		$response = $this->saas_request(
			'saas_check_vulnerabilities',
			array(
				'method'  => 'POST',
				'headers' => array(
					'Content-Type' => 'application/x-www-form-urlencoded',
				),
				'body'    => array(
					'components' => wp_json_encode( $components ),
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			$this->flog( 'ERROR: ' . $response->get_error_message() );
			return array(
				'success' => false,
				'error'   => $response->get_error_message(),
			);
		}

		$data = isset( $response['response'] ) && is_array( $response['response'] ) ? $response['response'] : array();

		if ( empty( $data ) || ! isset( $data['success'] ) || ! $data['success'] ) {
			$this->flog( 'Contract Violation: Unsuccessful response in vulnerability_scan' );
			return array();
		}

		if ( ! isset( $response['payload']['vulnerabilities'] ) ) {
			$this->flog( 'Contract Violation: Missing vulnerabilities payload' );
			return array();
		}

		$vulnerability_sets = $response['payload']['vulnerabilities'];

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

		// Process core findings (if any)
		if ( ! empty( $vulnerability_sets['core'] ) ) {
			$core_entries = $vulnerability_sets['core'];
			if ( isset( $core_entries['name'] ) ) {
				$core_entries = array( $core_entries );
			}
			foreach ( $core_entries as $core_details ) {
				if ( ! is_array( $core_details ) ) {
					continue;
				}
				$name                    = isset( $core_details['name'] ) ? sanitize_text_field( $core_details['name'] ) : 'WordPress';
				$signature               = isset( $core_details['id'] ) ? sanitize_text_field( $core_details['id'] ) : 'core';
				$issues['core'][ $name ] = $this->set_status( 'vulnerable', $name, $signature );
			}
		}

		// Process plugin findings
		if ( ! empty( $vulnerability_sets['plugins'] ) && is_array( $vulnerability_sets['plugins'] ) ) {
			foreach ( $vulnerability_sets['plugins'] as $plugin => $vulnerable ) {
				if ( ! is_array( $vulnerable ) ) {
					continue;
				}
				$plugin_slug = basename( dirname( $plugin ) );
				if ( empty( $plugin_slug ) ) {
					$plugin_slug = basename( $plugin );
				}
				$plugin_slug                       = sanitize_text_field( $plugin_slug );
				$name                              = isset( $vulnerable['name'] ) ? sanitize_text_field( $vulnerable['name'] ) : $plugin_slug;
				$signature                         = isset( $vulnerable['id'] ) ? sanitize_text_field( $vulnerable['id'] ) : $plugin_slug;
				$issues['plugins'][ $plugin_slug ] = $this->set_status( 'vulnerable', $name, $signature );
			}
		}

		// Process theme findings
		if ( ! empty( $vulnerability_sets['themes'] ) && is_array( $vulnerability_sets['themes'] ) ) {
			foreach ( $vulnerability_sets['themes'] as $theme => $vulnerable ) {
				if ( ! is_array( $vulnerable ) ) {
					continue;
				}
				$theme_slug = basename( dirname( $theme ) );
				if ( empty( $theme_slug ) ) {
					$theme_slug = basename( $theme );
				}
				$theme_slug                      = sanitize_text_field( $theme_slug );
				$name                            = isset( $vulnerable['name'] ) ? sanitize_text_field( $vulnerable['name'] ) : $theme_slug;
				$signature                       = isset( $vulnerable['id'] ) ? sanitize_text_field( $vulnerable['id'] ) : $theme_slug;
				$issues['themes'][ $theme_slug ] = $this->set_status( 'vulnerable', $name, $signature );
			}
		}

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

		$scan_id = ! empty( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : '';
		if ( ! empty( $scan_id ) ) {
			if ( ! empty( $issues['core'] ) ) {
				foreach ( $issues['core'] as $name => $issue ) {
					if ( $this->issue_exists_by_infection_id( $scan_id, 'vulnerability', $issue['signature'] ) ) {
						continue;
					}
					$this->insert_issue(
						$scan_id,
						array(
							'type'         => 'vulnerability',
							'severity'     => $issue['severity'],
							'infection_id' => $issue['signature'],
							'pointer'      => $name,
							'comment'      => $issue['message'],
						)
					);
				}
			}

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

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

		if ( ! empty( $scan_id ) ) {
			// set_transient( 'WPMR_log_' . $GLOBALS['WPMR']['timestamp'], json_encode( array( 'vulnerabilities' => $issues ) ), 30 * DAY_IN_SECONDS );
			$this->update_saved_records( $GLOBALS['WPMR']['timestamp'], array( 'vulnerabilities' => $issues ) );
		}
		return $issues;
	}

	/**
	 * Reset (truncate) the plugin-owned issues table so each scan starts clean.
	 *
	 * Shared surface: the AJAX init handler (wpmr_init_scan) and the WP-CLI scan
	 * path both call this, so browser and CLI scans share one mutation point.
	 * Returns true on execution, false if the table name is unavailable — that
	 * false case should never happen at runtime but is logged for diagnostics.
	 *
	 * @return bool True if the TRUNCATE was issued.
	 */
	public function reset_issues() {
		if ( empty( $this->table_issues ) ) {
			$this->dlog( 'reset_issues: no issues table name available, skipping truncate.' );
			return false;
		}

		global $wpdb;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// TRUNCATE is a DDL statement; %i identifier escaping via wpdb::prepare() is the
		// repo convention (see init_scan_routines(), wpmr_init_scan(), checksums.php).
		$wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $this->table_issues ) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$this->dlog( 'reset_issues: truncated ' . $this->table_issues );
		return true;
	}

	/**
	 * AJAX handler: initialize a scan and return the bootstrap payload.
	 *
	 * Registered on `wp_ajax_wpmr_init_scan`.
	 *
	 * Side effects:
	 * - Truncates the issues table once per scan start.
	 *
	 * Better name: ajax_init_scan()
	 *
	 * @return void Sends JSON response and exits.
	 */
	function wpmr_init_scan() {
		$start    = microtime( true );
		$log_perf = $this->should_log_scan_instrumentation();

		check_ajax_referer( 'wpmr_init_scan', 'wpmr_init_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}
		if ( $log_perf ) {
			$this->log_scan_perf(
				__FUNCTION__,
				'start',
				array(
					'scan_timestamp'   => ! empty( $_REQUEST['timestamp'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['timestamp'] ) ) : '',
					'do_vuln_scan'     => ! empty( $_REQUEST['do_vuln_scan'] ),
					'do_db_scan'       => ! empty( $_REQUEST['do_db_scan'] ),
					'do_file_scan'     => ! empty( $_REQUEST['do_file_scan'] ),
					'do_redirect_scan' => ! empty( $_REQUEST['do_redirect_scan'] ),
				)
			);
		}

		$this->reset_issues();

		$response = $this->bootstrap();
		if ( $log_perf ) {
			$this->log_scan_perf(
				__FUNCTION__,
				'end',
				array(
					'elapsed'            => microtime( true ) - $start,
					'queued_files'       => ( ! empty( $response['files'] ) && is_array( $response['files'] ) ) ? count( $response['files'] ) : 0,
					'db_tables'          => ( ! empty( $response['db_stats'] ) && is_array( $response['db_stats'] ) ) ? count( $response['db_stats'] ) : 0,
					'definition_version' => ! empty( $response['definition_version'] ) ? $response['definition_version'] : '',
				)
			);
		}

		wp_send_json( $response );
	}

	/**
	 * AJAX handler: return scan statistics or derived diagnostic data.
	 *
	 * Registered on `wp_ajax_wpmr_get_stats`.
	 * Supports stat_type values like: bootstrap, hidden_files, definition_count,
	 * definition_version, last_updated, memory_limit.
	 *
	 * Better name: ajax_get_scan_stats()
	 *
	 * @return void Sends JSON response and exits.
	 */
	function wpmr_get_stats() {

		check_ajax_referer( 'wpmr_stats', 'wpmr_stats_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}

		if ( ! empty( $_REQUEST['stat_type'] ) ) {
			$request_type = sanitize_text_field( wp_unslash( $_REQUEST['stat_type'] ) );
			switch ( $request_type ) {
				case 'bootstrap':
					$data = $this->bootstrap_stats();
					wp_send_json_success( $data );
				case 'hidden_files':
					$data   = $this->bootstrap();
					$hidden = array_filter(
						$data['files'],
						function ( $v ) {
							return ( ! strlen( explode( '.', basename( $v ) )[0] ) || ! strlen( explode( '.', basename( dirname( $v ) ) )[0] ) ) ? true : false;
						}
					);
					if ( ! empty( $hidden ) ) {
						$hidden  = array_values( $hidden );
						$newlist = array();
						foreach ( $hidden as $k => $v ) {
							$parts = explode( '.', basename( dirname( $v ) ) );
							if ( ! strlen( $parts[0] ) ) {
								$newlist[ dirname( $v ) ] = '<strong>[*DIR] ' . dirname( $v ) . '</strong>';
							}
							$newlist[ $v ] = '[FILE] ' . $v;
						}
						$newlist = implode( '<br />', $newlist );
						wp_send_json_success( $newlist );
					} else {
						wp_send_json_error( array() );
					}
				case 'definition_count':
					wp_send_json_success( $this->get_definition_count() );
				case 'definition_version':
					wp_send_json_success( $this->get_definition_version() );
				case 'last_updated':
					wp_send_json_success( $this->get_last_updated_ago() );
				case 'memory_limit':
					wp_send_json_success( (int) @ini_get( 'memory_limit' ) );
			}
		}
		wp_send_json_error( $_REQUEST );
	}

	/**
	 * AJAX handler: clear the "infected" flag stored in plugin settings.
	 *
	 * Registered on `wp_ajax_wpmr_clear_infection_stats`.
	 *
	 * Better name: ajax_clear_infection_flag()
	 *
	 * @return void Sends JSON response and exits.
	 */
	function wpmr_clear_infection_stats() {
		check_ajax_referer( 'wpmr_clear_infection_stats', 'wpmr_clear_infection_stats_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}
		$this->ss_delete_setting( 'infected' );
		wp_send_json( ! $this->get_setting( 'infected' ) || ( $this->get_setting( 'infected' ) && $this->delete_setting( 'infected' ) ) );
	}

	/**
	 * Check whether a given checksum is explicitly blacklisted.
	 *
	 * Better name: is_checksum_blacklisted()
	 *
	 * @param string $checksum SHA-256 checksum.
	 * @return bool True if blacklisted.
	 */
	function is_file_blacklisted( $checksum ) {
		return in_array( $checksum, array( 'ada53f04e24f787f126d5e7d07f42425a780f3d76f12c6d11e9535ed5106b94a' ) );
	}

	/**
	 * Return basic row-count statistics for key WordPress tables.
	 *
	 * Better name: get_database_table_stats()
	 *
	 * @global wpdb $wpdb
	 * @return array<string, array{count:int,min:int,max:int}> Stats keyed by logical table name.
	 */
	function db_stats() {
		global $wpdb;

		$wpstats = array();
		$tables  = array(
			'posts'    => array(
				'table'  => $wpdb->posts,
				'column' => 'ID',
			),
			'postmeta' => array(
				'table'  => $wpdb->postmeta,
				'column' => 'meta_id',
			),
			'options'  => array(
				'table'  => $wpdb->options,
				'column' => 'option_id',
			),
			'comments' => array(
				'table'  => $wpdb->comments,
				'column' => 'comment_ID',
			),
		);

		foreach ( $tables as $name => $table_info ) {
			$table  = $table_info['table'];
			$column = $table_info['column'];

			// Construct the SQL query for row count, min and max ID in one go
			// Table and column come from the fixed map above, not from runtime input.
			// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
			$query = "SELECT COUNT(*) AS count, MIN($column) AS min, MAX($column) AS max FROM $table";

			// Execute the query
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DB scan stats must read current row counts and bounds directly from fixed-table scan targets.
			$result = $wpdb->get_row( $query, ARRAY_A );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared

			// Handle cases when no rows are found (like 0 comments)
			if ( $result['count'] == 0 ) { // If no rows found, set min and max to 0
				$result['count'] = 0;
				$result['min']   = 0;
				$result['max']   = 0;
			}

			// Add the results to the table stats
			$wpstats[ $name ] = array(
				'count' => $result['count'],
				'min'   => $result['min'],
				'max'   => $result['max'],
			);
		}
		return $wpstats;
	}

	/**
	 * AJAX handler: scan the database for malware signatures (batched).
	 *
	 * Registered on `wp_ajax_wpmr_scan_db`.
	 * Requires nonce/capability and expects request params: table, batchsize, pointer.
	 * Returns JSON in web context; returns the result array in WP-CLI.
	 *
	 * Better name: scan_database_action()
	 *
	 * @param array $args Optional scan arguments (primarily for WP-CLI).
	 * @return array<string, mixed>|void Result array in WP-CLI; otherwise outputs JSON response.
	 */
	function wpmr_scan_db( $args = array() ) {

		check_ajax_referer( 'wpmr_scan_db', 'wpmr_scan_db_nonce' );
		// Check user_cap if the request is not made via WP CLI
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}

		$start    = microtime( true );
		$log_perf = $this->should_log_scan_instrumentation();
		$this->raise_limits_conditionally();
		$this->set_args( $args );

		if ( ! empty( $_REQUEST['table'] ) && ! empty( $_REQUEST['batchsize'] ) ) {
			$table     = sanitize_text_field( wp_unslash( $_REQUEST['table'] ) );
			$batchsize = sanitize_text_field( wp_unslash( $_REQUEST['batchsize'] ) );
			$pointer   = isset( $_REQUEST['pointer'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['pointer'] ) ) : 0;
			$report    = $this->db_scan_batch( $table, $batchsize, $pointer );

			if ( empty( $GLOBALS['WPMR']['response_debug'] ) ) {
				$GLOBALS['WPMR']['response_debug'] = array();
			}
			$cpu = $this->get_server_load();
			if ( empty( $cpu ) ) {
				$cpu = array( 0, 0, 0 );
			}
			$cpu = array_map(
				function ( $num ) {
					return number_format( $num, 2, '.', '' );
				},
				$cpu
			);

			$memory_limit                                    = @ini_get( 'memory_limit' );
			$GLOBALS['WPMR']['response_debug']['time_taken'] = ( microtime( true ) - $start );
			$result = array(
				'report'       => $report,
				'memory'       => ( memory_get_peak_usage( true ) / 1024 / 1024 ),
				'memory_limit' => $memory_limit,
				'cpu'          => $cpu,
				'debug'        => $GLOBALS['WPMR']['response_debug'],
			);
			if ( $log_perf ) {
				$this->log_scan_perf(
					__FUNCTION__,
					'end',
					array(
						'table'          => $table,
						'batchsize'      => intval( $batchsize ),
						'pointer'        => intval( $pointer ),
						'new_pointer'    => ( is_array( $report ) && isset( $report['new_pointer'] ) ) ? intval( $report['new_pointer'] ) : intval( $pointer ),
						'findings'       => ( is_array( $report ) && ! empty( $report['results'] ) && is_array( $report['results'] ) ) ? count( $report['results'] ) : 0,
						'elapsed'        => microtime( true ) - $start,
						'memory_peak_mb' => ( memory_get_peak_usage( true ) / 1024 / 1024 ),
					)
				);
			}

			if ( $this->wpmr_iscli() ) {
				return $result;
			} else {
				wp_send_json( $result );
			}
		}
	}

	/**
	 * Scan a database table batch against all DB signatures.
	 *
	 * Uses LIKE prefilters from definitions to narrow candidates, then applies regex.
	 * Maintains a checksum cache (`WPMR_db_checksums_cache`) to avoid repeat scans.
	 * Persists detected DB issues so the malware monitor reflects DB findings.
	 *
	 * Better name: scan_database_batch()
	 *
	 * @param string $table     Logical table key: posts|postmeta|options|comments.
	 * @param int    $batchsize Batch size.
	 * @param int    $pointer   Current pointer (inclusive lower-bound).
	 * @return array<string, mixed>|null Batch results structure, or null when table is invalid.
	 */
	function db_scan_batch( $table, $batchsize, $pointer ) {
		global $wpdb;
		$log_perf    = $this->should_log_scan_instrumentation();
		$batch_start = $log_perf ? microtime( true ) : 0.0;
		$batchsize   = intval( $batchsize );
		$pointer     = intval( $pointer );
		$sql         = '';
		$table_name  = '';

		switch ( $table ) {
			case 'posts':
				$sql        = "SELECT `ID` AS id, `post_content` AS content, `post_type` as post_type FROM ( SELECT `ID`, `post_content`, `post_type` FROM `{$wpdb->posts}` WHERE `ID` > %d AND `ID` <= %d ORDER BY `ID` ) AS limited_rows WHERE `post_content` LIKE %s";
				$table_name = $wpdb->posts;
				break;
			case 'postmeta':
				$sql        = "SELECT `meta_id` AS id, `meta_value` AS content FROM ( SELECT `meta_id`, `meta_value` FROM $wpdb->postmeta WHERE `meta_id` > %d AND `meta_id` <= %d ORDER BY `meta_id` ) AS limited_rows WHERE `meta_value` LIKE %s";
				$table_name = $wpdb->postmeta;
				break;
			case 'options':
				if ( ! function_exists( 'get_plugins' ) ) {
					require_once ABSPATH . 'wp-admin/includes/plugin.php';
				}
				$all_plugins = get_plugins();
				$all_plugins = get_option( 'active_plugins' );
				$exclusions  = array();
				if ( isset( $all_plugins['gotmls/index.php'] ) ) {
					$exclusions[] = 'GOTMLS_definitions_array';
				}
				if ( in_array( 'malcare-security/malcare.php', $all_plugins ) || in_array( 'blogvault-real-time-backup/blogvault.php', $all_plugins ) ) {
					$exclusions[] = 'bvruleset';
				}
				if ( ! empty( $exclusions ) ) {
					// Properly prepare SQL with placeholders for IN clause to prevent SQL injection
					$exclusions   = array_values( $exclusions ); // Ensure sequential keys
					$placeholders = implode( ',', array_fill( 0, count( $exclusions ), '%s' ) );
					$sql          = "SELECT `option_id` AS id, `option_value` AS content FROM ( SELECT `option_id`, `option_value` FROM $wpdb->options WHERE `option_id` > %d AND `option_id` <= %d AND `option_name` NOT IN ($placeholders) ORDER BY `option_id`) AS limited_rows WHERE `option_value` LIKE %s";
				} else {
					$sql = "SELECT `option_id` AS id, `option_value` AS content FROM ( SELECT `option_id`, `option_value` FROM $wpdb->options WHERE `option_id` > %d AND `option_id` <= %d ORDER BY `option_id` ) AS limited_rows WHERE `option_value` LIKE %s";
				}
				$table_name = $wpdb->options;
				break;
			case 'comments':
				$sql        = "SELECT `comment_ID` AS id, `comment_content` AS content FROM ( SELECT `comment_ID`, `comment_content` FROM $wpdb->comments WHERE `comment_approved` = '1' AND `comment_ID` > %d AND `comment_ID` <= %d ORDER BY comment_ID) AS limited_rows WHERE `comment_content` LIKE %s";
				$table_name = $wpdb->comments;
				break;
			default:
				return;
		}

		$definitions          = $this->get_definitions()['definitions']['db'];
		$db_results           = array(
			'results' => array(),
			'pointer' => $pointer,
		);
		$db_scan_log          = array();
		$query_hits           = 0;
		$regex_checks         = 0;
		$whitelist_skips      = 0;
		$checksum_skips       = 0;
		$clean_hash_additions = 0;
		if ( ! empty( $GLOBALS['WPMR']['wpmr_extra_db_query'] ) && ! empty( $GLOBALS['WPMR']['wpmr_extra_db_regex'] ) ) {
			$definitions['DWPMR'] = array(
				'severity'  => 'severe',
				'query'     => $GLOBALS['WPMR']['wpmr_extra_db_query'],
				'signature' => $GLOBALS['WPMR']['wpmr_extra_db_regex'],
			);
		}

		$results = array();

		$db_checksums   = $this->get_db_checksums();
		$maybe_infected = array();
		$hash           = false;
		foreach ( $definitions as $ver => $details ) {
			$time = microtime( true );
			// Prepare the SQL query within the loop
			// For queries with exclusions, merge exclusions array into parameters
			// Table/query templates come from the fixed switch above; runtime search values still flow through $wpdb->prepare().
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DB malware scanning must query live row content batches directly from the selected scan target.
			// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			if ( ! empty( $exclusions ) ) {
				$prepare_params = array_merge( array( $pointer, $pointer + $batchsize ), $exclusions, array( $this->decode( $details['query'] ) ) );
				$prepared_sql   = $wpdb->prepare( $sql, ...$prepare_params );
			} else {
				$prepared_sql = $wpdb->prepare( $sql, $pointer, $pointer + $batchsize, $this->decode( $details['query'] ) );
			}
			$results = $wpdb->get_results( $prepared_sql, ARRAY_A );
			// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			if ( $log_perf ) {
				$query_hits += is_array( $results ) ? count( $results ) : 0;
			}
			// $this->flog($sql);
			// $this->flog($prepared_sql);
			// $this->flog($results);
			if ( ! $results ) { // get_results also returns NULL which foreach can't handle
				continue;
			}

			foreach ( $results as $result ) {

				$id = sanitize_text_field( $result['id'] );
				if ( $this->is_db_record_whitelisted( $table, intval( $id ) ) ) {
					if ( $log_perf ) {
						++$whitelist_skips;
					}
					continue;
				}

				$content = $result['content'];
				$hash    = hash( 'sha256', $content );
				if ( in_array( $hash, $db_checksums, true ) ) {
					if ( $log_perf ) {
						++$checksum_skips;
					}
					continue;
					// This result was previously scanned and stored in the checksums.
					// No need to scan it again for this definition.
					// Skip this result and continue scanning the next result.
				}
				$post_status = empty( $result['post_type'] ) ? '' : 'post-type &rarr; ' . $result['post_type'] . '.';
				try {
					if ( @preg_match( $this->decode( $details['signature'] ), '' ) === false ) {
						throw new Exception( 'Invalid regular expression ' . $ver . ' in ' . __FUNCTION__ );
					}
					if ( $log_perf ) {
						++$regex_checks;
					}
					$matches = preg_match( $this->decode( $details['signature'] ), $content, $found );
				} catch ( Exception $e ) {
					$this->flog( 'Faulty Signature: ' . $ver, false, false, true );
					$this->flog( 'Faulty Pattern: ' . $this->decode( $details['signature'] ), false, false, true );
					$this->flog( $e->getMessage(), false, false, true );
					continue;
				}
				if ( $matches >= 1 ) {
					if ( in_array( $details['severity'], array( 'severe', 'high' ) ) ) {
						$this->update_setting( 'infected', true );
					}
					$status            = $this->set_status(
						$details['severity'],
						preg_replace( '/[^A-Za-z0-9]/', ' ', $table_name ) . " ID $id. $post_status " . '',
						$ver
					);
					$status['table']   = $table;
					$status['id']      = intval( $id );
					$status['db_hash'] = $hash;
					$db_results['results'][ $table_name . '_' . $id ] = $status;
					$db_scan_log[ $id ]                               = array(
						'severity'  => $details['severity'],
						'infection' => $ver,
						'type'      => ucwords( $table_name ),
						'id'        => $id,
					);
					$maybe_infected[ $hash ]                          = 1;
				} else {
					// $maybe_infected has should only be changed to 0 if it is not 1 already
					if ( ! isset( $maybe_infected[ $hash ] ) ) {
						$maybe_infected[ $hash ] = 0;
					}
				}
			} // foreach $results
		} // foreach $definitions

		foreach ( $maybe_infected as $checksum => $infected ) {
			if ( $infected == 0 ) {
				if ( $log_perf ) {
					++$clean_hash_additions;
				}
				$db_checksums[] = $checksum;
			}
		}
		$db_checksums = array_unique( $db_checksums );
		update_option( 'WPMR_db_checksums_cache', $db_checksums );

		switch ( $table ) {
			case 'posts':
				$sql = $wpdb->prepare( "SELECT MIN(`ID`) AS next_id FROM $wpdb->posts WHERE `ID` > %d", ( $pointer + $batchsize ) );
				break;
			case 'postmeta':
				$sql = $wpdb->prepare( "SELECT MIN(`meta_id`) AS next_id FROM $wpdb->postmeta WHERE `meta_id` > %d", ( $pointer + $batchsize ) );
				break;
			case 'options':
				$sql = $wpdb->prepare( "SELECT MIN(`option_id`) AS next_id FROM $wpdb->options WHERE `option_id` > %d", ( $pointer + $batchsize ) );
				break;
			case 'comments':
				$sql = $wpdb->prepare( "SELECT MIN(`comment_ID`) AS next_id FROM $wpdb->comments WHERE `comment_approved` = '1' AND `comment_ID` > %d", ( $pointer + $batchsize ) );
				break;
			default:
		}

		// The next-pointer query is selected from the fixed table switch above; only the pointer value is runtime data.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DB scan pointer advancement must read the next live record boundary directly from the selected scan target.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$new_pointer = $wpdb->get_var( $sql );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( ! empty( $new_pointer ) ) {
			$new_pointer               = intval( $new_pointer ) - 1;
			$db_results['new_pointer'] = $new_pointer;
		} else {
			$db_results['new_pointer'] = $pointer + $batchsize;
		}

		if ( $log_perf ) {
			$this->log_scan_perf(
				__FUNCTION__,
				'end',
				array(
					'table'                => $table,
					'batchsize'            => $batchsize,
					'pointer'              => $pointer,
					'new_pointer'          => intval( $db_results['new_pointer'] ),
					'definition_count'     => count( $definitions ),
					'query_hits'           => $query_hits,
					'regex_checks'         => $regex_checks,
					'whitelist_skips'      => $whitelist_skips,
					'checksum_skips'       => $checksum_skips,
					'findings'             => ! empty( $db_results['results'] ) && is_array( $db_results['results'] ) ? count( $db_results['results'] ) : 0,
					'clean_hash_additions' => $clean_hash_additions,
					'elapsed'              => microtime( true ) - $batch_start,
				)
			);
		}

		if ( $db_results['results'] ) {
			$db_results['results'] = array_values( $db_results['results'] );
			$scan_id               = ! empty( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : 'unknown';
			foreach ( $db_results['results'] as $report ) {
				$table_label = isset( $report['table'] ) ? $report['table'] : '';
				$table_label = $table_label ? ( $wpdb->prefix . $table_label ) : 'db';
				$record_id   = isset( $report['id'] ) ? $report['id'] : '';
				$this->insert_issue(
					$scan_id,
					array(
						'type'         => 'database',
						'severity'     => $report['severity'],
						'infection_id' => $report['signature'],
						'pointer'      => array(
							'table' => $table_label,
							'id'    => $record_id,
						),
						'comment'      => array( 'message' => $report['message'] ),
					)
				);
			}
			if ( ! empty( $db_scan_log ) && ! empty( $GLOBALS['WPMR']['timestamp'] ) ) {
				$record      = $GLOBALS['WPMR']['timestamp'];
				$db_scan_log = array( 'db' => $db_scan_log );
				$this->update_saved_records( $record, $db_scan_log );
			}
			return $db_results;
		}
		return $db_results;
	}

	/**
	 * Retrieve saved scan record payload for a given timestamp.
	 *
	 * Stored as a transient named `WPMR_log_{timestamp}`.
	 *
	 * Better name: get_scan_record()
	 *
	 * @param string|int $record Scan timestamp/identifier.
	 * @return array{vulnerabilities:array,db:array,files:array} Saved record data (with defaults).
	 */
	function get_saved_records( $record ) {
		$defaults = array(
			'vulnerabilities' => array(),
			'db'              => array(),
			'files'           => array(),
		);
		if ( ! $record ) {
			return $defaults;
		}

		$saved = get_transient( 'WPMR_log_' . $record );
		if ( $saved ) {
			$saved = json_decode( $saved, true );
		}

		if ( is_array( $saved ) ) {
			$saved = array_merge( $defaults, $saved );
		} else {
			$saved = $defaults;
		}
		return $saved;
	}

	/**
	 * Merge and persist scan record data for a given timestamp.
	 *
	 * Merges recursively into existing transient payload.
	 *
	 * Better name: update_scan_record()
	 *
	 * @param string|int $record Scan timestamp/identifier.
	 * @param array      $data   Data to merge into the saved record.
	 * @return bool|null True/false from set_transient when valid; null when invalid data.
	 */
	function update_saved_records( $record, $data ) {
		if ( is_array( $data ) ) {
			$saved = $this->get_saved_records( $record );
			$data  = array_merge_recursive( $saved, $data );
			return set_transient( 'WPMR_log_' . $record, json_encode( $data ), 30 * DAY_IN_SECONDS );
		} else {
			$this->flog( 'Invalid data sent to function ' . __FUNCTION__ );
		}
	}

	/**
	 * Scan a single user for identity-field gaps, app passwords, elevated caps, and session
	 * IP-spread anomalies.
	 *
	 * Build the canonical human-readable reason string for a flagged user account.
	 *
	 * Single source of truth for every render surface — persisted issue records
	 * (`scan_user()`), the WP-CLI summary (`inc/pro.php`), and the browser tooltip
	 * (via the stateful payload exporter) all consume this string. There is no other
	 * copy. Previously three hand-maintained builders drifted on the IP-spread
	 * condition, the threshold source, the name-missing predicate, and the ordering.
	 *
	 * Accepts the union of every factor shape in use across the codebase:
	 *   - `scan_user()`'s local vars ($missing_email, $session_anomaly, …)
	 *   - The persisted `comment` JSON booleans (missing_first_name, etc.)
	 *   - The CLI `$user` result array (same key names as scan_user()'s return)
	 *
	 * ## Why trait access, not self::
	 *
	 * Lives in WPMR_Scanner but is invoked from inc/pro.php's Malcure_Advanced class
	 * via $wpmr->format_user_reasons(). PHP trait constants resolve against the
	 * composing class at runtime, so $this::IP_SPREAD_THRESHOLD works from any caller
	 * that mixes in this trait (the WPMR_Init singleton) — and from external classes
	 * that hold a reference to it.
	 *
	 * ## Why session_anomaly is the gate, not severity === 'severe'
	 *
	 * The legacy builders gated the IP-spread reason on `severity === 'severe'`,
	 * which is dead in 19.9.4 (scan_user_severity() returns 'suspicious'). That made
	 * the IP reason vanish from server/CLI output while the browser still showed it.
	 * The correct gate is `session_anomaly`: it is only ever true when the Wilson
	 * score already crossed IP_SPREAD_THRESHOLD, which is the engine's own decision.
	 * Surfacing the reason whenever the engine flagged the anomaly is the contract.
	 *
	 * @param array $factors {
	 *     @type bool        $missing_email        Email empty or invalid.
	 *     @type bool        $session_anomaly      IP-spread score >= IP_SPREAD_THRESHOLD.
	 *     @type bool        $missing_name         First AND last name empty. (canonical)
	 *     @type bool        $missing_first_name   First name empty. (legacy persisted shape)
	 *     @type bool        $missing_last_name    Last name empty. (legacy persisted shape)
	 *     @type bool        $has_app_pw           One or more application passwords.
	 *     @type bool        $has_app_passwords    Alias of $has_app_pw (CLI/persisted shape).
	 *     @type int         $app_password_count   Application password count.
	 *     @type bool        $has_elevated_caps    Exceeds Contributor baseline.
	 * }
	 * @return string Comma-separated reason labels, or '' when no factor is set.
	 */
	function format_user_reasons( $factors ) {
		$factors = is_array( $factors ) ? $factors : array();

		// Unify the two factor shapes that exist in the codebase.
		$has_app_pw = ! empty( $factors['has_app_pw'] ) || ! empty( $factors['has_app_passwords'] );

		// Canonical name-missing predicate: first AND last empty. This is what
		// scan_user() computes locally as $missing_name. The persisted comment JSON
		// and the CLI $user array carry missing_first_name / missing_last_name
		// separately, so resolve back to the AND when those are the source.
		if ( isset( $factors['missing_name'] ) ) {
			$missing_name = ! empty( $factors['missing_name'] );
		} else {
			$missing_first = ! empty( $factors['missing_first_name'] );
			$missing_last  = ! empty( $factors['missing_last_name'] );
			$missing_name  = $missing_first && $missing_last;
		}

		$reasons = array();

		if ( ! empty( $factors['missing_email'] ) ) {
			$reasons[] = 'email missing';
		}
		if ( ! empty( $factors['session_anomaly'] ) ) {
			$reasons[] = 'session IP-spread anomaly';
		}
		if ( $has_app_pw ) {
			$count     = isset( $factors['app_password_count'] ) ? (int) $factors['app_password_count'] : 0;
			$reasons[] = 'app passwords: ' . $count;
		}
		if ( ! empty( $factors['has_elevated_caps'] ) ) {
			$reasons[] = 'elevated caps';
		}
		if ( $missing_name ) {
			$reasons[] = 'name missing';
		}

		return implode( ', ', $reasons );
	}

	/**
	 * Five named boolean factors resolved to severity by scan_user_severity().
	 * Uncached direct queries on scan-correctness path; get_user_meta()/get_userdata() go
	 * through the object cache and are intentionally avoided for scan-correctness reads.
	 *
	 * @param int    $user_id User ID.
	 * @param string $scan_id Optional. Scan identifier for persistence.
	 *                        When omitted, falls back to $GLOBALS['WPMR']['timestamp'].
	 * @return array|null Result array, or null if user is deleted/invalid.
	 */
	function scan_user( $user_id, $scan_id = null ) {
		global $wpdb;

		$user_id = (int) $user_id;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scan-correctness path must read live DB truth, not object-cache reuse.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT u.user_login, u.user_email,
						fn.meta_value AS first_name,
						ln.meta_value AS last_name,
						ap.meta_value AS app_passwords,
						cap.meta_value AS capabilities,
						st.meta_value AS session_tokens
				 FROM {$wpdb->users} u
				 LEFT JOIN {$wpdb->usermeta} fn
					 ON u.ID = fn.user_id AND fn.meta_key = 'first_name'
				 LEFT JOIN {$wpdb->usermeta} ln
					 ON u.ID = ln.user_id AND ln.meta_key = 'last_name'
				 LEFT JOIN {$wpdb->usermeta} ap
					 ON u.ID = ap.user_id AND ap.meta_key = %s
				 LEFT JOIN {$wpdb->usermeta} cap
					 ON u.ID = cap.user_id AND cap.meta_key = %s
				 LEFT JOIN {$wpdb->usermeta} st
					 ON u.ID = st.user_id AND st.meta_key = 'session_tokens'
				 WHERE u.ID = %d",
				'_application_passwords',
				$wpdb->get_blog_prefix() . 'capabilities',
				$user_id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( ! $row ) {
			return null;
		}

		// Factor 1: Email missing (empty or invalid format).
		$missing_email = empty( $row['user_email'] )
			|| ! filter_var( $row['user_email'], FILTER_VALIDATE_EMAIL );

		// Factor 2: Name missing (first AND last empty).
		$missing_name = empty( $row['first_name'] ) && empty( $row['last_name'] );

		// Factor 3: Has app passwords.
		$app_passwords = maybe_unserialize( $row['app_passwords'] );
		$has_app_pw    = is_array( $app_passwords ) && ! empty( $app_passwords );
		$app_pw_count  = is_array( $app_passwords ) ? count( $app_passwords ) : 0;

		// Factor 4: Has elevated caps (administrator, editor, or publish_posts).
		$caps              = maybe_unserialize( $row['capabilities'] );
		$has_elevated_caps = $this->user_exceeds_contributor( $caps );

		// Factor 5: Session IP-spread anomaly. Unique BUCKETS (IPv4 exact, IPv6 collapsed
		// to /64), not raw IPs. Bucketing absorbs IPv6 privacy-extension rotation within
		// a /64 so one human on one network can never look like 10 distinct networks.
		$session_anomaly = false;
		$session_count   = 0;
		$unique_ip_count = 0;                         // Unique BUCKETS — what the engine counts.
		$raw_unique_ips  = 0;                          // Diagnostic only, not surfaced in the result.
		$ip_spread_score = 0.0;

		$sessions = maybe_unserialize( $row['session_tokens'] );
		if ( is_array( $sessions ) ) {
			$buckets = array(); // bucket(IP) -> true. Bucketed, not raw IP.
			$raw_ips = array(); // raw IP -> true. Diagnostic only.
			$now     = time();
			foreach ( $sessions as $session ) {
				// Prune expired sessions — they represent past activity, not current access.
				if ( empty( $session['expiration'] ) || $session['expiration'] < $now ) {
					continue;
				}
				++$session_count;
				if ( ! empty( $session['ip'] ) ) {
					$raw_ips[ $session['ip'] ] = true;
					$bucket                    = $this->ip_bucket( $session['ip'] );
					if ( null !== $bucket ) {
						$buckets[ $bucket ] = true;
					}
				}
			}
			$unique_ip_count = count( $buckets );
			$raw_unique_ips  = count( $raw_ips );

			// Wilson score: (unique_buckets - 1, sessions - 1). Single-session user can never flag.
			if ( $session_count > 1 ) {
				$ip_spread_score = $this->wilson_lower_bound(
					$unique_ip_count - 1,
					$session_count - 1
				);
				$session_anomaly = $ip_spread_score >= self::IP_SPREAD_THRESHOLD;
			}
		}

		$severity = $this->scan_user_severity(
			$missing_email,
			$session_anomaly,
			$missing_name,
			$has_app_pw,
			$has_elevated_caps
		);

		// Canonical human-readable reason string. format_user_reasons() is the
		// single source of truth shared with the CLI and the browser tooltip —
		// any change here propagates everywhere. See its docblock for the contract.
		$reason = $this->format_user_reasons(
			array(
				'missing_email'      => $missing_email,
				'session_anomaly'    => $session_anomaly,
				'missing_name'       => $missing_name,
				'has_app_pw'         => $has_app_pw,
				'app_password_count' => $app_pw_count,
				'has_elevated_caps'  => $has_elevated_caps,
			)
		);

		// Persist flagged users to the issues table so the stateful scanner page
		// can render them without a fresh scan. Matches the convention used by
		// vulnerability_scan(), wpmr_scan_files(), and db_scan_batch() — every scan
		// type writes to the issues table at the point of detection.
		if ( 'none' !== $severity ) {
			$effective_scan_id = ( null !== $scan_id ) ? $scan_id : ( ! empty( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : '' );
			if ( $effective_scan_id && ! $this->issue_exists_by_infection_id( $effective_scan_id, 'user', 'user_' . $user_id ) ) {
				$this->insert_issue(
					$effective_scan_id,
					array(
						'type'         => 'user',
						'severity'     => $severity,
						'infection_id' => 'user_' . $user_id,
						'pointer'      => $user_id,
						'comment'      => array(
							'message'            => 'User account User ID ' . (int) $user_id . ' flagged: ' . $reason,
							'user_id'            => $user_id,
							'severity'           => $severity,
							'missing_email'      => $missing_email,
							'missing_first_name' => empty( $row['first_name'] ),
							'missing_last_name'  => empty( $row['last_name'] ),
							'has_app_passwords'  => $has_app_pw,
							'app_password_count' => $app_pw_count,
							'has_elevated_caps'  => $has_elevated_caps,
							'session_count'      => $session_count,
							'unique_ip_count'    => $unique_ip_count, // unique BUCKETS — IPv4 exact, IPv6 /64
							'ip_spread_score'    => $ip_spread_score,
							'reason'             => $reason, // Canonical reason string from format_user_reasons(); consumed by stateless browser UI so JS never re-implements factor logic.
						),
					)
				);
			}
		}

		return array(
			'ID'                 => $user_id,
			'username'           => $row['user_login'],
			'severity'           => $severity,
			'missing_first_name' => empty( $row['first_name'] ),
			'missing_last_name'  => empty( $row['last_name'] ),
			'missing_email'      => $missing_email,
			'has_app_passwords'  => $has_app_pw,
			'app_password_count' => $app_pw_count,
			'has_elevated_caps'  => $has_elevated_caps,
			'session_count'      => $session_count,
			'unique_ip_count'    => $unique_ip_count, // unique BUCKETS — IPv4 exact, IPv6 /64
			'ip_spread_score'    => $ip_spread_score,
			// $raw_unique_ips is intentionally NOT surfaced — it is the pre-bucketing count and
			// would mislead anyone comparing it against `unique_ip_count`. Kept only as a local
			// diagnostic so the call below is traceable.
		);
	}

	/**
	 * Resolve five evaluated user factors to a severity tier.
	 *
	 * Priority chain — dominance, not accumulation. Earlier rules short-circuit later: email
	 * and session anomaly dominate (bypassed registration / live compromise); then identity
	 * weakness combined with caps or persistence escalates; app-pw only is informational;
	 * name-only / clean are not surfaced (first/last name are optional in WordPress).
	 *
	 * @param bool $missing_email      Email is empty or invalid.
	 * @param bool $session_anomaly    IP-spread score >= IP_SPREAD_THRESHOLD (Wilson lower bound).
	 * @param bool $missing_name       First AND last name are empty.
	 * @param bool $has_app_pw         One or more application passwords.
	 * @param bool $has_elevated_caps  Has administrator, editor, or publish_posts capability.
	 * @return string 'none', 'skipped', 'suspicious', or 'severe' (in 19.9.5).
	 */
	function scan_user_severity( $missing_email, $session_anomaly, $missing_name, $has_app_pw, $has_elevated_caps ) {
		// 1. Email missing — always severe (bypassed registration path).
		if ( $missing_email ) {
			return 'suspicious'; // 'severe'; // gated till 19.9.5
		}

		// 2. Session anomaly — always severe (live compromise).
		if ( $session_anomaly ) {
			return 'suspicious'; // 'severe'; // gated till 19.9.5
		}

		// 3. Name missing + app passwords + elevated caps — full threat profile.
		if ( $missing_name && $has_app_pw && $has_elevated_caps ) {
			return 'suspicious'; // 'severe'; // gated till 19.9.5
		}

		// 4. Name missing + elevated caps — dormant admin / adminbackup pattern.
		if ( $missing_name && $has_elevated_caps ) {
			return 'suspicious';
		}

		// 5. Name missing + app passwords (no elevated caps) — bot-like, limited blast radius.
		if ( $missing_name && $has_app_pw ) {
			return 'suspicious';
		}

		// 6. App passwords only — legitimate integration, informational.
		if ( $has_app_pw ) {
			return 'skipped';
		}

		// 7. Name missing only — first/last name are optional, not surfaced.
		// 8. Everything else — clean.
		return 'none';
	}

	/**
	 * Scan all users for identity-field gaps, app passwords, caps, and session anomalies.
	 *
	 * Synchronous CLI loop — the CLI equivalent of the browser AJAX batch loop. Mirrors
	 * wpmr_scan_files() returning an array in CLI mode instead of wp_send_json(). Reuses
	 * scan_user() for per-user detection. Only flagged users (severity skipped/suspicious/severe)
	 * are returned; severity none is excluded.
	 *
	 * @param object $user_stats User stats object with min_id, max_id from bootstrap.
	 * @return array Flagged user result arrays (severity skipped/suspicious/severe only).
	 */
	function scan_users_cli( $user_stats ) {
		$results   = array();
		$min_id    = (int) $user_stats->min_id;
		$max_id    = (int) $user_stats->max_id;
		$batchsize = 100;
		$pointer   = $min_id - 1;

		while ( $pointer < $max_id ) {
			global $wpdb;
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scanner reads live DB truth for user account audit.
			$user_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->users} WHERE ID > %d AND ID <= %d ORDER BY ID",
					$pointer,
					$pointer + $batchsize
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

			foreach ( $user_ids as $uid ) {
				$result = $this->scan_user( (int) $uid );
				if ( $result && 'none' !== $result['severity'] ) {
					$results[] = $result;
				}
			}

			$pointer += $batchsize;
		}

		return $results;
	}

	/**
	 * Check whether an unserialized capabilities usermeta exceeds the Contributor baseline.
	 *
	 * Exclusion-based: returns true if the caps array contains ANYTHING not in SAFE_CAPS.
	 * Catches bundled-plugin and custom capabilities (manage_woocommerce, elementor editing,
	 * membership access, etc.) automatically. WordPress caps are flat (populate_roles_160 in
	 * wp-admin/includes/schema.php enumerates every role's caps by hand, no inheritance), so an
	 * inclusion-list check is structurally incomplete whereas exclusion is structurally complete.
	 * Roles are stored in usermeta as slug=>true; 'administrator' is not in SAFE_CAPS, so it flags.
	 *
	 * @param array|null $caps Unserialized $wpdb->capabilities meta value.
	 * @return bool True if any cap or role slug exceeds the Contributor baseline.
	 */
	function user_exceeds_contributor( $caps ) {
		if ( ! is_array( $caps ) ) {
			return false;
		}
		$caps_true = array_filter( $caps ); // drop false-y values
		$excess    = array_diff_key( $caps_true, array_flip( self::SAFE_CAPS ) );
		return ! empty( $excess );
	}

	/**
	 * Normalize a raw session IP into a comparison bucket.
	 *
	 * Bucketing, not classification. IPv4 keeps its exact address; IPv6 is masked to /64 so
	 * privacy-extension rotation within one /64 collapses to a single bucket and cannot
	 * inflate the unique count. Returns null for missing/invalid so the caller can skip
	 * the session's contribution to the bucket set while still incrementing session_count.
	 *
	 * @param string $ip Raw session_tokens 'ip' value.
	 * @return string|null Bucket key, or null for invalid/missing.
	 */
	function ip_bucket( $ip ) {
		$ip = (string) $ip;
		if ( '' === $ip ) {
			return null;
		}
		$packed = @inet_pton( $ip );
		if ( false === $packed ) {
			return null; // not parseable
		}

		$len = strlen( $packed );

		// 16-byte packed form = IPv6. Mask to the first 8 bytes (/64) and expand back to text.
		// Note: ::ffff:IPv4-mapped addresses parse to 16-byte form. Masking their first 8 bytes
		// collapses them to '::' (the all-zero /64), which is acceptable — multiple mapped-v4
		// addresses share that single bucket instead of inflating the unique count.
		if ( 16 === $len ) {
			$masked   = substr( $packed, 0, 8 ) . "\0\0\0\0\0\0\0\0";
			$expanded = inet_ntop( $masked );
			return false === $expanded ? null : $expanded;
		}

		// 4-byte packed form = IPv4.
		if ( 4 === $len ) {
			$expanded = inet_ntop( $packed );
			return false === $expanded ? null : $expanded;
		}

		return null; // unexpected length
	}

	/**
	 * Wilson score interval lower bound.
	 *
	 * Confidence-adjusted binomial proportion. Penalizes small n (few sessions), rewards
	 * large n with consistent signal. Standard statistics, no external dependency.
	 *
	 * @param int|float $k Successes (new buckets beyond baseline).
	 * @param int|float $n Trials (sessions beyond baseline).
	 * @param float     $z Z-score for confidence level (default 1.96 = 95%).
	 * @return float Lower bound of the Wilson score interval, clamped to [0, 1].
	 */
	function wilson_lower_bound( $k, $n, $z = 1.96 ) {
		$n = (float) $n;
		if ( $n <= 0 ) {
			return 0.0;
		}

		$k  = (float) $k;
		$p  = $k / $n;
		$z2 = $z * $z;

		$denom  = 1 + ( $z2 / $n );
		$center = $p + ( $z2 / ( 2 * $n ) );
		$margin = $z * sqrt(
			( ( $p * ( 1 - $p ) ) + ( $z2 / ( 4 * $n ) ) ) / $n
		);

		return (float) max( 0.0, min( 1.0, ( $center - $margin ) / $denom ) );
	}

	/**
	 * Get the maximum user ID from the users table.
	 *
	 * Uncached direct read on the scan-correctness path.
	 *
	 * @return int Maximum user ID, or 0 if no users.
	 */
	function get_max_user_id() {
		global $wpdb;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- MAX(ID) via PK index for user scan cursor.
		return (int) $wpdb->get_var( "SELECT MAX(ID) FROM {$wpdb->users}" );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * AJAX handler: scan a batch of users for identity-field gaps and app passwords.
	 *
	 * Stateless scanner endpoint. Batched via pointer/max_id. Only users with severity
	 * low/suspicious/severe are returned; severity 'none' is excluded.
	 *
	 * Registered on wp_ajax_wpmr_scan_users.
	 */
	function wpmr_scan_users_callback() {
		check_ajax_referer( 'wpmr_scan_users', 'wpmr_scan_users_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}

		$this->set_args();

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scanner reads live DB truth for user account audit.
		$batchsize = isset( $_REQUEST['batchsize'] ) ? intval( $_REQUEST['batchsize'] ) : 100;
		$pointer   = isset( $_REQUEST['pointer'] ) ? intval( $_REQUEST['pointer'] ) : 0;
		$max_id    = $this->get_max_user_id();
		$timestamp = isset( $_REQUEST['timestamp'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['timestamp'] ) ) : '';

		global $wpdb;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Scanner reads live DB truth for user account audit.
		$user_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->users} WHERE ID > %d AND ID <= %d ORDER BY ID",
				$pointer,
				$pointer + $batchsize
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		$results = array();
		foreach ( $user_ids as $uid ) {
			$result = $this->scan_user( (int) $uid );
			if ( $result && 'none' !== $result['severity'] ) {
				$results[] = $result;
			}
		}

		// Persist this batch's findings to the scan record transient server-side,
		// matching the contract of db_scan_batch(). Dedup against previously-written
		// batches by user ID so the JS retry loop (which can re-send a batch that
		// succeeded server-side) does not produce duplicate rows in page=wpmr_logs.
		if ( $timestamp && $results ) {
			$existing = $this->get_saved_records( $timestamp );
			$seen     = isset( $existing['users'] ) ? array_column( $existing['users'], 'ID' ) : array();
			$deduped  = array_filter(
				$results,
				function ( $u ) use ( $seen ) {
					return ! in_array( $u['ID'], $seen, true );
				}
			);
			if ( $deduped ) {
				$this->update_saved_records( $timestamp, array( 'users' => array_values( $deduped ) ) );
			}
		}

		$new_pointer = $pointer + $batchsize;

		wp_send_json(
			array(
				'results'     => $results,
				'new_pointer' => $new_pointer,
				'done'        => $new_pointer >= $max_id,
			)
		);
	}

	/**
	 * Perform a full DB scan in WP-CLI context (non-batched).
	 *
	 * Better name: scan_database_cli()
	 *
	 * @return array<string, mixed> Scan results keyed by record ID.
	 */
	function db_scan_cli() {
		global $wpdb;
		$scan_sqls = array(
			'post'      => "SELECT ID AS id, post_content AS content, post_type as post_type FROM $wpdb->posts where post_content LIKE '%s'",
			'post_meta' => "SELECT meta_id AS id, meta_value AS content FROM $wpdb->postmeta where meta_value LIKE '%s'",
			'option'    => "SELECT option_id AS id, option_value AS content FROM $wpdb->options WHERE option_value LIKE '%s' AND option_name NOT IN ('GOTMLS_definitions_array', 'bvruleset')",
			'comment'   => "SELECT comment_ID AS id, comment_content AS content FROM $wpdb->comments WHERE comment_content LIKE '%s' AND comment_approved = '1'",
		);

		$definitions = $this->get_definitions()['definitions']['db'];
		$db_results  = array();
		$db_scan_log = array();
		if ( ! empty( $GLOBALS['WPMR']['wpmr_extra_db_query'] ) && ! empty( $GLOBALS['WPMR']['wpmr_extra_db_regex'] ) ) {
			$definitions['DWPMR'] = array(
				'severity'  => 'severe',
				'query'     => $GLOBALS['WPMR']['wpmr_extra_db_query'],
				'signature' => $GLOBALS['WPMR']['wpmr_extra_db_regex'],
			);
		}
		foreach ( $definitions as $ver => $details ) {
			foreach ( $scan_sqls as $key => $sql ) {
				// The SQL template comes from the fixed scan map above; only the search term is runtime data.
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Legacy DB scan fallbacks must query live row content directly from fixed scan templates.
				// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
				$results = $wpdb->get_results( $wpdb->prepare( $sql, $this->decode( $details['query'] ) ), ARRAY_A );
				// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				foreach ( $results as $result ) {
					$content     = $result['content'];
					$id          = sanitize_text_field( $result['id'] );
					$post_status = empty( $result['post_type'] ) ? '' : 'post-type &rarr; ' . $result['post_type'] . '.';
					try {
						$matches = preg_match( $this->decode( $details['signature'] ), $content, $found );
					} catch ( Exception $e ) {
						$this->flog( 'Faulty Signature: ' . $definition, false, false, true );
						$this->flog( 'Faulty Pattern: ' . $this->decode( $signature['signature'] ), false, false, true );
						$this->flog( 'Key: ' . $key, false, false, true );
						$this->flog( $e->getMessage(), false, false, true );
						continue;
					}
					if ( $matches >= 1 ) {
						if ( in_array( $details['severity'], array( 'severe', 'high' ) ) ) {
							$this->update_setting( 'infected', true );
						}
						$db_results[ $id ]  = $this->set_status( $details['severity'], preg_replace( '/[^A-Za-z0-9]/', ' ', $key ) . " ID $id. ", $ver );
						$db_scan_log[ $id ] = array(
							'severity'  => $details['severity'],
							'infection' => $ver,
							'type'      => ucwords( $key ),
							'id'        => $id,
						);

						// Persist to the issues table at the point of detection — matches
						// db_scan_batch()'s contract so CLI scans and browser scans share
						// one canonical record via the stateful monitor page.
						$scan_id = ! empty( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : '';
						if ( $scan_id && ! $this->issue_exists_by_infection_id( $scan_id, 'database', $ver ) ) {
							$this->insert_issue(
								$scan_id,
								array(
									'type'         => 'database',
									'severity'     => $details['severity'],
									'infection_id' => $ver,
									'pointer'      => array(
										'table' => $wpdb->prefix . $key,
										'id'    => (int) $id,
									),
									'comment'      => array(
										'message' => preg_replace( '/[^A-Za-z0-9]/', ' ', $key ) . ' ID ' . (int) $id . '.',
									),
								)
							);
						}
					}
				}
			}
		}
		if ( $db_results ) {
			if ( ! empty( $db_scan_log ) && ! empty( $GLOBALS['WPMR']['timestamp'] ) ) {
				$record      = $GLOBALS['WPMR']['timestamp'];
				$db_scan_log = array( 'db' => $db_scan_log );
				$this->update_saved_records( $record, $db_scan_log );
			}
			return $db_results;
		}
		return $db_results;
	}

	/**
	 * Get the user-configured file whitelist from plugin settings.
	 *
	 * This is the Advanced Edition manual whitelist (path => sha256), distinct from
	 * the SaaS-served whitelist served via definitions (see get_file_whitelist()).
	 *
	 * @return array|false Whitelist mapping (file => sha256) or false when unset.
	 */
	function get_user_whitelist() {
		return $this->get_setting( 'whitelist' );
	}

	/**
	 * Render the whitelist entries as HTML.
	 *
	 * Intended for admin UI rendering.
	 *
	 * Better name: render_user_whitelist_html()
	 *
	 * @return void Outputs HTML.
	 */
	function render_whitelist() {
		$whitelist = $this->get_user_whitelist( 'whitelist' );
		if ( $whitelist ) {
			foreach ( $whitelist as $file => $hash ) {
				if ( @hash_file( 'sha256', $file ) == $hash ) {
					echo '<p data-file-wrap="' . esc_attr( $file ) . '"><span data-file="' . esc_attr( $file ) . '" class="dashicons dashicons-dismiss remove-from-whitelist"></span>' . esc_html( $file ) . '</p>';
				}
			}
		}
	}

	/**
	 * Check whether a page title appears to have been defaced.
	 *
	 * Fetches the page content and inspects <title> strings via DOMDocument.
	 *
	 * Better name: is_page_title_hacked()
	 *
	 * @param string $url URL to check.
	 * @return bool True when hacked strings are detected.
	 */
	function check_page_hack( $url = '' ) {
		if ( empty( $url ) || ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
			$this->flog( 'Invalid URL ' . $this->debug_value_to_string( $url ) . ' in ' . __FUNCTION__ );
			return false;
		}
		$url = add_query_arg( array( 'cachebust' => uniqid( '', 1 ) ), $url );
		if ( ! $content = $this->get_remote_response( $url ) ) {
			$this->flog( 'Failed to get remote response for URL: ' . $url );
			return false;
		}
		if ( ! $content = wp_remote_retrieve_body( $content ) ) {
			$this->flog( '$wp_remote_retrieve_body:' . $content );
			return false;
		}
		if ( empty( $content ) ) {
			$this->flog( 'empty content' );
			return false;
		}
		libxml_use_internal_errors( true );
		if ( ! class_exists( 'DOMDocument' ) ) {
			$this->flog( 'DOMDocument class not found in ' . __FUNCTION__ );
			return false;
		}
		$dom = new DOMDocument();
		$dom->loadHTML( $content );
		$content = $dom->getElementsByTagName( 'title' );
		$strings = array();
		foreach ( $content as $c ) {
			$strings[] = $c->nodeValue;
		}
		libxml_use_internal_errors( false );
		if ( empty( $strings ) ) {
			return false;
		}
		return $this->check_string_hack( $strings );
	}

	/**
	 * Check whether a URL redirects externally (possible redirect hijack).
	 *
	 * Uses get_headers() with a Referer header and detects 301/302 to a different host.
	 *
	 * Better name: is_redirect_hijacked()
	 *
	 * @param string $url     URL to test.
	 * @param string $referer Referer to send.
	 * @return bool|null True when redirecting to external domain; null when unable to evaluate.
	 */
	function check_redirect_hijack( $url, $referer = 'https://www.google.com/' ) {
		$url = trailingslashit( $url );
		stream_context_set_default(
			array(
				'http' => array(
					'method'          => 'GET',
					'header'          => array( 'Referer: ' . $referer ),
					'user_agent'      => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'WordPress',
					'follow_location' => 0,
				),
			)
		);
		$headers = @get_headers( $url, 1 ); // error control because if allow_url_fopen is not available then this fails
		if ( ! empty( $headers ) && is_array( $headers ) ) {
			preg_match( '/\d\d\d/', $headers[0], $matches );
		} else {
			return;
		}
		$status_code = $matches[0];
		if ( ( $status_code == 301 || $status_code == 302 ) && ! empty( $headers['Location'] ) ) {
			$r_host  = wp_parse_url( $headers['Location'], PHP_URL_HOST );
			$wp_host = wp_parse_url( $url, PHP_URL_HOST );
			if ( strpos( $this->normalize_host( $wp_host ), $this->normalize_host( $r_host ) ) === false ) {
				return true; // redirecting to external domain
			}
		}
	}

	/**
	 * Check a list of strings for common defacement/hack patterns.
	 *
	 * Adds the site's name and description to the scan list.
	 *
	 * Better name: contains_hack_defacement_markers()
	 *
	 * @param string[] $strings Strings to test.
	 * @return bool True when patterns match.
	 */
	function check_string_hack( $strings = array() ) {
		$strings[] = get_bloginfo( 'name' );
		$strings[] = get_bloginfo( 'description' );
		$regexes   = array( '/h[\@a]ck[3e]d.*by/is', '/[^<]*hack[3e][rd]/i' );
		$infected  = false;
		foreach ( $strings as $str ) {
			$this->flog( 'Checking string: ' . $str );
			foreach ( $regexes as $regex ) {
				if ( preg_match( $regex, $str ) ) {
					$infected = true;
				} else {
				}
			}
		}
		return $infected;
	}

	/**
	 * Check common front-page targets for redirect hijacking.
	 *
	 * Better name: is_site_redirect_hijacked()
	 *
	 * @return bool|null True when hijack detected; null when underlying check can't evaluate.
	 */
	function redirect_hijack() {
		if ( 'page' == get_option( 'show_on_front' ) ) {
			$page_on_front  = get_option( 'page_on_front' );
			$page_for_posts = get_option( 'page_for_posts' );
			return ( ! empty( $page_on_front ) ? $this->check_redirect_hijack( get_permalink( $page_on_front ) ) : false ) || ( ! empty( $page_for_posts ) ? $this->check_redirect_hijack( get_permalink( $page_for_posts ) ) : false );
		} else {
			return $this->check_redirect_hijack( home_url() );
		}
	}

	/**
	 * Check common front-page targets for title defacement/hack strings.
	 *
	 * Better name: is_site_title_hacked()
	 *
	 * @return bool True when defacement detected.
	 */
	function title_hack() {
		if ( 'page' == get_option( 'show_on_front' ) ) {
			$page_for_posts = get_option( 'page_for_posts' );
			$page_on_front  = get_option( 'page_on_front' );
			// there may be a chance that only one of these is set: page_on_front has not been set but page_for_posts has been set
			return $this->check_page_hack( get_permalink( $page_for_posts ) ) || $this->check_page_hack( get_permalink( $page_on_front ) );
		} else {
			return $this->check_page_hack( home_url() );
		}
	}

	/**
	 * Process and validate base64-encoded regex input from user requests.
	 *
	 * @param string $request_value Raw request value containing base64-encoded regex.
	 * @return string|false Encoded valid regex pattern, or false if invalid.
	 */
	private function process_regex_input( $request_value ) {
		$sanitized = sanitize_text_field( wp_unslash( $request_value ) );
		$decoded   = $this->validate_and_decode_base64_input( $sanitized );

		if ( false === $decoded ) {
			return false;
		}

		return $this->validate_and_encode_regex_pattern( $decoded );
	}

	/**
	 * Validate and encode a raw regex pattern for scanner runtime use.
	 *
	 * Shared by decoded web input and raw CLI regex arguments so both entry paths
	 * enforce the same regex-safety contract without changing the public CLI flag.
	 *
	 * @param mixed $pattern Raw regex pattern.
	 * @return string|false Encoded valid regex pattern, or false if invalid.
	 */
	private function validate_and_encode_regex_pattern( $pattern ) {
		if ( ! is_scalar( $pattern ) ) {
			$this->flog( 'Non-scalar regex pattern detected in scan parameters' );
			return false;
		}

		$pattern = (string) $pattern;

		if ( preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $pattern ) ) {
			$this->flog( 'Your custom regular expression contains raw control characters. This does not prevent scanning malware that uses control characters, but the pattern itself must use escaped forms like \\x00, \\r, \\n, or \\t instead of embedding hidden bytes directly.' );
			return false;
		}

		if ( strlen( $pattern ) > 10000 ) {
			$this->flog( 'Regex pattern exceeds maximum length' );
			return false;
		}

		if ( ! $this->is_safe_regex_pattern( $pattern ) ) {
			return false;
		}

		return $this->encode( $pattern );
	}

	/**
	 * Validate a CLI regex argument and fail loudly when it is unsafe.
	 *
	 * @param array  $args         CLI associative arguments.
	 * @param string $argument_key CLI argument key without leading dashes.
	 * @param string $flag_name    Human-readable CLI flag name.
	 * @return string|null Encoded valid regex pattern, or null when the flag was omitted.
	 */
	private function process_cli_regex_argument( $args, $argument_key, $flag_name ) {
		if ( empty( $args[ $argument_key ] ) ) {
			return null;
		}

		$encoded_pattern = $this->validate_and_encode_regex_pattern( $args[ $argument_key ] );

		if ( false !== $encoded_pattern ) {
			return $encoded_pattern;
		}

		if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'WP_CLI' ) ) {
			WP_CLI::error( 'Invalid or unsafe regular expression supplied via ' . $flag_name . '. If you are trying to match control characters in malware content, keep the same flag but express them with escapes like \\x00, \\r, \\n, or \\t instead of embedding raw control bytes directly in the pattern.' );
		}

		return null;
	}

	/**
	 * Process and validate base64-encoded input from user requests.
	 *
	 * @param string $request_value Raw request value containing base64-encoded data.
	 * @return string|false Encoded valid input, or false if invalid.
	 */
	private function process_base64_input( $request_value ) {
		$sanitized = sanitize_text_field( wp_unslash( $request_value ) );
		$decoded   = $this->validate_and_decode_base64_input( $sanitized );

		if ( false === $decoded ) {
			return false;
		}

		return $this->encode( $decoded );
	}

	/**
	 * Validate base64-decoded user input for regex patterns.
	 *
	 * Ensures decoded content is valid base64 and results in a legitimate pattern string.
	 * Prevents injection of arbitrary patterns that could cause ReDoS or bypass detection.
	 *
	 * @param string $encoded_input Base64-encoded input from user request.
	 * @return string|false Decoded and validated pattern, or false if invalid.
	 */
	private function validate_and_decode_base64_input( $encoded_input ) {
		// Strict base64 decode - returns false if invalid
		$decoded = base64_decode( $encoded_input, true );

		if ( false === $decoded ) {
			$this->flog( 'Invalid base64 input detected in scan parameters' );
			return false;
		}

		// Basic validation: check for null bytes and control characters (except newlines/tabs)
		if ( preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $decoded ) ) {
			$this->flog( 'Control characters detected in decoded input' );
			return false;
		}

		// Length check - prevent absurdly long patterns
		if ( strlen( $decoded ) > 10000 ) {
			$this->flog( 'Decoded input exceeds maximum length' );
			return false;
		}

		return $decoded;
	}

	/**
	 * Validate regex pattern for safety before execution.
	 *
	 * Checks for potentially dangerous regex patterns that could cause
	 * ReDoS (Regular Expression Denial of Service) attacks.
	 *
	 * @param string $pattern Regex pattern to validate.
	 * @return bool True if pattern appears safe, false otherwise.
	 */
	private function is_safe_regex_pattern( $pattern ) {
		// Check if pattern is valid PCRE syntax by testing against empty string
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Validation temporarily suppresses regex warnings while probing pattern syntax.
		$prev_error_handler = set_error_handler( function () {} );
		$result             = @preg_match( $pattern, '' );
		restore_error_handler();

		if ( false === $result ) {
			$this->flog( 'Invalid regex pattern detected: ' . preg_last_error_msg() );
			return false;
		}

		// Basic ReDoS heuristics - look for nested quantifiers and excessive backtracking potential
		// Patterns like (a+)+ or (a*)*  or (a|a)*
		if ( preg_match( '/\([^)]*[*+]\)[*+{]/', $pattern ) ) {
			$this->flog( 'Potential ReDoS pattern detected (nested quantifiers)' );
			return false;
		}

		return true;
	}
}
