<?php
/**
 * WPMR_Checksums_Trait
 *
 * Generated during Phase 2 restructuring
 * This trait contains methods extracted from the original monolithic wpmr.php
 *
 * @package WP_Malware_Removal
 */

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

}

trait WPMR_Checksums {

	/**
	 * Determine whether a file should be scanned based on checksum/blacklist validation.
	 *
	 * Call sites (notably `wpmr_scan_files()`) treat any truthy return value as
	 * "needs content scan" and a falsy return value as "skip content scan".
	 *
	 * Validation strategy:
	 * - Core files (wp-admin/, wp-includes/, and root core files): strict path + sha256 match
	 * - Non-core files (plugins/themes/others): sha256-only match (set membership)
	 *
	 * Side effects:
	 * - Writes debug entries via `flog()`.
	 *
	 * Suggested rename: `checksum_scan_decision()` or `should_scan_file_by_checksum()`.
	 *
	 * @param string $local_file Absolute path to the file being evaluated.
	 * @return true|'missing'|null Returns:
	 *                          - true: file is considered invalid but still scannable (force scan)
	 *                          - 'missing': checksum mismatch or blacklisted (force scan + may be reported as mismatch/unknown)
	 *                          - null: checksum validated or file should be skipped
	 */
	function fails_checksum( $local_file ) {
		// Step 1: Pre-validation checks
		if ( $this->is_invalid_file( $local_file ) ) {
			if ( $this->is_scannable_file( $local_file ) ) {
				return true; // Invalid but scannable - must scan
			}
			return; // Skip file
		}

		// Step 2: Calculate file hash
		$hash = @hash_file( 'sha256', $local_file );
		if ( empty( $hash ) ) {
			return; // Unable to hash file
		}

		$is_core = $this->is_in_core_wp_dir( $local_file );
		// Step 3: Select appropriate checksum array based on file location
		if ( $is_core ) {
			// Core files: Use associative array (path => hash)
			$checksums = $GLOBALS['WPMR']['core_checksums'];
		} else {
			// Non-core files: Use hash map (hash => true)
			$checksums = $GLOBALS['WPMR']['checksums'];
		}

		// Step 4: Perform checksum validation
		$checksum_failed = false;

		// $this->flog( 'Validating checksum for file: ' . $local_file );

		if ( $is_core ) {
			$core_relative_path = $this->get_core_relative_path( $local_file );
			// Core file validation: Path must exist AND hash must match
			$checksum_failed = ! isset( $checksums[ $core_relative_path ] ) || $checksums[ $core_relative_path ] !== $hash;
			// $this->flog( 'Checksum failed? ' . $checksum_failed . ' isset=' . isset( $checksums[ $core_relative_path ] ) . ' hash ' . $hash . ' expected: ' . ( ! empty( $checksums[ $core_relative_path ] ) ? $checksums[ $core_relative_path ] : 'N/A' ) );
			// $this->flog( array_slice( $checksums, 0, 2, true ) );

		} else {
			// Non-core file validation: Hash must exist in known checksums
			// Using isset() for O(1) lookup on hash map
			$checksum_failed = ! isset( $checksums[ $hash ] );
			// $this->flog( 'Checksum failed? ' . $checksum_failed . ' hash ' . $hash );
			// $this->flog( array_slice( $checksums, 0, 2, true ) );
		}

		// Step 5: Check blacklist and return result
		if ( $checksum_failed || $this->is_file_blacklisted( $hash ) ) {
			return 'missing'; // Checksum failed or file blacklisted
		}

		return; // Passed validation
	}

	/**
	 * Cache sha256 checksums for files confirmed clean by a scan.
	 *
	 * This is called after a scan completes (see `wpmr_scan_files()`), with the set of
	 * scanned files excluding files that had findings. It persists entries into
	 * `{$wpdb->prefix}wpmr_scanned_files` with empty signature/severity to indicate
	 * a clean result.
	 *
	 * The `signature_version` column is set to the current definitions version so
	 * cache reads can safely scope clean-file checksums to the active definitions.
	 *
	 * The cached checksums are later merged into scan-time checksum sets (see
	 * `get_all_checksums()` / `get_checksums_values()`), to reduce repeat scanning.
	 *
	 * Suggested rename: `cache_clean_file_checksums()`.
	 *
	 * @param array<int,string> $clean_files Absolute file paths that were scanned and found clean.
	 * @return void
	 */
	function update_cached_checksums( $clean_files ) {
		if ( empty( $clean_files ) ) {
			return;
		}

		$definition_version = $this->get_definition_version();
		if ( ! is_string( $definition_version ) ) {
			$definition_version = (string) $definition_version;
		}

		global $wpdb;
		$table  = $this->get_wpmr_table_name( WPMR_SCANNED_FILES );
		$values = array();
		$params = array();

		foreach ( $clean_files as $file ) {
			// bail if invalid file
			if ( $this->is_invalid_file( $file ) ) {
				continue;
			}

			$sha256 = @hash_file( 'sha256', $file );
			// bail if unable to hash or already cached
			if ( empty( $sha256 ) || isset( $GLOBALS['WPMR']['checksums'][ $sha256 ] ) ) {
				continue;
			}

			// Prepare batch insert data
			$values[] = '(%s, %s, %s, %s, %s, %s)';
			$params[] = $file;
			$params[] = $sha256;
			$params[] = '';
			$params[] = '';
			$params[] = $definition_version;
			$params[] = '';
		}

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

		// Batch insert all clean files in one query
		array_unshift( $params, $table );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This bulk write persists the authoritative clean-file scan cache; caching is inapplicable and no higher-level WordPress API exists for this plugin-owned table.
		// The tuple placeholders are generated internally; runtime values still flow through $wpdb->prepare().
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$wpdb->query(
			$wpdb->prepare(
				'INSERT INTO %i (path, checksum, signature_id, severity, signature_version, attributes) VALUES ' . implode( ', ', $values ) . ' ON DUPLICATE KEY UPDATE checksum = VALUES(checksum), signature_id = VALUES(signature_id), severity = VALUES(severity), signature_version = VALUES(signature_version), attributes = VALUES(attributes)',
				$params
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Retrieve official (vendor) checksums, loading from DB and optionally refreshing from SaaS.
	 *
	 * This method primarily reads from `{$wpdb->prefix}wpmr_checksums`. If the table is empty
	 * or a refresh is forced, it builds a components manifest and requests a checksum payload
	 * from the SaaS endpoint, persists it, then returns a flattened (path => sha256) map.
	 *
	 * Important: despite the name, this returns official checksums for core + plugins + themes
	 * (as persisted in the single checksums table). It does not include scan-generated cached
	 * clean-file checksums (see `get_cached_checksums()` / `get_all_checksums()`).
	 *
	 * Suggested rename: `get_official_checksums()` or `get_official_checksums_from_db()`.
	 *
	 * @param bool $cached Whether to prefer DB-cached values (true) or force a SaaS refresh (false).
	 * Core rows are read scoped to the current `$wp_version` so a WordPress upgrade doesn't
	 * accidentally reuse stale core checksums from a previous version.
	 *
	 * @return array<string,string> Associative array of official checksums (path => sha256), installation-relative.
	 */
	function get_core_checksums( $cached = true ) {
		$this->raise_limits_conditionally();

		global $wp_version;

		// Fetch official checksums from database table
		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_CHECKSUMS );

		$checksums = array();

		if ( ! $this->is_registered() ) {
			// Unregistered sites must never trigger a SaaS checksum fetch.
			// Return whatever is in the DB (may be empty). The JS layer + document.ready
			// gate ensures the dashboard never calls this for stats anyway; this gate
			// is defense-in-depth for cron, CLI, and any future caller.
			return $checksums;
		}

		// Check if we need to fetch fresh checksums
		$should_fetch = false;
		if ( ! $cached ) {
			$should_fetch = true;
			// $this->flog( 'Forced refresh of official checksums requested; fetching fresh checksums from SaaS.' );
		} else {
			// Check if we have checksums in the database
			// Only count core rows for the currently-running WP core version.
			// Otherwise old-version rows can incorrectly suppress refresh.
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checksum freshness reads must use current DB truth for version-scoped validation, and there is no higher-level reader for this plugin-owned table.
			$count = $wpdb->get_var(
				$wpdb->prepare(
					'SELECT COUNT(*) FROM %i WHERE type = %s AND version = %s',
					$table,
					'core',
					(string) $wp_version
				)
			);
			if ( ! $count ) {
				// $this->flog( 'No cached checksums found in database table for core version ' . (string) $wp_version . '; fetching fresh checksums from SaaS.' );
				$should_fetch = true;
			} else {
				// $this->flog( 'Using cached checksums from database table for core version ' . (string) $wp_version );
				// Load checksums from database table
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						'SELECT path, checksum FROM %i WHERE type <> %s OR (type = %s AND version = %s)',
						$table,
						'core',
						'core',
						(string) $wp_version
					),
					ARRAY_A
				);
				if ( ! empty( $rows ) && is_array( $rows ) ) {
					foreach ( $rows as $row ) {
						if ( isset( $row['path'], $row['checksum'] ) ) {
							$checksums[ $row['path'] ] = $row['checksum'];
						}
					}
				}
			}
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
		}

		// If fetch is locked, return what we have
		if ( get_transient( 'wpmr_checksum_fetch_lock' ) ) {
			return $checksums;
		}

		if ( $should_fetch ) {

			$manifest = $this->build_checksums_manifest();
			if ( empty( $manifest ) ) {
				return array();
			}

			$request = $this->saas_request(
				'saas_get_checksums',
				array(
					'method'     => 'POST',
					'send_state' => 'body',
					'body'       => array(
						'components' => wp_json_encode( $manifest ),
					),
					'timeout'    => (int) $this->timeout,
				)
			);

			if ( is_wp_error( $request ) ) {
				$this->flog( 'Checksum batch request failed: ' . $request->get_error_message() );
				set_transient( 'wpmr_checksum_fetch_lock', true, MINUTE_IN_SECONDS );
				return array();
			}

			$payload = null;
			if ( isset( $request['payload'] ) && is_array( $request['payload'] ) ) {
				$payload = $request['payload'];
			}

			if ( ! empty( $payload ) ) {
				$this->save_checksums_to_db( $payload );
				$checksums = $this->flatten_checksum_payload( $payload );
				delete_transient( 'wpmr_checksum_fetch_lock' );
			} else {
				set_transient( 'wpmr_checksum_fetch_lock', true, MINUTE_IN_SECONDS );
			}
		}

		return $checksums;
	}

