<?php

declare(strict_types=1);

namespace SiteFort\Modules\Tools;

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

/**
 * Rename the legacy admin login username.
 */
class AdminUsernameChanger {

	private const LOCK_NAME = 'admin_id_change';
	private const OLD_USERNAME = 'admin';
	private const MAX_USERNAME_LENGTH = 60;

	/**
	 * Change the user_login value for the user currently named "admin".
	 *
	 * @param string $new_username Requested replacement username.
	 * @return array{user_id:int,old_username:string,new_username:string,super_admin_rows_updated:int}|\WP_Error
	 */
	public function change( string $new_username ) {
		global $wpdb;

		$new_username = trim( $new_username );

		$validation = $this->validate_new_username( $new_username );
		if ( is_wp_error( $validation ) ) {
			return $validation;
		}

		$transactional_tables = [ $wpdb->users ];
		if ( is_multisite() ) {
			$transactional_tables[] = $wpdb->sitemeta;
		}

		$transactional_check = ToolDatabase::assert_transactional_tables( $transactional_tables, 'admin username change' );
		if ( is_wp_error( $transactional_check ) ) {
			return $transactional_check;
		}

		$lock = ToolLock::acquire( self::LOCK_NAME );
		if ( is_wp_error( $lock ) ) {
			return $lock;
		}

		if ( ! $lock ) {
			return new \WP_Error(
				'locked',
				'This user administration tool is already running. Please try again in a few minutes.'
			);
		}

		$transaction_started = false;

		try {
			$users_table = ToolDatabase::table_identifier( $wpdb->users );

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control.
			if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
				return new \WP_Error(
					'transaction_start_failed',
					'Unable to start a database transaction for the username change.'
				);
			}
			$transaction_started = true;

			// Lock the current admin row inside the transaction and fail closed if data is ambiguous.
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$admin_rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT ID, user_login FROM {$users_table} WHERE user_login = %s LIMIT 2 FOR UPDATE",
					self::OLD_USERNAME
				),
				ARRAY_A
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( ! is_array( $admin_rows ) ) {
				return $this->rollback_and_error(
					'db_error',
					'Unable to lock the admin user account before changing the username.',
					$transaction_started
				);
			}

			if ( count( $admin_rows ) === 0 ) {
				return $this->rollback_and_error(
					'no_user',
					'No user with the username "admin" exists.',
					$transaction_started
				);
			}

			if ( count( $admin_rows ) > 1 ) {
				return $this->rollback_and_error(
					'ambiguous_admin_username',
					'Multiple users match the admin username. Review the users table before running this tool.',
					$transaction_started
				);
			}

			$user_id      = (int) ( $admin_rows[0]['ID'] ?? 0 );
			$old_username = (string) ( $admin_rows[0]['user_login'] ?? self::OLD_USERNAME );
			if ( $user_id <= 0 ) {
				return $this->rollback_and_error(
					'db_error',
					'The admin user row could not be resolved safely.',
					$transaction_started
				);
			}

			$user = get_userdata( $user_id );
			if ( ! $user || ! $user->exists() ) {
				return $this->rollback_and_error(
					'no_user',
					'No user with the username "admin" exists.',
					$transaction_started
				);
			}

