<?php

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

trait WPMR_Helpers {

	/**
	 * Get sanitized remote IP address.
	 *
	 * Better name: get_sanitized_remote_ip()
	 *
	 * @return string Sanitized IP address or 'unknown'
	 */
	function get_remote_ip() {
		return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown';
	}

	/**
	 * Compute a safe timeout to use for outbound HTTP requests.
	 *
	 * Uses PHP's max_execution_time and subtracts a small buffer so requests finish
	 * before the process is terminated. Falls back to a conservative default when
	 * execution time is unavailable or too low.
	 *
	 * Better name: get_http_timeout_seconds()
	 *
	 * @return int Timeout in seconds.
	 */
	function get_remote_timeout() {
		$buffer   = 5;
		$fallback = 25;

		$raw = @ini_get( 'max_execution_time' ); // string|false

		if ( $raw === false || $raw === '' || $raw === null ) {
			return $fallback;
		}

		$met = (int) trim( (string) $raw ); // 0 => unlimited
		if ( $met <= $buffer ) {
			return $fallback;
		}

		return $met - $buffer;
	}

	/**
	 * Check whether a PHP runtime-tuning function is available on this host.
	 *
	 * @param string $function_name Function name to test.
	 * @return bool True when the function exists and is not disabled.
	 */
	protected function can_adjust_php_runtime( $function_name ) {
		if ( ! function_exists( $function_name ) ) {
			return false;
		}

		$disabled_functions = @ini_get( 'disable_functions' );
		if ( false === $disabled_functions || '' === $disabled_functions ) {
			return true;
		}

		$disabled_functions = array_filter( array_map( 'trim', explode( ',', strtolower( (string) $disabled_functions ) ) ) );

		return ! in_array( strtolower( $function_name ), $disabled_functions, true );
	}

	/**
	 * Set a PHP ini value when the host allows runtime tuning.
	 *
	 * @param string     $directive PHP ini directive name.
	 * @param int|string $value     Directive value.
	 * @return bool True when the change was accepted.
	 */
	protected function set_runtime_ini_value( $directive, $value ) {
		if ( ! $this->can_adjust_php_runtime( 'ini_set' ) ) {
			return false;
		}

		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Scan and long-running request orchestration need process-local ini tuning on hosts that permit it.
		return false !== @ini_set( (string) $directive, (string) $value );
	}

	/**
	 * Extend the PHP execution time limit when the host allows it.
	 *
	 * @param int $seconds Target execution time limit in seconds.
	 * @return bool True when the limit change was accepted.
	 */
	protected function extend_runtime_time_limit( $seconds ) {
		if ( ! $this->can_adjust_php_runtime( 'set_time_limit' ) ) {
			return false;
		}

		// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Scan and repair batches need explicit time-limit extension on hosts that permit it.
		return @set_time_limit( (int) $seconds );
	}