	/**
	 * Get checksum values as a hash map for O(1) lookups.
	 *
	 * Converts checksums to associative array (hash => true) instead of
	 * indexed array for instant isset() lookups vs O(n) in_array() scans.
	 *
	 * Suggested rename: `get_checksum_hash_set()`.
	 *
	 * @param bool $include_cached Whether to include cached clean files.
	 * @return array<string,bool> Hash map of checksums (sha256 => true).
	 */
	function get_checksums_values( $include_cached = true ) {
		$checksums = $include_cached ? $this->get_all_checksums() : $this->get_core_checksums();

		// Keep whitelist behavior consistent with 19.2, but without relying on the
		// `serve_checksums` filter chain. Whitelist stores (path => sha256); scan-time
		// non-core validation is hash-only, so we just need the sha256 values present.
		if ( $include_cached ) {
			$checksums = $this->whitelist( $checksums );
		}
		$checksum_values = array_values( $checksums );
		$checksum_values = array_unique( $checksum_values, SORT_REGULAR );

		// Convert to hash map for O(1) isset() lookups instead of O(n) in_array()
		return array_fill_keys( $checksum_values, true );
	}

	/**
	 * Get cached checksums for previously scanned clean files.
	 *
	 * Reads `{$wpdb->prefix}wpmr_scanned_files` and returns only entries representing clean
	 * scan results (empty signature_id and severity) scoped to the current definitions version.
	 *
	 * Note: this method is currently called directly by `get_all_checksums()`; although
	 * legacy code references a `serve_checksums` filter callback, this implementation
	 * does not accept/merge an incoming checksum array.
	 *
	 * Suggested rename: `get_cached_clean_file_checksums()`.
	 *
	 * @return array<string,string> Associative array (absolute path => sha256) for cached clean files.
	 */
	function get_cached_checksums() {
		$definition_version = $this->get_definition_version();
		if ( ! is_string( $definition_version ) ) {
			$definition_version = (string) $definition_version;
		}

		// Get clean files from wpmr_scanned_files table (files with no threats)
		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_SCANNED_FILES );
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Clean-file checksum rows must reflect current persisted scan truth, and there is no higher-level reader for this plugin-owned scanned-files table.
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $table_exists !== $table ) {
			return array();
		}

		$clean_files = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT path, checksum FROM %i WHERE signature_id = '' AND severity = '' AND signature_version = %s",
				$table,
				$definition_version
			),
			ARRAY_A
		);

		$cached_checksums = array();
		if ( ! empty( $clean_files ) && is_array( $clean_files ) ) {
			foreach ( $clean_files as $row ) {
				if ( isset( $row['path'], $row['checksum'] ) && ! empty( $row['checksum'] ) ) {
					$cached_checksums[ $row['path'] ] = $row['checksum'];
				}
			}
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching

		return $cached_checksums;
	}

	/**
	 * Get all checksums: official + cached clean files.
	 *
	 * Merges checksums from:
	 * 1. wp_wpmr_checksums (official from repositories)
	 * 2. wp_wpmr_scanned_files (cached clean files from previous scans)
	 *
	 * Cached checksums are omitted during custom regex scans.
	 *
	 * Suggested rename: `get_effective_checksums()`.
	 *
	 * @param bool $include_cached Whether to include cached checksums (default: true).
	 * @return array<string,string> Associative array (path => sha256) of all checksums.
	 */
	function get_all_checksums( $include_cached = true ) {
		$checksums = $this->get_core_checksums();

		if ( $include_cached && empty( $GLOBALS['WPMR']['regex'] ) ) {
			// Merge with cached checksums unless doing custom regex scan
			$cached = $this->get_cached_checksums();
			if ( ! empty( $cached ) ) {
				$checksums = array_merge( $checksums, $cached );
			}
		}

		return $checksums;
	}

	/**
	 * Delete cached clean-file checksums generated by scans.
	 *
	 * This clears entries from `{$wpdb->prefix}wpmr_scanned_files` that represent clean
	 * files (empty signature/severity) and resets the related cache option.
	 *
	 * Suggested rename: `delete_cached_clean_file_checksums()`.
	 *
	 * @return void
	 */
	function delete_generated_checksums() {

		// todo: problem: stateless scanner compares file against a) checksum b) definition version c) signature id
		// todo: we need a better way to purge checksums

		// global $wpdb;
		// $table = $this->get_wpmr_table_name( WPMR_SCANNED_FILES );
		// // Delete all clean files from wpmr_scanned_files.
		// // Guard against activation/first-run windows where custom tables may not exist yet.
		// $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		// if ( $table_exists === $table ) {
		// $wpdb->query( "DELETE FROM `$table` WHERE signature_id = '' AND severity = ''" );
		// }
		delete_option( 'WPMR_db_checksums_cache' );
	}

	/**
	 * Delete all core checksums on core upgrade.
	 *
	 * Truncates the checksums table to ensure all persisted checksum rows are cleared.
	 * This is safer than relying on legacy option storage and ensures future reads
	 * will trigger a fresh checksum fetch.
	 *
	 * @param mixed $upgrader   Upgrader instance (unused, may be null for internal calls).
	 * @param array $hook_extra Upgrade context array containing at least a `type` key.
	 *                          For plugins/themes, WordPress may provide either a singular
	 *                          key (`plugin`/`theme`) or a plural list (`plugins`/`themes`).
	 * @return void
	 */
	function delete_core_checksums( $upgrader, $hook_extra ) {
		$this->flog( '$upgrader' );
		$this->flog( $upgrader );
		$this->flog( '$hook_extra' );
		$this->flog( $hook_extra );

		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_CHECKSUMS );
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Upgrade-triggered checksum table resets are maintenance operations against a plugin-owned table; cache reuse is inapplicable.
		$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.NoCaching
	}

	/**
	 * Get the installed WordPress core version from disk.
	 *
	 * This intentionally reads `wp-includes/version.php` to avoid relying on the runtime
	 * global `$wp_version`, which can be stale inside upgrader hook requests.
	 *
	 * @return string Installed WordPress core version, or empty string on failure.
	 */
	protected function get_installed_wp_core_version() {
		static $cached = null;
		if ( null !== $cached ) {
			return $cached;
		}

		$cached = '';

		if ( ! defined( 'ABSPATH' ) || ! defined( 'WPINC' ) ) {
			return $cached;
		}

		$version_file = trailingslashit( ABSPATH ) . WPINC . '/version.php';
		if ( ! file_exists( $version_file ) ) {
			return $cached;
		}

		$wp_version = null;
		require $version_file;

		if ( ! empty( $wp_version ) && is_string( $wp_version ) ) {
			$cached = $wp_version;
			return $cached;
		}

		if ( ! empty( $GLOBALS['wp_version'] ) ) {
			$cached = (string) $GLOBALS['wp_version'];
		}

		return $cached;
	}

	/**
	 * Refresh checksums after an upgrade completes.
	 *
	 * Hooked to `upgrader_process_complete` and used by stateful scanning to request
	 * fresh checksums for the updated component(s), delete stale rows, and persist
	 * the new payload into `{$wpdb->prefix}wpmr_checksums`.
	 *
	 * Implementation notes:
	 * - Builds a minimal components manifest (only the updated component targets).
	 * - Fetches a replacement checksum payload from the SaaS endpoint.
	 * - Replaces checksum rows in the DB only when a replacement payload exists.
	 *
	 * Note: uses the internal plugin header reader with translation disabled to avoid
	 * WordPress 6.7+ just-in-time textdomain loading notices in early lifecycle paths.
	 *
	 * Suggested rename: `refresh_checksums_for_updated_components()`.
	 *
	 * @param mixed $upgrader   Upgrader instance (unused, may be null for internal calls).
	 * @param array $hook_extra Upgrade context array containing at least a `type` key.
	 *                          For plugins/themes, WordPress may provide either a singular
	 *                          key (`plugin`/`theme`) or a plural list (`plugins`/`themes`).
	 * @return void
	 */
	function refresh_component_checksums( $upgrader, $hook_extra ) {
		$this->flog( 'refresh_component_checksums triggered' );
		$this->flog( $hook_extra );

		$built = $this->build_partial_checksums_manifest_from_hook_extra( $hook_extra );
		if ( is_wp_error( $built ) ) {
			$this->flog( 'Partial checksum refresh aborted: ' . $built->get_error_message() );
			return;
		}

		$manifest = $built;

		$payload = $this->fetch_checksums_payload_for_manifest( $manifest );
		if ( is_wp_error( $payload ) ) {
			$this->flog( 'Partial checksum refresh failed: ' . $payload->get_error_message() );
			return;
		}

		$this->replace_component_checksums_from_payload( $manifest, $payload );
	}

	/**
	 * Build a partial components manifest for checksum refresh.
	 *
	 * Builds a minimal manifest containing only the component targets that should have
	 * checksums refreshed. For plugin/theme refreshes, this excludes core.
	 *
	 * This helper is designed to be called by `refresh_component_checksums()`.
	 *
	 * @param array $hook_extra Upgrader hook context.
	 * @return array{core?: array{slug:string,version:string,locale:string}, plugins?: array<int,array{slug:string,version:string}>, themes?: array<int,array{slug:string,version:string}>}|WP_Error
	 */
	protected function build_partial_checksums_manifest_from_hook_extra( $hook_extra ) {
		if ( empty( $hook_extra ) || ! is_array( $hook_extra ) ) {
			return new WP_Error( 'wpmr_invalid_hook_extra', 'Missing upgrader context.' );
		}
		if ( empty( $hook_extra['type'] ) || ! is_string( $hook_extra['type'] ) ) {
			return new WP_Error( 'wpmr_invalid_hook_extra', 'Missing upgrader context type.' );
		}

		$type = strtolower( (string) $hook_extra['type'] );
		if ( ! in_array( $type, array( 'core', 'plugin', 'theme' ), true ) ) {
			return new WP_Error( 'wpmr_invalid_hook_extra', 'Unsupported upgrader context type.' );
		}

		$manifest = array(
			'plugins' => array(),
			'themes'  => array(),
		);

		if ( 'plugin' === $type ) {
			$plugin_files = array();
			if ( ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
				$plugin_files = $hook_extra['plugins'];
			} elseif ( ! empty( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
				$plugin_files = array( $hook_extra['plugin'] );
			}

			foreach ( $plugin_files as $plugin_file ) {
				$plugin_file = (string) $plugin_file;
				if ( '' === $plugin_file ) {
					continue;
				}
				$full_path = WP_PLUGIN_DIR . '/' . ltrim( $plugin_file, '/' );
				if ( ! file_exists( $full_path ) ) {
					continue;
				}

				$plugin_data = $this->get_plugin_data( $full_path, false, false );
				$version     = isset( $plugin_data['Version'] ) ? (string) $plugin_data['Version'] : '';
				if ( '' === $version ) {
					$this->flog( 'Skipping plugin checksum refresh (missing version): ' . $plugin_file );
					continue;
				}

				$plugin_slug = dirname( $plugin_file );
				if ( '.' === $plugin_slug || '' === $plugin_slug ) {
					$plugin_slug = basename( $plugin_file, '.php' );
				}

				$entry = $this->build_checksums_manifest_component_entry( $plugin_slug, $version );
				if ( ! empty( $entry ) ) {
					$manifest['plugins'][] = $entry;
				}
			}

			if ( empty( $manifest['plugins'] ) ) {
				return new WP_Error( 'wpmr_no_components', 'No valid plugin components found to refresh.' );
			}
		}

		if ( 'theme' === $type ) {
			$theme_slugs = array();
			if ( ! empty( $hook_extra['themes'] ) && is_array( $hook_extra['themes'] ) ) {
				$theme_slugs = $hook_extra['themes'];
			} elseif ( ! empty( $hook_extra['theme'] ) && is_string( $hook_extra['theme'] ) ) {
				$theme_slugs = array( $hook_extra['theme'] );
			}

			foreach ( $theme_slugs as $theme_slug ) {
				$theme_slug = (string) $theme_slug;
				if ( '' === $theme_slug ) {
					continue;
				}
				$theme = wp_get_theme( $theme_slug );
				if ( ! $theme || ! $theme->exists() ) {
					continue;
				}
				$version = (string) $theme->get( 'Version' );
				if ( '' === $version ) {
					$this->flog( 'Skipping theme checksum refresh (missing version): ' . $theme_slug );
					continue;
				}
				$entry = $this->build_checksums_manifest_component_entry( $theme_slug, $version );
				if ( ! empty( $entry ) ) {
					$manifest['themes'][] = $entry;
				}
			}

			if ( empty( $manifest['themes'] ) ) {
				return new WP_Error( 'wpmr_no_components', 'No valid theme components found to refresh.' );
			}
		}

		if ( 'core' === $type ) {
			$core_version = $this->get_installed_wp_core_version();
			if ( '' === $core_version ) {
				global $wp_version;
				$core_version = ! empty( $wp_version ) ? (string) $wp_version : '';
			}
			if ( '' === $core_version ) {
				return new WP_Error( 'wpmr_missing_core_version', 'Unable to determine core version.' );
			}

			$core_locale = get_locale();
			if ( empty( $core_locale ) ) {
				$core_locale = 'en_US';
			}

			$manifest['core'] = $this->build_checksums_manifest_core_entry( (string) $core_version, (string) $core_locale );

			$runtime_version = '';
			if ( isset( $GLOBALS['wp_version'] ) ) {
				$runtime_version = (string) $GLOBALS['wp_version'];
			}
			$this->flog( 'Core refresh version check: disk=' . $core_version . ' runtime=' . $runtime_version );
		}

		return $manifest;
	}

	/**
	 * Build a manifest entry for WordPress core.
	 *
	 * @param string $core_version Core version.
	 * @param string $core_locale  Core locale.
	 * @return array{slug:string,version:string,locale:string}
	 */
	protected function build_checksums_manifest_core_entry( $core_version, $core_locale ) {
		$core_version = (string) $core_version;
		$core_locale  = (string) $core_locale;
		if ( '' === $core_locale ) {
			$core_locale = 'en_US';
		}
		return array(
			'slug'    => 'wordpress',
			'version' => $core_version,
			'locale'  => $core_locale,
		);
	}

	/**
	 * Build a manifest entry for a plugin/theme component.
	 *
	 * @param string $slug      Component slug.
	 * @param string $version   Component version.
	 * @return array{slug:string,version:string}|null
	 */
	protected function build_checksums_manifest_component_entry( $slug, $version ) {
		$slug    = (string) $slug;
		$version = (string) $version;

		if ( '' === $slug || '' === $version ) {
			return null;
		}

		return array(
			'slug'    => $slug,
			'version' => $version,
		);
	}

	/**
	 * Build a stable, storage-safe prefix for a component namespace.
	 *
	 * This stores plugin/theme checksums as ABSPATH-relative paths using standard
	 * WordPress directories.
	 * Storage keys are persisted as:
	 * - wp-content/plugins/<slug>/<file>
	 * - wp-content/themes/<slug>/<file>
	 *
	 * The scanner is responsible for any custom root mapping.
	 *
	 * @param string $type Component type: plugin|theme.
	 * @param string $slug Component slug (used verbatim, normalized for safety).
	 * @return string Prefix including trailing slash, or empty string on invalid input.
	 */
	protected function build_component_storage_prefix( $type, $slug ) {
		$type = strtolower( (string) $type );
		$slug = wp_normalize_path( (string) $slug );
		$slug = trim( $slug );
		$slug = trim( $slug, '/' );

		if ( '' === $slug ) {
			return '';
		}

		// Safety: keep only the first path segment.
		$parts = explode( '/', $slug );
		$slug  = (string) reset( $parts );
		if ( '' === $slug || '.' === $slug || '..' === $slug ) {
			return '';
		}

		if ( 'plugin' === $type ) {
			return 'wp-content/plugins/' . $slug . '/';
		}
		if ( 'theme' === $type ) {
			return 'wp-content/themes/' . $slug . '/';
		}
		return '';
	}

	/**
	 * Fetch a checksum payload from the SaaS endpoint for a given manifest.
	 *
	 * Important:
	 * - The input manifest is treated as a target selector (which components should be refreshed).
	 * - This method intentionally does NOT inject a core entry for partial refreshes.
	 *   The server supports selective manifests, and including core would cause unnecessary
	 *   core checksum generation and persistence.
	 *
	 * @param array $manifest Components manifest.
	 * @return array|WP_Error Checksum payload array on success.
	 */
	protected function fetch_checksums_payload_for_manifest( $manifest ) {
		if ( empty( $manifest ) || ! is_array( $manifest ) ) {
			return new WP_Error( 'wpmr_invalid_manifest', 'Manifest must be a non-empty array.' );
		}

		$request_manifest = $manifest;

		$request = $this->saas_request(
			'saas_get_checksums',
			array(
				'method'     => 'POST',
				'send_state' => 'body',
				'body'       => array(
					'components' => wp_json_encode( $request_manifest ),
				),
				'timeout'    => (int) $this->timeout,
			)
		);

		if ( is_wp_error( $request ) ) {
			return $request;
		}
		if ( empty( $request ) || ! is_array( $request ) ) {
			return new WP_Error( 'wpmr_bad_response', 'Unexpected SaaS response.' );
		}
		if ( empty( $request['payload'] ) || ! is_array( $request['payload'] ) ) {
			return new WP_Error( 'wpmr_empty_payload', 'Empty checksum payload.' );
		}

		return $request['payload'];
	}

	/**
	 * Replace checksum rows for the given manifest using a validated payload.
	 *
	 * Never deletes existing checksum rows unless a replacement payload exists.
	 *
	 * @param array $manifest Manifest used to scope the replacement.
	 * @param array $payload  Replacement checksum payload.
	 * @return void
	 */
	protected function replace_component_checksums_from_payload( $manifest, $payload ) {
		if ( empty( $payload ) || ! is_array( $payload ) ) {
			$this->flog( 'Partial checksum refresh aborted: empty payload.' );
			return;
		}

		$refresh_core = isset( $manifest['core'] ) && ! empty( $manifest['core'] );

		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_CHECKSUMS );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Component refresh must delete stale checksum rows against current persisted truth in a plugin-owned table.
		if ( ! empty( $manifest['plugins'] ) && is_array( $manifest['plugins'] ) ) {
			foreach ( $manifest['plugins'] as $p ) {
				$slug    = ! empty( $p['slug'] ) ? (string) $p['slug'] : '';
				$version = ! empty( $p['version'] ) ? (string) $p['version'] : '';
				$prefix  = $this->build_component_storage_prefix( 'plugin', $slug );
				if ( '' === $prefix ) {
					$this->flog( 'Skipping plugin checksum delete (invalid slug prefix): ' . $slug );
					continue;
				}

				// Purge stale rows for this plugin slug (removed/renamed files).
				// If version is known, only purge rows whose version differs to reduce churn.
				$delete_prefix = $prefix;
				if ( '' !== $version ) {
					$wpdb->query(
						$wpdb->prepare(
							'DELETE FROM %i WHERE path LIKE %s AND type = %s AND version <> %s',
							$table,
							$wpdb->esc_like( $delete_prefix ) . '%',
							'plugin',
							$version
						)
					);
				} else {
					$wpdb->query(
						$wpdb->prepare(
							'DELETE FROM %i WHERE path LIKE %s AND type = %s',
							$table,
							$wpdb->esc_like( $delete_prefix ) . '%',
							'plugin'
						)
					);
				}
			}
		}

		if ( ! empty( $manifest['themes'] ) && is_array( $manifest['themes'] ) ) {
			foreach ( $manifest['themes'] as $t ) {
				$slug    = ! empty( $t['slug'] ) ? (string) $t['slug'] : '';
				$version = ! empty( $t['version'] ) ? (string) $t['version'] : '';
				$prefix  = $this->build_component_storage_prefix( 'theme', $slug );
				if ( '' === $prefix ) {
					$this->flog( 'Skipping theme checksum delete (invalid slug prefix): ' . $slug );
					continue;
				}

				$delete_prefix = $prefix;
				if ( '' !== $version ) {
					$wpdb->query(
						$wpdb->prepare(
							'DELETE FROM %i WHERE path LIKE %s AND type = %s AND version <> %s',
							$table,
							$wpdb->esc_like( $delete_prefix ) . '%',
							'theme',
							$version
						)
					);
				} else {
					$wpdb->query(
						$wpdb->prepare(
							'DELETE FROM %i WHERE path LIKE %s AND type = %s',
							$table,
							$wpdb->esc_like( $delete_prefix ) . '%',
							'theme'
						)
					);
				}
			}
		}

		if ( $refresh_core ) {
			// Core refresh: purge stale core rows from older WP versions.
			// With `UNIQUE(path)` we don't need a path-pattern delete here; version-scoped
			// cleanup prevents old-version rows from lingering.
			$core_version = '';
			if ( isset( $manifest['core']['version'] ) ) {
				$core_version = (string) $manifest['core']['version'];
			}
			if ( '' === $core_version ) {
				$core_version = $this->get_installed_wp_core_version();
			}
			if ( '' === $core_version && isset( $GLOBALS['wp_version'] ) ) {
				$core_version = (string) $GLOBALS['wp_version'];
			}
			if ( '' !== $core_version ) {
				$wpdb->query(
					$wpdb->prepare(
						'DELETE FROM %i WHERE type = %s AND version <> %s',
						$table,
						'core',
						$core_version
					)
				);
			}
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		// Defensive scoping: persist only what this refresh flow intends to update.
		// Even if the SaaS returns extra sections, do not write them.
		if ( empty( $manifest['plugins'] ) && isset( $payload['plugins'] ) ) {
			unset( $payload['plugins'] );
		}
		if ( empty( $manifest['themes'] ) && isset( $payload['themes'] ) ) {
			unset( $payload['themes'] );
		}
		if ( ! $refresh_core && isset( $payload['core'] ) ) {
			unset( $payload['core'] );
		}

		$this->save_checksums_to_db( $payload );
	}

	/**
	 * AJAX handler: refresh core checksums.
	 *
	 * Registered on `wp_ajax_wpmr_refresh_checksums`.
	 *
	 * Better name: ajax_refresh_core_checksums()
	 *
	 * @return void Sends JSON response and exits.
	 */
	function wpmr_refresh_checksums() {
		$this->flog( 'Starting checksum refresh via AJAX' );
		check_ajax_referer( 'wpmr_refresh_checksums', 'nonce' );
		$this->flog( 'check_ajax_referer passed.' );
		if ( ! current_user_can( $this->cap ) ) {
			$this->flog( 'Unauthorized attempt to refresh checksums in ' . __FUNCTION__ );
			wp_send_json( array( 'error' => 'Unauthorized' ) );
		}
		$start_time = microtime( true );
		// Force a full refresh of core + plugins + themes from the SaaS endpoint.
		$this->get_core_checksums( false );
		$this->flog( 'Checksums refreshed in ' . ( microtime( true ) - $start_time ) . ' seconds' );
		wp_send_json_success( array( 'message' => 'Checksums refreshed' ) );
	}

	/**
	 * Remove checksum entries whose path starts with a prefix.
	 *
	 * Suggested rename: `filter_checksums_excluding_prefix()`.
	 *
	 * @param array<string,string> $checksums Checksums map (path => sha256).
	 * @param string               $prefix    Path prefix to remove.
	 * @return array<string,string>
	 */
	function remove_checksums_by_prefix( $checksums, $prefix ) {
		if ( empty( $prefix ) ) {
			return $checksums;
		}
		$prefix = trailingslashit( $prefix );
		foreach ( $checksums as $path => $hash ) {
			if ( strpos( $path, $prefix ) === 0 ) {
				unset( $checksums[ $path ] );
			}
		}
		return $checksums;
	}

	/**
	 * Build a local manifest of installed components for checksum retrieval.
	 *
	 * This manifest is sent to the SaaS checksums endpoint to request a version-specific
	 * payload for the current WordPress core, installed plugins, and installed themes.
	 *
	 * Suggested rename: `build_components_manifest_for_checksums()`.
	 *
	 * @return array{core: array{slug:string,version:string,locale:string}, plugins: array<int,array{slug:string,version:string}>, themes: array<int,array{slug:string,version:string}>}
	 */
	function build_checksums_manifest() {
		$core_version = $this->get_installed_wp_core_version();
		if ( '' === $core_version ) {
			global $wp_version;
			$core_version = (string) $wp_version;
		}

		$locale = $this->get_locale();
		if ( empty( $locale ) ) {
			$locale = get_locale();
		}
		if ( empty( $locale ) ) {
			$locale = 'en_US';
		}

		$manifest = array(
			'core'    => $this->build_checksums_manifest_core_entry( (string) $core_version, (string) $locale ),
			'plugins' => array(),
			'themes'  => array(),
		);

		$all_plugins = get_plugins();
		foreach ( $all_plugins as $key => $plugin_data ) {
			// Historically, single-file plugins (no directory) were skipped to avoid ambiguous base paths.
			if ( false === strpos( $key, '/' ) ) {
				continue;
			}

			$slug    = dirname( $key );
			$version = isset( $plugin_data['Version'] ) ? (string) $plugin_data['Version'] : '';
			if ( '' === $version ) {
				continue;
			}

			$entry = $this->build_checksums_manifest_component_entry( $slug, $version );
			if ( ! empty( $entry ) ) {
				$manifest['plugins'][] = $entry;
			}
		}

		$themes = wp_get_themes();
		foreach ( $themes as $slug => $theme ) {
			$theme_dir = $theme->get_stylesheet_directory();
			if ( empty( $theme_dir ) ) {
				continue;
			}
			$version = (string) $theme->get( 'Version' );
			if ( '' === $version ) {
				continue;
			}
			$entry = $this->build_checksums_manifest_component_entry( (string) $slug, $version );
			if ( ! empty( $entry ) ) {
				$manifest['themes'][] = $entry;
			}
		}

		return $manifest;
	}

	/**
	 * Flatten a SaaS checksum payload into a simple (path => sha256) map.
	 *
	 * Suggested rename: `flatten_saas_checksums_payload()`.
	 *
	 * @param array $payload Raw checksum payload from the SaaS endpoint.
	 * @return array<string,string>
	 */
	function flatten_checksum_payload( $payload ) {
		if ( empty( $payload ) || ! is_array( $payload ) ) {
			return array();
		}

		$flat = array();

		if ( ! empty( $payload['core'] ) && ! empty( $payload['core']['files'] ) && is_array( $payload['core']['files'] ) ) {
			foreach ( $payload['core']['files'] as $file => $hashes ) {
				$hash = $this->extract_checksum_hash( $hashes );
				if ( empty( $hash ) ) {
					continue;
				}
				$relative = $this->normalise_component_path( $file );
				if ( '' !== $relative ) {
					$flat[ $relative ] = $hash;
				}
			}
		}

		foreach ( array( 'plugins', 'themes' ) as $section ) {
			if ( empty( $payload[ $section ] ) || ! is_array( $payload[ $section ] ) ) {
				continue;
			}
			foreach ( $payload[ $section ] as $component ) {
				if ( empty( $component['files'] ) ) {
					continue;
				}

				$type   = rtrim( (string) $section, 's' );
				$slug   = isset( $component['slug'] ) ? (string) $component['slug'] : '';
				$prefix = $this->build_component_storage_prefix( $type, $slug );
				if ( '' === $prefix ) {
					continue;
				}
				foreach ( $component['files'] as $file => $hashes ) {
					$hash = $this->extract_checksum_hash( $hashes );
					if ( empty( $hash ) ) {
						continue;
					}
					$relative = ltrim( $this->normalise_component_path( (string) $file ), '/' );
					if ( '' === $relative ) {
						continue;
					}
					$flat[ $prefix . $relative ] = $hash;
				}
			}
		}

		return $flat;
	}

	/**
	 * Extract a SHA-256 checksum string from a payload entry.
	 *
	 * Suggested rename: `get_sha256_from_checksum_entry()`.
	 *
	 * This helper is intentionally defensive: the SaaS payload may contain unexpected
	 * types (e.g. arrays) and we must never pass non-scalars into wpdb::prepare.
	 *
	 * @param mixed $entry Payload entry (typically an array containing `sha256`).
	 * @return string
	 */
	function extract_checksum_hash( $entry ) {
		if ( empty( $entry ) ) {
			return '';
		}

		$sha256 = '';
		if ( is_string( $entry ) ) {
			$sha256 = $entry;
		} elseif ( is_array( $entry ) && isset( $entry['sha256'] ) ) {
			$sha256 = $this->coerce_checksum_scalar_first( $entry['sha256'] );
		}

		return $this->sanitize_sha256_checksum( $sha256 );
	}

	/**
	 * Coerce a checksum value into a scalar string, preferring the first usable value.
	 *
	 * The SaaS payload normally provides sha256 as a string. In rare cases it may be an
	 * array of candidates; when that happens we take the first usable element.
	 *
	 * @param mixed $value Raw checksum value.
	 * @return string Scalar checksum string (may be empty if no usable value exists).
	 */
	protected function coerce_checksum_scalar_first( $value ) {
		if ( is_string( $value ) ) {
			return $value;
		}

		if ( is_array( $value ) ) {
			foreach ( $value as $candidate ) {
				if ( is_string( $candidate ) && '' !== $candidate ) {
					return $candidate;
				}
				if ( is_scalar( $candidate ) && null !== $candidate && '' !== (string) $candidate ) {
					return (string) $candidate;
				}
			}
		}

		if ( is_scalar( $value ) && null !== $value ) {
			return (string) $value;
		}

		return '';
	}

	/**
	 * Normalize and validate a SHA-256 checksum string.
	 *
	 * @param mixed       $sha256 Candidate checksum value.
	 * @param string|null $reason Optional. Receives a machine-friendly reason on failure.
	 * @return string Valid lowercase sha256 hex string, or empty string.
	 */
	protected function sanitize_sha256_checksum( $sha256, &$reason = null ) {
		$reason = null;
		if ( ! is_string( $sha256 ) ) {
			$reason = 'not_string';
			return '';
		}
		$sha256 = strtolower( trim( $sha256 ) );
		if ( '' === $sha256 ) {
			$reason = 'empty_string';
			return '';
		}
		if ( 64 !== strlen( $sha256 ) ) {
			$reason = 'wrong_length';
			return '';
		}
		if ( ! preg_match( '/^[a-f0-9]{64}$/', $sha256 ) ) {
			$reason = 'non_hex';
			return '';
		}
		return $sha256;
	}

	/**
	 * Build a safe preview of a value for logging.
	 *
	 * @param mixed $value Any value.
	 * @return string
	 */
	protected function format_log_value_preview( $value ) {
		if ( is_null( $value ) ) {
			return 'null';
		}
		if ( is_bool( $value ) ) {
			return $value ? 'true' : 'false';
		}
		if ( is_int( $value ) || is_float( $value ) ) {
			return (string) $value;
		}
		if ( is_string( $value ) ) {
			$preview = $value;
			if ( strlen( $preview ) > 120 ) {
				$preview = substr( $preview, 0, 120 ) . '…';
			}
			return $preview;
		}
		if ( is_array( $value ) ) {
			$keys = array_slice( array_keys( $value ), 0, 10 );
			return 'array(keys=' . implode( ',', array_map( 'strval', $keys ) ) . ( count( $value ) > 10 ? ',…' : '' ) . ')';
		}
		if ( is_object( $value ) ) {
			return 'object(' . get_class( $value ) . ')';
		}
		return gettype( $value );
	}

	/**
	 * Normalize a component path to a stable, installation-relative format.
	 *
	 * Returned paths are relative to ABSPATH, with no leading slash and no trailing slash.
	 *
	 * Suggested rename: `normalize_component_relative_path()`.
	 *
	 * @param string $path Absolute or relative path.
	 * @return string
	 */
	function normalise_component_path( $path ) {
		if ( empty( $path ) ) {
			return '';
		}
		$normalised = wp_normalize_path( $path );
		$abspath    = trailingslashit( wp_normalize_path( ABSPATH ) );
		if ( 0 === strpos( $normalised, $abspath ) ) {
			$normalised = substr( $normalised, strlen( $abspath ) );
		}
		$normalised = ltrim( $normalised, '/' );
		$normalised = preg_replace( '#/+#', '/', $normalised );
		return untrailingslashit( $normalised );
	}

	/**
	 * Map relative checksum keys to absolute paths.
	 *
	 * Used as the first `serve_checksums` filter callback so scan-time comparisons can
	 * use absolute file paths.
	 *
	 * Suggested rename: `prefix_abspath_to_checksums()`.
	 *
	 * @param array<string,string> $checksums Checksums map (relative path => sha256).
	 * @return array<string,string> Checksums map (absolute path => sha256).
	 */
	function map_core_checksums( $checksums ) {
		$real_abspath = trailingslashit( $this->normalise_path( ABSPATH ) );
		foreach ( $checksums as $f => $c ) {
			$checksums[ $real_abspath . $f ] = $c;
			unset( $checksums[ $f ] );
		}
		return $checksums;
	}

	/**
	 * Delete cached clean-file checksum rows that point to invalid/unscannable files.
	 *
	 * Suggested rename: `delete_invalid_cached_clean_file_rows()`.
	 *
	 * @return void
	 */
	function checksums_delete_invalid() {
		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_SCANNED_FILES );

		// Get all clean files
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Invalid-row cleanup must enumerate and delete current persisted checksum rows directly from a plugin-owned table.
		$clean_files = $wpdb->get_results(
			$wpdb->prepare( "SELECT path FROM %i WHERE signature_id = '' AND severity = ''", $table ),
			ARRAY_A
		);

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

		// Delete invalid files
		foreach ( $clean_files as $row ) {
			if ( isset( $row['path'] ) && $this->is_invalid_file( $row['path'] ) ) {
				$wpdb->delete(
					$table,
					array( 'path' => $row['path'] ),
					array( '%s' )
				);
			}
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Read the DB checksums cache option.
	 *
	 * This cache is used by the DB-content scanner to avoid re-scanning the same row
	 * contents (by sha256 of the scanned content).
	 *
	 * Suggested rename: `get_db_scan_checksum_cache()`.
	 *
	 * @return array<int,string> List of sha256 hashes.
	 */
	function get_db_checksums() {
		return get_option( 'WPMR_db_checksums_cache', array() );
	}

	/**
	 * Get record-based DB whitelist.
	 *
	 * Structure:
	 * - posts:    [ <ID:int> => 1, ... ]
	 * - postmeta: [ <meta_id:int> => 1, ... ]
	 * - options:  [ <option_id:int> => 1, ... ]
	 * - comments: [ <comment_ID:int> => 1, ... ]
	 *
	 * This whitelist is intentionally pointer-based so a known-good record can be
	 * ignored even if its content changes (e.g., plugin options updates).
	 *
	 * @return array<string, array<int,int>>
	 */
	public function get_db_record_whitelist() {
		$whitelist = $this->get_setting( 'db_record_whitelist' );
		if ( ! is_array( $whitelist ) ) {
			$whitelist = array();
		}
		return $whitelist;
	}

	/**
	 * Check if a DB record is whitelisted by table + primary key.
	 *
	 * @param string $table Logical table key: posts|postmeta|options|comments.
	 * @param int    $id    Record primary key.
	 * @return bool
	 */
	public function is_db_record_whitelisted( $table, $id ) {
		$table = (string) $table;
		$id    = intval( $id );
		if ( $id <= 0 ) {
			return false;
		}
		$allowed = array( 'posts', 'postmeta', 'options', 'comments' );
		if ( ! in_array( $table, $allowed, true ) ) {
			return false;
		}
		$whitelist = $this->get_db_record_whitelist();
		return isset( $whitelist[ $table ][ $id ] );
	}

	/**
	 * Whitelist a DB record by table + primary key.
	 *
	 * This is different from the content-hash checksum cache: it prevents a known
	 * good record from being flagged again even if the record value changes in
	 * future updates.
	 *
	 * @param string $table Logical table key: posts|postmeta|options|comments.
	 * @param int    $id    Record primary key.
	 * @return true|WP_Error
	 */
	public function add_db_record_whitelist( $table, $id ) {
		$table = (string) $table;
		$id    = intval( $id );
		if ( $id <= 0 ) {
			return new WP_Error( 'invalid_id', 'Invalid record ID.' );
		}
		$allowed = array( 'posts', 'postmeta', 'options', 'comments' );
		if ( ! in_array( $table, $allowed, true ) ) {
			return new WP_Error( 'invalid_table', 'Invalid table.' );
		}

		$whitelist = $this->get_db_record_whitelist();
		if ( ! isset( $whitelist[ $table ] ) || ! is_array( $whitelist[ $table ] ) ) {
			$whitelist[ $table ] = array();
		}
		$whitelist[ $table ][ $id ] = 1;
		$this->update_setting( 'db_record_whitelist', $whitelist );
		return true;
	}

	/**
	 * Remove a DB record from the record-based whitelist.
	 *
	 * @param string $table Logical table key: posts|postmeta|options|comments.
	 * @param int    $id    Record primary key.
	 * @return true|WP_Error
	 */
	public function remove_db_record_whitelist( $table, $id ) {
		$table = (string) $table;
		$id    = intval( $id );
		if ( $id <= 0 ) {
			return new WP_Error( 'invalid_id', 'Invalid record ID.' );
		}
		$allowed = array( 'posts', 'postmeta', 'options', 'comments' );
		if ( ! in_array( $table, $allowed, true ) ) {
			return new WP_Error( 'invalid_table', 'Invalid table.' );
		}

		$whitelist = $this->get_db_record_whitelist();
		if ( isset( $whitelist[ $table ][ $id ] ) ) {
			unset( $whitelist[ $table ][ $id ] );
			if ( empty( $whitelist[ $table ] ) ) {
				unset( $whitelist[ $table ] );
			}
			$this->update_setting( 'db_record_whitelist', $whitelist );
			return true;
		}

		return new WP_Error( 'not_found', 'DB record is not whitelisted.' );
	}

	/**
	 * Render the DB record whitelist entries as HTML.
	 *
	 * Intended for admin UI rendering.
	 *
	 * @return void Outputs HTML.
	 */
	public function render_db_record_whitelist() {
		$whitelist = $this->get_db_record_whitelist();
		if ( ! is_array( $whitelist ) || empty( $whitelist ) ) {
			return;
		}

		foreach ( $whitelist as $table => $ids ) {
			if ( ! is_array( $ids ) || empty( $ids ) ) {
				continue;
			}
			foreach ( $ids as $id => $flag ) {
				$id = intval( $id );
				if ( $id <= 0 ) {
					continue;
				}
				$wrap = $table . ':' . $id;
				echo '<p data-db-wrap="' . esc_attr( $wrap ) . '"><span data-table="' . esc_attr( $table ) . '" data-id="' . esc_attr( $id ) . '" class="dashicons dashicons-dismiss remove-db-from-whitelist"></span>' . esc_html( $table . ' ID ' . $id ) . '</p>';
			}
		}
	}

	/**
	 * Add a content hash to the DB checksums cache (whitelist).
	 *
	 * The DB scanner uses `WPMR_db_checksums_cache` to skip already-reviewed
	 * clean content. By inserting a sha256 hash of the inspected content here we
	 * effectively whitelist that exact content string for future scans.
	 *
	 * @param string $hash sha256 hash of scanned content.
	 * @return true|WP_Error True on success, WP_Error on failure.
	 */
	public function add_db_whitelist_checksum( $hash ) {
		$hash = (string) $hash;
		if ( '' === $hash || ! preg_match( '/^[a-f0-9]{64}$/i', $hash ) ) {
			return new WP_Error( 'invalid_hash', 'Invalid hash.' );
		}

		$db_checksums = $this->get_db_checksums();
		if ( ! is_array( $db_checksums ) ) {
			$db_checksums = array();
		}

		if ( in_array( $hash, $db_checksums, true ) ) {
			return true;
		}

		$db_checksums[] = $hash;
		$db_checksums   = array_values( array_unique( $db_checksums ) );
		$updated        = update_option( 'WPMR_db_checksums_cache', $db_checksums, false );
		if ( false === $updated ) {
			// update_option returns false when value is unchanged; treat that as success.
			return true;
		}

		return true;
	}

	/**
	 * Persist a SaaS checksum payload into the checksums table.
	 *
	 * This upserts rows into `{$wpdb->prefix}wpmr_checksums` for each component type
	 * (core/plugin/theme), storing (path, checksum, type, version).
	 *
	 * Suggested rename: `persist_official_checksums_payload()`.
	 *
	 * @param array $payload Raw checksum payload from the SaaS endpoint.
	 * @return void
	 */
	function save_checksums_to_db( $payload ) {
		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_CHECKSUMS );

		if ( empty( $payload ) || ! is_array( $payload ) ) {
			return;
		}

		// Track malformed payload entries so we can log actionable summaries.
		$invalid_entries = 0;
		$invalid_reasons = array();
		$invalid_samples = array();

		// Process core checksums.
		if ( ! empty( $payload['core'] ) && ! empty( $payload['core']['files'] ) ) {
			$core_version   = isset( $payload['core']['version'] ) ? $payload['core']['version'] : $GLOBALS['wp_version'];
			$core_checksums = array();

			foreach ( $payload['core']['files'] as $file => $hashes ) {
				// Record malformed payload entries (non-string or invalid sha256).
				$candidate = null;
				$meta      = array();
				if ( is_array( $hashes ) ) {
					$meta['hashes_type'] = 'array';
					$meta['hashes_keys'] = array_slice( array_map( 'strval', array_keys( $hashes ) ), 0, 10 );
					$candidate           = isset( $hashes['sha256'] ) ? $this->coerce_checksum_scalar_first( $hashes['sha256'] ) : null;
					if ( null === $candidate ) {
						++$invalid_entries;
						$invalid_reasons['missing_sha256_key'] = isset( $invalid_reasons['missing_sha256_key'] ) ? ( $invalid_reasons['missing_sha256_key'] + 1 ) : 1;
						if ( count( $invalid_samples ) < 25 ) {
							$invalid_samples[] = array(
								'type'        => 'core',
								'file'        => (string) $file,
								'reason'      => 'missing_sha256_key',
								'hashes'      => $this->format_log_value_preview( $hashes ),
								'hashes_keys' => $meta['hashes_keys'],
							);
						}
					}
				} else {
					$meta['hashes_type'] = gettype( $hashes );
					$candidate           = $this->coerce_checksum_scalar_first( $hashes );
				}

				if ( null !== $candidate ) {
					$reason = null;
					if ( '' === $this->sanitize_sha256_checksum( $candidate, $reason ) ) {
						++$invalid_entries;
						$reason_key                     = $reason ? (string) $reason : 'invalid_sha256';
						$invalid_reasons[ $reason_key ] = isset( $invalid_reasons[ $reason_key ] ) ? ( $invalid_reasons[ $reason_key ] + 1 ) : 1;
						if ( count( $invalid_samples ) < 25 ) {
							$invalid_samples[] = array(
								'type'       => 'core',
								'file'       => (string) $file,
								'reason'     => $reason_key,
								'value_type' => gettype( $candidate ),
								'preview'    => $this->format_log_value_preview( $candidate ),
								'hashes'     => $this->format_log_value_preview( $hashes ),
							);
						}
					}
				}

				$hash = $this->extract_checksum_hash( $hashes );
				if ( empty( $hash ) ) {
					continue;
				}
				$relative = $this->normalise_component_path( $file );
				if ( '' !== $relative ) {
					$core_checksums[ $relative ] = $hash;
				}
			}

			if ( ! empty( $core_checksums ) ) {
				$this->bulk_insert_checksums( $core_checksums, 'core', $core_version );
			}
		}

		// Process plugins and themes
		foreach ( array( 'plugins', 'themes' ) as $section ) {
			if ( empty( $payload[ $section ] ) || ! is_array( $payload[ $section ] ) ) {
				continue;
			}

			foreach ( $payload[ $section ] as $component ) {
				if ( empty( $component['files'] ) ) {
					continue;
				}

				$slug    = isset( $component['slug'] ) ? (string) $component['slug'] : '';
				$type    = rtrim( (string) $section, 's' );
				$prefix  = $this->build_component_storage_prefix( $type, $slug );
				$version = isset( $component['version'] ) ? (string) $component['version'] : '';
				if ( '' === $prefix ) {
					continue;
				}

				$component_checksums = array();
				foreach ( $component['files'] as $file => $hashes ) {
					$candidate = null;
					$meta      = array();
					if ( is_array( $hashes ) ) {
						$meta['hashes_type'] = 'array';
						$meta['hashes_keys'] = array_slice( array_map( 'strval', array_keys( $hashes ) ), 0, 10 );
						$candidate           = isset( $hashes['sha256'] ) ? $this->coerce_checksum_scalar_first( $hashes['sha256'] ) : null;
						if ( null === $candidate ) {
							++$invalid_entries;
							$invalid_reasons['missing_sha256_key'] = isset( $invalid_reasons['missing_sha256_key'] ) ? ( $invalid_reasons['missing_sha256_key'] + 1 ) : 1;
							if ( count( $invalid_samples ) < 25 ) {
								$invalid_samples[] = array(
									'type'        => $type,
									'slug'        => $slug,
									'file'        => (string) $file,
									'reason'      => 'missing_sha256_key',
									'hashes'      => $this->format_log_value_preview( $hashes ),
									'hashes_keys' => $meta['hashes_keys'],
								);
							}
						}
					} else {
						$meta['hashes_type'] = gettype( $hashes );
						$candidate           = $this->coerce_checksum_scalar_first( $hashes );
					}

					if ( null !== $candidate ) {
						$reason = null;
						if ( '' === $this->sanitize_sha256_checksum( $candidate, $reason ) ) {
							++$invalid_entries;
							$reason_key                     = $reason ? (string) $reason : 'invalid_sha256';
							$invalid_reasons[ $reason_key ] = isset( $invalid_reasons[ $reason_key ] ) ? ( $invalid_reasons[ $reason_key ] + 1 ) : 1;
							if ( count( $invalid_samples ) < 25 ) {
								$invalid_samples[] = array(
									'type'       => $type,
									'slug'       => $slug,
									'file'       => (string) $file,
									'reason'     => $reason_key,
									'value_type' => gettype( $candidate ),
									'preview'    => $this->format_log_value_preview( $candidate ),
									'hashes'     => $this->format_log_value_preview( $hashes ),
								);
							}
						}
					}

					$hash = $this->extract_checksum_hash( $hashes );
					if ( empty( $hash ) ) {
						continue;
					}
					$relative = ltrim( $this->normalise_component_path( (string) $file ), '/' );
					if ( '' === $relative ) {
						continue;
					}
					$component_checksums[ $prefix . $relative ] = $hash;
				}

				if ( ! empty( $component_checksums ) ) {
					$this->bulk_insert_checksums( $component_checksums, $type, $version );
				}
			}
		}

		if ( $invalid_entries > 0 ) {
			$this->flog( 'Checksum payload issues: skipped ' . $invalid_entries . ' invalid checksum entries.' );
			$this->flog(
				array(
					'checksum_payload_invalid' => array(
						'count'     => $invalid_entries,
						'by_reason' => $invalid_reasons,
						'samples'   => $invalid_samples,
					),
				)
			);
		}
	}

	/**
	 * Bulk insert checksum rows into the checksums table.
	 *
	 * Performs an UPSERT (insert or update-on-duplicate) for the provided checksum map.
	 *
	 * Suggested rename: `bulk_upsert_checksums()`.
	 *
	 * @param array<string,string> $arr_checksums Checksums map (path => sha256).
	 * @param string               $type          Component type: core|plugin|theme.
	 * @param string               $version       Component version.
	 * @return void
	 */
	function bulk_insert_checksums( $arr_checksums, $type, $version ) {
		global $wpdb;
		$table = $this->get_wpmr_table_name( WPMR_CHECKSUMS );

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

		$query              = "INSERT INTO $table (path, checksum, type, version) VALUES ";
		$value_placeholders = array();
		$params             = array();

		foreach ( $arr_checksums as $path => $checksum ) {
			if ( ! is_string( $path ) || ! is_string( $checksum ) ) {
				continue;
			}
			$value_placeholders[] = '(%s, %s, %s, %s)';
			$params[]             = $path;
			$params[]             = $checksum;
			$params[]             = $type;
			$params[]             = $version;
		}

		if ( ! empty( $value_placeholders ) ) {
			array_unshift( $params, $table );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk checksum persistence is a write-only maintenance path against a plugin-owned table; caching is inapplicable.
			// The tuple placeholders are generated internally; runtime values still flow through $wpdb->prepare().
			// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$wpdb->query(
				$wpdb->prepare(
					'INSERT INTO %i (path, checksum, type, version) VALUES ' . implode( ', ', $value_placeholders ) . ' ON DUPLICATE KEY UPDATE checksum = VALUES(checksum), type = VALUES(type), version = VALUES(version)',
					$params
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		}
	}
}
