<?php

declare(strict_types=1);

namespace SiteFort\Modules\TwoFactor;

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

/**
 * RFC 6238 TOTP and RFC 4226 HOTP implementation.
 *
 * Provides Time-based One-Time Password generation and verification
 * with base32 encoding/decoding per RFC 4648.
 *
 */
class TOTPProvider {

	/** Associated-data context binding a stored TOTP secret to the 2FA slot. */
	public const SECRET_CONTEXT = 'sitefort_2fa:totp';

	/**
	 * TOTP time step in seconds.
	 */
	private const TIME_STEP = 30;

	/**
	 * Number of digits in the OTP code.
	 */
	private const CODE_DIGITS = 6;

	/**
	 * Base32 alphabet per RFC 4648.
	 */
	private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

	/**
	 * Generate a new random TOTP secret.
	 *
	 * Generates 20 bytes of cryptographically secure random data
	 * and returns it as a base32-encoded string.
	 *
	 * @return string Base32-encoded secret (32 characters).
	 * @throws \Exception If random_bytes() fails.
	 */
	public static function generate_secret(): string {
		$secret = random_bytes( 20 );
		return self::base32_encode( $secret );
	}

	/**
	 * Generate an otpauth:// URI for QR code provisioning.
	 *
	 * @param string   $secret Base32-encoded TOTP secret.
	 * @param \WP_User $user   The WordPress user.
	 * @return string The otpauth:// URI.
	 */
	public static function generate_otp_url( string $secret, \WP_User $user ): string {
		$site_name = get_bloginfo( 'name' );
		$email     = $user->user_email;

		$label  = rawurlencode( $site_name ) . ':' . rawurlencode( $email );
		$params = http_build_query( [
			'secret'    => $secret,
			'issuer'    => $site_name,
			'algorithm' => 'SHA1',
			'digits'    => (string) self::CODE_DIGITS,
			'period'    => (string) self::TIME_STEP,
		], '', '&', PHP_QUERY_RFC3986 );

		return 'otpauth://totp/' . $label . '?' . $params;
	}

	/**
	 * Verify a TOTP code against a secret.
	 *
	 * Checks the code against the current time step and a window
	 * of adjacent time steps to account for clock drift.
	 *
	 * @param string $secret Base32-encoded TOTP secret.
	 * @param string $code   The 6-digit code to verify.
	 * @param int    $window Number of time steps to check in each direction (default 1).
	 * @return bool True if the code is valid.
	 */
	public static function verify_code( string $secret, string $code, int $window = 1 ): bool {
		return self::verify_code_with_step( $secret, $code, $window ) !== false;
	}

	/**
	 * Verify a TOTP code and return the matched time step.
	 *
	 * Used internally to support replay prevention. Returns the time step
	 * that matched, or false if no match was found.
	 *
	 * @param string $secret Base32-encoded TOTP secret.
	 * @param string $code   The 6-digit code to verify.
	 * @param int    $window Number of time steps to check in each direction (default 1).
	 * @return int|false The matched time step, or false if invalid.
	 */
	public static function verify_code_with_step( string $secret, string $code, int $window = 1 ) {
		// Normalize: strip whitespace and ensure it looks like a 6-digit code.
		$code = trim( $code );
		if ( ! preg_match( '/^\d{' . self::CODE_DIGITS . '}$/', $code ) ) {
			return false;
		}

		$binary_secret  = self::base32_decode( $secret );
		if ( $binary_secret === '' ) {
			return false;
		}

		$current_step = self::get_current_time_step();

		for ( $offset = -$window; $offset <= $window; $offset++ ) {
			$test_step = $current_step + $offset;
			$expected  = self::generate_hotp( $binary_secret, $test_step );

			if ( hash_equals( $expected, $code ) ) {
				return $test_step;
			}
		}

		return false;
	}

	/**
	 * Verify a TOTP code with replay prevention for a specific user.
	 *
	 * After successful verification, stores the used time step in user meta
	 * to prevent the same code from being reused within its validity window.
	 *
	 * @param int    $user_id The WordPress user ID.
	 * @param string $secret  Base32-encoded TOTP secret.
	 * @param string $code    The 6-digit code to verify.
	 * @param int    $window  Number of time steps to check in each direction (default 1).
	 * @return bool True if the code is valid and has not been used before.
	 */
	public static function verify_code_for_user( int $user_id, string $secret, string $code, int $window = 1 ): bool {
		$matched_step = self::verify_code_with_step( $secret, $code, $window );

		if ( $matched_step === false ) {
			return false;
		}

		global $wpdb;
		$lock_name = 'sitefort_totp_replay_' . $user_id;

		// Acquire an advisory lock to make the replay-check-and-store atomic.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Advisory locking requires a database-native GET_LOCK() call.
		$lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 1)', $lock_name ) );
		if ( '1' !== (string) $lock ) {
			// Could not acquire lock within 1 s; refuse to allow the attempt.
			return false;
		}

