<?php

declare(strict_types=1);

namespace SecurWP\Modules\Tools;

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

/**
 * Move the legacy admin user from ID 1 to a new ID.
 */
class AdminIdChanger {

	/**
	 * Generate an unused user ID.
	 *
	 * @return int A user ID that is not in use.
	 */
	public function generate_safe_id(): int {
		global $wpdb;
		$users_table = $this->table_identifier( $wpdb->users );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Validated table identifier.
		$max_id = (int) $wpdb->get_var( "SELECT MAX(ID) FROM {$users_table}" );
		$new_id = max( 2, $max_id + 1 );

		// Safety loop: ensure the generated ID is truly unused.
		while ( get_user_by( 'id', $new_id ) ) {
			$new_id++;
		}

		return $new_id;
	}

	/**
	 * Acquire a mutex lock for the requested operation.
	 *
	 * @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;
		$options_table = $this->table_identifier( $wpdb->options );

		$lock_option = 'securwp_lock_' . $name;
		$now         = time();
		$release_at  = $now + $expires;

			// INSERT IGNORE acquires the lock only when the row is absent.
		// 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')",
			$lock_option,
			(string) $release_at
		);
		// 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 -- Mutex query.
			$inserted = $wpdb->query( $insert_sql );

		if ( $inserted ) {
			return true;
		}

		// Row exists. Check if expired and atomically reclaim with WHERE.
		// 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",
			$lock_option
		);
		// 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 -- Mutex query.
			$locked_until = (int) $wpdb->get_var( $lock_sql );
		if ( $locked_until < $now ) {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$reclaim_sql = $wpdb->prepare(
				"UPDATE {$options_table} SET option_value = %s WHERE option_name = %s AND option_value = %s",
				(string) $release_at,
				$lock_option,
				(string) $locked_until
			);
			// 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 -- Mutex query.
				$reclaimed = $wpdb->query( $reclaim_sql );
			return $reclaimed > 0;
		}

		return false;
	}

	/**
	 * Release a mutex lock.
	 *
	 * @param string $name Lock name.
	 */
	private function release_lock( string $name ): void {
		delete_option( 'securwp_lock_' . $name );
	}

	/**
	 * Change the user with ID 1 to a new ID.
	 *
	 * @param int $new_id The new user ID (must be >= 2).
	 * @return true|\WP_Error True on success, WP_Error on failure.
	 */
	public function change( int $new_id ) {
		global $wpdb;

		if ( $new_id < 2 ) {
			return new \WP_Error(
				'invalid_id',
				'New ID must be at least 2.'
			);
		}

		// Check if user ID 1 exists.
		$user = get_user_by( 'id', 1 );
		if ( ! $user ) {
			return new \WP_Error(
				'no_user',
				'No user with ID 1 exists.'
			);
		}

		// Check if new ID is already taken.
		if ( get_user_by( 'id', $new_id ) ) {
			return new \WP_Error(
				'id_taken',
				'User ID ' . $new_id . ' is already in use.'
			);
		}

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

			// Run all mutations in one transaction.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Transaction control.
			$wpdb->query( 'START TRANSACTION' );

		$ok = true;

		// Update users table.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table update.
			$ok = $ok && ( false !== $wpdb->update( $wpdb->users, [ 'ID' => $new_id ], [ 'ID' => 1 ] ) );

		// Update usermeta.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table update.
			$ok = $ok && ( false !== $wpdb->update( $wpdb->usermeta, [ 'user_id' => $new_id ], [ 'user_id' => 1 ] ) );

		// Update posts (post_author).
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table update.
			$ok = $ok && ( false !== $wpdb->update( $wpdb->posts, [ 'post_author' => $new_id ], [ 'post_author' => 1 ] ) );

		// Update comments.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table update.
			$ok = $ok && ( false !== $wpdb->update( $wpdb->comments, [ 'user_id' => $new_id ], [ 'user_id' => 1 ] ) );

		// Update links.
		$links_table = $this->table_identifier( $wpdb->links );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$link_update_sql = $wpdb->prepare(
			"UPDATE {$links_table} SET link_owner = %d WHERE link_owner = 1",
			$new_id
		);
		// 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 core table update.
			$ok = $ok && ( false !== $wpdb->query( $link_update_sql ) );

		if ( ! $ok ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Transaction control.
				$wpdb->query( 'ROLLBACK' );
			$this->release_lock( 'admin_id_change' );
			return new \WP_Error(
				'db_error',
				'A database error occurred during migration. All changes have been rolled back.'
			);
		}

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

		// Destroy all sessions for the migrated user. After COMMIT, usermeta
		// (including session_tokens) is now keyed by $new_id, not 1.
		$session_tokens = \WP_Session_Tokens::get_instance( $new_id );
		$session_tokens->destroy_all();

		// Flush WordPress user object cache for both old and new IDs.
		clean_user_cache( 1 );
		clean_user_cache( $new_id );

		/**
		 * Fires after the admin user ID has been changed from 1.
		 *
		 * @param int      $new_id The new user ID.
		 * @param \WP_User $user   The original user object (with ID 1).
		 */
		do_action( 'securwp_admin_user_id_changed', $new_id, $user );

		$this->release_lock( 'admin_id_change' );

		return true;
	}

	private 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 . '`';
	}
}
