<?php
/**
 * TOTP helper for authenticator apps (RFC 6238).
 *
 * @package TwoFactorAuthentication
 * @since 1.2.0
 */

namespace TwoFactorAuthentication;

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

final class TOTP {

	private const PERIOD = 30;
	private const DIGITS = 6;

	/**
	 * Generate a random Base32 secret (16 chars = 80 bits).
	 */
	public static function generateSecret(): string {
		$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
		$secret = '';
		for ( $i = 0; $i < 16; $i++ ) {
			$secret .= $chars[ random_int( 0, 31 ) ];
		}
		return $secret;
	}

	/**
	 * Get provisioning URI for QR code (otpauth://totp/...).
	 */
	public static function getProvisioningUri( string $secret, string $account_name, string $issuer ): string {
		$params = [
			'secret'   => $secret,
			'issuer'   => $issuer,
			'algorithm' => 'SHA1',
			'digits'   => self::DIGITS,
			'period'   => self::PERIOD,
		];
		$query = http_build_query( $params );
		$label = rawurlencode( $issuer ) . ':' . rawurlencode( $account_name );
		return 'otpauth://totp/' . $label . '?' . $query;
	}

	/**
	 * Verify a 6-digit TOTP code against the secret.
	 * Allows ±1 time step (90 second window).
	 */
	public static function verify( string $secret, string $code ): bool {
		return self::verifyAndGetTimeStep( $secret, $code ) !== null;
	}

	/**
	 * Verify TOTP and return the matched time step (for replay protection), or null.
	 */
	public static function verifyAndGetTimeStep( string $secret, string $code ): ?int {
		$code = preg_replace( '/\D/', '', $code );
		if ( strlen( $code ) !== self::DIGITS ) {
			return null;
		}
		$time = (int) floor( time() / self::PERIOD );
		for ( $i = -1; $i <= 1; $i++ ) {
			$step     = $time + $i;
			$expected = self::getCode( $secret, $step );
			if ( hash_equals( $expected, $code ) ) {
				return $step;
			}
		}
		return null;
	}

	/**
	 * Get current TOTP code for the secret (for testing).
	 */
	public static function getCode( string $secret, ?int $timestamp = null ): string {
		$time = $timestamp ?? (int) floor( time() / self::PERIOD );
		$secret_bin = self::base32Decode( $secret );
		if ( $secret_bin === false ) {
			return '';
		}
		$time_bytes = pack( 'N*', 0 ) . pack( 'N*', $time );
		$hash = hash_hmac( 'sha1', $time_bytes, $secret_bin, true );
		$offset = ord( $hash[ strlen( $hash ) - 1 ] ) & 0x0f;
		$truncated = (
			( ( ord( $hash[ $offset ] ) & 0x7f ) << 24 ) |
			( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) |
			( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) |
			( ord( $hash[ $offset + 3 ] ) & 0xff )
		);
		return str_pad( (string) ( $truncated % ( 10 ** self::DIGITS ) ), self::DIGITS, '0', STR_PAD_LEFT );
	}

	/**
	 * Base32 decode (RFC 4648).
	 */
	private static function base32Decode( string $input ): string|false {
		$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
		$input = strtoupper( preg_replace( '/\s/', '', $input ) );
		$buffer = 0;
		$bits = 0;
		$output = '';
		for ( $i = 0; $i < strlen( $input ); $i++ ) {
			$pos = strpos( $chars, $input[ $i ] );
			if ( $pos === false ) {
				return false;
			}
			$buffer = ( $buffer << 5 ) | $pos;
			$bits += 5;
			if ( $bits >= 8 ) {
				$bits -= 8;
				$output .= chr( ( $buffer >> $bits ) & 0xff );
			}
		}
		return $output;
	}

	/**
	 * Generate backup codes (8-digit alphanumeric).
	 */
	public static function generateBackupCodes( int $count = 10 ): array {
		$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
		$codes = [];
		while ( count( $codes ) < $count ) {
			$code = '';
			for ( $j = 0; $j < 8; $j++ ) {
				$code .= $chars[ random_int( 0, strlen( $chars ) - 1 ) ];
			}
			$codes[ $code ] = true;
		}
		return array_keys( $codes );
	}
}