		try {
			// Check for replay: ensure this time step has not already been used.
			$last_used_step = (int) get_user_meta( $user_id, '_sitefort_2fa_last_step', true );

			if ( $last_used_step >= $matched_step ) {
				return false;
			}

			// Store the used time step to prevent replay.
			update_user_meta( $user_id, '_sitefort_2fa_last_step', $matched_step );

			return true;
		} finally {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Advisory locking requires a database-native RELEASE_LOCK() call.
			$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
		}
	}

	/**
	 * Encode binary data to base32 per RFC 4648.
	 *
	 * @param string $data Raw binary data.
	 * @return string Base32-encoded string (uppercase, no padding).
	 */
	public static function base32_encode( string $data ): string {
		if ( $data === '' ) {
			return '';
		}

		$binary = '';
		$length = strlen( $data );
		for ( $i = 0; $i < $length; $i++ ) {
			$binary .= str_pad( decbin( ord( $data[ $i ] ) ), 8, '0', STR_PAD_LEFT );
		}

		$encoded   = '';
		$bit_len   = strlen( $binary );
		$position  = 0;

		while ( $position < $bit_len ) {
			$chunk = substr( $binary, $position, 5 );
			// Pad the last chunk to 5 bits if needed.
			if ( strlen( $chunk ) < 5 ) {
				$chunk = str_pad( $chunk, 5, '0', STR_PAD_RIGHT );
			}
			$encoded .= self::BASE32_ALPHABET[ bindec( $chunk ) ];
			$position += 5;
		}

		return $encoded;
	}

	/**
	 * Decode a base32-encoded string per RFC 4648.
	 *
	 * @param string $base32 Base32-encoded string (case-insensitive, padding optional).
	 * @return string Raw binary data, or empty string on invalid input.
	 */
	public static function base32_decode( string $base32 ): string {
		// Remove padding and convert to uppercase.
		$base32 = rtrim( strtoupper( $base32 ), '=' );

		if ( $base32 === '' ) {
			return '';
		}

		// Validate characters.
		if ( preg_match( '/[^A-Z2-7]/', $base32 ) ) {
			return '';
		}

		$binary = '';
		$length = strlen( $base32 );
		for ( $i = 0; $i < $length; $i++ ) {
			$index  = strpos( self::BASE32_ALPHABET, $base32[ $i ] );
			$binary .= str_pad( decbin( $index ), 5, '0', STR_PAD_LEFT );
		}

		$decoded   = '';
		$byte_len  = (int) floor( strlen( $binary ) / 8 );

		for ( $i = 0; $i < $byte_len; $i++ ) {
			$byte    = substr( $binary, $i * 8, 8 );
			$decoded .= chr( bindec( $byte ) );
		}

		return $decoded;
	}

	/**
	 * Generate an HOTP code per RFC 4226.
	 *
	 * @param string $key     Raw binary secret key.
	 * @param int    $counter The counter value (time step for TOTP).
	 * @return string The generated OTP code as a zero-padded string.
	 */
	private static function generate_hotp( string $key, int $counter ): string {
		// Pack counter as 8-byte big-endian unsigned integer.
		$counter_bytes = pack( 'N*', 0, $counter );

		// Compute HMAC-SHA1.
		$hash = hash_hmac( 'sha1', $counter_bytes, $key, true );

		// Dynamic truncation per RFC 4226 section 5.4.
		$offset = ord( $hash[19] ) & 0x0F;

		$binary_code = (
			( ( ord( $hash[ $offset ] ) & 0x7F ) << 24 ) |
			( ( ord( $hash[ $offset + 1 ] ) & 0xFF ) << 16 ) |
			( ( ord( $hash[ $offset + 2 ] ) & 0xFF ) << 8 ) |
			( ord( $hash[ $offset + 3 ] ) & 0xFF )
		);

		$otp = $binary_code % ( 10 ** self::CODE_DIGITS );

		return str_pad( (string) $otp, self::CODE_DIGITS, '0', STR_PAD_LEFT );
	}

	/**
	 * Get the current TOTP time step.
	 *
	 * @return int Current time step (floor of Unix time / period).
	 */
	private static function get_current_time_step(): int {
		return (int) floor( time() / self::TIME_STEP );
	}
}