	/**
	 * Conditionally raise PHP memory/time limits for scanning operations.
	 *
	 * In normal admin/web contexts, increases max_execution_time (min 90s).
	 * In WP-CLI, removes execution time limits.
	 *
	 * Better name: raise_php_limits_for_scan()
	 *
	 * @return void
	 */
	function raise_limits_conditionally() {
		$current_limit = $this->get_memory_limit_in_mb();
		if ( $current_limit < $this->mem ) {
			$this->set_runtime_ini_value( 'memory_limit', $this->mem . 'M' );
		}
		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			// Do WP-CLI specific things.
			$this->set_runtime_ini_value( 'max_execution_time', 0 );
		} else {
			$this->set_runtime_ini_value( 'max_execution_time', max( (int) @ini_get( 'max_execution_time' ), 90 ) );
		}
	}

	/**
	 * Get the site's timezone string or a GMT offset fallback.
	 *
	 * Returns `timezone_string` when configured; otherwise returns a formatted
	 * offset like +05:30 built from `gmt_offset`.
	 *
	 * Better name: get_timezone_string_or_offset()
	 *
	 * @return string Timezone string (e.g. "Europe/London") or offset (e.g. "+02:00").
	 */
	function timezone_string_compat() {
		$timezone_string = get_option( 'timezone_string' );
		if ( $timezone_string ) {
			return $timezone_string;
		}
		$offset    = (float) get_option( 'gmt_offset' );
		$hours     = (int) $offset;
		$minutes   = ( $offset - $hours );
		$sign      = ( $offset < 0 ) ? '-' : '+';
		$abs_hour  = abs( $hours );
		$abs_mins  = abs( $minutes * 60 );
		$tz_offset = sprintf( '%s%02d:%02d', $sign, $abs_hour, $abs_mins );
		return $tz_offset;
	}

	/**
	 * Encode a value into a URL-safe string.
	 *
	 * Encodes as JSON, then base64, then translates characters to be URL-safe.
	 *
	 * Better name: encode_json_base64url()
	 *
	 * @param mixed $str Value to encode.
	 * @return string Encoded representation.
	 */
	function encode( $str ) {
		return strtr( base64_encode( json_encode( $str ) ), '+/=', '-_,' );
	}

	/**
	 * Decode a value previously encoded by encode().
	 *
	 * Better name: decode_json_base64url()
	 *
	 * @param string $str Encoded input.
	 * @return mixed Decoded value (typically array/scalar) or null on failure.
	 */
	function decode( $str ) {
		return json_decode( base64_decode( strtr( $str, '-_,', '+/=' ) ), true );
	}

	/**
	 * Convert a value into a boolean using PHP's FILTER_VALIDATE_BOOLEAN.
	 *
	 * Better name: to_bool()
	 *
	 * @param mixed $var Value to interpret.
	 * @return bool Boolean interpretation.
	 */
	function mc_get_bool( $var ) {
		return filter_var( $var, FILTER_VALIDATE_BOOLEAN );
	}

	/**
	 * Remove any leading slashes/backslashes from a path string.
	 *
	 * Better name: ltrim_slashes()
	 *
	 * @param string $string Input string.
	 * @return string String without leading slashes.
	 */
	function unleadingslashit( $string ) {
		return ltrim( $string, '/\\' );
	}

	/**
	 * Decode a filename that was base64-encoded then URL-encoded.
	 *
	 * Used to workaround when other security scanners or firewalls block the scan.
	 *
	 * Better name: decode_base64url_filename()
	 *
	 * @param string $filename Encoded filename.
	 * @return string|false Decoded filename, or false on base64 decode failure.
	 */
	function decode_filename( $filename ) {
		return urldecode( base64_decode( $filename ) );
	}

	/**
	 * Check whether the current runtime is WP-CLI.
	 *
	 * Better name: is_wp_cli()
	 *
	 * @return bool True when running under WP-CLI.
	 */
	function wpmr_iscli() {
		return defined( 'WP_CLI' ) && WP_CLI;
	}

	/**
	 * Plugin deactivation handler.
	 *
	 * Clears scheduled hooks and deactivates the license.
	 *
	 * Better name: on_deactivate()
	 *
	 * @return void
	 */
	function deactivate() {
		wp_clear_scheduled_hook( 'wpmr_daily' );
		wp_clear_scheduled_hook( 'wpmr_hourly' );
		wp_clear_scheduled_hook( 'wpmr_scheduled_scan' );
		wp_clear_scheduled_hook( 'wpmr_scan_monitor_event' );

		// Silently deactivate license without user messages
		$this->deactivate_license();
	}

	/**
	 * Reset plugin data/state (options + custom tables).
	 *
	 * When invoked via AJAX, validates nonce/capability and responds with JSON.
	 * When invoked programmatically/WP-CLI, returns a status string.
	 *
	 * Better name: reset_plugin_state()
	 *
	 * @param bool $reset_logs When true (and in WP-CLI), also resets logs.
	 * @return string|void String in non-AJAX contexts; otherwise sends JSON and exits.
	 */
	function reset( $reset_logs = false ) {
		global $wpdb;

		if ( wp_doing_ajax() ) {
			check_ajax_referer( 'wpmr_reset', 'wpmr_reset_nonce' );
			if ( ! current_user_can( $this->cap ) ) {
				return;
			}
		}

		if ( ! empty( $_REQUEST['reset_logs'] ) || ( $this->wpmr_iscli() && $reset_logs ) ) {
			$this->reset_logs();
		}

		// Delete options
		delete_option( 'wpmr_fw_settings' );
		delete_option( 'WPMR' );
		delete_option( 'WPMR_checksums' );
		delete_option( 'WPMR_db_checksums_cache' );
		$this->clear_license_status();

		// Truncate all WPMR tables
		$tables = array(
			$this->get_wpmr_table_name( WPMR_CHECKSUMS ),
			$this->get_wpmr_table_name( WPMR_SCANNED_FILES ),
			$this->get_wpmr_table_name( WPMR_ISSUES ),
			$this->get_wpmr_table_name( WPMR_LOGS ),
			$this->get_wpmr_table_name( WPMR_EVENTS ),
		);

		foreach ( $tables as $table ) {
			$wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) );
		}

		if ( wp_doing_ajax() ) {
			return wp_send_json_success( 'Reset Successful!' );
		} else {
			return 'Reset Successful!';
		}
	}

	/**
	 * Locate wp-config.php.
	 *
	 * Checks the standard locations: ABSPATH/wp-config.php and one directory above
	 * ABSPATH (for setups where WordPress core is in a subdirectory).
	 *
	 * Better name: find_wp_config_path()
	 *
	 * @return string|false Absolute path to wp-config.php, or false when not found.
	 */
	function get_wp_config_path() {
		$search = array( wp_normalize_path( ABSPATH . 'wp-config.php' ), wp_normalize_path( dirname( ABSPATH ) . DIRECTORY_SEPARATOR . 'wp-config.php' ) );
		foreach ( $search as $path ) {
			if ( is_file( $path ) ) {
				return $path;
			}
		}
		return false;
	}

	/**
	 * Determine the filesystem "home" directory for the site.
	 *
	 * Attempts to detect the actual home directory when WordPress core is installed
	 * in a subdirectory (home != siteurl), except under WP-CLI.
	 *
	 * Better name: get_site_home_path()
	 *
	 * @return string Absolute path to the site's home directory (with trailing slash).
	 */
	function get_home_dir() {
		$home    = set_url_scheme( get_option( 'home' ), 'http' );
		$siteurl = set_url_scheme( get_option( 'siteurl' ), 'http' );
		if ( ! empty( $home ) && 0 !== strcasecmp( $home, $siteurl ) &&
			! ( defined( 'WP_CLI' ) && WP_CLI ) // Don't detect when using WP CLI
		) {
			$script_filename     = isset( $_SERVER['SCRIPT_FILENAME'] ) ? $this->normalise_path( sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_FILENAME'] ) ) ) : '';
			$wp_path_rel_to_home = str_ireplace( $home, '', $siteurl );
			$pos                 = strripos( $script_filename, trailingslashit( $wp_path_rel_to_home ) );
			$home_path           = substr( $script_filename, 0, $pos );
			$home_path           = trailingslashit( $home_path );
		} else {
			$home_path = ABSPATH;
		}
		$home_path = $this->normalise_path( $home_path );
		return trailingslashit( $home_path );
	}

	/**
	 * Recursively glob files.
	 *
	 * Uses PHP's glob() to collect files under a directory pattern.
	 *
	 * Better name: recursive_glob_files()
	 *
	 * @param string   $dir      Glob pattern (e.g. /path/*).
	 * @param int|null $flags    Optional glob flags.
	 * @param string[] $results  Accumulator array (passed by reference).
	 * @return string[]            List of file paths.
	 */
	function rglob( $dir, $flags = null, &$results = array() ) {
		$ls = glob( $dir, $flags );

		if ( is_array( $ls ) ) {
			foreach ( $ls as $item ) {
				if ( is_dir( $item ) ) {
					$this->rglob( $item . '/*', $flags, $results );
				}
				if ( is_file( $item ) ) {
					$results[] = $item;
				}
			}
		}

		return $results;
	}

	/**
	 * Build a debug string for uploaded files from $_FILES.
	 *
	 * Intended for diagnostic logging; does not validate nonces.
	 *
	 * Better name: build_files_debug_querystring()
	 *
	 * @return string|false|null Query-string-like debug output, false when empty, null when $_FILES is not available.
	 */
	function build_files() {
		// Firewall and diagnostics callers may serialize uploaded-file metadata before any plugin nonce flow exists.
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- This helper only builds debug output from inbound upload metadata.
		if ( isset( $_FILES ) && is_array( $_FILES ) ) {
			$flies = '&';
			foreach ( $_FILES as $req => $fils ) {
				foreach ( array( 'tmp_name', 'name' ) as $val ) {
					if ( isset( $fils[ "$val" ] ) ) {
						// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r, Squiz.PHP.DiscouragedFunctions.Discouraged -- Firewall regex heuristics depend on this legacy array-flattening shape for uploaded-file metadata.
						$flies .= "$req.$val=" . ( is_array( $fils[ "$val" ] ) ? print_r( $fils[ "$val" ], 1 ) : $fils[ "$val" ] ) . '&';
					}
				}
			}
			return strcasecmp( $flies, '&' ) != 0 ? $flies : false;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	/**
	 * Build a debug string for request parameters from $_REQUEST.
	 *
	 * Intended for diagnostic logging; does not validate nonces.
	 *
	 * Better name: build_request_debug_querystring()
	 *
	 * @return string|false|null Query-string-like debug output, false when empty, null when $_REQUEST is not available.
	 */
	function build_request() {
		// Firewall callers intentionally inspect live inbound requests before any nonce flow exists.
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- This helper serializes request data for firewall heuristics, not for form processing.
		if ( isset( $_REQUEST ) && is_array( $_REQUEST ) ) {
			$request = '&';
			foreach ( $_REQUEST as $req => $val ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r, Squiz.PHP.DiscouragedFunctions.Discouraged -- Firewall regex heuristics depend on this legacy request-array serialization contract.
				$request .= "$req=" . ( is_array( $val ) ? print_r( $val, 1 ) : $val ) . '&';
			}
			return strcasecmp( $request, '&' ) != 0 ? $request : false;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Build a debug string for server variables from $_SERVER.
	 *
	 * Intended for diagnostic logging.
	 *
	 * Better name: build_server_debug_querystring()
	 *
	 * @return string|false|null Query-string-like debug output, false when empty, null when $_SERVER is not available.
	 */
	function build_server() {
		if ( isset( $_SERVER ) && is_array( $_SERVER ) ) {
			$server = '&';
			foreach ( $_SERVER as $srv => $val ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r, Squiz.PHP.DiscouragedFunctions.Discouraged -- Firewall regex heuristics depend on this legacy server-array serialization contract.
				$server .= "$srv=" . ( is_array( $val ) ? print_r( $val, 1 ) : $val ) . '&';
			}
			return strcasecmp( $server, '&' ) != 0 ? $server : false;
		}
	}

	/**
	 * Build diagnostic payload for support/API calls.
	 *
	 * Includes environment details, plugin/signature versions, and a nonce for
	 * authenticated AJAX operations.
	 *
	 * Better name: build_diagnostics_payload()
	 *
	 * @global string $wp_version
	 * @return array<string, mixed> Diagnostic payload.
	 */
	function get_diag_data() {
		global $wp_version;
		$current_user = wp_get_current_user();
		$data         = array(
			'php'            => phpversion(),
			'web_server'     => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : 'unknown',
			'wp'             => $wp_version,
			'key'            => md5( site_url() ),
			'site_url'       => trailingslashit( site_url() ),
			'signatures'     => $this->get_definition_version(),
			'plugin'         => $this->plugin_data['Version'],
			'return_url'     => esc_url( get_admin_url( null, 'options-general.php?page=wpmr' ) ),
			'origin_ajaxurl' => admin_url( 'admin-ajax.php' ),
			'origin_nonce'   => wp_create_nonce( 'wpmr_gscapi' ),
			'user_firstname' => $current_user->user_firstname,
			'user_lastname'  => $current_user->user_lastname,
			'user_email'     => $current_user->user_email,
		);
		return $data;
	}

	/**
	 * Detect MIME encoding / file type for a file using the system `file` command.
	 *
	 * Only runs when exec() is available and the file passes is_invalid_file().
	 *
	 * Better name: get_file_mime_encoding()
	 *
	 * @param string $file Absolute path.
	 * @return string|null Detected encoding/type string, or null when unavailable.
	 */
	function get_file_type( $file ) {
		if ( function_exists( 'exec' ) && ! $this->is_invalid_file( $file ) ) {
			$start_time = microtime( true );
			$out        = exec( 'file -b --mime-encoding ' . escapeshellarg( $file ), $output, $return );
			if ( ! empty( $out ) ) {
				return $out;
			}
		}
	}

	/**
	 * Read plugin header data from a plugin file.
	 *
	 * This is a compatibility wrapper around WordPress' plugin header parsing.
	 *
	 * Better name: read_plugin_headers()
	 *
	 * @param string $plugin_file Absolute path to plugin file.
	 * @param bool   $markup      Whether to apply markup.
	 * @param bool   $translate   Whether to translate. Note: this plugin intentionally does not
	 *                            support localization; translation is always disabled to avoid
	 *                            triggering WordPress 6.7+ just-in-time textdomain loading notices.
	 * @return array<string, mixed> Parsed plugin header data.
	 */
	function get_plugin_data( $plugin_file, $markup = true, $translate = false ) {
		$default_headers = array(
			'Name'        => 'Plugin Name',
			'PluginURI'   => 'Plugin URI',
			'Version'     => 'Version',
			'Description' => 'Description',
			'Author'      => 'Author',
			'AuthorURI'   => 'Author URI',
			'TextDomain'  => 'Text Domain',
			'DomainPath'  => 'Domain Path',
			'Network'     => 'Network',
			'_sitewide'   => 'Site Wide Only',
		);
		$plugin_data     = get_file_data( $plugin_file, $default_headers, 'plugin' );
		if ( ! $plugin_data['Network'] && $plugin_data['_sitewide'] ) {

			$plugin_data['Network'] = $plugin_data['_sitewide'];
		}
		$plugin_data['Network'] = ( 'true' == strtolower( $plugin_data['Network'] ) );
		unset( $plugin_data['_sitewide'] );
		if ( ! $plugin_data['TextDomain'] ) {
			$plugin_slug = dirname( plugin_basename( $plugin_file ) );
			if ( '.' !== $plugin_slug && false === strpos( $plugin_slug, '/' ) ) {
				$plugin_data['TextDomain'] = $plugin_slug;
			}
		}

		// Translation is intentionally disabled (see docblock). Keep markup behavior.
		$translate = false;

		if ( $markup ) {
			if ( ! function_exists( '_get_plugin_data_markup_translate' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}
			// Keep this wrapper aligned with core for now. `set_plugin_data()` caches the
			// same metadata contract and `saas_request()` later sends that compatibility
			// payload upstream, so the SaaS side may depend on the current field shape.
			// TODO: Replace this core helper once the SaaS compatibility payload no longer depends on this exact field shape.
			$plugin_data = _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup, false ); // phpcs:ignore Generic.PHP.ForbiddenFunctions.Found -- Preserve the current compatibility payload shape used by saas_request().
		} else {
			$plugin_data['Title']      = $plugin_data['Name'];
			$plugin_data['AuthorName'] = $plugin_data['Author'];
		}
		return $plugin_data;
	}

	/**
	 * Determine whether a file appears to be binary.
	 *
	 * Uses get_file_type() and checks for the substring "binary". Records
	 * execution time under $GLOBALS['WPMR']['response_debug'][__FUNCTION__].
	 *
	 * Better name: is_binary_file()
	 *
	 * @param string $file Absolute path.
	 * @return bool True when file is detected as binary.
	 */
	function is_file_binary( $file ) {
		$start  = microtime( true );
		$result = ( strpos( $this->get_file_type( $file ), 'binary' ) !== false );
		$GLOBALS['WPMR']['response_debug'][ __FUNCTION__ ] = ( microtime( true ) - $start );
		return $result;
	}

	/**
	 * Trigger an asynchronous refresh of core checksums via admin-ajax.
	 *
	 * Sends a non-blocking POST to `wp_ajax_wpmr_refresh_checksums`.
	 *
	 * Better name: trigger_checksums_refresh_async()
	 *
	 * @return bool True when the request was dispatched; false on permission or transport failure.
	 */
	function refresh_checksums_async() {
		if ( ! current_user_can( $this->cap ) ) {
			$this->flog( 'Unauthorized attempt to refresh checksums in ' . __FUNCTION__ );
			return false;
		}
		// make an ajax request to admin-ajax.php to trigger the refresh_checksums function
		$ajax_url   = admin_url( 'admin-ajax.php' );
		$args       = array(
			'timeout'  => $this->timeout,
			'blocking' => false,
			'body'     => array(
				'action' => 'wpmr_refresh_checksums',
				'nonce'  => wp_create_nonce( 'wpmr_refresh_checksums' ),
			),
		);
		$start_time = microtime( true );
		// Use POST instead of GET to avoid nonce in URL
		$response = wp_remote_post( $ajax_url, $args );

		$this->flog( 'Async checksum refresh triggered in ' . ( microtime( true ) - $start_time ) . ' seconds' );
		if ( is_wp_error( $response ) ) {
			$this->flog( 'WPMR: Failed to trigger async checksum refresh: ' . $this->debug_value_to_string( $response ) );
			return false;
		} else {
			$this->flog( 'WPMR: Async checksum refresh request sent successfully.' . $this->debug_value_to_string( $response ) );
		}

		return true;
	}

	/**
	 * Populate $this->plugin_data used throughout the plugin.
	 *
	 * Called on `init` in wpmr.php.
	 *
	 * Better name: init_plugin_metadata()
	 *
	 * @return void
	 */
	function set_plugin_data() {
		$this->plugin_data         = $this->get_plugin_data( WPMR_PLUGIN, false, false );
		$this->plugin_data['Slug'] = WPMR_SLUG;
	}

	/**
	 * Get the plugin name without its tagline.
	 *
	 * Strips everything after the first em-dash in the plugin header Name.
	 * E.g. "Malcure Malware Shield — Removal, Repair, Monitor" → "Malcure Malware Shield".
	 *
	 * @return string
	 */
	function get_plugin_simple_name() {
		$name = isset( $this->plugin_data['Name'] ) ? $this->plugin_data['Name'] : 'Malcure Malware Shield';
		$pos  = strpos( $name, ' — ' );
		if ( false !== $pos ) {
			$name = substr( $name, 0, $pos );
		}
		return trim( $name );
	}

	/**
	 * AJAX handler: activate/deactivate license key.
	 *
	 * Registered on `wp_ajax_wpmr_license_action`.
	 * Validates nonce/capability, calls licensing API, updates stored license key,
	 * and may trigger background registration + definition updates.
	 *
	 * Better name: ajax_handle_license_action()
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function wpmr_license_action() {
		check_ajax_referer( 'wpmr_license_action', 'wpmr_license_action_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Insufficient permissions.' );
		}

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

		if ( $license_action === 'deactivate' ) {
			// Handle deactivation
			$key = $this->get_setting( 'license_key' );

			if ( ! $key ) {
				wp_send_json_error( 'No active license found to deactivate.' );
			}

			$response = $this->get_license_api_response( 'deactivate_license', $key );

			$this->flog( 'License ' . $key . ' deactivation attempt via AJAX : ' . $this->debug_value_to_string( wp_unslash( $_REQUEST ) ) . '. API response: ' . $this->debug_value_to_string( $response ) );

			if ( is_wp_error( $response ) ) {
				wp_send_json_error( $response->get_error_message() );
			}

			if ( ! empty( $response['success'] ) ) {
				$this->delete_setting( 'license_key' );
				$this->clear_license_status();
				wp_send_json_success(
					array(
						'message' => 'License deactivated successfully!',
						'reload'  => true,
					)
				);
			} else {
				wp_send_json_error( 'License deactivation failed.' );
			}
		} else {
			// Handle activation
			if ( ! isset( $_REQUEST['license_key'] ) || empty( $_REQUEST['license_key'] ) ) {
				wp_send_json_error( 'License key is required.' );
			}

			$license_key = sanitize_text_field( wp_unslash( $_REQUEST['license_key'] ) );
			$response    = $this->get_license_api_response( 'activate_license', $license_key );

			if ( is_wp_error( $response ) ) {
				wp_send_json_error( $response->get_error_message() );
			}

			if ( ! empty( $response['success'] ) ) {
				$this->flog( 'License activation successful via AJAX. API response: ' . $this->debug_value_to_string( $response ) );
				$this->update_setting( 'license_key', $license_key );
				$status = $this->save_license_status( $response );

				if ( ! $this->is_registered() ) {
					$name  = $status['customer_name'];
					$name  = array_filter( explode( ' ', $name ) );
					$email = $status['customer_email'];
					$fn    = empty( $name ) ? explode( '@', $email )[0] : array_shift( $name );
					$ln    = empty( $name ) ? explode( '@', $email )[0] : array_shift( $name );
					$this->flog( 'User is not registered. Proceeding to register via API.' );
					$start_time = microtime( true );
					$this->wpmr_cli_register( $email, $fn, $ln, false );
					$this->flog( 'User registration completed in ' . ( microtime( true ) - $start_time ) . ' seconds.' );

					if ( ! $this->get_setting( 'sig_time' ) ) {
						$this->flog( 'No signature time found. Proceeding to update definitions via API.' );
						$start_time = microtime( true );
						$update     = $this->update_definitions_cli( false );
						$this->flog( 'Definitions update completed in ' . ( microtime( true ) - $start_time ) . ' seconds. Update response: ' . $this->debug_value_to_string( $update ) );
					} else {
						$this->flog( 'Signature time found. Skipping definitions update via API.' );
					}
				} else {
					$this->flog( 'User already registered. Skipping registration via API.' );
				}
				
				// Anchor the 7-day pulse clock at activation time so the first pulse
				// fires 7 days from now, not immediately. This is a runtime anchor,
				// not a setting default — keep it.
				$this->update_setting( 'wpmr_last_pulse_sent', time() );

				wp_send_json_success(
					array(
						'message' => 'License activated successfully!',
						'status'  => $status,
						'reload'  => true,
					)
				);
			} else {
				$this->flog( 'License activation ' . $this->debug_value_to_string( wp_unslash( $_REQUEST ) ) . ' failed via AJAX. API response: ' . $this->debug_value_to_string( $response ) );
				wp_send_json_error( 'License activation failed. Please check your license key.' );
			}
		}
	}

	/**
	 * Add quick action links on the Plugins list page.
	 *
	 * Filter: `plugin_action_links_{plugin_basename}`.
	 *
	 * Better name: add_plugin_action_links()
	 *
	 * @param string[] $links Existing action links.
	 * @return string[] Modified links.
	 */
	function plugin_action_links( $links ) {
		$links[] = '<a href="' . esc_url( get_admin_url( null, 'admin.php?page=wpmr' ) ) . '">Run Site Scan</a>';
		$links[] = '<a href="' . esc_url( get_admin_url( null, 'admin.php?page=wpmr_license' ) ) . '">Enter License Key</a>';
		return $links;
	}

	/**
	 * Build a UTM-tagged malcure.com URL with standardised parameters.
	 *
	 * Central malcure.com URL builder. UTM parameters were removed for privacy;
	 * the campaign/content/medium params are accepted but ignored to preserve
	 * backward compatibility with all existing call sites.
	 *
	 * @since 19.9.4
	 *
	 * @param string $path     Query path or page ID without the leading '?'
	 *                         (e.g. 'p=107', 'p=2074&ssig=ABC123').
	 * @param string $campaign Ignored — retained for backward compatibility.
	 * @param string $content  Ignored — retained for backward compatibility.
	 * @param string $medium   Ignored — retained for backward compatibility.
	 * @return string Clean URL, esc_url-ready.
	 */
	function wpmr_url( $path, $campaign, $content = '', $medium = 'web' ) {
		return trailingslashit( MALCURE_API ) . '?' . $path;
	}

	/**
	 * Add plugin row meta links on the Plugins list page.
	 *
	 * Filter: `plugin_row_meta`.
	 *
	 * Better name: add_plugin_row_meta_links()
	 *
	 * @param string[] $links Existing row meta links.
	 * @param string   $file  Plugin file relative path.
	 * @return string[] Modified links.
	 */
	function plugin_meta_links( $links, $file ) {
		if ( $file !== plugin_basename( WPMR_PLUGIN ) ) {
			return $links;
		}
		$links[] = '<strong><a target="_blank" href="' . esc_url( $this->wpmr_url( 'p=107', 'sidebar-cta', 'service-request' ) ) . '" title="Malware Cleanup Service">Malware Support</a></strong>';
		$links[] = '<strong><a target="_blank" href="https://wordpress.org/support/plugin/wp-malware-removal/reviews/" title="Rate ' . $this->get_plugin_data( $this->file )['Name'] . '">Rate the plugin ★★★★★</a></strong>';
		return $links;
	}

	/**
	 * Enqueue admin styles for the plugin.
	 *
	 * Hook: `admin_enqueue_scripts`.
	 *
	 * Better name: enqueue_admin_styles()
	 *
	 * @param string $hook_suffix Current admin page hook suffix.
	 * @return void
	 */
	function wpmr_admin_styles( $hook_suffix = '' ) {

		wp_enqueue_style( 'wpmr-stylesheet', WPMR_PLUGIN_DIR_URL . 'assets/admin-styles.css', array(), filemtime( WPMR_PLUGIN_DIR . 'assets/admin-styles.css' ) );

		/*
		if ( ! function_exists( 'get_current_screen' ) ) {
			return;
		}
		$screen = get_current_screen();
		if ( empty( $screen ) || empty( $screen->id ) ) {
			return;
		}
		if ( ! preg_match( '/wpmr/i', (string) $screen->id ) ) {
			return;
		}
		*/
	}

	/**
	 * Convert mixed debug data into a readable string without print_r().
	 *
	 * @param mixed $value  Value to format.
	 * @param bool  $pretty Pretty-print non-scalar JSON output when true.
	 * @return string
	 */
	function debug_value_to_string( $value, $pretty = true ) {
		if ( is_string( $value ) ) {
			return $value;
		}

		if ( is_bool( $value ) ) {
			return $value ? 'true' : 'false';
		}

		if ( null === $value ) {
			return 'null';
		}

		if ( is_scalar( $value ) ) {
			return (string) $value;
		}

		if ( is_wp_error( $value ) ) {
			$value = array(
				'errors'     => $value->errors,
				'error_data' => $value->error_data,
			);
		}

		$json_flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR;
		if ( $pretty ) {
			$json_flags |= JSON_PRETTY_PRINT;
		}

		$encoded = wp_json_encode( $value, $json_flags );
		if ( false !== $encoded ) {
			return $encoded;
		}

		if ( is_object( $value ) ) {
			return '[object ' . get_class( $value ) . ']';
		}

		return '[' . gettype( $value ) . ']';
	}

	/**
	 * Short for file-log. Log data to a file if WP_DEBUG is enabled or forced.
	 *
	 * @param mixed  $data       Data to log.
	 * @param string $file       Optional log filename.
	 * @param bool   $timestamp  Prepend timestamp when true.
	 * @param int    $force      Force logging when WP_DEBUG is off.
	 */
	function flog( $data = '', $file = 'log.log', $timestamp = false, $force = 0 ) {
		if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || ! empty( $force ) ) {
			if ( empty( $file ) ) {
				$file = 'log.log';
			}
			// $file = $this->dir . sanitize_text_field( $file );
			// $file = wp_normalize_path( $this->dir . sanitize_file_name( $file ) );
			$file = $this->dir . basename( sanitize_file_name( $file ) );

			if ( $timestamp ) {
				$date = gmdate( 'Ymd-G:i:s' ) . '-' . microtime( true );
				file_put_contents( $file, $date . PHP_EOL, FILE_APPEND | LOCK_EX );
			}
			$data   = $this->debug_value_to_string( $data );
			$result = file_put_contents( $file, $data . PHP_EOL, FILE_APPEND | LOCK_EX );
			if ( $result === false ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log, Squiz.PHP.DiscouragedFunctions.Discouraged -- Fall back to PHP's error log only when the plugin log file itself is unwritable.
				error_log( 'Failed to write to log file: ' . $file );
			}
		}
	}

	/**
	 * Dump data with var_dump output and route it through flog.
	 *
	 * @param mixed  $data       Data to dump.
	 * @param string $file       Optional log filename.
	 * @param bool   $timestamp  Prepend timestamp when true.
	 * @param int    $force      Force logging when WP_DEBUG is off.
	 */
	function fdump( $data, $file = 'log.log', $timestamp = false, $force = 0 ) {
		$dump = $this->get_dump( $data );
		$this->flog( $dump, $file, $timestamp, $force );
	}

	/**
	 * Format a value into a readable debug string for dump-style logging.
	 *
	 * This keeps dump-style logging aligned with the shared debug formatter instead of
	 * invoking raw var_dump() in shipped code.
	 *
	 * @param mixed $obj The variable or object to be dumped.
	 * @return string Readable debug output.
	 */
	function get_dump( $obj ) {
		return $this->debug_value_to_string( $obj );
	}

	/**
	 * Logs or returns a formatted representation of a variable.
	 *
	 * This function formats the provided variable into a readable string. It can
	 * either echo the output wrapped in <pre> tags for better readability in HTML
	 * contexts, or return it as a string for further processing.
	 *
	 * @param mixed $str  The variable to be logged or returned.
	 * @param bool  $echo If true, echoes the output; if false, returns it as a string.
	 * @return string|null Returns the formatted string if $echo is false; otherwise, null.
	 */
	function llog( $str, $echo = true ) {
		$formatted = $this->debug_value_to_string( $str );
		if ( $echo ) {
			echo '<pre>';
			echo esc_html( $formatted );
			echo '</pre>';
		} else {
			return $formatted;
		}
	}

	/**
	 * Extracts the file extension from a filename.
	 *
	 * This function takes a filename and returns its lowercase extension.
	 * The implementation prepends a dot to ensure filenames without extensions
	 * don't cause errors, then splits on dots and takes the last segment.
	 * The result is converted to lowercase for consistent comparison.
	 *
	 * @param string $filename The filename to extract the extension from
	 * @return string The lowercase file extension (without the dot)
	 */
	function get_fileext( $filename ) {
		$nameparts = explode( '.', ".$filename" );
		return strtolower( $nameparts[ ( count( $nameparts ) - 1 ) ] );
	}

	/**
	 * Prepare a status array for a scanned file.
	 *
	 * This function sanitizes the provided message and constructs an associative
	 * array containing the severity, message, and signature version for a scanned file.
	 * The output format is consistent regardless of whether the request is from CLI
	 * or web context.
	 *
	 * @param string $severity Severity level (e.g., 'clean', 'suspicious', 'malicious').
	 * @param string $msg      Message describing the status.
	 * @param string $ver      Signature version related to the status.
	 * @return array<string, string> Associative array with severity, message, and signature.
	 */
	function set_status( $severity, $msg, $ver ) {
		$msg = wp_strip_all_tags( $msg );
		if ( ! $this->wpmr_iscli() ) {
			return array(
				'severity'  => $severity,
				'message'   => $msg,
				'signature' => $ver,
			);
		} else {
			return array(
				'severity'  => $severity,
				'message'   => $msg,
				'signature' => $ver,
			);
		}
	}

	/**
	 * Get the list of file extensions to exclude from scanning.
	 *
	 * This function returns an array of file extensions that should be excluded
	 * from the malware scanning process. The list can be modified via the
	 * 'wpmr_excluded_ext' filter.
	 *
	 * @return string[] Array of excluded file extensions.
	 */
	function get_excluded() {
		return apply_filters( 'wpmr_excluded_ext', array( '7z', 'bmp', 'bz2', 'css', 'doc', 'docx', 'exe', 'fla', 'flv', 'gif', 'gz', 'ico', 'jpeg', 'jpg', 'less', 'mo', 'mov', 'mp3', 'mp4', 'pdf', 'png', 'po', 'pot', 'ppt', 'pptx', 'psd', 'rar', 'scss', 'so', 'svg', 'tar', 'tgz', 'tif', 'tiff', 'ttf', 'txt', 'webp', 'wmv', 'z', 'zip' ) );
	}

	/**
	 * Filter out suspicious files based on settings.
	 *
	 * This function removes files marked as 'suspicious' from the provided
	 * array if the global setting for suspicious files is disabled.
	 *
	 * @param array<string, array<string, string>> $files Array of files with their statuses.
	 * @return array<string, array<string, string>> Filtered array of files.
	 */
	function may_be_filter_suspicious( $files ) {
		foreach ( $files as $file => $val ) {
			if ( ! $this->is_in_core_wp_dir( $file ) // anything suspicious in the core directories must be shown
			&& $val['severity'] == 'suspicious' && $val['id'] != 'unknown' && ! $GLOBALS['WPMR']['suspicious'] ) {
				unset( $files[ $file ] );
			}
		}
		return $files;
	}

	/**
	 * Whitelist additional checksums.
	 *
	 * This function merges user-defined whitelisted checksums into the provided
	 * array of checksums if the whitelist setting is enabled and the plugin is
	 * in advanced edition mode.
	 *
	 * @param string[] $checksums Array of existing checksums.
	 * @return string[] Modified array of checksums with whitelisted entries.
	 */
	function whitelist( $checksums ) {
		$whitelist = is_array( $this->get_setting( 'whitelist' ) ) ? $this->get_setting( 'whitelist' ) : array();
		if ( is_array( $whitelist ) && ! empty( $whitelist ) && $this->is_advanced_edition() ) {
			return array_merge( $checksums, $whitelist );
		}
		return $checksums;
	}

	/**
	 * Get the current server load averages.
	 */
	function get_server_load() {
		if ( function_exists( 'sys_getloadavg' ) ) {
			// This function exists, so we are likely not on Windows.
			return sys_getloadavg();
		} elseif ( class_exists( 'COM' ) ) {
			// We are likely on Windows and can attempt to use COM to get the CPU load.
			try {
				$wmi     = new COM( 'Winmgmts://' );
				$servers = $wmi->execquery( 'SELECT LoadPercentage FROM Win32_Processor' );

				$cpuNum    = 0;
				$loadTotal = 0;
				foreach ( $servers as $server ) {
					$loadTotal += $server->LoadPercentage;
					++$cpuNum;
				}

				return $cpuNum > 0 ? array( $loadTotal / $cpuNum ) : array();
			} catch ( Exception $e ) {
				// Handle exceptions or use another method to obtain system load.
				return array();
			}
		} else {
			// No known method to get system load, return an empty array or null.
			return array();
		}
	}

	/**
	 * Delete WPMR log entries from the options table.
	 *
	 * This function queries the WordPress options table for all options
	 * with names matching the pattern '%WPMR_log_%' and deletes them.
	 */
	function delete_wpmr_logs() {
		global $wpdb;

		// Query to get all options with names like %WPMR_log_%
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Log cleanup must enumerate current persisted scan log options before deletion, and wildcard option lookup has no equivalent core helper.
		$option_names = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '%WPMR_log_%'" );

		// Loop through the options and delete each one
		foreach ( $option_names as $option_name ) {
			delete_option( $option_name );
		}
	}

	/**
	 * Enqueue necessary JavaScript dependencies for the admin interface.
	 *
	 * This function enqueues jQuery and other WordPress core scripts
	 * required for the plugin's admin pages.
	 */
	function wpmr_enqueue_js_dependencies() {
		wp_enqueue_script( 'jquery' );
		wp_enqueue_script( 'common' );
		wp_enqueue_script( 'wp-lists' );
		wp_enqueue_script( 'postbox' );
	}

	/**
	 * AJAX handler: update default auto-update setting.
	 *
	 * Registered on `wp_ajax_wpmr_def_auto_update`.
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function update_wpmr_def_auto_update() {
		check_ajax_referer( 'wpmr_def_auto_update_enabled', 'wpmr_def_auto_update_enabled_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}
		$enabled = isset( $_REQUEST['enabled'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['enabled'] ) ) : '';
		if ( $enabled == 'false' ) {
			$this->update_setting( 'def_auto_update_enabled', false );
		}
		if ( $enabled == 'true' ) {
			$this->update_setting( 'def_auto_update_enabled', true );
		}
		wp_send_json_success( $this->get_setting( 'def_auto_update_enabled' ) );
	}

	/**
	 * Add inline CSS styles for the admin menu icon.
	 *
	 * This function outputs custom CSS to style the plugin's admin menu icon.
	 * It is hooked into the admin head section.
	 */
	function wpmr_add_admin_inline_styles() {
		?>
		<style type="text/css">
		#toplevel_page_wpmr .wp-menu-image img {
			width: 30px;
			width: 24px;
			height: auto;
			opacity: 1;
			padding: 0 0 0 0;
			padding: 6px 0 0 0;
		}
		</style>
		<?php
		// remove_submenu_page( 'index.php', 'malcure-firstrun' );
	}

	/**
	 * Reset the log file by clearing its contents.
	 *
	 * This function overwrites the log file with an empty string,
	 * effectively resetting it.
	 */
	function reset_flog() {
		$file = $this->dir . 'log.log';
		file_put_contents( $file, '', LOCK_EX );
	}

	/**
	 * Display the plugin update message with changelog details.
	 *
	 * This function fetches the changelog from the plugin's readme.txt file
	 * in the WordPress plugin repository and displays it in the update message.
	 *
	 * @param object $data     Plugin data object.
	 * @param object $response Update response object.
	 */
	function plugin_update_message( $data, $response ) {
		$changelog = 'https://plugins.trac.wordpress.org/browser/' . basename( $this->dir ) . '/trunk/readme.txt?format=txt&cachebust=' . time(); // should translate into https://plugins.trac.wordpress.org/browser/wp-malware-removal/trunk/readme.txt?format=txt since repo doesn't allow changing slugs
		$res       = wp_safe_remote_get( $changelog, array( 'timeout' => $this->timeout ) );
		if ( is_wp_error( $res ) ) {
			return;
		}
		$res    = wp_remote_retrieve_body( $res );
		$regexp = '~==\s*Changelog\s*==\s*=\s*[0-9.]+\s*=(.*)(=\s*' . preg_quote( $this->plugin_data['Version'] ) . '\s*=|$)~Uis';
		if ( ! preg_match( $regexp, $res, $matches ) ) {
			return;
		}
		$changelog      = (array) preg_split( '~[\r\n]+~', trim( $matches[1] ) );
		$upgrade_notice = '';
		foreach ( $changelog as $index => $line ) {
			if ( preg_match( '~^\s*\*\s*~', $line ) ) {
				$line            = preg_replace( '~^\s*\*\s*~', '', htmlspecialchars( $line ) );
				$upgrade_notice .= '<span style="font-weight:bold;">&#x2605;</span> ' . $line . '<br />';
			} else {
			}
		}
		$upgrade_notice = '<strong>Upgrading is a must to ensure that this plugin works with the latest signatures.</strong><br />' . $upgrade_notice;
		echo '<br /><br /><span style="display:block; border: 1px solid hsl(200, 100%, 80%); padding: 1em; background: hsl(200, 100%, 90%); line-height:2">' . wp_kses_post( $upgrade_notice ) . '</span>';
	}

	/**
	 * Generate random secret keys and salts for WordPress configuration.
	 *
	 * This function attempts to generate eight random keys and salts using
	 * cryptographically secure methods. If that fails, it falls back to fetching
	 * them from the WordPress.org secret key API. If that also fails, it uses
	 * wp_generate_password() as a last resort.
	 *
	 * @return string[] Array of generated secret keys and salts.
	 */
	function generate_salt() {
		try {
			$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|';
			$max   = strlen( $chars ) - 1;
			for ( $i = 0; $i < 8; $i++ ) {
				$key = '';
				for ( $j = 0; $j < 64; $j++ ) {
					$key .= substr( $chars, random_int( 0, $max ), 1 );
				}
				$secret_keys[] = $key;
			}
		} catch ( Exception $ex ) {
			$secret_keys = wp_remote_get( 'https://api.wordpress.org/secret-key/1.1/salt/', array( 'timeout' => $this->timeout ) );
			if ( is_wp_error( $secret_keys ) ) {
				$secret_keys = array();
				for ( $i = 0; $i < 8; $i++ ) {
					$secret_keys[] = wp_generate_password( 64, true, true );
				}
			} else {
				$secret_keys = explode( "\n", wp_remote_retrieve_body( $secret_keys ) );
				foreach ( $secret_keys as $k => $v ) {
					$secret_keys[ $k ] = substr( $v, 28, 64 );
				}
			}
		}
		return $secret_keys;
	}

	/**
	 * AJAX handler: inspect file contents.
	 *
	 * Registered on `wp_ajax_wpmr_inspect_file`.
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function wpmr_inspect_file() {
		check_ajax_referer( 'wpmr_inspect_file', 'wpmr_inspect_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}

		$encoded_file = isset( $_REQUEST['file'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['file'] ) ) : '';
		if ( '' === $encoded_file ) {
			wp_send_json_error( 'Invalid file path detected.' );
			return;
		}
		$file = base64_decode( $encoded_file );

		// Additional security check: validate file path to prevent path traversal
		if ( ! $this->is_safe_file_path( $file ) ) {
			wp_send_json_error( 'Invalid file path detected.' );
			return;
		}

		$result = $this->fetch_file_contents( $file );
		if ( ! is_wp_error( $result ) ) {
			wp_send_json_success( $result );
		} else {
			wp_send_json_error( $result->get_error_message() );
		}
	}

	/**
	 * Fetch the contents of a file with security checks.
	 *
	 * This function retrieves the contents of the specified file after
	 * performing security checks to ensure the file path is safe and
	 * the file is valid for reading.
	 *
	 * @param string $file The file path to fetch contents from.
	 * @return string|WP_Error The file contents on success, or WP_Error on failure.
	 */
	function fetch_file_contents( $file ) {
		// Security check: validate file path to prevent path traversal
		if ( ! $this->is_safe_file_path( $file ) ) {
			return new WP_Error( 'invalid_path', 'Invalid file path detected.' );
		}

		if ( $this->is_valid_file( $file ) ) {
			$content = file_get_contents( $file );
			if ( $content !== false ) {
				if ( ! $content ) {
					return new WP_Error( 'unprintable_chars', 'File contains non-printable characters.' );
				}
				return $content;
			} else {
				return new WP_Error( 'file_read_failure', 'Error getting file contents.' );
			}
		} else {
			return new WP_Error( 'file_unhandled', 'Empty or inaccessible or too large a file.' );
		}
	}

	/**
	 * AJAX handler: inspect database-record content.
	 *
	 * Mirrors the file inspector flow but returns the DB `content` field that is
	 * actually scanned by the DB scanner (e.g. `post_content`, `meta_value`,
	 * `option_value`, `comment_content`).
	 *
	 * Also returns a small table-specific identifier field to help operators
	 * quickly understand what the scanned content belongs to:
	 * - posts: post_name
	 * - postmeta: meta_key
	 * - options: option_name
	 * - comments: comment_author_email
	 *
	 * Registered on `wp_ajax_wpmr_inspect_db_record`.
	 *
	 * Request params:
	 * - table: posts|postmeta|options|comments
	 * - id: integer
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function wpmr_inspect_db_record() {
		check_ajax_referer( 'wpmr_inspect_db_record', 'wpmr_inspect_db_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}

		$table = isset( $_REQUEST['table'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['table'] ) ) : '';
		$id    = isset( $_REQUEST['id'] ) ? intval( wp_unslash( $_REQUEST['id'] ) ) : 0;

		$result = $this->fetch_db_record_inspection_payload( $table, $id );
		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
			return;
		}

		wp_send_json_success( $result );
	}

	/**
	 * AJAX handler: whitelist a database record by pointer (table + primary key).
	 *
	 * This is an exact-record whitelist: it skips only the targeted row.
	 *
	 * Registered on `wp_ajax_wpmr_whitelist_db_record`.
	 *
	 * Request params:
	 * - table: posts|postmeta|options|comments
	 * - id: integer
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function wpmr_whitelist_db_record() {
		check_ajax_referer( 'wpmr_whitelist_db_record', 'wpmr_db_whitelist_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Unauthorized access' );
			return;
		}

		if ( ! $this->is_advanced_edition() ) {
			wp_send_json_error( 'Invalid license.' );
			return;
		}

		$table = isset( $_REQUEST['table'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['table'] ) ) : '';
		$id    = isset( $_REQUEST['id'] ) ? intval( wp_unslash( $_REQUEST['id'] ) ) : 0;

		// Validate the record exists and the table is allowlisted.
		$exists = $this->fetch_db_record_scanned_content( $table, $id );
		if ( is_wp_error( $exists ) ) {
			wp_send_json_error( $exists->get_error_message() );
			return;
		}

		$ok = $this->add_db_record_whitelist( $table, $id );
		if ( is_wp_error( $ok ) ) {
			wp_send_json_error( $ok->get_error_message() );
			return;
		}

		wp_send_json_success( 'DB record whitelisted successfully.' );
	}

	/**
	 * Fetch inspection payload for a DB record: scanned content + identifier.
	 *
	 * Security model:
	 * - Strict allowlist of logical table keys.
	 * - Prepared SQL with integer IDs.
	 *
	 * @param string $table Logical table key: posts|postmeta|options|comments.
	 * @param int    $id    Record ID in that table.
	 * @return array|WP_Error Payload array or WP_Error.
	 */
	function fetch_db_record_inspection_payload( $table, $id ) {
		global $wpdb;
		$table = (string) $table;
		$id    = intval( $id );
		$row   = null;

		if ( $id <= 0 ) {
			return new WP_Error( 'invalid_id', 'Invalid record ID.' );
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DB inspection must read current record state for operator review, and there is no uniform higher-level API that preserves these raw mixed-table lookup semantics.
		switch ( $table ) {
			case 'posts':
				$identifier_label = 'post_name';
				$row              = $wpdb->get_row(
					$wpdb->prepare(
						"SELECT post_content AS content, post_name AS identifier FROM {$wpdb->posts} WHERE ID = %d",
						$id
					),
					ARRAY_A
				);
				break;
			case 'postmeta':
				$identifier_label = 'meta_key';
				$row              = $wpdb->get_row(
					$wpdb->prepare(
						"SELECT meta_value AS content, meta_key AS identifier FROM {$wpdb->postmeta} WHERE meta_id = %d",
						$id
					),
					ARRAY_A
				);
				break;
			case 'options':
				$identifier_label = 'option_name';
				$row              = $wpdb->get_row(
					$wpdb->prepare(
						"SELECT option_value AS content, option_name AS identifier FROM {$wpdb->options} WHERE option_id = %d",
						$id
					),
					ARRAY_A
				);
				break;
			case 'comments':
				$identifier_label = 'comment_author_email';
				$row              = $wpdb->get_row(
					$wpdb->prepare(
						"SELECT comment_content AS content, comment_author_email AS identifier FROM {$wpdb->comments} WHERE comment_ID = %d",
						$id
					),
					ARRAY_A
				);
				break;
			default:
				return new WP_Error( 'invalid_table', 'Invalid table.' );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( null === $row ) {
			return new WP_Error( 'not_found', 'Record not found.' );
		}

		$content = isset( $row['content'] ) ? (string) $row['content'] : '';
		$ident   = isset( $row['identifier'] ) ? (string) $row['identifier'] : '';

		// Safety cap: avoid returning arbitrarily large blobs to the browser.
		$max_bytes = 512 * 1024;
		if ( strlen( $content ) > $max_bytes ) {
			$content = substr( $content, 0, $max_bytes ) . "\n\n[WPMR] Output truncated to {$max_bytes} bytes.";
		}

		// Cap identifier length (UI-only).
		$max_ident_bytes = 512;
		if ( strlen( $ident ) > $max_ident_bytes ) {
			$ident = substr( $ident, 0, $max_ident_bytes ) . '…';
		}

		return array(
			'content'          => $content,
			'identifier_label' => $identifier_label,
			'identifier_value' => $ident,
		);
	}

	/**
	 * Fetch the exact database content field that is scanned by the DB scanner.
	 *
	 * The scanner always runs regex against a `content` string selected from the
	 * relevant table; this method retrieves that same field for inspection.
	 *
	 * Security model:
	 * - Strict allowlist of logical table keys.
	 * - Prepared SQL with integer IDs.
	 *
	 * @param string $table Logical table key: posts|postmeta|options|comments.
	 * @param int    $id    Record ID in that table.
	 * @return string|WP_Error Content string or WP_Error.
	 */
	function fetch_db_record_scanned_content( $table, $id ) {
		global $wpdb;
		$table   = (string) $table;
		$id      = intval( $id );
		$content = null;

		if ( $id <= 0 ) {
			return new WP_Error( 'invalid_id', 'Invalid record ID.' );
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DB content inspection must read current persisted record content, and there is no uniform higher-level API that preserves these raw mixed-table lookup semantics.
		switch ( $table ) {
			case 'posts':
				$content = $wpdb->get_var( $wpdb->prepare( "SELECT post_content FROM {$wpdb->posts} WHERE ID = %d", $id ) );
				break;
			case 'postmeta':
				$content = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_id = %d", $id ) );
				break;
			case 'options':
				$content = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_id = %d", $id ) );
				break;
			case 'comments':
				$content = $wpdb->get_var( $wpdb->prepare( "SELECT comment_content FROM {$wpdb->comments} WHERE comment_ID = %d", $id ) );
				break;
			default:
				return new WP_Error( 'invalid_table', 'Invalid table.' );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( null === $content ) {
			return new WP_Error( 'not_found', 'Record not found.' );
		}

		$content = (string) $content;

		// Safety cap: avoid returning arbitrarily large blobs to the browser.
		$max_bytes = 512 * 1024;
		if ( strlen( $content ) > $max_bytes ) {
			$content = substr( $content, 0, $max_bytes ) . "\n\n[WPMR] Output truncated to {$max_bytes} bytes.";
		}

		return $content;
	}

	/**
	 * AJAX handler: remove file from whitelist.
	 */
	function wpmr_unwhitelist_file() {
		check_ajax_referer( 'wpmr_unwhitelist_file', 'wpmr_unwhitelist_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}
		$encoded_file = isset( $_REQUEST['file'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['file'] ) ) : '';
		if ( '' === $encoded_file ) {
			wp_send_json_error( 'Invalid file path detected.' );
			return;
		}
		$file      = $this->normalise_path( base64_decode( $encoded_file ) );
		$whitelist = is_array( $this->get_setting( 'whitelist' ) ) ? $this->get_setting( 'whitelist' ) : array();
		if ( array_key_exists( $file, $whitelist ) ) {
			unset( $whitelist[ $file ] );
			$this->update_setting( 'whitelist', $whitelist );
			wp_send_json_success( 'File removed from whitelist successfully. File: ' . $file );
		} else {
			wp_send_json_error( 'Failed to remove file from whitelist. File: ' . $file );
		}
	}

	/**
	 * AJAX handler: remove DB record from record-based whitelist.
	 *
	 * Request params:
	 * - table: posts|postmeta|options|comments
	 * - id: integer
	 */
	function wpmr_unwhitelist_db_record() {
		check_ajax_referer( 'wpmr_unwhitelist_db_record', 'wpmr_unwhitelist_db_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}

		$table = isset( $_REQUEST['table'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['table'] ) ) : '';
		$id    = isset( $_REQUEST['id'] ) ? intval( wp_unslash( $_REQUEST['id'] ) ) : 0;

		$result = $this->remove_db_record_whitelist( $table, $id );
		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
			return;
		}

		wp_send_json_success( 'DB record removed from whitelist successfully.' );
	}

	/**
	 * AJAX handler: whitelist a file.
	 *
	 * Registered on `wp_ajax_wpmr_whitelist_file`.
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function wpmr_whitelist_file() {
		check_ajax_referer( 'wpmr_whitelist_file', 'wpmr_whitelist_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}

		$encoded_file = isset( $_REQUEST['file'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['file'] ) ) : '';
		if ( '' === $encoded_file ) {
			wp_send_json_error( 'Invalid file path detected.' );
			return;
		}
		$file = base64_decode( $encoded_file );

		// Security check: validate file path to prevent path traversal
		if ( ! $this->is_safe_file_path( $file ) ) {
			wp_send_json_error( 'Invalid file path detected.' );
		}

		$normalized_file = $this->normalise_path( $file );

		// return wp_send_json_error( 'Debugging' );
		if ( $this->is_in_core_wp_dir( $normalized_file ) || $this->is_repairable( $file ) ) {
			wp_send_json_error( 'Error: Whitelisting core WordPress files is a security risk. File: ' . $normalized_file );
		}

		if ( $file == $this->get_wp_config_path() || // if file is not wp-config.php
			$file == trailingslashit( ABSPATH ) . '.htaccess' // if file is not .htaccess
			) {
				$this->flog( 'detected wp-config or htaccess in wpmr_whitelist_file: ' . $file );
				wp_send_json_error( 'Error: Whitelisting .htaccess or wp-config.php is a security risk. File: ' . $normalized_file );

		}

		if ( ! file_exists( $normalized_file ) ) {
			wp_send_json_error( 'File doesn\'t exist. File: ' . $normalized_file );
		}

		// Request whitelist action from SaaS control plane (license REQUIRED)
		$response = $this->request_saas_action( 'saas_whitelist_file', $normalized_file );

		if ( is_wp_error( $response ) ) {
			$error_data = $response->get_error_data();
			if ( ! empty( $error_data ) ) {
				wp_send_json_error( $error_data );
			}
			wp_send_json_error( $response->get_error_message() );
		}

		// Validate response signature
		// Validation is now handled inside request_saas_action
		// $validation = $this->validate_saas_response( $response );
		// if ( ! $validation['valid'] ) {
		// wp_send_json_error( $validation['error'] );
		// }

		if ( isset( $response['payload']['message'] ) ) {
			$response['payload']['message'] = $this->sanitize_saas_reason_html( $response['payload']['message'] );
		}

		// Check if action is available
		if ( empty( $response['payload']['available'] ) ) {
			wp_send_json_error( isset( $response['payload']['message'] ) ? $response['payload']['message'] : 'Action unavailable' );
		}

		// Execute whitelist action
		$result = $this->perform_whitelist_action( $response, $normalized_file );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		wp_send_json_success( 'File whitelisted successfully. File: ' . $normalized_file );
	}

	/**
	 * Validate if a file path is safe and within allowed directories.
	 *
	 * This function checks if the provided file path is safe by normalizing
	 * it, checking for path traversal attempts, and ensuring it resides
	 * within predefined allowed directories.
	 *
	 * Enforces operation-level file safety paths such as inspection,
	 * repair, deletion, or other read/write style file actions.
	 *
	 * @param string $file_path The file path to validate.
	 * @return bool True if the file path is safe, false otherwise.
	 */
	function is_safe_file_path( $file_path ) {
		if ( empty( $file_path ) ) {
			return false;
		}

		// Normalize the path
		$normalized_path = $this->normalise_path( $file_path );

		// Check for path traversal attempts and block outright
		if ( strpos( $normalized_path, '../' ) !== false || strpos( $normalized_path, '..\/' ) !== false ) {
			return false;
		}

		// Resolve the real path to handle symbolic links and relative paths
		$real_path = realpath( $normalized_path );
		if ( $real_path === false ) {
			// If realpath fails, the file might not exist, but we can still validate the path structure
			$real_path = $normalized_path;
		} else {
			$real_path = $this->normalise_path( $real_path );
		}

		// Define allowed base directories
		$allowed_dirs = array(
			$this->normalise_path( ABSPATH ),
			$this->normalise_path( WP_CONTENT_DIR ),
			$this->normalise_path( WP_PLUGIN_DIR ),
			$this->normalise_path( get_theme_root() ),
		);

		$uploads = wp_get_upload_dir();
		if ( ! empty( $uploads['basedir'] ) && $this->is_valid_dir( $uploads['basedir'] ) ) {
			$allowed_dirs[] = $this->normalise_path( $uploads['basedir'] );
		}

		// Check if the file is within any of the allowed directories
		$is_within_allowed = false;
		foreach ( $allowed_dirs as $allowed_dir ) {
			if ( strpos( $normalized_path, $allowed_dir ) === 0 ) {
				$is_within_allowed = true;
				break;
			}
		}

		return $is_within_allowed;
	}

	/**
	 * Determine if a file is repairable based on core checksums.
	 *
	 * This function checks if the provided file exists in the core WordPress
	 * checksums list, indicating that it can be repaired by restoring the
	 * original version.
	 *
	 * @param string $local_file The file path to check.
	 * @return bool True if the file is repairable, false otherwise.
	 */
	function is_repairable( $local_file ) {
		// Security check: validate file path to prevent path traversal
		if ( ! $this->is_safe_file_path( $local_file ) ) {
			return false;
		}

		if ( $this->is_valid_file( $local_file ) ) {

			if ( $local_file == $this->get_wp_config_path() || // if file is not wp-config.php
			$local_file == trailingslashit( ABSPATH ) . '.htaccess' // if file is not .htaccess
			) {
				$this->flog( 'detected wp-config or htaccess in is_repairable: ' . $local_file );
				return false;
			}

			$checksums     = $this->get_core_checksums();
			$relative_file = str_replace( trailingslashit( $this->normalise_path( ABSPATH ) ), '', $this->normalise_path( $local_file ) );
			if ( array_key_exists( $relative_file, $checksums ) ) {
				return true;
			}
		}
	}

	/**
	 * Determine if a file is safe to delete.
	 *
	 * This function checks if the provided file is not part of core checksums,
	 * and is not a critical configuration file like wp-config.php or .htaccess.
	 *
	 * @param string $local_file The file path to check.
	 * @return bool True if the file is safe to delete, false otherwise.
	 */
	function is_deletable( $local_file ) {
		// Security check: validate file path to prevent path traversal
		if ( ! $this->is_safe_file_path( $local_file ) ) {
			return false;
		}

		if ( ! $this->is_valid_file( $local_file ) ) {
			$this->flog( 'is_deletable: Invalid file: ' . $local_file );
			return false;
		}

		if ( $local_file == $this->get_wp_config_path() || // if file is wp-config.php
			$local_file == trailingslashit( ABSPATH ) . '.htaccess' // if file is .htaccess
			) {
				$this->flog( 'detected wp-config or htaccess in is_repairable: ' . $local_file );
				return false;
		}

		if ( ! $this->is_repairable( $local_file ) // if file is not a part of core checksums
		) {
			return true;
		}
	}

	/**
	 * AJAX handler: clean a file by repairing it.
	 *
	 * Registered on `wp_ajax_wpmr_clean_file`.
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function wpmr_clean_file() {
		check_ajax_referer( 'wpmr_clean_file', 'wpmr_clean_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}

		WP_Filesystem();
		$encoded_file = isset( $_REQUEST['file'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['file'] ) ) : '';
		if ( '' === $encoded_file ) {
			wp_send_json_error( 'Invalid file path detected.' );
			return;
		}
		$file = base64_decode( $encoded_file );

		// Security check: validate file path to prevent path traversal
		if ( ! $this->is_safe_file_path( $file ) ) {
			wp_send_json_error( 'Invalid file path detected.' );
		}

		if ( ! file_exists( $file ) ) {
			wp_send_json_error( 'File doesn\'t exist. File: ' . $file );
		}

		if ( ! $this->is_valid_file( $file ) || ! $this->is_repairable( $file ) ) {
			$result = new WP_Error( 'cleanup_failed', 'File is not repairable. Please cleanup manually. File: ' . $file );
			wp_send_json_error( $result->get_error_message() );
		}

		// Request repair action from SaaS control plane
		$response = $this->request_saas_action( 'saas_repair_file', $file );

		if ( is_wp_error( $response ) ) {
			$error_data = $response->get_error_data();
			if ( ! empty( $error_data ) ) {
				wp_send_json_error( $error_data );
			}
			wp_send_json_error( $response->get_error_message() );
		}

		// Validate response signature
		// Validation is now handled inside request_saas_action
		// $validation = $this->validate_saas_response( $response );
		// if ( ! $validation['valid'] ) {
		// wp_send_json_error( $validation['error'] );
		// }

		if ( isset( $response['payload']['message'] ) ) {
			$response['payload']['message'] = $this->sanitize_saas_reason_html( $response['payload']['message'] );
		}

		// Check if action is available
		if ( empty( $response['payload']['available'] ) ) {
			wp_send_json_error( isset( $response['payload']['message'] ) ? $response['payload']['message'] : 'Action unavailable' );
		}

		// Execute repair action
		$result = $this->perform_repair_action( $response, $file );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		wp_send_json_success( $this->fetch_file_contents( $file ) );
	}

	/**
	 * AJAX handler: delete a file.
	 *
	 * Registered on `wp_ajax_wpmr_delete_file`.
	 *
	 * @return void Sends JSON response via wp_send_json_*.
	 */
	function wpmr_delete_file() {
		check_ajax_referer( 'wpmr_delete_file', 'wpmr_delete_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}

		WP_Filesystem();
		$encoded_file = isset( $_REQUEST['file'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['file'] ) ) : '';
		if ( '' === $encoded_file ) {
			wp_send_json_error( 'Invalid file path detected.' );
			return;
		}
		$file = base64_decode( $encoded_file );

		if ( ! $this->is_valid_file( $file ) ) {
			wp_send_json_error( 'File is empty or inaccessible or too large a file. File: ' . $file );
		}

		// Security check: validate file path to prevent path traversal
		if ( ! $this->is_safe_file_path( $file ) ) {
			wp_send_json_error( 'Invalid file path detected.' );
		}

		if ( ! file_exists( $file ) ) {
			wp_send_json_error( 'File doesn\'t exist. File: ' . $file );
		}

		// Local safety check (fail-fast for critical files)
		if ( ! $this->is_deletable( $file ) ) {
			wp_send_json_error( 'File is repairable. Skipping deletion. File: ' . $file );
		}

		// Request delete action from SaaS control plane (NO license required)
		$response = $this->request_saas_action( 'saas_delete_file', $file );

		if ( is_wp_error( $response ) ) {
			$error_data = $response->get_error_data();
			if ( ! empty( $error_data ) ) {
				wp_send_json_error( $error_data );
			}
			wp_send_json_error( $response->get_error_message() );
		}

		// Validation is now handled inside request_saas_action
		// $validation = $this->validate_saas_response( $response );
		// if ( ! $validation['valid'] ) {
		// wp_send_json_error( $validation['error'] );
		// wp_send_json_error( $validation['error'] );
		// }

		if ( isset( $response['payload']['message'] ) ) {
			$response['payload']['message'] = $this->sanitize_saas_reason_html( $response['payload']['message'] );
		}

		// Check if file is repairable - suggest repair instead
		if ( empty( $response['payload']['available'] ) && ! empty( $response['payload']['is_repairable'] ) ) {
			wp_send_json_error( isset( $response['payload']['message'] ) ? $response['payload']['message'] : 'File is repairable' );
		}

		// Check if action is available
		if ( empty( $response['payload']['available'] ) ) {
			wp_send_json_error( isset( $response['payload']['message'] ) ? $response['payload']['message'] : 'Action unavailable' );
		}

		// Execute delete action
		$result = $this->perform_delete_action( $response, $file );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( $result->get_error_message() );
		}

		wp_send_json_success( 'File deleted successfully. File: ' . $file );
	}

	/**
	 * Get remote response for a given URL.
	 *
	 * This function performs a safe remote request to the specified URL
	 * and checks for a successful response. If the response code is not 200
	 * or if there is an error, it logs the issue and returns null.
	 *
	 * @param string $url The URL to request.
	 * @return array|null The response array on success, or null on failure.
	 */
	function get_remote_response( $url ) {
		$response = wp_safe_remote_request( $url, array( 'timeout' => $this->timeout ) );
		if ( 200 != wp_remote_retrieve_response_code( $response ) ) {
			$this->flog( 'Failed to get remote response for URL: ' . $url . ' with response code: ' . wp_remote_retrieve_response_code( $response ) );
			return;
		}
		if ( is_wp_error( $response ) ) {
			$this->flog( 'Error retrieving remote response: ' . $response->get_error_message() );
			return;
		}
		return $response;
	}

	/**
	 * Get the default uploads path based on site configuration.
	 *
	 * This function determines the expected uploads directory path
	 * based on whether the site is a single site or part of a multisite
	 * network, and whether it is the main site or a subsite.
	 *
	 * @return string The default uploads path.
	 */
	function default_uploads_path() {
		if ( ! is_multisite() ) {
			$expected_uploads_path = trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'uploads';
		} else {
			// Determine if this is the main site or a subsite
			$site_id = get_current_blog_id();
			if ( $site_id == 1 ) {
				// Main site follows the single-site structure even in multisite
				$expected_uploads_path = trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'uploads';
			} else {
				// Subsites follow the subsite structure
				$expected_uploads_path = trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'uploads/sites/' . $site_id;
			}
			$expected_uploads_path = trailingslashit( $this->normalise_path( WP_CONTENT_DIR ) ) . 'uploads';
		}
		return $expected_uploads_path;
	}

	/**
	 * Check if a file is a core WordPress file.
	 */
	function is_core_wp_file( $file ) {
		global $wpdb;
		$file = $this->normalise_path( $file );
		if ( empty( $checksums ) ) {
			$checksums = array();
		}
		$key = str_replace( trailingslashit( $this->normalise_path( ABSPATH ) ), '', $file );

		// Check database table for core checksums
		$table = $this->get_wpmr_table_name( WPMR_CHECKSUMS );
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core checksum classification must use current checksum-table truth during file inspection, and there is no higher-level reader for this plugin-owned checksum table.
		$raw_checksums = $wpdb->get_results(
			$wpdb->prepare( 'SELECT path FROM %i WHERE type = %s', $table, 'core' ),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		$checksum_paths = array();
		if ( ! empty( $raw_checksums ) && is_array( $raw_checksums ) ) {
			foreach ( $raw_checksums as $row ) {
				if ( isset( $row['path'] ) ) {
					$checksum_paths[ $row['path'] ] = true;
				}
			}
		}

		$response = $this->is_in_core_wp_dir( $file ) ||
		( dirname( $key ) == '.' && array_key_exists( $key, $checksum_paths ) ); // If there are no slashes in path, a dot ('.') is returned
		return $response;
	}

	/**
	 * Get the core relative path of a file.
	 *
	 * This function returns the path of the file relative to the WordPress
	 * installation root (ABSPATH). For core files, this will be the path
	 * within the WordPress directory structure.
	 *
	 * @param string $file The absolute file path.
	 * @return string The core relative path.
	 */
	function get_core_relative_path( $file ) {
		$file = $this->normalise_path( $file );
		// Core files: relative to ABSPATH (e.g., wp-admin/index.php)
		// Plugins: relative to WP_PLUGIN_DIR (e.g., plugin-name/plugin.php)
		// Themes: relative to theme root (e.g., theme-name/style.css)
		// Everything else: relative to ABSPATH
		return str_replace( trailingslashit( $this->normalise_path( ABSPATH ) ), '', $file );
	}

	/**
	 * Check if a file is located in core WordPress directories.
	 *
	 * This function checks if the given file path is within the
	 * wp-admin or wp-includes directories of the WordPress installation.
	 *
	 * @param string $file The absolute file path.
	 * @return bool True if the file is in core WordPress directories, false otherwise.
	 */
	function is_in_core_wp_dir( $file ) {
		$file = $this->normalise_path( $file );
		if ( strpos( $file, trailingslashit( $this->normalise_path( ABSPATH ) ) . 'wp-admin/' ) !== false ||
		strpos( $file, trailingslashit( trailingslashit( $this->normalise_path( ABSPATH ) ) . WPINC ) ) !== false ) {
			return true;
		}
		return false;
	}

	/**
	 * Prioritise core WordPress files in a list.
	 *
	 * This function sorts the provided list of files, prioritising
	 * core WordPress files first, followed by wp-content files,
	 * then files in the WordPress root directory, and finally
	 * other files. It returns the sorted list of files.
	 *
	 * @param array $files The list of file paths to prioritise.
	 * @return array An array containing the sorted list of files.
	 */
	function prioritise_core_files( $files ) {
		$files_c   = array();
		$files_wpc = array();
		$files_wpr = array();
		sort( $files );
		foreach ( $files as $key => $file ) {
			if ( $this->is_in_core_wp_dir( $file ) || $this->is_in_root_dir( $file ) ) {
				$files_c[] = $file;
			} elseif ( $this->str_starts_with( $this->normalise_path( dirname( $file ) ), $this->normalise_path( WP_CONTENT_DIR ) ) ) {
				$files_wpc[] = $file;
			} elseif ( $this->str_starts_with( $this->normalise_path( dirname( $file ) ), $GLOBALS['WPMR']['home_dir'] ) ) {
				$files_wpr[] = $file;
			}
		}
		// Implement chunked sorting for memory efficiency
		$files   = $this->chunked_natcasesort( $files );
		$files_c = $this->chunked_natcasesort( $files_c );
		$files   = array_merge( array_values( $files_c ), array_values( $files_wpc ), array_values( $files_wpr ), array_values( $files ) );
		$files   = array_unique( $files );
		return array( 'files' => $files );
	}

	/**
	 * Perform chunked natural case-insensitive sorting on an array.
	 *
	 * This function divides the input array into smaller chunks,
	 * sorts each chunk using natural case-insensitive sorting,
	 * and then merges the sorted chunks back into a single array.
	 *
	 * @param array $array The array to sort.
	 * @param int   $chunk_size The size of each chunk for sorting.
	 * @return array The sorted array.
	 */
	function chunked_natcasesort( $array, $chunk_size = 1000 ) {
		$chunked_array = array_chunk( $array, $chunk_size );
		$sorted_array  = array();

		foreach ( $chunked_array as $chunk ) {
			natcasesort( $chunk );
			$sorted_array = array_merge( $sorted_array, $chunk );
		}

		return $sorted_array;
	}

	/**
	 * Check if a file is located in the WordPress root directory.
	 */
	function is_in_root_dir( $file ) {
		return $this->normalise_path( ABSPATH ) === $this->normalise_path( dirname( $file ) );
	}

	/**
	 * Process files to only include those in specified scan directories.
	 */
	function process_only_scan_dirs( $files ) {
		if ( ! empty( $GLOBALS['WPMR']['only_scan_dirs'] ) ) {
			// Always scan core
			$GLOBALS['WPMR']['only_scan_dirs'][] = trailingslashit( ABSPATH ) . 'wp-admin';
			$GLOBALS['WPMR']['only_scan_dirs'][] = trailingslashit( ABSPATH ) . WPINC;
			foreach ( $files as $k => $file ) {
				if ( ! $this->path_begins_with_any( $file, $GLOBALS['WPMR']['only_scan_dirs'] ) ) {
					unset( $files[ $k ] );
				}
			}
		}
		return $files;
	}

	/**
	 * Check if a path begins with any of the specified directories.
	 */
	function path_begins_with_any( $path, $arr_dirs ) {
		foreach ( $arr_dirs as $dir ) {
			if ( $this->str_starts_with( $path, $dir ) ) {
				return 1;
			}
		}
	}

	/**
	 * Check if a string starts with a specified substring.
	 */
	function str_starts_with( $string, $startswith ) {
		return ( strpos( (string) $string, (string) $startswith ) === 0 ); // we are not looking for occurance but specifically the begining
	}

	/**
	 * Determine if a directory should be skipped during scanning.
	 */
	function wpmr_skip_dir( $path ) {

		if ( $path == untrailingslashit( $this->normalise_path( ABSPATH ) ) ) {
			return false;
		}

		if ( ! empty( $GLOBALS['WPMR']['skipdirs'] ) && in_array( basename( $this->normalise_path( $path ) ), $GLOBALS['WPMR']['skipdirs'] ) ) {
			return true;
		}

		if ( file_exists( $this->normalise_path( $path . DIRECTORY_SEPARATOR . '.mcignore' ) ) ) {
			return true;
		}

		// A simple way to check if a symlink recursive is to check if it is pointing to a parent directory
		// recursive /parent/subdir/path (points to) -> /parent/subdir/
		// recursive /parent/subdir/path (points to) -> /parent/somedir/
		if ( is_link( $path ) ) {
			// readlink( realpath DOESNT WORK. STAY AWAY FROM IT. Readlink only reads symlinks. Not real paths
			$link   = $this->normalise_path( dirname( $path ) . DIRECTORY_SEPARATOR . basename( $path ) );
			$target = $this->normalise_path( $link ); // use realpath. readlink result can be another symlink
			if ( $target && str_starts_with( $link, $target ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Return all files from a specified path.
	 */
	function return_all_files( $path = false ) {
		// It takes around 4 to 10 seconds to generate a list of files.
		// If the calls are made simultaneously, the list is generated multiple times.
		// It must be possible to store this in a short transient.
		// TBD
		// $time = microtime( true );
		$files = $this->get_all_files( $path );
		return $files;
	}

	/**
	 * Recursively get all files from a specified path.
	 */
	function get_all_files( $path = false ) {
		if ( ! $path ) {
			$path = ( ! empty( $GLOBALS['WPMR']['home_dir'] ) ) ? wp_normalize_path( $GLOBALS['WPMR']['home_dir'] ) : ABSPATH;
			if ( empty( $path ) ) {
				$this->flog( 'Failed to get path.', false, false, true );
				return array();
			}
		}
		$path = untrailingslashit( wp_normalize_path( $path ) ); // This could be a symlink or whatever. Let's not touch it before testing via wpmr_skip_dir
		// $path = untrailingslashit( $this->realpath( $path ) ); // NEVER DO THIS ELSE WE CAN'T TEST SYMLINK RECURSION. Dont confuse readlink vs realpath
		$wpmr_skip_dir = apply_filters( 'wpmr_skip_dir', $path );
		if ( ! $wpmr_skip_dir ) {
			$children = @scandir( $path );
			if ( is_array( $children ) ) {
				$children = array_diff( $children, array( '..', '.' ) );
				$files    = array();
				foreach ( $children as $child ) {
					$target = trailingslashit( wp_normalize_path( $path ) ) . $child;
					if ( is_dir( $target ) ) {
						$elements = $this->get_all_files( $target );
						if ( $elements ) {
							foreach ( $elements as $element ) {
								$files[] = $this->normalise_path( $element );
							}
						}
					}
					if ( $this->is_scannable_file( $target ) ) {
						$files[] = $this->normalise_path( $target );
					}
				}
				return $files;
			} else {
				$this->flog( "Failed to read directory: {$path}" );
				return array();
			}
		} else {
			return array();
		}
	}

	/**
	 * Normalize a file path.
	 *
	 * This function normalizes the given file path using WordPress's
	 * wp_normalize_path function. It attempts to resolve the real path
	 * first, and if successful, returns the normalized real path.
	 * If realpath fails, it returns the normalized original path.
	 *
	 * @param string $path The file path to normalize.
	 * @return string The normalized file path.
	 */
	function normalise_path( $path ) {
		$realpath = wp_normalize_path( realpath( $path ) );
		if ( $realpath ) {
			return $realpath;
		}
		return wp_normalize_path( $path );
	}

	/**
	 * Checks if a file is scannable based on several criteria:
	 */
	function is_scannable_file( $file ) {
		$file = $this->normalise_path( $file );
		// File is scannable if: exists, is a file, is within size limit, and either has content OR is an empty core file
		$return = ( file_exists( $file ) && is_file( $file ) && ( filesize( $file ) || ( ! filesize( $file ) && $this->is_core_wp_file( $file ) ) || ! is_readable( $file ) ) && filesize( $file ) <= $this->maxsize );
		return $return;
	}

	/**
	 * Checks if a file is valid for scanning.
	 */
	function is_valid_file( $file ) {
		return ! $this->is_invalid_file( $file );
	}

	/** Checks if a file is invalid for scanning based on several criteria:
	 * - File does not exist
	 * - File is not readable
	 * - Path is not a file (could be a directory)
	 * - File is empty (0 bytes) and not a core WordPress file
	 * - File exceeds maximum allowed size for scanning
	 */
	function is_invalid_file( $file ) {
		$file = $this->normalise_path( $file );

		return ( ! file_exists( $file ) // Check if file doesn't exist
		|| ! is_readable( $file ) // Check if file is not readable
		|| ! is_file( $file ) // Check if path is not a file (could be a directory)
		|| ( ! filesize( $file ) && ! $this->is_core_wp_file( $file ) ) // Check if file is empty (0 bytes) - empty files in core dirs need special handling
		|| filesize( $file ) > $this->maxsize // Check if file exceeds maximum allowed size for scanning
		);
	}

	/**
	 * Checks if a directory is valid for scanning.
	 */
	function is_valid_dir( $dir ) {
		$dir = $this->normalise_path( $dir );
		return ( file_exists( $dir ) && is_dir( $dir ) && is_readable( $dir ) );
	}

	/**
	 * Glob all files from a specified path using RecursiveDirectoryIterator.
	 */
	function glob_files( $path = false ) {
		if ( ! $path ) {
			$path = ABSPATH;
			if ( empty( $path ) ) {
				return array();
			}
			$path = untrailingslashit( $path );
		}
		// $allfiles = new RecursiveIteratorIterator(
		// new RecursiveDirectoryIterator(
		// $path,
		// RecursiveDirectoryIterator::SKIP_DOTS |
		// RecursiveDirectoryIterator::FOLLOW_SYMLINKS |
		// RecursiveDirectoryIterator::KEY_AS_PATHNAME
		// ),
		// RecursiveIteratorIterator::SELF_FIRST,
		// RecursiveIteratorIterator::CATCH_GET_CHILD
		// );
		$allfiles = new RecursiveDirectoryIterator(
			$path,
			RecursiveDirectoryIterator::SKIP_DOTS |
			RecursiveDirectoryIterator::FOLLOW_SYMLINKS |
			RecursiveDirectoryIterator::KEY_AS_PATHNAME
		);

		$files = new RecursiveCallbackFilterIterator(
			$allfiles,
			function ( $current, $key, $iterator ) {
				$this->llog( $current );
				return true;
				if ( $iterator->hasChildren() && $current->isFile() ) {
					return true;
				} else {
					return false;
				}
				// return $current->isFile();
			}
		);

		$this->llog( $files );

		$it = new RecursiveIteratorIterator(
			$files,
			RecursiveIteratorIterator::SELF_FIRST,
			RecursiveIteratorIterator::CATCH_GET_CHILD
		);
		$this->llog( $it );
		foreach ( $it as $k => $v ) {
			echo esc_html( $this->debug_value_to_string( $k ) );
			echo esc_html( $this->debug_value_to_string( $v ) );
		}

		return;

		// $allfiles = iterator_to_array( $allfiles, 1 );
		$files = array();
		foreach ( $allfiles as $k => $v ) {
			if ( is_file( $k ) ) {
				// $files[] = $k;
				$this->llog( $k );
			}
			// print_r( $k  );
		}

		return $files;

		$files = array();
		foreach ( new RecursiveDirectoryIterator( $allfiles ) as $filename => $cur ) {
			$this->flog( $filename );
			$files[] = $filename;
		}
		sort( $files );
		return $files;
	}

	/**
	 * Determine if a path is excluded based on skipdirs and only_scan_dirs.
	 */
	function is_excluded( $path ) {
		$skipdirs       = $GLOBALS['WPMR']['skipdirs'];
		$only_scan_dirs = $GLOBALS['WPMR']['only_scan_dirs'];

		$path_excluded_by_skipdirs       = $this->path_excluded_by_skipdirs( $path, $skipdirs );
		$path_included_in_only_scan_dirs = $this->path_included_in_only_scan_dirs( $path, $only_scan_dirs );

		$result = $path_excluded_by_skipdirs && ! $path_included_in_only_scan_dirs;
		return $result;
	}

	/**
	 * Check if a path is excluded by skipdirs.
	 */
	function path_excluded_by_skipdirs( $path, $dirs ) {
		$skipped = false;
		if ( ! empty( $dirs ) && is_array( $dirs ) ) {
			foreach ( $dirs as $skipdir ) {
				if ( preg_match( '/' . preg_quote( wp_normalize_path( $skipdir ), '/' ) . '/', $path ) ) {
					return true;
				}
			}
		}
	}

	/**
	 * Check if a path is included in only_scan_dirs.
	 */
	function path_included_in_only_scan_dirs( $path, $dirs ) {
		if ( empty( $dirs ) || ! is_array( $dirs ) ) {
			return;
		}
		$present  = false;
		$abs_dirs = $dirs;
		// Check if the path to file is present in any of the only_scan_dirs
		foreach ( $abs_dirs as $abs_dir ) {
			if ( preg_match( '/' . preg_quote( wp_normalize_path( $abs_dir ), '/' ) . '/', $path ) ) { // Check if file path has matches in only_scan_dir
				$present = true;
			}
		}
		return $present;
	}

	/**
	 * Get a specific setting from the WPMR options.
	 */
	function get_setting( $setting ) {
		$settings = $this->get_wpmr_option();

		$return = isset( $settings[ $setting ] ) ? $settings[ $setting ] : false;

		return $return;
	}

	/**
	 * Update a specific setting in the WPMR options.
	 */
	function update_setting( $setting, $value ) {
		$settings = $this->get_wpmr_option();
		if ( ! $settings ) {
			$settings = array();
		}
		$settings[ $setting ] = $value;
		if ( $setting == 'signatures' || $setting == 'sig_time' ) {
			$this->delete_generated_checksums();
		}
		wp_cache_delete( 'WPMR', 'options' );
		return update_option( 'WPMR', $settings );
	}

	/**
	 * Delete a specific setting from the WPMR options.
	 */
	function delete_setting( $setting ) {
		$settings = $this->get_wpmr_option();
		if ( ! $settings ) {
			$settings = array();
		}
		unset( $settings[ $setting ] );
		$result = update_option( 'WPMR', $settings ); // assignment gets around an opcache bug
		return $result;
	}

	/**
	 * Get a specific firewall setting.
	 */
	function get_fw_setting( $setting ) {
		$defaults = $this->wpmr_fw_settings_defaults();
		$settings = get_option( 'wpmr_fw_settings' );
		if ( ! $settings ) {
			return $defaults[ $setting ];
		}
		return isset( $settings[ $setting ] ) ? $settings[ $setting ] : 'no';
	}

	/**
	 * Get all WPMR options, with per-key defaults applied.
	 *
	 * Unlike a plain get_option() default (which only fires when the whole
	 * option row is absent), this merges per-key defaults onto whatever is
	 * currently persisted. That means upgraders whose option row already
	 * exists still receive new defaults for keys they don't yet have — which
	 * is the case the previous whole-row fallback could not handle.
	 *
	 * Explicit writes win: defaults apply only to keys absent from the stored
	 * array. A key set to false, 0, '', or null is honoured as an intentional
	 * value and never overwritten by a default.
	 *
	 * @return array
	 */
	function get_wpmr_option() {
		$stored = get_option( 'WPMR' );

		// Row absent, corrupt, or a non-array value: return a full default set.
		if ( ! is_array( $stored ) ) {
			return $this->wpmr_option_defaults();
		}

		return array_merge( $this->wpmr_option_defaults(), $stored );
	}

	/**
	 * Default values for the WPMR option array.
	 *
	 * Used by get_wpmr_option() to fill missing keys. Keep this list to
	 * settings whose absence should be transparent on read (UX skins,
	 * notification toggles).
	 *
	 * @return array
	 */
	protected function wpmr_option_defaults() {
		return array(
			'wpmr_skin'                  => 'dark',
			'wpmr_scan_notify_licensee'  => true,
			'wpmr_pulse_notify_licensee' => true,
			// 'wpmr_scan_notify_registrant'  => false,
			// 'wpmr_pulse_notify_registrant' => false,
		);
	}

	/**
	 * Read a plugin option value.
	 *
	 * @param string $key     Option key.
	 * @param mixed  $default Default value when key is absent.
	 * @return mixed
	 */
	function get_option( $option_name, $default = false ) {
		$option = get_option( WPMRNS . $option_name, array() );
		if ( isset( $option ) ) {
			return $option;
		}
		return $default;
	}

	/**
	 * Update a plugin setting.
	 *
	 * @param string $key   Setting key.
	 * @param mixed  $value Setting value.
	 * @return bool
	 */
	function update_option( $key, $value ) {
		return update_option( WPMRNS . $key, $value, false );
	}

	/**
	 * Delete a plugin setting.
	 *
	 * @param string $key Setting key.
	 * @return bool
	 */
	function delete_option( $key ) {
		return delete_option( WPMRNS . $key );
	}

	// Stateless scanner option helpers

	/**
	 * Get all stateless scanner options.
	 */
	function ss_get_wpmr_option() {
		return get_option( WPMRNS . 'S_' );
	}

	/**
	 * Read a plugin option value.
	 *
	 * @param string $key     Option key.
	 * @param mixed  $default Default value when key is absent.
	 * @return mixed
	 */
	function ss_get_option( $option_name, $default = false ) {
		$option = get_option( WPMRNS . 'S_' . $option_name, array() );
		if ( isset( $option ) ) {
			return $option;
		}
		return $default;
	}

	/**
	 * Update a stateless scanner setting.
	 *
	 * @param string $key   Setting key.
	 * @param mixed  $value Setting value.
	 * @return bool
	 */
	function ss_update_option( $key, $value ) {
		return update_option( WPMRNS . 'S_' . $key, $value, false );
	}

	/**
	 * Delete a stateless scanner setting.
	 *
	 * @param string $key Setting key.
	 * @return bool
	 */
	function ss_delete_option( $key ) {
		return delete_option( WPMRNS . 'S_' . $key );
	}

	/**
	 * Get a specific setting from the WPMR options.
	 */
	function ss_get_setting( $setting ) {
		$settings = $this->ss_get_wpmr_option();

		$return = isset( $settings[ $setting ] ) ? $settings[ $setting ] : false;

		return $return;
	}

	/**
	 * Update a specific setting in the WPMR options.
	 */
	function ss_update_setting( $setting, $value ) {
		$settings = $this->ss_get_wpmr_option();
		if ( ! $settings ) {
			$settings = array();
		}
		$settings[ $setting ] = $value;
		return update_option( WPMRNS . 'S_', $settings );
	}

	/**
	 * Delete a specific setting from the stateless scanner options.
	 */
	function ss_delete_setting( $setting ) {
		$settings = $this->ss_get_wpmr_option();
		if ( ! $settings ) {
			$settings = array();
		}
		unset( $settings[ $setting ] );
		$result = update_option( WPMRNS . 'S_', $settings ); // assignment gets around an opcache bug
		return $result;
	}

	/**
	 * Register firewall settings.
	 */
	function register_settings() {
		register_setting(
			'wpmr_fw_settings',
			'wpmr_fw_settings',
			array(
				'default'           => $this->wpmr_fw_settings_defaults(),
				'sanitize_callback' => array(
					$this,
					'sanitize_fw',
				),
			)
		);
		// add_settings_section( string $id, string $title, callable $callback, string $page, array $args = array() )
		add_settings_section( 'wpmr_fw', 'Security Hardening &amp; Protection', array( $this, 'firewall_section_ui' ), 'wpmr_firewall' );
		// add_settings_field( string $id, string $title, callable $callback, string $page, string $section = 'default', array $args = array() )
		add_settings_field( 'fw_block_path_traversal', '', array( $this, 'fw_block_path_traversal_ui' ), 'wpmr_firewall', 'wpmr_fw' );
		add_settings_field( 'fw_disable_php_upload', '', array( $this, 'fw_disable_php_upload_ui' ), 'wpmr_firewall', 'wpmr_fw' );
		add_settings_field( 'fw_disable_restapi_user_listing', '', array( $this, 'fw_disable_restapi_user_listing_ui' ), 'wpmr_firewall', 'wpmr_fw' );
		add_settings_field( 'fw_disable_user_enumeration', '', array( $this, 'fw_disable_user_enumeration_ui' ), 'wpmr_firewall', 'wpmr_fw' );
	}

	/**
	 * Sanitize firewall settings.
	 */
	function sanitize_fw( $values ) {
		if ( empty( $values['fw_block_path_traversal'] ) ) {
			$values['fw_block_path_traversal'] = 'no';
		}
		if ( empty( $values['fw_disable_php_upload'] ) ) {
			$values['fw_disable_php_upload'] = 'no';
		}
		if ( empty( $values['fw_disable_restapi_user_listing'] ) ) {
			$values['fw_disable_restapi_user_listing'] = 'no';
		}
		if ( empty( $values['fw_disable_user_enumeration'] ) ) {
			$values['fw_disable_user_enumeration'] = 'no';
		}
		return $values;
	}

	/**
	 * Get default firewall settings.
	 */
	function wpmr_fw_settings_defaults() {
		$defaults = array(
			'fw_block_path_traversal'         => 'yes',
			'fw_disable_php_upload'           => 'yes',
			'fw_disable_restapi_user_listing' => 'yes',
			'fw_disable_user_enumeration'     => 'yes',
		);
		return $defaults;
	}

	/**
	 * Processes AJAX requests for the `wpmr_ajax_request` action.
	 *
	 * Validates the request using a nonce, checks user permissions, dynamically
	 * calls the appropriate class method, and returns a JSON response.
	 *
	 * @return void Outputs a JSON response using wp_send_json, wp_send_json_success,
	 *              or wp_send_json_error.
	 */
	function wpmr_ajax_request() {
		check_ajax_referer( 'wpmr_ajax_data', 'wpmr_ajax_data_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Not allowed.' );
		}
		if ( empty( $_REQUEST['request'] ) || ! is_array( $_REQUEST['request'] ) || empty( $_REQUEST['request'][0] ) ) {
			$this->flog( 'not callable', false, false, true );
			wp_send_json_error( 'Not sure what to do.', false, false, true );
		}

		$request_handler = sanitize_text_field( wp_unslash( $_REQUEST['request'][0] ) );

		if ( ! is_callable( array( $this, $request_handler ) ) ) {

			wp_send_json_error( 'Not sure what to do.', false, false, true );
		}

		if ( ! empty( $_REQUEST['request'][1] ) ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Dynamic AJAX dispatcher forwards a validated array payload; the called handler owns contextual sanitization.
			$request_args = wp_unslash( $_REQUEST['request'][1] );
			if ( ! is_array( $request_args ) ) {
				$formatted_args = $this->debug_value_to_string( $request_args );
				wp_send_json_error( 'Arguments must be an array. You passed ' . gettype( $request_args ) . ' "' . $formatted_args . '"' );
			}
			$result = call_user_func_array(
				array( $this, $request_handler ),
				$request_args
			);
		} else {
			$result = call_user_func( array( $this, $request_handler ) );
		}
		if ( is_null( $result ) || $result === true ) { // Function did it's job; may be silently or returned true. Simply return success.
			wp_send_json_success( $result );
		} else { // We don't know if the message is a success or an error. Simply return the message.
			wp_send_json( $result );
		}
	}

	/**
	 * Get the locale of the WordPress installation.
	 */
	function get_locale() {
		$file = ABSPATH . WPINC . '/version.php';

		if ( $this->is_valid_file( $file ) ) {
			$code = file_get_contents( $file );
		}

		if ( preg_match( '/\$wp_local_package\s*=\s*[\'"](.*?)[\'"]\s*;/', $code, $matches ) ) {
			$locale = $matches[1];
		} else {
			$locale = 'en_US';
		}
		return $locale;
	}

	/**
	 * Reset logs by deleting relevant transient options from the database.
	 */
	function reset_logs() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Resetting persisted log transients is a maintenance delete path, and prefix-based option cleanup has no equivalent higher-level bulk API.
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
				'_transient_WPMR\_log\_%',
				'_transient\_timeout\_WPMR\_log\_%'
			)
		);
	}

	/**
	 * Logs debug messages to the scanner's status log table.
	 *
	 * Canonical debug logging helper used across the scanners.
	 *
	 * Note: older/internal docs may refer to a legacy `wpmr_utils::dlog()` shim. That shim file is
	 * not part of this repository; `dlog()` is implemented directly in this trait.
	 *
	 * @param mixed  $msg      The message/value to log.
	 * @param int    $severity The severity level of the message (default: 2).
	 * @param string $comment  Additional comment information (default: empty string).
	 * @return bool  Always returns true to indicate successful logging
	 */
	function dlog( $msg, $severity = 2, $comment = '' ) {
		global $wpdb;

		$msg = $this->debug_value_to_string( $msg );

		if ( ! is_string( $msg ) || empty( $msg ) ) {
			return false;
		}

		switch ( strtolower( $severity ) ) {
			case 1:
			case 'debug':
			case 'd':
				$severity = 'd'; // debug
				break;
			case 2:
			case 'info':
			case 'i':
				$severity = 'i'; // info
				break;
			case 3:
			case 'warning':
			case 'w':
				$severity = 'w'; // warning
				break;
			case 4:
			case 'error':
			case 'e':
				$severity = 'e'; // error
				break;
			case 5:
			case 'critical':
			case 'c':
				$severity = 'c'; // critical
				break;
			default:
				$severity = 'i'; // info
				break;
		}

		$table = $this->table_logs;

		// self::flog( 'INFO: Message received: ' . $msg );
		// self::flog( 'INFO: Sanitized to: ' . sanitize_text_field( $msg ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Debug log persistence writes to a plugin-owned table with no higher-level WordPress API.
		$insert = $wpdb->insert(
			$table,
			array(
				'message'  => $msg,
				'severity' => $severity,
				'comment'  => json_encode(
					array(
						'time'    => gmdate( 'Y-m-d H:i:s\Z' ),
						'comment' => $comment,
					)
				),
			),
			array( '%s', '%s', '%s' )
		);
		// self::flog( 'Insert: ' . print_r( $insert ) );
		return $insert;
	}

	/**
	 * Query debug logs from the database.
	 *
	 * Parity note: This intentionally matches MSS `mss_utils::query_dlog()` semantics.
	 * - `$offset` is treated as a *row offset* (not a id cursor).
	 * - `$total_count` is `COUNT(*)`.
	 * - For `$offset === 0`, the MSS implementation uses `LIMIT 0` (returns no rows).
	 *
	 * @param int    $offset   Number of records to skip (default: 0).
	 * @param string $severity Optional severity filter (kept for parity; currently unused).
	 * @return array An associative array containing 'logs' and 'total_count'.
	 */
	function query_dlog( $offset = 0, $severity = '' ) {
		global $wpdb;

		$table = $this->table_logs;
		// Base query
		if ( $offset === 0 ) {
			// For offset 0, get last 100 rows in ascending order using subquery
			$query = "SELECT * FROM (
                SELECT * FROM {$table} 
                ORDER BY id DESC 
                LIMIT 0
            ) AS sub 
            ORDER BY id ASC";
		} else {
			// For pagination, use normal query
			$query = "SELECT * FROM {$table}";
		}

		$params = array();

		// Add severity filter if specified
		if ( ! empty( $severity ) ) {
			// Convert numeric severity to text
			if ( is_numeric( $severity ) ) {
				$severity = array_search( (int) $severity, $severity_levels ) ?: 'info';
			}

			// Get minimum severity level
			$min_severity = $severity_levels[ strtolower( $severity ) ] ? $severity_levels[ strtolower( $severity ) ] : 2;

			// Build WHERE clause for severity filtering
			if ( $offset === 0 ) {
				// For subquery, need to add WHERE in the inner query
				$query = str_replace(
					"SELECT * FROM {$table}",
					"SELECT * FROM {$table} WHERE CASE severity
                        WHEN 'debug' THEN 1
                        WHEN 'info' THEN 2
                        WHEN 'warning' THEN 3
                        WHEN 'error' THEN 4
                        WHEN 'critical' THEN 5
                    END >= %d",
					$query
				);
			} else {
				$query .= " WHERE CASE severity
                    WHEN 'debug' THEN 1
                    WHEN 'info' THEN 2
                    WHEN 'warning' THEN 3
                    WHEN 'error' THEN 4
                    WHEN 'critical' THEN 5
                END >= %d";
			}
			$params[] = $min_severity;
		}

		// Add ordering and offset for pagination queries
		if ( $offset > 0 ) {
			$query   .= ' ORDER BY id ASC';
			$query   .= ' LIMIT 18446744073709551615 OFFSET %d';
			$params[] = (int) $offset;
		}

		// MSS parity depends on the current query shape; only the internal WPMR log table is interpolated,
		// while runtime values still flow through $wpdb->prepare() when placeholders are present.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Operator log pagination must read current persisted debug-log truth, and this plugin-owned log table has no higher-level query API.
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$prepared_query = ! empty( $params ) ? $wpdb->prepare( $query, $params ) : $query;
		$logs           = $wpdb->get_results( $prepared_query, ARRAY_A );
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$total_count = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		return array(
			'logs'        => $logs,
			'total_count' => $total_count,
		);
	}

	/**
	 * Clear all display logs from the database.
	 */
	function clear_dlog() {
		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_LOGS );
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Debug-log clearing is a maintenance path against a plugin-owned table; cache reuse is inapplicable and no higher-level bulk API exists.
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $table_exists === $table ) {
			$wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Normalize a host by removing the 'www.' prefix.
	 *
	 * @param string $host The host to normalize.
	 * @return string The normalized host.
	 */
	function normalize_host( $host ) {
		return preg_replace( '/^www\./', '', $host );
	}

	/**
	 * Automate routine tasks during cron jobs.
	 */
	function automate_routines() {
		if ( ! ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
			return;
		}
		if ( ! $this->is_registered() ) {
			// Cron on unregistered sites has no SaaS work to do. checksums_delete_invalid()
			// (called below) is local hygiene; on unregistered sites the checksum table is
			// empty so it's a no-op anyway. Safe to short-circuit.
			return;
		}
		$check   = $this->check_definitions();
		$updates = $this->definition_updates_available();
		$this->checksums_delete_invalid();

		// Auto-update definitions if updates are available and auto-update is enabled and advanced edition
		if ( $updates && $this->get_setting( 'def_auto_update_enabled' ) && $this->is_advanced_edition() ) {
			$update = $this->update_definitions_cli( true );
		}
	}

	/**
	 * Get users who are currently logged in.
	 */
	function get_users_loggedin() {
		return get_users(
			array(
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Logged-in session inspection needs the session_tokens existence query on this narrow admin surface.
				'meta_key'     => 'session_tokens',
				'meta_compare' => 'EXISTS',
			)
		);
	}

	/**
	 * Display logged-in user sessions in the admin interface.
	 */
	function malcure_user_sessions() {
		?>
		<tr><th>Logged-In Users:</th><td>
		<?php
		submit_button( 'Logout All Users', 'primary', 'malcure_destroy_sessions', false );
		submit_button( 'Shuffle WordPress Salts', 'primary', 'malcure_shuffle_salts', false );
		$users = $this->get_users_loggedin();

		$total_users = count( $users );
		// Fetch only the first 25 users
		$display_users = array_slice( $users, 0, 25 );

		// Display heading when users are skipped
		if ( $total_users > count( $display_users ) ) {
			$skipped_users = $total_users - count( $display_users );
			echo '<h3>Showing ' . esc_html( count( $display_users ) ) . ' of ' . esc_html( $total_users ) . ' logged-in users; ' . esc_html( $skipped_users ) . ' users skipped.</h3>';
		}
		foreach ( $display_users  as $user ) {
			echo '<table class="user_details" id="user_details_' . esc_html( $user->ID ) . '">';
			echo '<tr><th class="user_details_id">User ID</th><td>' . esc_html( $user->ID ) . '</td></tr>';
			echo '<tr><th class="user_details_roles">User Roles</th><td>' . esc_html( implode( ',', $user->roles ) ) . '</td></tr>';
			echo '<tr><th class="user_details_user_login">User Login</th><td>' . esc_html( $user->user_login ) . '</td></tr>';
			echo '<tr><th class="user_details_user_email">User Email</th><td>' . esc_html( $user->user_email ) . '</td></tr>';
			echo '<tr><th class="user_details_display_name">Display Name</th><td>' . esc_html( $user->display_name ) . '</td></tr>';
			echo '<tr><th class="user_details_user_registered">Registered</th><td>' . esc_html( gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $user->user_registered ) ) ) . '</td></tr>';
			$s_details = '';
			$s_details = get_user_meta( $user->ID, 'session_tokens', true );
			echo '<tr><th  class="wpmr_user_details_session_ip">Sessions</th><td>';
			foreach ( $s_details as $s_detail ) {
				echo '<table class="wpmr_user_details_session">';
				echo '<tr><th  class="wpmr_user_details_session_ip">IP Address</th><td>' . esc_html( $s_detail['ip'] ) . '</td></tr>';
				// $hostname = gethostbyaddr( $s_detail['ip'] );
				// if ( $hostname && $hostname !== $s_detail['ip'] ) {
				// echo '<tr><th class="wpmr_user_details_session_hostname">Hostname</th><td>' . esc_html( $hostname ) . '</td></tr>';
				// }
				echo '<tr><th  class="wpmr_user_details_session_ua">User-Agent</th><td>' . esc_html( $s_detail['ua'] ) . '</td></tr>';
				echo '<tr><th  class="wpmr_user_details_session_login">Session Start</th><td>' . esc_html( gmdate( 'Y-m-d\TH:i:s\Z', $s_detail['login'] ) ) . '</td></tr>';
				echo '<tr><th  class="wpmr_user_details_session_expiration">Session Expiration</th><td>' . esc_html( gmdate( 'Y-m-d\TH:i:s\Z', $s_detail['expiration'] ) ) . '</td></tr>';
				echo '</table>';
			}
			echo '</td></tr>';
			echo '</table>';
		}
		?>
		</td></tr>
		<?php
	}

	/**
	 * Convert a duration in seconds to a human readable string.
	 *
	 * @param int|float $seconds Duration in seconds.
	 * @return string
	 */
	function human_readable_time_diff_old( $seconds ) {
		if ( $seconds < 1 ) {
			return '0 seconds';
		}
		return human_time_diff( time() - $seconds );
	}

	function human_readable_time_diff( $timestamp, $another_timestamp = '' ) {

		if ( empty( $another_timestamp ) ) {
			$diff = abs( $timestamp );
		} else {
			$diff = abs( $another_timestamp - $timestamp );
		}

		$units = array(
			'year'   => 31556926,
			'month'  => 2629744,
			'week'   => 604800,
			'day'    => 86400,
			'hour'   => 3600,
			'minute' => 60,
			'second' => 1,
		);

		$parts = array();

		foreach ( $units as $name => $divisor ) {
			if ( $diff < $divisor ) {
				continue;
			}

			$time  = floor( $diff / $divisor );
			$diff %= $divisor;

			$parts[] = $time . ' ' . $name . ( $time > 1 ? 's' : '' );
		}

		$last = array_pop( $parts );

		if ( empty( $parts ) ) {
			return $last;
		} else {
			return join( ', ', $parts ) . ' and ' . $last;
		}
	}

	/**
	 * Convert a byte count to a human readable string.
	 *
	 * @param int|float $bytes Bytes.
	 * @return string
	 */
	function human_readable_bytes_old( $bytes ) {
		return size_format( $bytes );
	}

	function human_readable_bytes( $bytes, $decimals = 2 ) {
		$size   = array( 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' );
		$factor = floor( ( strlen( $bytes ) - 1 ) / 3 );
		return sprintf( "%.{$decimals}f", $bytes / pow( 1024, $factor ) ) . @$size[ $factor ];
	}

	/**
	 * Normalize a path.
	 *
	 * @param string $path Path.
	 * @return string
	 */
	function realpath( $path ) {
		$realpath = realpath( $path );
		if ( $realpath ) {
			return $realpath;
		}
		return $path;
	}

	/**
	 * Output shared JavaScript helpers for WPMR admin screens.
	 *
	 * Hooked to `admin_footer` at an early priority from wpmr.php.
	 * Only outputs on WPMR-related screens to avoid polluting other admin pages.
	 *
	 * @return void
	 */
	function js_lib() {
		if ( ! function_exists( 'get_current_screen' ) ) {
			return;
		}
		$screen = get_current_screen();
		if ( ! $screen || ! preg_match( '/wpmr/', (string) $screen->id ) ) {
			return;
		}
		?>
		<script type="text/javascript">
			/**
			 * IoC lookup URL helper for scan-result links rendered in JS.
			 *
			 * UTM parameters were removed for privacy; the object and buildIocUrl
			 * signature are preserved for backward compatibility with all call sites.
			 */
			window.wpmrUtm = {
				base: '<?php echo esc_js( trailingslashit( MALCURE_API ) . '?p=2074' ); ?>',
				buildIocUrl: function( ssig, issueType ) {
					return this.base + '&ssig=' + encodeURIComponent( ssig );
				}
			};
			/**
			 * Parse a PHP timestamp (time(), microtime(), microtime(true)) into a
			 * millisecond‐precision JS timestamp.
			 *
			 * @param {number|string} ts
			 * @return {number} milliseconds since Unix epoch
			 */
			function parsePhpTimestamp( ts ) {
				// If it's already a number (seconds or float‑seconds)
				if ( typeof ts === 'number' ) {
				return ts * 1000;
				}

				// If it's a string containing two parts: "microseconds seconds"
				if ( typeof ts === 'string' && ts.includes(' ') ) {
				const [ microStr, secStr ] = ts.split(' ');
				const micro = parseFloat( microStr ) || 0;
				const sec   = parseInt( secStr, 10 ) || 0;
				return ( sec + micro ) * 1000;
				}

				// Otherwise assume a numeric string
				const num = parseFloat( ts );
				return isNaN( num ) ? Date.now() : num * 1000;
			}

			/**
			 * Human‐readable difference between two PHP‐style timestamps.
			 *
			 * @param {number|string} from  PHP timestamp (see parsePhpTimestamp)
			 * @param {number|string} [to]  PHP timestamp; defaults to now
			 * @return {string} e.g. "5 minutes", "1 day", "3 weeks"
			 */
			function humanTimeDiffPhp( from, to = undefined ) {
				const fromMs = parsePhpTimestamp( from );
				const toMs   = to == null
							? Date.now()
							: parsePhpTimestamp( to );
				const diffSec = Math.abs( toMs - fromMs ) / 1000;

				const MINUTE = 60;
				const HOUR   = 60 * MINUTE;
				const DAY    = 24 * HOUR;
				const WEEK   = 7  * DAY;
				const MONTH  = 30 * DAY;    // WP uses 30‑day months
				const YEAR   = 365 * DAY;

				let count, unit;

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

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

			function js_stamp_to_browser_time(timestamp) {
				let ts = Number(timestamp);

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

				const date = new Date(ts);

				const options = { 
					year: 'numeric', 
					month: 'long', 
					day: 'numeric',
					hour: '2-digit',
					minute: '2-digit',
					hour12: false
				};

				return date.toLocaleString(undefined, options);
			}

			var wpmr_ux_notifications_enabled = <?php echo $this->get_setting( 'ux_notifications_enabled' ) === 'off' ? 'false' : 'true'; ?>;

			function wpmr_notify_audio(audioFilename) {
				// Respect live checkbox if present; fall back to persisted setting.
				var $checkbox = jQuery('#wpmr_ux_notifications_enabled');
				var enabled = $checkbox.length ? $checkbox.is(':checked') : (typeof wpmr_ux_notifications_enabled !== 'undefined' ? wpmr_ux_notifications_enabled : true);
				if (!enabled) {
					return;
				}
				if (!window.Audio) {
					return;
				}
				if (!audioFilename) {
					return;
				}
				var audioPath = '<?php echo esc_js( WPMR_PLUGIN_DIR_URL ); ?>assets/sounds/' + audioFilename + '.wav';
				var audio = new Audio(audioPath);
				audio.addEventListener('canplaythrough', function () {
					audio.play();
				}, false);
				audio.addEventListener('error', function (error) {
					console.error('Error playing audio:', error);
				}, false);
			}

			/**
			 * Floating toast renderer used by wpmr_ajax_request callbacks.
			 *
			 * Animates #wpmr_messaging up from the bottom-right corner, holds for 10s, then hides.
			 *
			 * @param {string} message HTML content to display.
			 */
			function wpmr_show_message(message) {
				jQuery('#wpmr_message_content').html(message);
				jQuery('#wpmr_messaging').css('bottom', '-' + (jQuery('#wpmr_messaging').height() + 10) + 'px').animate({
					'bottom': '10px',
					'opacity': '1'
				}, {
					duration: 300,
					complete: function () {
						jQuery('#wpmr_message_content').addClass('showing');
					}
				}).delay(10000).animate({
					'bottom': '-' + jQuery('#wpmr_messaging').height() + 'px',
					'opacity': '0'
				}, {
					duration: 300,
					complete: function () {
						jQuery('#wpmr_message_content').removeClass('showing');
					}
				});
			}

			/**
			 * AJAX helper — posts a structured request to the shared wpmr_ajax_request dispatcher.
			 *
			 * @param {string}   f_call          Server-side method name (dispatched via call_user_func).
			 * @param {Array}    [a_args]        Optional arguments array passed to the server method.
			 * @param {string}   [successMsg]    Optional HTML shown as floating toast on success.
			 * @param {string}   [failureMsg]    Optional HTML shown as floating toast on error.
			 * @return {jqXHR}                   jQuery XHR object (supports .done()/.fail() chaining).
			 */
			function wpmr_ajax_request( f_call, a_args, successMsg, failureMsg ) {
				wpmr_messaging_arguments = arguments;
				var data = {
					wpmr_ajax_data_nonce: '<?php echo esc_js( wp_create_nonce( 'wpmr_ajax_data' ) ); ?>',
					action: "wpmr_ajax_request",
					request: [ f_call, a_args ]
				};
				$ = jQuery;
				return $.ajax({
					url: ajaxurl,
					method: 'POST',
					data: data,
					complete: function( jqXHR, textStatus ) {
						if ( textStatus !== 'success' || ! jqXHR.hasOwnProperty( 'responseJSON' ) ) {
							return;
						}
						var r = jqXHR.responseJSON;
						if ( ! r.hasOwnProperty( 'success' ) ) {
							return;
						}
						if ( r.success ) {
							if ( wpmr_messaging_arguments[2] && typeof wpmr_show_message === 'function' ) {
								wpmr_show_message( wpmr_messaging_arguments[2] );
							}
						} else {
							if ( wpmr_messaging_arguments[3] && typeof wpmr_show_message === 'function' ) {
								wpmr_show_message( wpmr_messaging_arguments[3] + ' ' + r.data );
							} else if ( typeof wpmr_show_message === 'function' ) {
								var msg = typeof r.data === 'string' ? r.data : '';
								if ( msg && ! /^<p>[\s\S]*<\/p>$/.test( msg ) ) {
									msg = '<p>' + msg + '</p>';
								}
								if ( msg ) {
									wpmr_show_message( msg );
								}
							}
						}
					}
				});
			}

			jQuery(document).ready(function($) {
				// Auto-save notification checkboxes on change.
				$(document).on('change', '#wpmr_scan_notify_form input[type="checkbox"], #wpmr_pulse_notify_form input[type="checkbox"]', function() {
					var $cb = $(this);
					wpmr_ajax_request(
						'save_notify_toggle',
						[$cb.attr('name'), $cb.is(':checked') ? true : false],
						'<p>Email notification settings updated.</p>',
						'<p>Could not update notification settings.</p>'
					);
				});

				// Auto-save CC email lists on textarea blur.
				$(document).on('blur', '#wpmr_scan_notify_form textarea, #wpmr_pulse_notify_form textarea', function() {
					var $ta = $(this);
					wpmr_ajax_request(
						'save_notify_cc_list',
						[$ta.attr('name'), $ta.val()],
						'<p>Email notification settings updated.</p>',
						'<p>Could not update notification settings.</p>'
					);
				});
			});

			/**
			 * Shared overlay for long-running AJAX operations.
			 * Self-healing: creates #wpmr_operation_overlay if it doesn't exist.
			 */
			var wpmrOverlayTimeout = null;

			function showOverlay(message) {
				if (!message) { message = ''; }

				var $overlay = jQuery('#wpmr_operation_overlay');
				if (!$overlay.length) {
					$overlay = jQuery(
						'<div id="wpmr_operation_overlay" style="display:none;">' +
						'<div class="wpmr_overlay_content">' +
						'<div id="wpmr_overlay_message"></div>' +
						'<div class="wpmr_progress_bar"><div class="wpmr_progress_indicator"></div></div>' +
						'</div></div>'
					);
					jQuery('body').append($overlay);
				}

				jQuery('#wpmr_overlay_message').text(message);
				$overlay.fadeIn(200);

				if (wpmrOverlayTimeout) { clearTimeout(wpmrOverlayTimeout); }
				wpmrOverlayTimeout = setTimeout(function() {
					jQuery('#wpmr_overlay_message').html(message + '<br><small>(Taking longer than expected, please be patient\u00A0…)</small>');
				}, 5000);
			}

			function hideOverlay() {
				jQuery('#wpmr_operation_overlay').fadeOut(200);
				if (wpmrOverlayTimeout) {
					clearTimeout(wpmrOverlayTimeout);
					wpmrOverlayTimeout = null;
				}
			}

			/**
			 * Inline button spinner — replaces button content with an animated SVG spinner
			 * during AJAX operations. Preserves button width to prevent layout shift.
			 *
			 * @param {string} selector  jQuery selector for the button element.
			 * @param {string} [label]   Optional text next to the spinner.
			 */
			function showButtonSpinner(selector, label) {
				var $btn = jQuery(selector);
				if (!$btn.length) return;

				if (!$btn.data('_wpmr_spinner_original')) {
					$btn.data('_wpmr_spinner_original', $btn.html());
				}
				$btn.prop('disabled', true);
				$btn.css('min-width', $btn.outerWidth() + 'px');

				var html = '<span class="wpmr-spinner-inline"></span>';
				if (label) {
					html += ' <span class="wpmr-spinner-label">' + label + '</span>';
				}
				$btn.html(html);
			}

			/**
			 * Restore a button to its original state after showButtonSpinner().
			 *
			 * @param {string} selector  jQuery selector for the button element.
			 */
			function hideButtonSpinner(selector) {
				var $btn = jQuery(selector);
				if (!$btn.length) return;

				var original = $btn.data('_wpmr_spinner_original');
				if (original !== undefined) {
					$btn.html(original);
					$btn.removeData('_wpmr_spinner_original');
				}
				$btn.css('min-width', '');
				$btn.prop('disabled', false);
			}

			/**
			 * First-time setup after registration.
			 *
			 * Self-invoking. Returns early unless ?wpmr_registration=true query arg
			 * is present. Fires checksums + definitions AJAX calls simultaneously
			 * via jQuery.when(), then triggers wpmr_setup_complete for stat refresh.
			 */
			function wpmr_first_time_setup() {
				
				console.log( 'wpmr_first_time_setup() invoked. Query args:', window.location.search );
				if ( ! ( new URLSearchParams( window.location.search ).has( 'wpmr_registration' ) ) ) {
					console.log( 'Early return — wpmr_registration query arg not present.' );
					return;
				}

				console.log( 'wpmr_registration query arg detected. Firing AJAX...' );

				// Fire both calls simultaneously — they are independent
				console.log( 'Firing get_all_checksums + update_definitions_cli simultaneously...' );
				var checksums   = wpmr_ajax_request( 'get_all_checksums' );
				var definitions = wpmr_ajax_request( 'update_definitions_cli');

				jQuery.when( checksums, definitions ).done( function() {
					console.log( 'Both AJAX calls resolved. Triggering wpmr_setup_complete.' );
					jQuery( document ).trigger( 'wpmr_setup_complete' );
				} ).fail( function( a, b ) {
					console.log( 'One or both AJAX calls failed.', arguments );
				} );
			}

			wpmr_first_time_setup();
		</script>
		<?php
	}

	function debug() {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {

		}
	}
}
