<?php
// =============================================================
//  include/Totp.php
//
//  RFC 6238 TOTP (HMAC-SHA1, 30s step, 6 digits) — hand-rolled rather
//  than a Composer dependency, consistent with this codebase's existing
//  "no external package for something this self-contained" convention
//  (see include/env_loader.php). The HMAC-truncation algorithm here is
//  verified against the official RFC 4226 Appendix D test vectors in
//  tests/TotpTest.php — not just "looks right."
//
//  Compatible with Google Authenticator, Authy, Microsoft Authenticator,
//  and any other standard TOTP app (they all implement the same RFC).
// =============================================================

final class Totp
{
    private const DIGITS = 6;
    private const PERIOD = 30;
    private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

    /** A fresh random secret, base32-encoded (the form TOTP apps expect). */
    public static function generateSecret(int $bytes = 20): string
    {
        return self::base32Encode(random_bytes($bytes));
    }

    /** The otpauth:// URI a QR code / authenticator app enrolls from. */
    public static function getOtpAuthUri(string $secret, string $accountLabel, string $issuer = 'STH NRMS'): string
    {
        $label  = rawurlencode($issuer . ':' . $accountLabel);
        $params = http_build_query([
            'secret'    => $secret,
            'issuer'    => $issuer,
            'algorithm' => 'SHA1',
            'digits'    => self::DIGITS,
            'period'    => self::PERIOD,
        ]);
        return "otpauth://totp/{$label}?{$params}";
    }

    /**
     * Verifies a 6-digit code, tolerating +/- $window steps of clock drift
     * (default 1 step = +/-30s, so a code is valid for up to 90s total).
     */
    public static function verifyCode(string $secret, string $code, int $window = 1): bool
    {
        $code = trim($code);
        if (!preg_match('/^\d{6}$/', $code)) return false;

        $timestep = (int)floor(time() / self::PERIOD);
        for ($i = -$window; $i <= $window; $i++) {
            if (hash_equals(self::generateCode($secret, $timestep + $i), $code)) {
                return true;
            }
        }
        return false;
    }

    /** Exposed for testing against RFC vectors — not part of the normal enroll/verify flow. */
    public static function generateCode(string $secret, int $counter): string
    {
        $key       = self::base32Decode($secret);
        $time      = pack('N*', 0) . pack('N*', $counter); // 8-byte big-endian counter
        $hash      = hash_hmac('sha1', $time, $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 % (10 ** self::DIGITS);
        return str_pad((string)$code, self::DIGITS, '0', STR_PAD_LEFT);
    }

    /** Generates $count recovery codes (plaintext, shown once) — format XXXX-XXXX. */
    public static function generateRecoveryCodes(int $count = 8): array
    {
        $codes = [];
        for ($i = 0; $i < $count; $i++) {
            $codes[] = substr(bin2hex(random_bytes(4)), 0, 4) . '-' . substr(bin2hex(random_bytes(4)), 0, 4);
        }
        return $codes;
    }

    public static function base32Encode(string $data): string
    {
        $binaryString = '';
        foreach (str_split($data) as $char) {
            $binaryString .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
        }
        $encoded = '';
        foreach (str_split($binaryString, 5) as $chunk) {
            if (strlen($chunk) < 5) $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
            $encoded .= self::BASE32_ALPHABET[bindec($chunk)];
        }
        return $encoded;
    }

    public static function base32Decode(string $data): string
    {
        $data = strtoupper(rtrim($data, '='));
        $binaryString = '';
        foreach (str_split($data) as $char) {
            $pos = strpos(self::BASE32_ALPHABET, $char);
            if ($pos === false) continue;
            $binaryString .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
        }
        $bytes = '';
        foreach (str_split($binaryString, 8) as $byte) {
            if (strlen($byte) === 8) $bytes .= chr(bindec($byte));
        }
        return $bytes;
    }

    /**
     * True once scripts/add_2fa_columns.sql has actually been run against
     * this database. Checked defensively (not assumed) so login.php never
     * breaks for every staff member just because the code deployed before
     * the migration did — same class of ordering hazard that broke
     * db_connection.php earlier when .env shipped before the server had it.
     * Cached per-request; the schema doesn't change mid-request.
     */
    public static function columnsExist(mysqli $conn): bool
    {
        static $exists = null;
        if ($exists === null) {
            $r = $conn->query("SHOW COLUMNS FROM `users` LIKE 'totp_enabled'");
            $exists = $r && $r->num_rows > 0;
        }
        return $exists;
    }

    public static function isEnabledFor(mysqli $conn, int $userId): bool
    {
        if (!self::columnsExist($conn)) return false;
        $stmt = $conn->prepare('SELECT totp_enabled FROM users WHERE id = ?');
        $stmt->bind_param('i', $userId);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        return !empty($row['totp_enabled']);
    }

    /** Fetches the secret only when actually verifying — never pulled into general account lookups. */
    public static function getSecretFor(mysqli $conn, int $userId): ?string
    {
        if (!self::columnsExist($conn)) return null;
        $stmt = $conn->prepare('SELECT totp_secret FROM users WHERE id = ? AND totp_enabled = 1');
        $stmt->bind_param('i', $userId);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        return $row['totp_secret'] ?? null;
    }

    /** @return string[]|null Recovery code hashes (password_hash'd), or null if none/not enrolled. */
    public static function getRecoveryHashesFor(mysqli $conn, int $userId): ?array
    {
        if (!self::columnsExist($conn)) return null;
        $stmt = $conn->prepare('SELECT totp_recovery_codes FROM users WHERE id = ?');
        $stmt->bind_param('i', $userId);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        if (empty($row['totp_recovery_codes'])) return null;
        $decoded = json_decode($row['totp_recovery_codes'], true);
        return is_array($decoded) ? $decoded : null;
    }

    /** Removes one consumed recovery code hash from the stored set. */
    public static function consumeRecoveryHash(mysqli $conn, int $userId, string $usedHash): void
    {
        $hashes = self::getRecoveryHashesFor($conn, $userId) ?? [];
        $remaining = array_values(array_filter($hashes, fn($h) => $h !== $usedHash));
        $stmt = $conn->prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?');
        $json = json_encode($remaining);
        $stmt->bind_param('si', $json, $userId);
        $stmt->execute();
        $stmt->close();
    }
}
