<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

final class TwoFactor
{
    private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

    public static function generateSecret(int $bytes = 20): string
    {
        return self::base32Encode(self::randomBytes(max(10, min(64, $bytes))));
    }

    public static function otpauthUri(string $issuer, string $account, string $secret): string
    {
        $issuer = self::cleanLabel($issuer !== '' ? $issuer : 'SHIELD-X');
        $account = self::cleanLabel($account !== '' ? $account : 'WordPress');

        return 'otpauth://totp/' . rawurlencode($issuer . ':' . $account)
            . '?secret=' . rawurlencode(self::normalizeSecret($secret))
            . '&issuer=' . rawurlencode($issuer)
            . '&algorithm=SHA1&digits=6&period=30';
    }

    public static function totp(string $secret, ?int $time = null, int $period = 30, int $digits = 6): string
    {
        $time = $time ?? time();
        $counter = intdiv(max(0, $time), max(1, $period));

        return self::hotp($secret, $counter, $digits);
    }

    public static function verifyTotp(string $secret, string $code, ?int $time = null, int $window = 1): bool
    {
        return self::matchingTotpCounter($secret, $code, $time, $window) !== null;
    }

    public static function verifyTotpOnce(string $secret, string $code, string $scope, ?int $time = null, int $window = 1): bool
    {
        $counter = self::matchingTotpCounter($secret, $code, $time, $window);
        if ($counter === null) {
            return false;
        }

        if (!function_exists('get_transient') || !function_exists('set_transient')) {
            return false;
        }

        $fingerprint = hash('sha256', self::normalizeSecret($secret) . '|' . preg_replace('/\D+/', '', $code) . '|' . (string) $counter);
        $key = 'shieldx_totp_used_' . hash('sha256', $scope . '|' . $fingerprint);

        return self::claimTotpReplayGuard($key);
    }

    private static function claimTotpReplayGuard(string $key): bool
    {
        if (!function_exists('get_transient') || !function_exists('set_transient') || !function_exists('add_option') || !function_exists('get_option') || !function_exists('delete_option')) {
            return false;
        }

        $lockKey = 'shieldx_totp_lock_' . hash('sha256', $key);
        $lockToken = hash('sha256', $lockKey . '|' . microtime(true) . '|' . (string) wp_rand());

        for ($attempt = 0; $attempt < 5; $attempt++) {
            $lock = [
                'token' => $lockToken,
                'until' => time() + 5,
            ];

            if (add_option($lockKey, $lock, '', false)) {
                try {
                    if (get_transient($key) !== false) {
                        return false;
                    }

                    return (bool) set_transient($key, 1, 90);
                } finally {
                    $existing = get_option($lockKey, []);
                    if (is_array($existing) && isset($existing['token']) && is_string($existing['token']) && hash_equals($lockToken, $existing['token'])) {
                        delete_option($lockKey);
                    }
                }
            }

            $existing = get_option($lockKey, []);
            $until = is_array($existing) && isset($existing['until']) && is_numeric($existing['until']) ? (int) $existing['until'] : 0;
            if ($until > 0 && $until <= time()) {
                delete_option($lockKey);
                continue;
            }

            usleep(10000);
        }

        return false;
    }

    private static function matchingTotpCounter(string $secret, string $code, ?int $time = null, int $window = 1): ?int
    {
        $code = preg_replace('/\D+/', '', $code) ?: '';

        if ($code === '') {
            return null;
        }

        $time = $time ?? time();

        for ($offset = -$window; $offset <= $window; $offset++) {
            $candidateTime = $time + ($offset * 30);

            if (hash_equals(self::totp($secret, $candidateTime), $code)) {
                return intdiv(max(0, $candidateTime), 30);
            }
        }

        return null;
    }

    public static function hotp(string $secret, int $counter, int $digits = 6): string
    {
        $key = self::base32Decode($secret);
        if ($key === '') {
            return '';
        }

        $counter = max(0, $counter);
        $binaryCounter = pack('N2', intdiv($counter, 4294967296), $counter & 0xffffffff);
        $hash = hash_hmac('sha1', $binaryCounter, $key, true);
        $offset = ord($hash[19]) & 0x0f;
        $value = ((ord($hash[$offset]) & 0x7f) << 24)
            | ((ord($hash[$offset + 1]) & 0xff) << 16)
            | ((ord($hash[$offset + 2]) & 0xff) << 8)
            | (ord($hash[$offset + 3]) & 0xff);
        $modulo = 10 ** max(6, min(8, $digits));

        return str_pad((string) ($value % $modulo), max(6, min(8, $digits)), '0', STR_PAD_LEFT);
    }

