<?php
declare(strict_types=1);

/**
 * Pure PHP TOTP implementation (RFC 6238 / HOTP RFC 4226).
 * No external dependencies.
 */
class TOTP
{
    private const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    private const DIGITS       = 6;
    private const PERIOD       = 30; // seconds

    /** Generate a random Base32-encoded secret. */
    public static function generateSecret(int $bytes = 20): string
    {
        return self::base32Encode(random_bytes($bytes));
    }

    /** Get the current TOTP code for a secret. */
    public static function getCode(string $secret, ?int $time = null): string
    {
        $time    ??= time();
        $counter = (int)floor($time / self::PERIOD);
        return self::hotp($secret, $counter);
    }

    /**
     * Verify a code, allowing ±$window time steps for clock drift.
     * Returns true on match.
     */
    public static function verify(string $secret, string $code, int $window = 1): bool
    {
        return self::verifyAndGetCounter($secret, $code, $window) !== null;
    }

    /**
     * Verify a code and return the matched counter value (for replay protection).
     * Returns null on failure. Reject codes whose counter is <= $minCounter to
     * prevent replay within the validity window.
     */
    public static function verifyAndGetCounter(string $secret, string $code, int $window = 1, int $minCounter = 0): ?int
    {
        $code = trim($code);
        if (!preg_match('/^\d{6}$/', $code)) {
            return null;
        }
        $counter = (int)floor(time() / self::PERIOD);
        for ($i = -$window; $i <= $window; $i++) {
            $c = $counter + $i;
            if ($c <= $minCounter) continue;
            if (hash_equals(self::hotp($secret, $c), $code)) {
                return $c;
            }
        }
        return null;
    }

    /** Build the otpauth:// URI for QR code generation. */
    public static function getOtpAuthUrl(string $secret, string $accountName, string $issuer): string
    {
        return 'otpauth://totp/'
            . rawurlencode($issuer . ':' . $accountName)
            . '?secret='  . rawurlencode($secret)
            . '&issuer='  . rawurlencode($issuer)
            . '&algorithm=SHA1'
            . '&digits='  . self::DIGITS
            . '&period='  . self::PERIOD;
    }

    // ── Internal ──────────────────────────────────────────────────────────────

    private static function hotp(string $secret, int $counter): string
    {
        $key  = self::base32Decode($secret);
        $msg  = pack('J', $counter);           // 8 bytes, big-endian unsigned 64-bit
        $hash = hash_hmac('sha1', $msg, $key, true);

        $offset = ord($hash[19]) & 0x0F;
        $code   = (
            ((ord($hash[$offset])     & 0x7F) << 24) |
            ((ord($hash[$offset + 1]) & 0xFF) << 16) |
            ((ord($hash[$offset + 2]) & 0xFF) <<  8) |
             (ord($hash[$offset + 3]) & 0xFF)
        ) % (10 ** self::DIGITS);

        return str_pad((string)$code, self::DIGITS, '0', STR_PAD_LEFT);
    }

    private static function base32Encode(string $data): string
    {
        $chars    = self::BASE32_CHARS;
        $result   = '';
        $buffer   = 0;
        $bitsLeft = 0;

        foreach (str_split($data) as $byte) {
            $buffer   = ($buffer << 8) | ord($byte);
            $bitsLeft += 8;
            while ($bitsLeft >= 5) {
                $bitsLeft -= 5;
                $result   .= $chars[($buffer >> $bitsLeft) & 0x1F];
            }
        }
        if ($bitsLeft > 0) {
            $result .= $chars[($buffer << (5 - $bitsLeft)) & 0x1F];
        }
        return $result;
    }

    private static function base32Decode(string $data): string
    {
        $chars    = self::BASE32_CHARS;
        $data     = strtoupper(str_replace([' ', '-'], '', $data));
        $result   = '';
        $buffer   = 0;
        $bitsLeft = 0;

        foreach (str_split($data) as $char) {
            $val = strpos($chars, $char);
            if ($val === false) {
                continue;
            }
            $buffer   = ($buffer << 5) | $val;
            $bitsLeft += 5;
            if ($bitsLeft >= 8) {
                $bitsLeft -= 8;
                $result   .= chr(($buffer >> $bitsLeft) & 0xFF);
            }
        }
        return $result;
    }
}
