<?php

declare(strict_types=1);

namespace SecurWP\Modules\TwoFactor;

use SecurWP\Core\Database\SchemaInspector;

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

/**
 * Recovery code manager for Two-Factor Authentication.
 *
 * Recovery codes are stored as one-way hashes in a plugin-owned table so they
 * remain usable even when the master encryption key is unavailable.
 */
final class RecoveryCodes {

	public const TABLE_NAME = 'securwp_two_factor_recovery_codes';

	private const DEFAULT_COUNT = 8;

	public static function generate( int $count = self::DEFAULT_COUNT ): array {
		$codes = [];

		for ( $i = 0; $i < $count; $i++ ) {
			$raw_hex = bin2hex( random_bytes( 8 ) );
			$codes[] = self::format_code( $raw_hex );
		}

		return $codes;
	}

	public static function verify_and_consume( int $user_id, string $input_code ): bool {
		global $wpdb;
		$lock_name = 'securwp_recovery_' . $user_id;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 2)', $lock_name ) );
		if ( '1' !== (string) $lock ) {
			return false;
		}

		try {
			$normalized_input = self::normalize_code( $input_code );
			if ( $normalized_input === '' ) {
				return false;
			}

			$hashed_codes = self::load_hashed_codes( $user_id );
			if ( empty( $hashed_codes ) ) {
				return false;
			}

			return self::consume_hashed_code( $user_id, $normalized_input, $hashed_codes );
		} finally {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
		}
	}

	public static function get_remaining_count( int $user_id ): int {
		return self::count_hashed_codes( $user_id );
	}

	public static function save_codes( int $user_id, array $codes ): bool {
		return self::persist_hashed_codes( $user_id, $codes );
	}

	public static function clear_codes( int $user_id ): bool {
		return self::persist_hashed_codes( $user_id, [] );
	}

	public static function delete_all(): void {
		global $wpdb;

		$table_name = $wpdb->prefix . self::TABLE_NAME;
		if ( ! SchemaInspector::table_exists( $table_name ) ) {
			return;
		}

		$ids = self::list_ids( $table_name );
		if ( empty( $ids ) ) {
			return;
		}

		foreach ( $ids as $id ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Deletes a single plugin-owned recovery-code row by primary key during a destructive global reset.
			$wpdb->delete(
				$table_name,
				[ 'id' => $id ],
				[ '%d' ]
			);
		}
	}

	public static function is_schema_current(): bool {
		global $wpdb;

		$codes_table = $wpdb->prefix . self::TABLE_NAME;

		return SchemaInspector::has_columns(
			$codes_table,
			[
				'id',
				'user_id',
				'code_hash',
				'batch_id',
				'created_at',
				'used_at',
			]
		) && SchemaInspector::has_indexes( $codes_table, [ 'idx_user_active', 'idx_batch_id' ] );
	}

	public static function has_codes_for_enabled_users(): bool {
		global $wpdb;

		$two_factor_table_name = $wpdb->prefix . 'securwp_two_factor';
		$codes_table_name      = $wpdb->prefix . self::TABLE_NAME;

		if ( ! SchemaInspector::table_exists( $two_factor_table_name ) ) {
			return false;
		}

		$two_factor_table = SchemaInspector::table_identifier( $two_factor_table_name );

		if ( SchemaInspector::table_exists( $codes_table_name ) ) {
			$codes_table = SchemaInspector::table_identifier( $codes_table_name );

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$active = $wpdb->get_var(
				"SELECT COUNT(*)
				 FROM {$codes_table} codes
				 INNER JOIN {$two_factor_table} tf ON tf.user_id = codes.user_id
				 WHERE tf.is_enabled = 1
				   AND tf.confirmed_at IS NOT NULL
				   AND codes.used_at IS NULL"
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( (int) $active > 0 ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @return array<int,array{id:int,code_hash:string}>
	 */
	private static function load_hashed_codes( int $user_id ): array {
		global $wpdb;

		$table_name = $wpdb->prefix . self::TABLE_NAME;
		if ( ! SchemaInspector::table_exists( $table_name ) ) {
			return [];
		}

		$table = SchemaInspector::table_identifier( $table_name );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, code_hash
				 FROM {$table}
				 WHERE user_id = %d AND used_at IS NULL
				 ORDER BY id ASC",
				$user_id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		return is_array( $rows ) ? $rows : [];
	}

	private static function count_hashed_codes( int $user_id ): int {
		global $wpdb;

		$table_name = $wpdb->prefix . self::TABLE_NAME;
		if ( ! SchemaInspector::table_exists( $table_name ) ) {
			return 0;
		}

		$table = SchemaInspector::table_identifier( $table_name );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$count = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND used_at IS NULL",
				$user_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		return max( 0, (int) $count );
	}

	private static function consume_hashed_code( int $user_id, string $normalized_input, array $hashed_codes ): bool {
		global $wpdb;

		$table = SchemaInspector::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		foreach ( $hashed_codes as $row ) {
			$id        = (int) ( $row['id'] ?? 0 );
			$code_hash = (string) ( $row['code_hash'] ?? '' );
			if ( $id <= 0 || $code_hash === '' ) {
				continue;
			}

			if ( password_verify( $normalized_input, $code_hash ) ) {
				$now = time();
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$result = $wpdb->query(
					$wpdb->prepare(
						"UPDATE {$table}
						 SET used_at = %d
						 WHERE id = %d AND user_id = %d AND used_at IS NULL",
						$now,
						$id,
						$user_id
					)
				);
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

				return (int) $result > 0;
			}
		}

		return false;
	}

	private static function persist_hashed_codes( int $user_id, array $codes, bool $lock_held = false ): bool {
		global $wpdb;

		$two_factor_table_name = $wpdb->prefix . 'securwp_two_factor';
		$codes_table_name      = $wpdb->prefix . self::TABLE_NAME;
		if ( ! SchemaInspector::table_exists( $two_factor_table_name ) || ! SchemaInspector::table_exists( $codes_table_name ) ) {
			return false;
		}

		$two_factor_table = SchemaInspector::table_identifier( $two_factor_table_name );
		$codes_table      = SchemaInspector::table_identifier( $codes_table_name );
		$normalized_codes = [];

		foreach ( $codes as $code ) {
			$normalized = self::normalize_code( (string) $code );
			if ( $normalized !== '' ) {
				$normalized_codes[] = $normalized;
			}
		}

		$normalized_codes = array_values( array_unique( $normalized_codes ) );
		$now              = time();
		$batch_id         = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : bin2hex( random_bytes( 16 ) );
		$lock_name        = 'securwp_recovery_' . $user_id;
		$acquired_lock    = false;
		$max_attempts     = 3;

		try {
			if ( ! $lock_held ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 2)', $lock_name ) );
				if ( '1' !== (string) $lock ) {
					return false;
				}
				$acquired_lock = true;
			}

			for ( $attempt = 1; $attempt <= $max_attempts; $attempt++ ) {
				try {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
					$wpdb->query( 'START TRANSACTION' );

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Deletes prior recovery codes for this user through wpdb's structured delete helper.
					$wpdb->delete(
						trim( $codes_table, '`' ),
						[ 'user_id' => $user_id ],
						[ '%d' ]
					);

					foreach ( $normalized_codes as $normalized ) {
						$hash = password_hash( $normalized, PASSWORD_DEFAULT );
						if ( ! is_string( $hash ) || $hash === '' ) {
							throw new \RuntimeException( 'Failed to hash recovery code.' );
						}

						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
						$inserted = $wpdb->insert(
							trim( $codes_table, '`' ),
							[
								'user_id'    => $user_id,
								'code_hash'  => $hash,
								'batch_id'   => $batch_id,
								'created_at' => $now,
								'used_at'    => null,
							],
							[ '%d', '%s', '%s', '%d', null ]
						);

						if ( false === $inserted ) {
							$error = is_string( $wpdb->last_error ) ? trim( $wpdb->last_error ) : '';
							throw new \RuntimeException( $error !== '' ? $error : 'Failed to store recovery code hashes.' );
						}
					}

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
					$updated = $wpdb->update(
						trim( $two_factor_table, '`' ),
						[
							'updated_at' => $now,
						],
						[ 'user_id' => $user_id ],
						[ '%d' ],
						[ '%d' ]
					);

					if ( false === $updated ) {
						$error = is_string( $wpdb->last_error ) ? trim( $wpdb->last_error ) : '';
						throw new \RuntimeException( $error !== '' ? $error : 'Failed to update recovery code metadata.' );
					}

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

					if ( $attempt < $max_attempts && self::is_retryable_write_error( $throwable->getMessage() ) ) {
						continue;
					}

					securwp_log( 'SecurWP RecoveryCodes: ' . $throwable->getMessage() );
					return false;
				}
			}

			return false;
		} finally {
			if ( $acquired_lock ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
			}
		}
	}

	private static function normalize_code( string $code ): string {
		return strtolower( str_replace( '-', '', trim( $code ) ) );
	}

	private static function format_code( string $hex ): string {
		$hex = strtolower( $hex );
		return implode( '-', str_split( $hex, 4 ) );
	}

	/**
	 * @return array<int,int>
	 */
	private static function list_ids( string $table_name ): array {
		global $wpdb;

		$table_identifier = SchemaInspector::table_identifier( $table_name );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads primary keys from a validated plugin-owned table before structured deletion.
		$ids = $wpdb->get_col( "SELECT id FROM {$table_identifier} ORDER BY id ASC" );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( ! is_array( $ids ) ) {
			return [];
		}

		return array_values(
			array_filter(
				array_map( 'intval', $ids ),
				static fn( int $id ): bool => $id > 0
			)
		);
	}

	private static function is_retryable_write_error( string $message ): bool {
		$message = strtolower( $message );

		return str_contains( $message, 'deadlock found when trying to get lock' )
			|| str_contains( $message, 'lock wait timeout exceeded' );
	}

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

		return '`' . $table . '`';
	}
}
