<?php

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

trait WPMR_Scanner {

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

		$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 ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'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 ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'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 ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'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 ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'suspicious'          => $cli ? $args['mcsuspicious'] : ( ! empty( $_REQUEST['suspicious'] ) ? $this->mc_get_bool( sanitize_text_field( wp_unslash( $_REQUEST['suspicious'] ) ) ) : false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'skipdirs'            => $cli ? ( ! empty( $args['mcskipdirs'] ) ? $args['mcskipdirs'] : null ) : ( ! empty( $_REQUEST['skipdirs'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['skipdirs'] ) ) : null ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'regex'               => $cli ? ( ! empty( $args['mcregex'] ) ? $this->encode( $args['mcregex'] ) : null ) : ( ! empty( $_REQUEST['regex'] ) ? $this->encode( base64_decode( sanitize_text_field( wp_unslash( $_REQUEST['regex'] ) ) ) ) : false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'wpmr_extra_db_query' => $cli ? ( ! empty( $args['mcdbquery'] ) ? $this->encode( $args['mcdbquery'] ) : null ) : ( ! empty( $_REQUEST['wpmr_extra_db_query'] ) ? $this->encode( base64_decode( sanitize_text_field( wp_unslash( $_REQUEST['wpmr_extra_db_query'] ) ) ) ) : false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'wpmr_extra_db_regex' => $cli ? ( ! empty( $args['mcdbregex'] ) ? $this->encode( $args['mcdbregex'] ) : null ) : ( ! empty( $_REQUEST['wpmr_extra_db_regex'] ) ? $this->encode( base64_decode( sanitize_text_field( wp_unslash( $_REQUEST['wpmr_extra_db_regex'] ) ) ) ) : false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				// scan single file
				'files'               => $cli ?
						( ! empty( $args['mcfiles'] ) ? $args['mcfiles'] : null )
					: ( ! empty( $_REQUEST['files'] ) ? array_map( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
						array( $this, 'decode_filename' ),
						array_map( 'sanitize_text_field', wp_unslash( $_REQUEST['files'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified elsewhere, Sanitized via array_map
					) : 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 ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification

				'timestamp'           => $cli ? $args['timestamp'] : ( ! empty( $_REQUEST['timestamp'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['timestamp'] ) ) : false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
				'debug'               => $cli ? ( ! empty( $args['mcdebug'] ) && ( $args['mcdebug'] == 'true' ) ? true : false ) : ( isset( $_REQUEST['debug'] ) ? true : false ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Scan parameters processed by AJAX handlers with nonce verification
			)
		);
		if ( ! $this->is_registered() ) {
			$GLOBALS['WPMR']['suspicious'] = true;
		}

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

	/**
	 * 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 );
		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();
		}

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

		$all_files = $this->prioritise_core_files( $all_files );
		$files     = $this->process_only_scan_dirs( $all_files['files'] );
		$files     = array_values( $files );
		$response  = array(
			'files'     => $files,
			'timestamp' => $GLOBALS['WPMR']['timestamp'],
			'count'     => count( $files ),
			'debug'     => $_REQUEST, // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Debug information, not processing form data
			'checksums' => count( $this->get_db_checksums() ) . '+' . count( $this->get_all_checksums() ),
			'db_stats'  => $this->db_stats(),
		);

		if ( ! empty( $_REQUEST['wpmr_init_nonce'] ) || $this->wpmr_iscli() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification handled by calling function

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

			if ( $GLOBALS['WPMR']['do_db_scan'] ) {
				$db_stats = @$this->db_stats();
				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 {
					$response['db_stats'] = @$this->db_stats();
				}
			} else {
				unset( $response['db_stats'] );
			}
			$response['title_hack'] = (bool) $this->title_hack();

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

			if ( $GLOBALS['WPMR']['do_vuln_scan'] ) {
				$response['vulnerabilities_scan'] = @$this->vulnerability_scan();
			} else {
				$response['vulnerabilities_scan'] = false;
			}
			$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'] );
		}

		return $response;
	}

	/**
	 * 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 ( defined( 'WP_CLI' ) && WP_CLI ) {

		} else {
			@ini_set( 'max_execution_time', min( 90, count( $files ) + 1 ) ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Necessary for file scanning execution time management
		}
		// @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 ) {
			// $this->flog( 'Scanning file: ' . $file );
			if ( is_link( $file ) || ! $this->is_scannable_file( $file ) ) { // skip if this is a directory symlink
				continue;
			}

			$checksum_failure = $this->fails_checksum( $file );
			// $this->flog( 'Checksum failure status for file ' . $file . ': ' . print_r( $checksum_failure, true ) );
			if ( $checksum_failure ) {
				$threat = 0;
				$threat = $this->wpmr_scan_file_threats( $file );
				if ( $threat ) {
					$affected_files[ $file ] = $threat;
					$this->insert_issue(
						isset( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : 'unknown',
						array(
							'type'         => 'file',
							'severity'     => $threat['severity'],
							'infection_id' => $threat['id'],
							'pointer'      => $file,
							'comment'      => array( 'message' => 'File <span class="filename">' . $file . '</span> has <span class="severity ' . $threat['severity'] . '">' . $threat['severity'] . '</span> infection.' ),
						)
					);
				} 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',
					);
					$this->insert_issue(
						isset( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : 'unknown',
						array(
							'type'         => 'file',
							'severity'     => 'suspicious',
							'infection_id' => 'mismatch',
							'pointer'      => $file,
							'comment'      => array( 'message' => 'File <span class="filename">' . $file . '</span> 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',
					);
					$this->insert_issue(
						isset( $GLOBALS['WPMR']['timestamp'] ) ? $GLOBALS['WPMR']['timestamp'] : 'unknown',
						array(
							'type'         => 'file',
							'severity'     => 'suspicious',
							'infection_id' => 'unknown',
							'pointer'      => $file,
							'comment'      => array( 'message' => 'Unknown core file <span class="filename">' . $file . '</span>.' ),
						)
					);
				} elseif ( $GLOBALS['WPMR']['debug'] ) {
				}
			} elseif ( $GLOBALS['WPMR']['debug'] ) {

			}
		}

		$this->update_cached_checksums( array_diff( array_values( $files ), array_keys( $affected_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 ( 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 ( $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'];
		if ( strpos( @ini_get( 'disable_functions' ), 'ini_set' ) === false ) {
			@ini_set( 'pcre.backtrack_limit', 1000000 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Required for malware pattern matching with large files
		}

		// Set execution time limit per pattern to prevent hangs
		$max_pattern_time = 5; // seconds per pattern

		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 );
				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();

				$execution_time = microtime( true ) - $start_time;
				if ( $execution_time > 2 ) {
					$this->flog( 'WARNING: Slow pattern execution (' . number_format( $execution_time, 2 ) . 's) for ' . $definition . ' on file ' . $file, false, false, true );
				}

				// $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.
	 *
	 * 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();
		}

		if ( ! empty( $GLOBALS['WPMR']['timestamp'] ) ) {
			// 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;
	}

	/**
	 * AJAX handler: initialize a scan and return the bootstrap payload.
	 *
	 * Registered on `wp_ajax_wpmr_init_scan`.
	 *
	 * Better name: ajax_init_scan()
	 *
	 * @return void Sends JSON response and exits.
	 */
	function wpmr_init_scan() {

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

		wp_send_json( $this->bootstrap() );
	}

	/**
	 * 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();
					unset( $data['files'] );
					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;
		}
		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
			$query = "SELECT COUNT(*) AS count, MIN($column) AS min, MAX($column) AS max FROM $table";

			// Execute the query
			$result = $wpdb->get_row( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for table statistics, no user input, caching not beneficial for one-time stats.

			// 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 );
		$this->raise_limits_conditionally();
		$this->set_args( $args );

		if ( ! empty( $_REQUEST['table'] ) && ! empty( $_REQUEST['batchsize'] ) ) {
			$table     = sanitize_text_field( wp_unslash( $_REQUEST['table'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized on same line
			$batchsize = sanitize_text_field( wp_unslash( $_REQUEST['batchsize'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized on same line
			$pointer   = isset( $_REQUEST['pointer'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['pointer'] ) ) : 0; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized on same line

			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'       => $this->db_scan_batch( $table, $batchsize, $pointer ),
				'memory'       => ( memory_get_peak_usage( true ) / 1024 / 1024 ),
				'memory_limit' => $memory_limit,
				'cpu'          => $cpu,
				'debug'        => $GLOBALS['WPMR']['response_debug'],
			);

			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.
	 *
	 * 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;
		$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 ) ) {// Prepare the string for SQL query
					$exclusions     = array_map( 'esc_sql', $exclusions );
					$exclusion_list = "'" . implode( "','", $exclusions ) . "'";
					$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 (" . $exclusion_list . ') 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();
		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 );
			// $raw_query    = ;
			// Prepare the SQL query within the loop
			$prepared_sql = $wpdb->prepare( $sql, $pointer, $pointer + $batchsize, $this->decode( $details['query'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared.
			$results      = $wpdb->get_results( $prepared_sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared already, direct query needed for malware scanning, caching not suitable for scan results.
			// $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 ) ) ) {
					continue;
				}

				$content = $result['content'];
				$hash    = hash( 'sha256', $content );
				if ( in_array( $hash, $db_checksums, true ) ) {
					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__ );
					}
					$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 ) {
				$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:
		}

		$new_pointer = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL prepared already, direct query needed for batch processing pointer calculation, caching not suitable for dynamic pointers.
		if ( ! empty( $new_pointer ) ) {
			$new_pointer               = intval( $new_pointer ) - 1;
			$db_results['new_pointer'] = $new_pointer;
		} else {
			$db_results['new_pointer'] = $pointer + $batchsize;
		}

		if ( $db_results['results'] ) {
			$db_results['results'] = array_values( $db_results['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;
	}

	/**
	 * 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__ );
		}
	}

	/**
	 * 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 ) {
				$results = $wpdb->get_results( $wpdb->prepare( $sql, $this->decode( $details['query'] ) ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SQL is prepared, direct query required for malware signature scanning across multiple tables, caching not appropriate for security scan results.
				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,
						);
					}
				}
			}
		}
		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 file whitelist mapping from plugin settings.
	 *
	 * Better name: get_file_whitelist()
	 *
	 * @return array|false Whitelist mapping (file => sha256) or false when unset.
	 */
	function get_whitelist() {
		return $this->get_setting( 'whitelist' );
	}

	/**
	 * Render the whitelist entries as HTML.
	 *
	 * Intended for admin UI rendering.
	 *
	 * Better name: render_file_whitelist_html()
	 *
	 * @return void Outputs HTML.
	 */
	function render_whitelist() {
		$whitelist = $this->get_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 ' . print_r( $url, 1 ) . ' 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', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized on same line
					'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() );
		}
	}
}
