<?php

declare(strict_types=1);

namespace SecurWP\Modules\Tools;

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

use SecurWP\Includes\MasterKey;
use SecurWP\Includes\SecretManager;
use SecurWP\Modules\Cloud\Service\CloudSettings;
use SecurWP\Modules\Cloud\Service\KeyBackupService;
use SecurWP\Modules\Scanner\Queue\ScanQueue;

/**
 * Rotate the SecurWP master key and re-encrypt key-bound data.
 */
class KeyRotation {

	private const LOCK_OPTION = 'securwp_lock_key_rotation';
	private const LOCK_TTL   = 120; // seconds
	private const KEY_BYTES  = 32;

	/** @var string|null Path to wp-config.php backup (constant mode only). */
	private ?string $config_backup_path = null;
	/** @var string|null Path to target wp-config.php (constant mode only). */
	private ?string $config_target_path = null;

	/**
	 * Rotate the master encryption key.
	 *
	 * @return array|\WP_Error Result array on success, WP_Error on failure.
	 */
	public function rotate() {

		$source = MasterKey::get_source();

		// Block if a previous rotation crashed (backup file exists).
		$backup_path = MasterKey::get_key_file_path() . '.bak';
		if ( file_exists( $backup_path ) ) {
			return new \WP_Error(
				'previous_rotation_incomplete',
				'A previous key rotation did not complete cleanly (backup key file exists). '
				. 'Please restore the backup manually or delete ' . basename( $backup_path )
				. ' from your WordPress root, then try again.'
			);
		}

		// Block if a scan is running.
		$active_scan = ScanQueue::get();
		if ( $active_scan && in_array( $active_scan['status'] ?? '', [ 'pending', 'running', 'completing' ], true ) ) {
			return new \WP_Error(
				'scan_active',
				'Cannot rotate key while a scan is in progress. Please wait for the scan to complete.'
			);
		}

		// Acquire the rotation mutex.
		if ( ! $this->acquire_lock() ) {
			return new \WP_Error(
				'rotation_in_progress',
				'A key rotation is already in progress. Please wait.'
			);
		}

		try {
			$old_key = MasterKey::get();
		} catch ( \RuntimeException $e ) {
			$this->release_lock();
			return new \WP_Error(
				'key_load_failed',
				'Cannot load current encryption key: ' . $e->getMessage()
			);
		}

		$secrets = $this->collect_secrets();
		if ( is_wp_error( $secrets ) ) {
			$this->release_lock();
			return $secrets;
		}

		if ( $source === 'constant' ) {
			$swap_result = $this->swap_key_constant( $old_key );
		} else {
			$swap_result = $this->swap_key_file( $old_key );
		}

		if ( is_wp_error( $swap_result ) ) {
			$this->release_lock();
			return $swap_result;
		}

		$new_key = $swap_result;

		$reencrypt_result = $this->reencrypt_all( $secrets, $old_key, $source );
		if ( is_wp_error( $reencrypt_result ) ) {
			$this->release_lock();
			return $reencrypt_result;
		}

		// Remove backup files.
		if ( $source === 'file' ) {
			$key_backup = MasterKey::get_key_file_path() . '.bak';
			if ( file_exists( $key_backup ) ) {
				wp_delete_file( $key_backup );
			}
		} elseif ( $this->config_backup_path && file_exists( $this->config_backup_path ) ) {
			wp_delete_file( $this->config_backup_path );
			$this->config_backup_path = null;
			$this->config_target_path = null;
		}

		// Send the new key backup to Console.
		KeyBackupService::send_backup();

		$this->release_lock();

		securwp_log( '[SecurWP] Encryption key rotated successfully (source: ' . $source . ').' );

		return [ 'source' => $source ];
	}

