<?php

namespace App\Support;

/**
 * Pure-PHP RFC 6238 TOTP implementation.
 *
 * Uses HMAC-SHA1, 30-second window, 6-digit codes, base32 shared secret.
 * Compatible with Google Authenticator, Authy, 1Password, Microsoft Authenticator.
 */
class Totp
{
    private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

    /**
     * Generate a fresh 16-character base32 secret (uppercase A-Z, 2-7).
     */
    public static function generateSecret(): string
    {
        $out = '';
        for ($i = 0; $i < 16; $i++) {
            $out .= self::ALPHABET[random_int(0, 31)];
        }

        return $out;
    }

    /**
     * Verify a user-supplied 6-digit code against the current 30-second window,
     * allowing ±$window steps of clock skew.
     */
    public static function verify(string $secret, string $code, int $window = 1): bool
    {
        $code = preg_replace('/\s+/', '', $code);
        if (! preg_match('/^\d{6}$/', $code)) {
            return false;
        }

        $timeStep = (int) floor(time() / 30);
        for ($i = -$window; $i <= $window; $i++) {
            if (hash_equals(self::hotp($secret, $timeStep + $i), $code)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build an otpauth:// URL for provisioning apps via QR code.
     */
    public static function otpauthUrl(string $secret, string $account, string $issuer): string
    {
        $label = rawurlencode($issuer) . ':' . rawurlencode($account);
        $query = http_build_query([
            'secret' => $secret,
            'issuer' => $issuer,
            'algorithm' => 'SHA1',
            'digits' => 6,
            'period' => 30,
        ]);

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

    /**
     * RFC 4226 HOTP: 6-digit code derived from an 8-byte counter under HMAC-SHA1.
     */
    private static function hotp(string $secret, int $counter): string
    {
        $key = self::base32Decode($secret);
        // Pack counter as 8-byte big-endian.
        $binCounter = pack('N*', 0, $counter);
        $hash = hash_hmac('sha1', $binCounter, $key, 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);
        $code = $truncated % 1000000;

        return str_pad((string) $code, 6, '0', STR_PAD_LEFT);
    }

    /**
     * Base32 decode per RFC 4648. Tolerates missing padding and lower case.
     */
    private static function base32Decode(string $input): string
    {
        $input = strtoupper(preg_replace('/\s+/', '', $input));
        // Strip padding for simpler processing; we recompute expected length.
        $input = rtrim($input, '=');
        if ($input === '') {
            return '';
        }

        $bits = '';
        $map = array_flip(str_split(self::ALPHABET));
        $len = strlen($input);
        for ($i = 0; $i < $len; $i++) {
            $ch = $input[$i];
            if (! isset($map[$ch])) {
                // Invalid character — treat as zero to avoid throwing here; verify() will fail anyway.
                return '';
            }
            $bits .= str_pad(decbin($map[$ch]), 5, '0', STR_PAD_LEFT);
        }

        $out = '';
        $chunks = str_split($bits, 8);
        foreach ($chunks as $chunk) {
            if (strlen($chunk) === 8) {
                $out .= chr(bindec($chunk));
            }
        }

        return $out;
    }
}
