<?php

declare(strict_types=1);

namespace SecurWP\Modules\Tools;

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Renames WordPress tables to a new prefix and updates wp-config.php.
 */
class DatabasePrefixChanger {

	private const MAX_IDENTIFIER_LENGTH = 64;

	/**
	 * Warnings from the most recent change() run.
	 *
	 * @var array<int, string>
	 */
	private array $last_warnings = [];

	/**
	 * Locate wp-config.php, which may reside one directory above ABSPATH.
	 *
	 * @return string|false Full path to wp-config.php, or false if not found.
	 */
	private function locate_wp_config() {
		return ConfigFilePolicy::get_wp_config_path();
	}

	/**
	 * Get warnings from the most recent successful run.
	 *
	 * @return array<int, string>
	 */
	public function get_last_warnings(): array {
		return $this->last_warnings;
	}

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

		return str_replace( '`', '``', $identifier );
	}

	/**
	 * Build RENAME TABLE pairs from a baseline table list.
	 *
	 * @param array<int, array{0: string}> $tables     Baseline table names with old prefix.
	 * @param string                       $old_prefix Old prefix.
	 * @param string                       $new_prefix New prefix.
	 * @param bool                         $reverse    True to build new->old pairs.
	 * @return array<int, string>
	 */
	private function build_rename_pairs( array $tables, string $old_prefix, string $new_prefix, bool $reverse = false ): array {
		$old_prefix_len = strlen( $old_prefix );
		$pairs          = [];

		foreach ( $tables as $row ) {
			if ( ! isset( $row[0] ) || ! is_string( $row[0] ) ) {
				throw new \InvalidArgumentException( 'Invalid table list row.' );
			}

			$old_name = $row[0];
			if ( substr( $old_name, 0, $old_prefix_len ) !== $old_prefix ) {
				throw new \InvalidArgumentException( 'Table does not match the current prefix.' );
			}

			$suffix   = substr( $old_name, $old_prefix_len );
			$from     = $reverse ? $new_prefix . $suffix : $old_prefix . $suffix;
			$to       = $reverse ? $old_prefix . $suffix : $new_prefix . $suffix;

			$pairs[] = sprintf(
				'`%s` TO `%s`',
				$this->escape_identifier( $from ),
				$this->escape_identifier( $to )
			);
		}

		return $pairs;
	}

	/**
	 * Execute an atomic RENAME TABLE statement.
	 *
	 * @param array<int, string> $pairs Rename pairs.
	 * @return bool
	 */
	private function rename_tables_atomic( array $pairs ): bool {
		global $wpdb;

		if ( empty( $pairs ) ) {
			return true;
		}

		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Validated table identifiers.
		return $wpdb->query( 'RENAME TABLE ' . implode( ', ', $pairs ) ) !== false;
	}

	/**
	 * Check whether a database table exists.
	 *
	 * @param string $table Table name.
	 * @return bool
	 */
	private function table_exists( string $table ): bool {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Existence check for a known table name.
		$existing = $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) )
		);

		return is_string( $existing ) && $existing === $table;
	}

	/**
	 * Resolve the physical lock table for a given table prefix.
	 *
	 * @param string $prefix Table prefix currently backing the site.
	 * @return string
	 */
	private function get_lock_table_for_prefix( string $prefix ): string {
		return $prefix . 'options';
	}

	/**
	 * Resolve the lock table.
	 *
	 * Uses the network options table on multisite.
	 *
	 * @return string
	 */
	private function get_lock_table(): string {
		global $wpdb;

		if ( is_multisite() && isset( $wpdb->base_prefix ) && $wpdb->base_prefix !== '' ) {
			return $this->get_lock_table_for_prefix( $wpdb->base_prefix );
		}

		return $this->get_lock_table_for_prefix( $wpdb->prefix );
	}

	/**
	 * Acquire a mutex lock.
	 *
	 * @param string $name    Lock name.
	 * @param int    $expires TTL in seconds.
	 * @return bool True if the lock was acquired.
	 */
	private function acquire_lock( string $name, int $expires = 180 ): bool {
		global $wpdb;

		$lock_option           = 'securwp_lock_' . $name;
		$now                   = time();
		$release_at            = $now + $expires;
		$lock_table            = $this->get_lock_table();
		$lock_table_identifier = '`' . $this->escape_identifier( $lock_table ) . '`';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$inserted = $wpdb->query( $wpdb->prepare(
			"INSERT IGNORE INTO {$lock_table_identifier} (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')",
			$lock_option,
			(string) $release_at
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( $inserted ) {
			return true;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$locked_until = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT `option_value` FROM {$lock_table_identifier} WHERE `option_name` = %s LIMIT 1",
				$lock_option
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( $locked_until < $now ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Mutex row update.
			$updated = $wpdb->update(
				$lock_table,
				[ 'option_value' => (string) $release_at ],
				[ 'option_name' => $lock_option ],
				[ '%s' ],
				[ '%s' ]
			);

			if ( $updated === false ) {
				return false;
			}
			if ( $updated > 0 ) {
				return true;
			}

			// Row may have been deleted between SELECT and UPDATE; retry insert once.
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$reinserted = $wpdb->query( $wpdb->prepare(
				"INSERT IGNORE INTO {$lock_table_identifier} (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')",
				$lock_option,
				(string) $release_at
			) );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			return (bool) $reinserted;
		}

		return false;
	}

	/**
	 * Release a mutex lock.
	 *
	 * @param string              $name        Lock name.
	 * @param array<int, string> $lock_tables Candidate physical tables that may contain the lock row.
	 */
	private function release_lock( string $name, array $lock_tables = [] ): void {
		global $wpdb;

		$lock_option = 'securwp_lock_' . $name;
		if ( $lock_tables === [] ) {
			$lock_tables = [ $this->get_lock_table() ];
		}

		$lock_tables = array_values(
			array_unique(
				array_filter(
					$lock_tables,
					static fn( string $table ): bool => $table !== ''
				)
			)
		);

		foreach ( $lock_tables as $lock_table ) {
			if ( ! $this->table_exists( $lock_table ) ) {
				continue;
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Mutex row delete.
			$wpdb->delete( $lock_table, [ 'option_name' => $lock_option ], [ '%s' ] );
		}
	}

	/**
	 * Flush filesystem and opcode caches for a file that was just rewritten.
	 *
	 * @param string $path Absolute file path.
	 */
	private function invalidate_file_caches( string $path ): void {
		clearstatcache( true, $path );

		if ( function_exists( 'opcache_invalidate' ) ) {
			@opcache_invalidate( $path, true );
		}
	}

	/**
	 * Realign WordPress runtime globals to the active table prefix.
	 *
	 * This keeps the remainder of the current request consistent after the
	 * physical table rename has already completed.
	 *
	 * @param string $prefix Active table prefix.
	 */
	private function sync_runtime_prefix( string $prefix ): void {
		global $wpdb;

		if ( method_exists( $wpdb, 'set_prefix' ) ) {
			$wpdb->set_prefix( $prefix );
		}

		$GLOBALS['table_prefix'] = $prefix;
	}

	/**
	 * Read a file with reliable fallbacks.
	 *
	 * @param string $file Full path.
	 * @return string|false
	 */
	private function read_file( string $file ) {
		return @file_get_contents( $file );
	}

	/**
	 * Write content to a file with reliable fallbacks.
	 *
	 * @param string $file     Full path.
	 * @param string $contents File contents.
	 * @return bool
	 */
	private function write_file( string $file, string $contents ): bool {
		$data_length = strlen( $contents );
		$written = @file_put_contents( $file, $contents, LOCK_EX );
		return $written === $data_length;
	}

	/**
	 * Atomically write a file and verify post-write content.
	 *
	 * @param string $target   Full path to target file.
	 * @param string $contents File contents.
	 * @return bool
	 */
	private function write_file_atomic( string $target, string $contents ): bool {
		$dir = dirname( $target );
		if ( ! is_dir( $dir ) || ! wp_is_writable( $dir ) ) {
			return false;
		}

		$temp_file = tempnam( $dir, '.securwp-prefix-' );
		if ( $temp_file === false ) {
			return false;
		}

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

		if ( ! $this->write_file( $temp_file, $contents ) ) {
			wp_delete_file( $temp_file );
			return false;
		}

		if ( ! $this->move_file( $temp_file, $target ) ) {
			if ( ! @copy( $temp_file, $target ) ) {
				wp_delete_file( $temp_file );
				return false;
			}
			wp_delete_file( $temp_file );
		}

		clearstatcache( true, $target );
		$verify = $this->read_file( $target );
		if ( $verify === false || $verify !== $contents ) {
			return false;
		}

		$this->invalidate_file_caches( $target );
		return true;
	}

	/**
	 * Roll back database+config changes after a post-rename failure.
	 *
	 * Keeps the site in a consistent state (old/old or new/new) even if one
	 * rollback step fails.
	 *
	 * @param string                       $old_prefix          Old prefix.
	 * @param string                       $new_prefix          New prefix.
	 * @param string                       $wp_config           wp-config path.
	 * @param string                       $old_config_content  Original config content.
	 * @param array<int, string>           $forward_pairs       Validated old-to-new rename pairs.
	 * @param array<int, string>           $reverse_pairs       Validated new-to-old rename pairs.
	 * @return true|\WP_Error
	 */
	private function rollback_after_reference_failure(
		string $old_prefix,
		string $new_prefix,
		string $wp_config,
		string $old_config_content,
		array $forward_pairs,
		array $reverse_pairs
	) {
		if ( ! $this->rename_tables_atomic( $reverse_pairs ) ) {
			$reconcile_new = $this->reconcile_references_safely( $new_prefix, $old_prefix, $new_prefix, false );
			if ( is_wp_error( $reconcile_new ) ) {
				return new \WP_Error(
					'rollback_tables_failed',
					'Failed to roll back database tables and failed to reconcile reference keys on the new prefix. Manual recovery is required.'
				);
			}

			return new \WP_Error(
				'rollback_tables_failed',
				'Failed to roll back database tables after a prefix update error. The site remains on the new table prefix to keep it operational.'
			);
		}

		$reconcile_old = $this->reconcile_references_safely( $old_prefix, $new_prefix, $old_prefix, false );
		if ( is_wp_error( $reconcile_old ) ) {
			// Failed to recover old-state references; return to known new-state.
			if ( $this->rename_tables_atomic( $forward_pairs ) ) {
				$reconcile_new = $this->reconcile_references_safely( $new_prefix, $old_prefix, $new_prefix, false );
				if ( ! is_wp_error( $reconcile_new ) ) {
					return new \WP_Error(
						'rollback_reference_restore_failed',
						'Failed to restore old prefix references during rollback. The site remains on the new table prefix to keep it operational.'
					);
				}
			}

			return new \WP_Error(
				'rollback_reference_restore_failed',
				'Failed to restore reference keys during rollback. Manual recovery is required.'
			);
		}

		if ( $this->write_file_atomic( $wp_config, $old_config_content ) ) {
			return true;
		}

		// Config restore failed after table rollback; attempt DB+reference re-alignment to new prefix.
		if ( $this->rename_tables_atomic( $forward_pairs ) ) {
			$reconcile_new = $this->reconcile_references_safely( $new_prefix, $old_prefix, $new_prefix, false );
			if ( ! is_wp_error( $reconcile_new ) ) {
				return new \WP_Error(
					'rollback_config_restore_failed',
					'Failed to restore wp-config.php after rollback. Database tables and references were re-aligned to the new prefix to keep the site operational. Update wp-config.php manually.'
				);
			}

			return new \WP_Error(
				'rollback_config_restore_failed',
				'Failed to restore wp-config.php after rollback and failed to re-align database references. Manual recovery is required.'
			);
		}

		return new \WP_Error(
			'rollback_inconsistent_state',
			'Rollback could not restore wp-config.php and could not re-align database tables. Manual recovery is required immediately.'
		);
	}

	/**
	 * Reconcile prefix-dependent references after a table-prefix move.
	 *
	 * @param string $table_prefix     Physical table prefix currently in use.
	 * @param string $from_key_prefix  Existing key prefix to replace.
	 * @param string $to_key_prefix    Target key prefix.
	 * @param bool   $collect_warnings Whether to capture zero-row updates as warnings.
	 * @return true|\WP_Error
	 */
	private function reconcile_references( string $table_prefix, string $from_key_prefix, string $to_key_prefix, bool $collect_warnings ) {
		global $wpdb;

		$from_len       = strlen( $from_key_prefix );
		$options_table  = $this->escape_identifier( $table_prefix . 'options' );
		$usermeta_table = $this->escape_identifier( $table_prefix . 'usermeta' );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$options_updated = $wpdb->query( $wpdb->prepare(
			"UPDATE `{$options_table}`
			 SET option_name = CONCAT(%s, SUBSTRING(option_name, %d))
			 WHERE option_name LIKE %s",
			$to_key_prefix,
			$from_len + 1,
			$wpdb->esc_like( $from_key_prefix ) . '%'
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( $options_updated === false ) {
			return new \WP_Error(
				'options_update_failed',
				'Failed to update prefix references in the options table.'
			);
		}
		if ( $collect_warnings && $options_updated === 0 ) {
			$this->last_warnings[] = 'No option keys were updated in the options table. Verify role option keys manually.';
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$usermeta_updated = $wpdb->query( $wpdb->prepare(
			"UPDATE `{$usermeta_table}`
			 SET meta_key = CONCAT(%s, SUBSTRING(meta_key, %d))
			 WHERE meta_key LIKE %s",
			$to_key_prefix,
			$from_len + 1,
			$wpdb->esc_like( $from_key_prefix ) . '%'
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( $usermeta_updated === false ) {
			return new \WP_Error(
				'usermeta_update_failed',
				'Failed to update prefix references in usermeta.'
			);
		}
		if ( $collect_warnings && $usermeta_updated === 0 ) {
			$this->last_warnings[] = 'No usermeta keys were updated. Verify user capability keys manually.';
		}

		if ( is_multisite() ) {
			$blogs_table = $this->escape_identifier( $table_prefix . 'blogs' );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$blog_ids = $wpdb->get_col(
				"SELECT blog_id FROM `{$blogs_table}`
				 WHERE archived = '0' AND spam = '0'
				 ORDER BY blog_id DESC"
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			if ( $blog_ids === null ) {
				return new \WP_Error(
					'multisite_blogs_read_failed',
					'Failed to load multisite blog IDs while reconciling role keys.'
				);
			}

			foreach ( $blog_ids as $blog_id ) {
				$blog_id            = (int) $blog_id;
				$blog_options_table = $this->escape_identifier( $table_prefix . $blog_id . '_options' );
				$from_key           = $from_key_prefix . $blog_id . '_user_roles';
				$to_key             = $to_key_prefix . $blog_id . '_user_roles';

				// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$updated = $wpdb->query( $wpdb->prepare(
					"UPDATE `{$blog_options_table}` SET option_name = %s WHERE option_name = %s LIMIT 1",
					$to_key,
					$from_key
				) );
				// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

				if ( $updated === false ) {
					return new \WP_Error(
						'multisite_roles_update_failed',
						'Failed to update multisite role keys.'
					);
				}

				if ( $collect_warnings && $updated === 0 ) {
					$this->last_warnings[] = sprintf(
						'No role key updated for blog ID %d.',
						$blog_id
					);
				}
			}
		}

		return true;
	}

	private function reconcile_references_safely( string $table_prefix, string $from_key_prefix, string $to_key_prefix, bool $collect_warnings ) {
		try {
			return $this->reconcile_references( $table_prefix, $from_key_prefix, $to_key_prefix, $collect_warnings );
		} catch ( \InvalidArgumentException $e ) {
			return new \WP_Error(
				'invalid_table_identifier',
				'A required database table name is invalid or overlong.'
			);
		}
	}

	/**
	 * Change the WordPress database table prefix.
	 *
	 * Renames all tables, updates prefix-specific option and usermeta keys,
	 * and modifies wp-config.php.  A mutex lock prevents concurrent execution.
	 * On partial table-rename failure, already-renamed tables are rolled back
	 * and wp-config.php is restored.
	 *
	 * @param string $new_prefix The new table prefix.
	 * @return true|\WP_Error True on success, WP_Error on failure.
	 */
	public function change( string $new_prefix ) {
		global $wpdb;

		$this->last_warnings = [];

		$old_prefix     = is_multisite() ? $wpdb->base_prefix : $wpdb->prefix;
		$old_prefix_len = strlen( $old_prefix );
		$old_lock_table = $this->get_lock_table_for_prefix( $old_prefix );
		$new_lock_table = $this->get_lock_table_for_prefix( $new_prefix );

		// Validate prefix format.
		if ( ! preg_match( '/^[a-zA-Z_][a-zA-Z0-9_]*$/', $new_prefix ) ) {
			return new \WP_Error(
				'invalid_prefix',
				'Prefix must start with a letter/underscore and contain only alphanumeric/underscore characters.'
			);
		}
		if ( $new_prefix === $old_prefix ) {
			return new \WP_Error(
				'same_prefix',
				'New prefix is the same as the current prefix.'
			);
		}
		if ( ! ConfigFilePolicy::can_write_to_files() ) {
			return new \WP_Error(
				'writes_disabled',
				'Write to Files is disabled in SecurWP Settings. Enable it to change the database prefix automatically, or update wp-config.php manually.'
			);
		}

		// Gather tables to rename (prepared statement with esc_like).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads the current table list once before the atomic rename operation.
		$tables = $wpdb->get_results(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $old_prefix ) . '%' ),
			ARRAY_N
		);

		if ( empty( $tables ) ) {
			return new \WP_Error(
				'no_tables',
				'No tables found with the current prefix "' . $old_prefix . '".'
			);
		}

		try {
			$this->escape_identifier( $old_lock_table );
			$this->escape_identifier( $new_lock_table );
			$rename_pairs         = $this->build_rename_pairs( $tables, $old_prefix, $new_prefix, false );
			$reverse_rename_pairs = $this->build_rename_pairs( $tables, $old_prefix, $new_prefix, true );
		} catch ( \InvalidArgumentException $e ) {
			return new \WP_Error(
				'invalid_table_identifier',
				'The requested prefix would create an invalid or overlong database table name. No changes were made.'
			);
		}

		// Check for table name collisions before making any changes.
		foreach ( $tables as $row ) {
			$old_name = $row[0];
			$new_name = $new_prefix . substr( $old_name, $old_prefix_len );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks for destination-table collisions before any rename occurs.
			$existing = $wpdb->get_var(
				$wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $new_name ) )
			);
			if ( $existing ) {
				return new \WP_Error(
					'table_collision',
					'Target table "' . $new_name . '" already exists. Aborting to prevent data loss.'
				);
			}
		}

		// Acquire mutex to prevent concurrent execution.
		if ( ! $this->acquire_lock( 'prefix_change' ) ) {
			return new \WP_Error(
				'locked',
				'This tool is already running. Please try again in a few minutes.'
			);
		}

		try {
			// If this fails, abort without touching the database.
			$wp_config = $this->locate_wp_config();
			if ( $wp_config === false ) {
				return new \WP_Error(
					'config_not_found',
					'Could not locate wp-config.php in ABSPATH or one directory above.'
				);
			}

			$config_dir = dirname( $wp_config );
			if ( ! file_exists( $wp_config ) || ! wp_is_writable( $wp_config ) ) {
				return new \WP_Error(
					'config_not_writable',
					'wp-config.php is not writable. Aborting prefix change to avoid an inconsistent state.'
				);
			}
			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 = $this->read_file( $wp_config );
			if ( $config_content === false ) {
				return new \WP_Error(
					'config_read_failed',
					'Could not read wp-config.php. Aborting prefix change.'
				);
			}

			$replace_count   = 0;
			$updated_content = preg_replace(
				'/(\$table_prefix\s*=\s*)([\'"]).+?\\2(\s*;)/',
				"\${1}'$new_prefix'\${3}",
				$config_content,
				1,
				$replace_count
			);
			if ( ! is_string( $updated_content ) || $replace_count < 1 ) {
				return new \WP_Error(
					'config_pattern_not_found',
					'Could not find $table_prefix definition in wp-config.php. Aborting.'
				);
			}

			if ( ! $this->write_file_atomic( $wp_config, $updated_content ) ) {
				return new \WP_Error(
					'config_write_failed',
					'Failed to write wp-config.php. Aborting prefix change.'
				);
			}

			if ( ! $this->rename_tables_atomic( $rename_pairs ) ) {
				if ( ! $this->write_file_atomic( $wp_config, $config_content ) ) {
					return new \WP_Error(
						'rename_failed_config_restore_failed',
						'Failed to rename tables and failed to restore wp-config.php. Manual recovery is required.'
					);
				}

				return new \WP_Error(
					'rename_failed',
					'Failed to rename tables. wp-config.php has been restored.'
				);
			}

			$this->sync_runtime_prefix( $new_prefix );

			$reconcile = $this->reconcile_references_safely( $new_prefix, $old_prefix, $new_prefix, true );
			if ( is_wp_error( $reconcile ) ) {
				$rollback = $this->rollback_after_reference_failure( $old_prefix, $new_prefix, $wp_config, $config_content, $rename_pairs, $reverse_rename_pairs );
				if ( ! is_wp_error( $rollback ) ) {
					$this->sync_runtime_prefix( $old_prefix );
				}
				if ( is_wp_error( $rollback ) ) {
					return $rollback;
				}

				return new \WP_Error(
					$reconcile->get_error_code(),
					$reconcile->get_error_message() . ' Changes were rolled back.'
				);
			}

			/**
			 * Fires after the database prefix has been changed.
			 *
			 * Allows other plugins and modules to update their own prefix-dependent
			 * references (custom tables, serialized options, etc.).
			 *
			 * @param string $new_prefix The new table prefix.
			 * @param string $old_prefix The old table prefix.
			 */
			do_action( 'securwp_database_prefix_changed', $new_prefix, $old_prefix );

			return true;
		} finally {
			$this->release_lock( 'prefix_change', [ $old_lock_table, $new_lock_table ] );
		}
	}

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

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