			// Lock any conflicting row/gap for the requested username before mutating.
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$conflicting_rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT ID FROM {$users_table} WHERE user_login = %s AND ID <> %d LIMIT 1 FOR UPDATE",
					$new_username,
					$user_id
				),
				ARRAY_A
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( ! is_array( $conflicting_rows ) ) {
				return $this->rollback_and_error(
					'db_error',
					'Unable to verify whether the requested username is available.',
					$transaction_started
				);
			}

			if ( ! empty( $conflicting_rows ) ) {
				return $this->rollback_and_error(
					'username_taken',
					'A user already exists with that username.',
					$transaction_started
				);
			}

			$case_conflict = $this->find_case_insensitive_username_conflict( $new_username, $user_id );
			if ( is_wp_error( $case_conflict ) ) {
				return $this->rollback_and_error(
					$case_conflict->get_error_code(),
					$case_conflict->get_error_message(),
					$transaction_started
				);
			}

			if ( (int) $case_conflict > 0 ) {
				return $this->rollback_and_error(
					'username_taken',
					'A user already exists with that username.',
					$transaction_started
				);
			}

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$update_sql = $wpdb->prepare(
				"UPDATE {$users_table} SET user_login = %s WHERE ID = %d AND user_login = %s",
				$new_username,
				$user_id,
				$old_username
			);
			// 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.
			$user_update_result = $wpdb->query( $update_sql );

			$ok = 1 === $user_update_result;
			if ( false === $user_update_result ) {
				return $this->rollback_and_error(
					'db_error',
					'Unable to update the admin username.',
					$transaction_started
				);
			}

			if ( ! $ok ) {
				return $this->rollback_and_error(
					'concurrent_change',
					'The admin user changed while this tool was running. Refresh the page and try again.',
					$transaction_started
				);
			}

			$super_admin_rows_updated = 0;
			$site_admin_cache_ids     = [];
			if ( is_multisite() ) {
				$super_admin_update = $this->update_multisite_super_admins( $old_username, $new_username );
				if ( is_wp_error( $super_admin_update ) ) {
					return $this->rollback_and_error(
						$super_admin_update->get_error_code(),
						$super_admin_update->get_error_message(),
						$transaction_started
					);
				} else {
					$super_admin_rows_updated = (int) ( $super_admin_update['updated'] ?? 0 );
					$site_admin_cache_ids     = array_map( 'intval', (array) ( $super_admin_update['site_ids'] ?? [] ) );
				}
			}

			$verification = $this->verify_pending_username_change( $user_id, $new_username );
			if ( is_wp_error( $verification ) ) {
				return $this->rollback_and_error(
					$verification->get_error_code(),
					$verification->get_error_message(),
					$transaction_started
				);
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control.
			if ( false === $wpdb->query( 'COMMIT' ) ) {
				return $this->rollback_and_error(
					'transaction_commit_failed',
					'Unable to commit the admin username change. All changes have been rolled back.',
					$transaction_started
				);
			}
			$transaction_started = false;

			\WP_Session_Tokens::get_instance( $user_id )->destroy_all();
			clean_user_cache( $user );
			wp_cache_delete( $old_username, 'userlogins' );
			wp_cache_delete( strtolower( $old_username ), 'userlogins' );
			wp_cache_delete( $new_username, 'userlogins' );
			wp_cache_delete( strtolower( $new_username ), 'userlogins' );
			foreach ( array_unique( $site_admin_cache_ids ) as $site_id ) {
				wp_cache_delete( (int) $site_id . ':site_admins', 'site-options' );
			}

			do_action( 'sitefort_admin_username_changed', $user_id, $old_username, $new_username );

			return [
				'user_id'                  => $user_id,
				'old_username'             => $old_username,
				'new_username'             => $new_username,
				'super_admin_rows_updated' => $super_admin_rows_updated,
			];
		} catch ( \Throwable $e ) {
			if ( $transaction_started ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control.
				$wpdb->query( 'ROLLBACK' );
			}

			throw $e;
		} finally {
			$lock->release();
		}
	}

	/**
	 * Validate a replacement username before direct database mutation.
	 *
	 * @param string $new_username Requested username.
	 * @return true|\WP_Error
	 */
	private function validate_new_username( string $new_username ) {
		if ( $new_username === '' ) {
			return new \WP_Error(
				'missing_username',
				'The new_username parameter is required.'
			);
		}

		if ( strlen( $new_username ) > self::MAX_USERNAME_LENGTH ) {
			return new \WP_Error(
				'username_too_long',
				'Username must be 60 characters or fewer.'
			);
		}

		if ( strtolower( $new_username ) === self::OLD_USERNAME ) {
			return new \WP_Error(
				'reserved_username',
				'Choose a username other than "admin".'
			);
		}

		if ( ! validate_username( $new_username ) ) {
			return new \WP_Error(
				'invalid_username',
				'Invalid username.'
			);
		}

		return true;
	}

	/**
	 * Detect username conflicts independent of the database collation.
	 *
	 * WordPress installations normally use a case-insensitive collation for
	 * user_login, but hardened or migrated databases can differ. Because
	 * usernames are already constrained to ASCII by validate_username(), LOWER()
	 * gives us a portable fail-closed duplicate check.
	 *
	 * @param string $new_username   Requested username.
	 * @param int    $excluded_user_id User being renamed.
	 * @return int|\WP_Error Conflicting user ID, 0 when clear, or error.
	 */
	private function find_case_insensitive_username_conflict( string $new_username, int $excluded_user_id ) {
		global $wpdb;

		$users_table = ToolDatabase::table_identifier( $wpdb->users );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT ID FROM {$users_table} WHERE LOWER(user_login) = LOWER(%s) AND ID <> %d LIMIT 1 FOR UPDATE",
				$new_username,
				$excluded_user_id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( ! is_array( $rows ) ) {
			return new \WP_Error(
				'db_error',
				'Unable to verify whether the requested username conflicts by case.'
			);
		}

		if ( empty( $rows ) ) {
			return 0;
		}

		return max( 0, (int) ( $rows[0]['ID'] ?? 0 ) );
	}

	/**
	 * Update multisite super-admin username lists after user_login changes.
	 *
	 * @param string $old_username Previous login name.
	 * @param string $new_username Replacement login name.
	 * @return array{updated:int,site_ids:int[]}|\WP_Error Update count and touched network IDs.
	 */
	private function update_multisite_super_admins( string $old_username, string $new_username ) {
		global $wpdb;

		$sitemeta_table = ToolDatabase::table_identifier( $wpdb->sitemeta );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT meta_id, site_id, meta_value FROM {$sitemeta_table} WHERE meta_key = %s",
				'site_admins'
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( ! is_array( $rows ) ) {
			return new \WP_Error(
				'db_error',
				'Unable to read multisite super admin metadata.'
			);
		}

		$updated  = 0;
		$site_ids = [];
		$seen_site_ids = [];

		foreach ( $rows as $row ) {
			$site_id = max( 1, (int) ( $row['site_id'] ?? get_current_network_id() ) );
			$seen_site_ids[ $site_id ] = true;

			$original_meta_value = (string) ( $row['meta_value'] ?? '' );
			$admins              = maybe_unserialize( $original_meta_value );
			if ( ! is_array( $admins ) ) {
				return new \WP_Error(
					'invalid_super_admin_metadata',
					'Multisite super admin metadata is not in the expected format. Review the site_admins network option before running this tool.'
				);
			}

			$changed = false;
			foreach ( $admins as $index => $admin_username ) {
				if ( is_string( $admin_username ) && $admin_username === $old_username ) {
					$admins[ $index ] = $new_username;
					$changed          = true;
				}
			}

			if ( ! $changed ) {
				continue;
			}

			$meta_id = (int) ( $row['meta_id'] ?? 0 );
			if ( $meta_id <= 0 ) {
				return new \WP_Error(
					'db_error',
					'Invalid multisite super admin metadata row.'
				);
			}

			$new_meta_value = maybe_serialize( array_values( $admins ) );

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$result = $wpdb->query(
				$wpdb->prepare(
					"UPDATE {$sitemeta_table} SET meta_value = %s WHERE meta_id = %d AND meta_value = %s",
					$new_meta_value,
					$meta_id,
					$original_meta_value
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( false === $result ) {
				return new \WP_Error(
					'db_error',
					'Unable to update multisite super admin metadata.'
				);
			}

			if ( 1 !== (int) $result ) {
				return new \WP_Error(
					'concurrent_change',
					'Multisite super admin metadata changed while this tool was running. All changes have been rolled back.'
				);
			}

			$updated++;
			$site_ids[] = $site_id;
		}

		foreach ( $this->get_multisite_network_ids() as $network_id ) {
			if ( isset( $seen_site_ids[ $network_id ] ) ) {
				continue;
			}

			// Missing site_admins rows default to array( 'admin' ) in WordPress core.
			// Persist the replacement username so the rename preserves super-admin access.
			$new_meta_value = maybe_serialize( [ $new_username ] );

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$result = $wpdb->query(
				$wpdb->prepare(
					"INSERT INTO {$sitemeta_table} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)",
					$network_id,
					'site_admins',
					$new_meta_value
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( false === $result || 1 !== (int) $result ) {
				return new \WP_Error(
					'db_error',
					'Unable to preserve default multisite super admin metadata.'
				);
			}

			$updated++;
			$site_ids[] = $network_id;
		}

		return [
			'updated'  => $updated,
			'site_ids' => array_values( array_unique( $site_ids ) ),
		];
	}

	/**
	 * Return network IDs whose missing site_admins row should be treated as the WordPress default.
	 *
	 * @return int[]
	 */
	private function get_multisite_network_ids(): array {
		$current_network_id = function_exists( 'get_current_network_id' ) ? max( 1, (int) get_current_network_id() ) : 1;

		if ( function_exists( 'get_networks' ) ) {
			$network_ids = get_networks(
				[
					'fields' => 'ids',
					'number' => 0,
				]
			);

			if ( is_array( $network_ids ) && ! empty( $network_ids ) ) {
				$network_ids = array_map( 'intval', $network_ids );
				$network_ids = array_filter(
					$network_ids,
					static fn( int $network_id ): bool => $network_id > 0
				);

				if ( ! empty( $network_ids ) ) {
					return array_values( array_unique( $network_ids ) );
				}
			}
		}

		return [ $current_network_id ];
	}

	/**
	 * Verify the pending transaction has exactly one owner for the new username.
	 *
	 * @param int    $user_id      Expected user ID.
	 * @param string $new_username Expected username.
	 * @return true|\WP_Error
	 */
	private function verify_pending_username_change( int $user_id, string $new_username ) {
		global $wpdb;

		$users_table = ToolDatabase::table_identifier( $wpdb->users );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT ID, user_login FROM {$users_table} WHERE user_login = %s LIMIT 2 FOR UPDATE",
				$new_username
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( ! is_array( $rows ) || count( $rows ) !== 1 ) {
			return new \WP_Error(
				'verification_failed',
				'The username change could not be verified before commit.'
			);
		}

		if ( (int) ( $rows[0]['ID'] ?? 0 ) !== $user_id || (string) ( $rows[0]['user_login'] ?? '' ) !== $new_username ) {
			return new \WP_Error(
				'verification_failed',
				'The username change verified against an unexpected user row.'
			);
		}

		return true;
	}

	/**
	 * Roll back an active transaction and return a WP_Error.
	 *
	 * @param string $code                Error code.
	 * @param string $message             Error message.
	 * @param bool   $transaction_started Whether a transaction is active.
	 * @return \WP_Error
	 */
	private function rollback_and_error( string $code, string $message, bool &$transaction_started ): \WP_Error {
		global $wpdb;

		if ( $transaction_started ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control.
			$wpdb->query( 'ROLLBACK' );
			$transaction_started = false;
		}

		return new \WP_Error( $code, $message );
	}

}