    /**
     * @return array<int,string>
     */
    public static function generateRecoveryCodes(int $count = 10): array
    {
        $codes = [];

        for ($index = 0; $index < max(1, min(20, $count)); $index++) {
            $raw = substr(self::base32Encode(self::randomBytes(10)), 0, 16);
            $codes[] = implode('-', str_split($raw, 4));
        }

        return $codes;
    }

    public static function normalizeRecoveryCode(string $code): string
    {
        return strtoupper(preg_replace('/[^A-Z0-9]+/i', '', $code) ?: '');
    }

    public static function hashRecoveryCode(string $code): string
    {
        return hash_hmac('sha256', self::normalizeRecoveryCode($code), self::hashSalt());
    }

    public static function verifyRecoveryCode(string $code, string $hash): bool
    {
        $normalized = self::normalizeRecoveryCode($code);

        return $normalized !== '' && hash_equals($hash, self::hashRecoveryCode($normalized));
    }

    public static function randomToken(int $bytes = 32): string
    {
        return bin2hex(self::randomBytes(max(16, min(64, $bytes))));
    }

    public static function base32Encode(string $binary): string
    {
        $bits = '';
        $encoded = '';
        $alphabet = self::BASE32_ALPHABET;

        for ($index = 0, $length = strlen($binary); $index < $length; $index++) {
            $bits .= str_pad(decbin(ord($binary[$index])), 8, '0', STR_PAD_LEFT);
        }

        for ($index = 0, $length = strlen($bits); $index < $length; $index += 5) {
            $chunk = substr($bits, $index, 5);

            if (strlen($chunk) < 5) {
                $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
            }

            $encoded .= $alphabet[bindec($chunk)];
        }

        return $encoded;
    }

    public static function base32Decode(string $encoded): string
    {
        $encoded = self::normalizeSecret($encoded);
        $remainder = strlen($encoded) % 8;
        if ($encoded === '' || in_array($remainder, [1, 3, 6], true)) {
            return '';
        }

        $alphabet = self::BASE32_ALPHABET;
        $bits = '';
        $binary = '';

        for ($index = 0, $length = strlen($encoded); $index < $length; $index++) {
            $position = strpos($alphabet, $encoded[$index]);

            if ($position === false) {
                return '';
            }

            $bits .= str_pad(decbin($position), 5, '0', STR_PAD_LEFT);
        }

        for ($index = 0, $length = strlen($bits); $index + 8 <= $length; $index += 8) {
            $binary .= chr(bindec(substr($bits, $index, 8)));
        }

        return $binary;
    }

    public static function normalizeSecret(string $secret): string
    {
        // Users often paste a grouped manual key. Permit grouping whitespace
        // and hyphens, but reject every other character instead of silently
        // changing a malformed secret into a different valid one.
        $secret = strtoupper(trim($secret));
        $secret = preg_replace('/[\s-]+/', '', $secret) ?: '';

        return preg_match('/^[A-Z2-7]+$/', $secret) === 1 ? $secret : '';
    }

    public static function randomBytes(int $bytes): string
    {
        if (function_exists('random_bytes')) {
            try {
                return random_bytes($bytes);
            } catch (\Throwable $error) {
                // Fall through to OpenSSL below.
            }
        }

        if (function_exists('openssl_random_pseudo_bytes')) {
            $strong = false;
            $random = openssl_random_pseudo_bytes($bytes, $strong);

            if ($strong === true && is_string($random) && strlen($random) === $bytes) {
                return $random;
            }
        }

        throw new \RuntimeException('Secure randomness is required for SHIELD-X 2FA secrets and recovery tokens.');
    }

    private static function hashSalt(): string
    {
        $salt = function_exists('wp_salt') ? (string) wp_salt('auth') : '';
        if ($salt === '') {
            throw new \RuntimeException('WordPress authentication salts are required for SHIELD-X recovery-code hashes.');
        }

        return $salt;
    }

    private static function cleanLabel(string $label): string
    {
        $label = function_exists('wp_strip_all_tags')
            ? wp_strip_all_tags($label)
            : (preg_replace('/<[^>]*>/', '', $label) ?: '');
        $label = trim($label);
        $label = preg_replace('/[\x00-\x1F\x7F:]+/', ' ', $label) ?: '';

        return trim($label);
    }
}