	/**
	 * Acquire the key-rotation mutex.
	 *
	 * @return bool True if the lock was acquired.
	 */
	private function acquire_lock(): bool {
		global $wpdb;
		$options_table = self::table_identifier( $wpdb->options );

		$now     = time();
		$expires = $now + self::LOCK_TTL;

		// Try to insert the lock row atomically.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$insert_sql = $wpdb->prepare(
			"INSERT IGNORE INTO {$options_table} (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')",
			self::LOCK_OPTION,
			(string) $expires
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		if ( is_string( $insert_sql ) && '' !== $insert_sql ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
				$wpdb->query( $insert_sql );
		}

		// Check if we own it (our value was inserted).
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$lock_sql = $wpdb->prepare(
			"SELECT `option_value` FROM {$options_table} WHERE `option_name` = %s LIMIT 1",
			self::LOCK_OPTION
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$locked_until = 0;
		if ( is_string( $lock_sql ) && '' !== $lock_sql ) {
					// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
					$locked_until = (int) $wpdb->get_var( $lock_sql );
		}

		// If expired, reclaim: update only if still expired (atomic WHERE).
		if ( $locked_until < $now ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Mutex row update.
				$updated = $wpdb->update(
				$wpdb->options,
				[ 'option_value' => (string) $expires ],
				[ 'option_name' => self::LOCK_OPTION ],
				[ '%s' ],
				[ '%s' ]
			);
			return $updated > 0;
		}

		return $locked_until === $expires;
	}

	/**
	 * Release the mutex lock.
	 */
	private function release_lock(): void {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Mutex row delete.
		$wpdb->delete( $wpdb->options, [ 'option_name' => self::LOCK_OPTION ], [ '%s' ] );
	}

	/**
	 * Collect decrypted secrets before key replacement.
	 *
	 * @return array|\WP_Error Collected secrets or error.
	 */
	private function collect_secrets() {
		$collected = [
			'options' => [],
			'cloud'   => [],
			'2fa'     => [],
		];

		$option_names = $this->get_encrypted_option_names();
		foreach ( $option_names as $name ) {
			$value = SecretManager::get( $name );
			if ( $value !== false ) {
				$collected['options'][ $name ] = $value;
			} else {
				// Option exists but cannot be decrypted - skip it.
				// These are all recoverable (user re-enters API token, HMAC regenerates).
				securwp_log( '[SecurWP] Key rotation: skipping option "' . $name . '" - could not decrypt (may already be broken).' );
			}
		}

		$cloud = CloudSettings::get_all();
		$collected['cloud']['site_token']  = $cloud['site_token'] ?? '';
		$collected['cloud']['site_secret'] = $cloud['site_secret'] ?? '';

		// Authenticator app secrets remain key-bound.
		global $wpdb;
		$table_name = $wpdb->prefix . 'securwp_two_factor';
		$table      = self::table_identifier( $table_name );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table existence check.
		$table_exists = $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name )
		);

		if ( $table_exists ) {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$sql = "SELECT user_id, totp_secret
				FROM {$table}
				WHERE totp_secret IS NOT NULL AND totp_secret != ''";
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared repository query.
				$rows = $wpdb->get_results( $sql, ARRAY_A );

			if ( is_array( $rows ) ) {
				foreach ( $rows as $row ) {
					$user_id = (int) $row['user_id'];
					$entry = [ 'user_id' => $user_id ];

					if ( ! empty( $row['totp_secret'] ) ) {
						$decrypted = SecretManager::decrypt( $row['totp_secret'] );
						if ( $decrypted === false ) {
							return new \WP_Error(
								'2fa_decrypt_failed',
								sprintf(
									'Cannot decrypt 2FA TOTP secret for user ID %d. Key may already be broken - use the recovery flow instead.',
									$user_id
								)
							);
						}
						$entry['totp_secret'] = $decrypted;
					}

					$collected['2fa'][] = $entry;
				}
			}
		}

