<?php

namespace App\Services;

/**
 * TOTP (RFC 6238) two-factor authentication service.
 *
 * Pure PHP implementation – no external dependencies.
 * Generates and verifies time-based one-time passwords using HMAC-SHA1.
 */
class TotpService
{
    /** Time step in seconds. */
    private const PERIOD = 30;

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

    /** HMAC algorithm. */
    private const ALGORITHM = 'sha1';

    /** Base32 alphabet (RFC 4648). */
    private const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

    // ─────────────────────────────────────────────────────────────────────────
    // Secret generation
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Generate a cryptographically secure random secret (base32-encoded).
     *
     * @param int $byteLength Raw byte length (default 20 → 160-bit key)
     * @return string Base32-encoded secret (32 chars for 20 bytes)
     */
    public static function generateSecret(int $byteLength = 20): string
    {
        return self::base32Encode(random_bytes($byteLength));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Code generation & verification
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Generate the current TOTP code for the given secret.
     */
    public static function getCode(string $secret, ?int $timestamp = null): string
    {
        $timestamp = $timestamp ?? time();
        $counter   = intdiv($timestamp, self::PERIOD);
        $key       = self::base32Decode($secret);

        // Pack counter as 8-byte big-endian unsigned integer
        $msg = pack('N*', 0, $counter);

        $hash = hash_hmac(self::ALGORITHM, $msg, $key, true);

        // Dynamic truncation (RFC 4226 §5.4)
        $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);
    }

    /**
     * Verify a TOTP code against the secret.
     *
     * Allows ±$window time steps to account for clock skew.
     */
    public static function verify(string $secret, string $code, int $window = 1): bool
    {
        $code = trim($code);
        if (strlen($code) !== self::DIGITS || !ctype_digit($code)) {
            return false;
        }

        $now = time();
        for ($i = -$window; $i <= $window; $i++) {
            $checkTime = $now + ($i * self::PERIOD);
            if (hash_equals(self::getCode($secret, $checkTime), $code)) {
                return true;
            }
        }
        return false;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // OTP Auth URI
    // ─────────────────────────────────────────────────────────────────────────

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

    // ─────────────────────────────────────────────────────────────────────────
    // Recovery codes
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Generate a set of single-use recovery codes.
     *
     * @return string[] Array of recovery codes (format: XXXX-XXXX)
     */
    public static function generateRecoveryCodes(int $count = 10): array
    {
        $codes = [];
        for ($i = 0; $i < $count; $i++) {
            $codes[] = strtoupper(bin2hex(random_bytes(4)))
                     . '-'
                     . strtoupper(bin2hex(random_bytes(4)));
        }
        return $codes;
    }

    /**
     * Verify a recovery code against the stored list.
     *
     * Returns the remaining codes (with the matched one removed), or null if invalid.
     *
     * @param string   $code        Code entered by user
     * @param string[] $storedCodes All stored recovery codes
     * @return string[]|null        Remaining codes on success, null on failure
     */
    public static function useRecoveryCode(string $code, array $storedCodes): ?array
    {
        $code = strtoupper(trim($code));
        foreach ($storedCodes as $i => $stored) {
            if (hash_equals(strtoupper($stored), $code)) {
                unset($storedCodes[$i]);
                return array_values($storedCodes);
            }
        }
        return null;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Base32 encoding/decoding (RFC 4648)
    // ─────────────────────────────────────────────────────────────────────────

    public static function base32Encode(string $data): string
    {
        $binary = '';
        foreach (str_split($data) as $char) {
            $binary .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
        }

        $result = '';
        foreach (str_split($binary, 5) as $chunk) {
            $chunk   = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
            $result .= self::BASE32_CHARS[bindec($chunk)];
        }

        return $result;
    }

    public static function base32Decode(string $data): string
    {
        $data   = strtoupper(rtrim($data, '='));
        $binary = '';

        for ($i = 0, $len = strlen($data); $i < $len; $i++) {
            $pos = strpos(self::BASE32_CHARS, $data[$i]);
            if ($pos === false) {
                continue; // skip invalid chars
            }
            $binary .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
        }

        $result = '';
        foreach (str_split($binary, 8) as $byte) {
            if (strlen($byte) < 8) {
                break;
            }
            $result .= chr(bindec($byte));
        }

        return $result;
    }
}
