<?php

namespace KeyLair\TwoFactor\Providers;

use KeyLair\TwoFactor\TwoFactorProviderInterface;

/**
 * TOTP (RFC 6238) via any authenticator app (Microsoft Authenticator,
 * Google Authenticator, Authy, ...). Implemented from scratch (base32 +
 * HMAC-SHA1) to avoid pulling in a Composer dependency for something this
 * small — keeps the install to plain PHP + MySQL + Apache as requested.
 */
class TotpProvider implements TwoFactorProviderInterface
{
    private const DIGITS = 6;
    private const PERIOD = 30;
    private const ALGO = 'sha1';

    public function methodName(): string
    {
        return 'totp';
    }

    public function displayName(): string
    {
        return 'Authenticator app';
    }

    public function generateSetup(string $accountLabel): array
    {
        $secret = $this->generateBase32Secret();

        return [
            'secret' => $secret,
            'setup_data' => $this->buildSetupData($secret, $accountLabel),
        ];
    }

    /**
     * Builds the QR/manual-entry display data for an *existing* secret,
     * e.g. to re-render the setup screen for a pending secret after a wrong
     * confirmation code, without generating (and displaying!) an unrelated
     * new secret that wouldn't match what's actually being verified against.
     */
    public function buildSetupData(string $secret, string $accountLabel): array
    {
        $issuer = \KeyLair\Settings::siteName();

        $otpAuthUri = sprintf(
            'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d',
            rawurlencode($issuer),
            rawurlencode($accountLabel),
            $secret,
            rawurlencode($issuer),
            self::DIGITS,
            self::PERIOD
        );

        // Grouped into blocks of 4 for easier manual entry into an authenticator app.
        $manualEntry = trim(chunk_split($secret, 4, ' '));

        return [
            'otpauth_uri' => $otpAuthUri,
            'manual_entry_key' => $manualEntry,
        ];
    }

    public function verifyCode(string $secret, string $code): bool
    {
        $code = preg_replace('/\s+/', '', $code);
        if (!preg_match('/^\d{6}$/', $code)) {
            return false;
        }

        $timeStep = intdiv(time(), self::PERIOD);

        // Allow +/- 1 step (30s) of clock drift between server and phone.
        for ($offset = -1; $offset <= 1; $offset++) {
            if (hash_equals($this->generateCode($secret, $timeStep + $offset), $code)) {
                return true;
            }
        }

        return false;
    }

    private function generateCode(string $base32Secret, int $timeStep): string
    {
        $key = $this->base32Decode($base32Secret);
        $timeBytes = pack('N*', 0, $timeStep); // 64-bit big-endian counter

        $hash = hash_hmac(self::ALGO, $timeBytes, $key, true);
        $offset = ord($hash[strlen($hash) - 1]) & 0x0F;

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

        $otp = $binary % (10 ** self::DIGITS);

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

    private function generateBase32Secret(int $bytes = 20): string
    {
        return $this->base32Encode(random_bytes($bytes));
    }

    private function base32Encode(string $data): string
    {
        $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $binaryString = '';
        foreach (str_split($data) as $char) {
            $binaryString .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
        }

        $output = '';
        foreach (str_split($binaryString, 5) as $chunk) {
            $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
            $output .= $alphabet[bindec($chunk)];
        }

        return $output;
    }

    private function base32Decode(string $base32): string
    {
        $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
        $base32 = strtoupper(preg_replace('/[^A-Z2-7]/', '', $base32));

        $binaryString = '';
        foreach (str_split($base32) as $char) {
            $pos = strpos($alphabet, $char);
            if ($pos === false) {
                continue;
            }
            $binaryString .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
        }

        $output = '';
        foreach (str_split($binaryString, 8) as $byte) {
            if (strlen($byte) === 8) {
                $output .= chr(bindec($byte));
            }
        }

        return $output;
    }
}