		return $collected;
	}

	/**
	 * Swap key in file mode: backup old file, delete, auto-generate new.
	 *
	 * @param string $old_key The current master key bytes.
	 * @return string|\WP_Error New key bytes on success.
	 */
	private function swap_key_file( string $old_key ) {
		$key_path    = MasterKey::get_key_file_path();
		$backup_path = $key_path . '.bak';

		if ( file_exists( $key_path ) ) {
			if ( ! copy( $key_path, $backup_path ) ) {
				return new \WP_Error(
					'backup_failed',
					'Could not create backup of encryption key file.'
				);
			}
		}

		if ( file_exists( $key_path ) && ! $this->delete_file( $key_path ) ) {
			wp_delete_file( $backup_path );
			return new \WP_Error(
				'delete_failed',
				'Could not remove old key file. Check file permissions.'
			);
		}

		MasterKey::clear_cache();

		try {
			$new_key = MasterKey::get(); // Auto-generates new key file.
		} catch ( \RuntimeException $e ) {
			// Restore backup.
			if ( file_exists( $backup_path ) ) {
				copy( $backup_path, $key_path );
				wp_delete_file( $backup_path );
			}
			MasterKey::clear_cache();
			return new \WP_Error(
				'regenerate_failed',
				'Could not create new key file: ' . $e->getMessage()
			);
		}

		if ( $new_key === $old_key ) {
			if ( file_exists( $backup_path ) ) {
				copy( $backup_path, $key_path );
				wp_delete_file( $backup_path );
			}
			MasterKey::clear_cache();
			return new \WP_Error(
				'key_unchanged',
				'New key matches old key. Please try again.'
			);
		}

		return $new_key;
	}

	/**
	 * Swap key in constant mode: generate new key, update wp-config.php, set override.
	 *
	 * Rewrites the SECURWP_MASTER_KEY constant in wp-config.php with the
	 * new base64 value using the same atomic-write pattern as SaltsRegeneration.
	 * Also writes the key file as a backup source.
	 *
	 * @param string $old_key The current master key bytes (from constant).
	 * @return string|\WP_Error New key bytes on success.
	 */
	private function swap_key_constant( string $old_key ) {
		if ( ! ConfigFilePolicy::can_write_to_files() ) {
			return new \WP_Error(
				'writes_disabled',
				'Write to Files is disabled in SecurWP Settings. Enable it to rotate constant-based keys automatically, or update wp-config.php manually.'
			);
		}

		$wp_config = ConfigFilePolicy::get_wp_config_path();
		if ( $wp_config === false ) {
			return new \WP_Error(
				'config_not_found',
				'Could not locate wp-config.php in ABSPATH or one directory above.'
			);
		}

		$this->config_target_path = $wp_config;
		$config_dir = dirname( $wp_config );

		// Require writable wp-config.php for constant mode.
		if ( ! file_exists( $wp_config ) || ! wp_is_writable( $wp_config ) ) {
			return new \WP_Error(
				'config_not_writable',
				'wp-config.php is not writable. Cannot update SECURWP_MASTER_KEY constant.'
			);
		}
		if ( ! wp_is_writable( $config_dir ) ) {
			return new \WP_Error(
				'dir_not_writable',
				'wp-config.php parent directory is not writable (required for safe atomic write).'
			);
		}

		$config_content = file_get_contents( $wp_config );
		if ( ! $config_content ) {
			return new \WP_Error(
				'config_read_failed',
				'Could not read wp-config.php.'
			);
		}

		$new_key = random_bytes( self::KEY_BYTES );
		$new_b64 = base64_encode( $new_key );

		$pattern = "/define\s*\(\s*['\"]SECURWP_MASTER_KEY['\"]\s*,\s*['\"].*?['\"]\s*\)/";
		$replacement_value = $new_b64;
		$config_content = preg_replace_callback( $pattern, function () use ( $replacement_value ) {
			return "define('SECURWP_MASTER_KEY', '" . $replacement_value . "')";
		}, $config_content, 1, $count );

		if ( $count < 1 ) {
			return new \WP_Error(
				'constant_not_found',
				'Could not locate SECURWP_MASTER_KEY define in wp-config.php.'
			);
		}

		$backup = $wp_config . '.securwp-key-backup-' . bin2hex( random_bytes( 8 ) );
		if ( ! copy( $wp_config, $backup ) ) {
			return new \WP_Error(
				'config_backup_failed',
				'Could not create backup of wp-config.php.'
			);
		}

		// Atomic write: temp file → rename.
		$temp_file = tempnam( $config_dir, '.wp-config-securwp-' );
		if ( $temp_file === false ) {
			wp_delete_file( $backup );
			return new \WP_Error(
				'temp_failed',
				'Could not create temporary file for atomic write.'
			);
		}

		$original_perms = fileperms( $wp_config );
		if ( $original_perms !== false ) {
			$this->apply_file_permissions( $temp_file, $original_perms & 0777 );
		}

		if ( file_put_contents( $temp_file, $config_content, LOCK_EX ) === false ) {
			wp_delete_file( $temp_file );
			wp_delete_file( $backup );
			return new \WP_Error(
				'config_write_failed',
				'Could not write updated wp-config.php.'
			);
		}

		if ( ! $this->move_file( $temp_file, $wp_config ) ) {
			wp_delete_file( $temp_file );
			copy( $backup, $wp_config );
			wp_delete_file( $backup );
			return new \WP_Error(
				'config_rename_failed',
				'Atomic rename of wp-config.php failed.'
			);
		}

		// Keep backup for rollback - cleaned up in post-rotation.
		$this->config_backup_path = $backup;

		$key_path = MasterKey::get_key_file_path();
		$key_dir  = dirname( $key_path );
		$key_content = implode(
			"\n",
			[
				'<?php',
				'/**',
				' * SecurWP Encryption Key',
				' *',
				' * WARNING: DO NOT MODIFY OR DELETE THIS FILE.',
				' *',
				' * This file contains the master encryption key used by the SecurWP security',
				' * plugin. All cloud credentials and sensitive data stored by the plugin are',
				' * encrypted with a key derived from this value.',
				' *',
				' * Generated by SecurWP key rotation. Do not edit.',
				' */',
				"if ( ! defined( 'ABSPATH' ) ) { exit; }",
				"return '{$new_b64}';",
				'',
			]
		);

		if ( ! is_dir( $key_dir ) && ! wp_mkdir_p( $key_dir ) ) {
			return $this->abort_constant_rotation( $backup, $wp_config, 'Could not create backup key directory.' );
		}

		if ( file_exists( $key_path ) ) {
			wp_delete_file( $key_path );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Permissions are applied before writing key material.
		$key_handle = @fopen( $key_path, 'x' );
		if ( false === $key_handle ) {
			return $this->abort_constant_rotation( $backup, $wp_config, 'Could not create backup key file.' );
		}

		$this->apply_file_permissions( $key_path, 0440 );
		clearstatcache( true, $key_path );
		$key_perms = @fileperms( $key_path );
		if ( false === $key_perms || ( $key_perms & 0007 ) !== 0 ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose( $key_handle );
			wp_delete_file( $key_path );
			return $this->abort_constant_rotation( $backup, $wp_config, 'Could not apply restrictive permissions to backup key file.' );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
		$key_written = fwrite( $key_handle, $key_content );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		fclose( $key_handle );
		if ( false === $key_written || $key_written < strlen( $key_content ) ) {
			wp_delete_file( $key_path );
			return $this->abort_constant_rotation( $backup, $wp_config, 'Could not write backup key file.' );
		}

		// Override MasterKey's in-memory cache so SecretManager uses the new key
		// for all re-encryption calls during this request.
		MasterKey::set_override( $new_key );

		return $new_key;
	}

	/**
	 * Re-encrypt all collected secrets with the new key.
	 *
	 * If 2FA re-encryption fails, rolls back to the old key.
	 *
	 * @param array  $secrets  Collected decrypted secrets.
	 * @param string $old_key  Old master key for rollback.
	 * @param string $source   Key source ('file' or 'constant').
	 * @return true|\WP_Error
	 */
	private function reencrypt_all( array $secrets, string $old_key, string $source ) {
		// Re-encrypt authenticator app secrets first because they are the only
		// remaining key-bound 2FA data. Doing this first keeps rollback scoped
		// to a single data class if anything fails.
		if ( ! empty( $secrets['2fa'] ) ) {
			$result = $this->reencrypt_2fa( $secrets['2fa'], $old_key, $source );
			if ( is_wp_error( $result ) ) {
				return $result;
			}
		}

		foreach ( $secrets['options'] as $name => $plaintext ) {
			SecretManager::set( $name, $plaintext );
		}

		$cloud_token  = $secrets['cloud']['site_token'] ?? '';
		$cloud_secret = $secrets['cloud']['site_secret'] ?? '';
		if ( $cloud_token !== '' || $cloud_secret !== '' ) {
			$this->reencrypt_cloud_credentials( $cloud_token, $cloud_secret );
		}

		return true;
	}

	/**
	 * Re-encrypt cloud credentials in the settings table.
	 *
	 * @param string $token  Decrypted site token.
	 * @param string $secret Decrypted site secret.
	 */
	private function reencrypt_cloud_credentials( string $token, string $secret ): void {
		$update = [];
		if ( $token !== '' ) {
			$update['site_token'] = $token;
		}
		if ( $secret !== '' ) {
			$update['site_secret'] = $secret;
		}
		if ( ! empty( $update ) ) {
			CloudSettings::update( $update );
		}
	}

	/**
	 * Re-encrypt all key-bound 2FA data within a database transaction.
	 *
	 * On failure, restores the old key and rolls back the transaction.
	 *
	 * @param array  $entries Array of ['user_id' => int, 'totp_secret' => string].
	 * @param string $old_key Old master key for rollback.
	 * @param string $source  Key source ('file' or 'constant').
	 * @return true|\WP_Error
	 */
	private function reencrypt_2fa( array $entries, string $old_key, string $source ) {
		global $wpdb;
		$table = $wpdb->prefix . 'securwp_two_factor';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control.
		$wpdb->query( 'START TRANSACTION' );

		try {
			foreach ( $entries as $entry ) {
				$user_id = (int) $entry['user_id'];
				$update  = [ 'updated_at' => time() ];
				$format  = [ '%d' ];

				if ( isset( $entry['totp_secret'] ) ) {
					$update['totp_secret'] = SecretManager::encrypt( $entry['totp_secret'] );
					$format[] = '%s';
				}

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transactional row update.
					$result = $wpdb->update( $table, $update, [ 'user_id' => $user_id ], $format, [ '%d' ] );

				if ( $result === false ) {
					throw new \RuntimeException(
						sprintf( 'Failed to update 2FA row for user %d: %s', $user_id, $wpdb->last_error )
					);
				}
			}

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control.
					$wpdb->query( 'COMMIT' );
				} catch ( \Throwable $e ) {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control.
					$wpdb->query( 'ROLLBACK' );

			// Restore old key.
			$this->rollback_key( $old_key, $source );

			return new \WP_Error(
				'2fa_reencrypt_failed',
				'Failed to re-encrypt authenticator data protected by the current key. The previous key has been restored. Error: ' . $e->getMessage()
			);
		}

		return true;
	}

	/**
	 * Restore the old key after a failed re-encryption.
	 *
	 * @param string $old_key Old master key bytes.
	 * @param string $source  Key source ('file' or 'constant').
	 */
	private function rollback_key( string $old_key, string $source ): void {
		if ( $source === 'file' ) {
			// Restore key file from backup.
			$key_path    = MasterKey::get_key_file_path();
			$backup_path = $key_path . '.bak';

			if ( file_exists( $backup_path ) ) {
				if ( file_exists( $key_path ) ) {
					wp_delete_file( $key_path );
				}
				copy( $backup_path, $key_path );
				wp_delete_file( $backup_path );
			}
		} elseif ( $source === 'constant' ) {
			// Restore wp-config.php from backup (reverts the constant value).
			if ( $this->config_backup_path && file_exists( $this->config_backup_path ) ) {
				$wp_config = $this->config_target_path ?: ConfigFilePolicy::get_wp_config_path();
				if ( is_string( $wp_config ) && $wp_config !== '' ) {
					copy( $this->config_backup_path, $wp_config );
				}
				wp_delete_file( $this->config_backup_path );
				$this->config_backup_path = null;
				$this->config_target_path = null;
			}
			// Remove the new key file we wrote as backup source.
			$key_path = MasterKey::get_key_file_path();
			if ( file_exists( $key_path ) ) {
				wp_delete_file( $key_path );
			}
		}

		// Restore the in-memory key to old value.
		MasterKey::clear_cache();
	}


	/**
	 * Get the list of encrypted option names (without the prefix).
	 *
	 * @return array<string>
	 */
	private function get_encrypted_option_names(): array {
		global $wpdb;
		$options_table = self::table_identifier( $wpdb->options );

		$prefix = SecretManager::OPTION_PREFIX;
		$prefix_len = strlen( $prefix );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$sql = $wpdb->prepare(
			"SELECT option_name FROM {$options_table} WHERE option_name LIKE %s",
			$wpdb->esc_like( $prefix ) . '%'
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = [];
		if ( is_string( $sql ) && '' !== $sql ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared options query.
				$rows = $wpdb->get_col( $sql );
		}

		return array_map(
			fn( string $name ) => substr( $name, $prefix_len ),
			$rows ?: []
		);
	}

	/**
	 * Delete a file through WordPress-aware APIs.
	 */
	private function delete_file( string $path ): bool {
		wp_delete_file( $path );
		return ! file_exists( $path );
	}

	/**
	 * Move a file into place using copy + delete semantics.
	 */
	private function move_file( string $source, string $destination ): bool {
		if ( ! @copy( $source, $destination ) ) {
			return false;
		}

		wp_delete_file( $source );
		return ! file_exists( $source );
	}

	/**
	 * Restore wp-config.php after a constant-mode rotation setup failure.
	 */
	private function abort_constant_rotation( string $backup, string $wp_config, string $message ) {
		if ( ! @copy( $backup, $wp_config ) ) {
			return new \WP_Error(
				'config_restore_failed',
				$message . ' wp-config.php backup could not be restored automatically; backup preserved at ' . basename( $backup ) . '.'
			);
		}

		wp_delete_file( $backup );
		return new \WP_Error( 'key_backup_failed', $message );
	}

	/**
	 * Apply filesystem permissions without calling chmod() directly.
	 */
	private function apply_file_permissions( string $path, int $permissions ): void {
		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		WP_Filesystem();
		global $wp_filesystem;

		if ( is_object( $wp_filesystem ) && method_exists( $wp_filesystem, 'chmod' ) ) {
			$wp_filesystem->chmod( $path, $permissions );
		}
	}

	private static function table_identifier( string $table_name ): string {
		if ( $table_name === '' || strlen( $table_name ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table_name ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . $table_name . '`';
	}
}
