<?php

declare(strict_types=1);

namespace SiteFort\Modules\Tools;

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

use SiteFort\Includes\MasterKey;
use SiteFort\Includes\SecretManager;
use SiteFort\Modules\Cloud\Service\CloudSettings;
use SiteFort\Modules\Cloud\Service\KeyBackupService;
use SiteFort\Modules\Scanner\Queue\ScanQueue;
use SiteFort\Modules\TwoFactor\TOTPProvider;

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

	private const LOCK_OPTION = 'sitefort_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 the SiteFort key directory, then try again.'
			);
		}

		// Block if a scan is running.
		$active_scan = ScanQueue::get();
		if ( $active_scan && in_array( $active_scan['status'] ?? '', [ 'pending', 'running' ], 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 ) ) {
				MasterKey::delete_path( $key_backup );
			}
		} elseif ( $this->config_backup_path && file_exists( $this->config_backup_path ) ) {
			MasterKey::delete_path( $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();

		sitefort_log( '[SiteFort] 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).
				sitefort_log( '[SiteFort] 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 . 'sitefort_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'], TOTPProvider::SECRET_CONTEXT );
						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';
		$key_perms   = file_exists( $key_path ) ? ( @fileperms( $key_path ) & 0777 ) : null;

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

		if ( file_exists( $key_path ) && ! $this->delete_file( $key_path ) ) {
			MasterKey::delete_path( $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 ) ) {
				MasterKey::copy_path( $backup_path, $key_path, $key_perms );
				MasterKey::delete_path( $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 ) ) {
				MasterKey::copy_path( $backup_path, $key_path, $key_perms );
				MasterKey::delete_path( $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.
	 *
	 * @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 ) {
		$new_key = random_bytes( self::KEY_BYTES );
		$config_update = MasterKey::persist_key_to_wp_config( $new_key, true, true );
		if ( is_wp_error( $config_update ) ) {
			return $config_update;
		}

		$this->config_target_path = (string) ( $config_update['path'] ?? '' );
		$this->config_backup_path = isset( $config_update['backup_path'] ) && is_string( $config_update['backup_path'] )
			? $config_update['backup_path']
			: null;

		// 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 . 'sitefort_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'], TOTPProvider::SECRET_CONTEXT );
					$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 ) ) {
					MasterKey::delete_path( $key_path );
				}
				$key_perms = @fileperms( $backup_path );
				MasterKey::copy_path(
					$backup_path,
					$key_path,
					is_int( $key_perms ) ? ( $key_perms & 0777 ) : null
				);
				MasterKey::delete_path( $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 !== '' ) {
					$config_perms = @fileperms( $wp_config );
					MasterKey::copy_path(
						$this->config_backup_path,
						$wp_config,
						is_int( $config_perms ) ? ( $config_perms & 0777 ) : null
					);
				}
				MasterKey::delete_path( $this->config_backup_path );
				$this->config_backup_path = null;
				$this->config_target_path = null;
			}
			// Remove any leftover file-backed key artifact after constant-mode rollback.
			$key_path = MasterKey::get_key_file_path();
			if ( file_exists( $key_path ) ) {
				MasterKey::delete_path( $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 {
		return MasterKey::delete_path( $path );
	}

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