<?php

declare(strict_types=1);

namespace App\Services;

use App\Core\Database;

/**
 * TwoFactorService
 *
 * "Two Factor Authentication Ready" per the Phase 2 brief: this is a
 * complete, working TOTP (RFC 6238) implementation — secret issuance,
 * a standard otpauth:// provisioning URI any authenticator app (Google
 * Authenticator, Authy, 1Password...) can scan, code verification with
 * clock-drift tolerance, and single-use recovery codes — but it is NOT
 * force-enabled for any role by default (see
 * config('security.two_factor.required_for_roles')). Enabling it is a
 * per-user opt-in via the Profile page (Phase 2's ProfileController).
 *
 * Deliberately dependency-free: implements RFC 6238 TOTP directly with
 * hash_hmac() rather than pulling in a Composer package for something
 * this small and security-sensitive to get exactly right ourselves.
 */
final class TwoFactorService
{
    private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

    public function generateSecret(int $length = 20): string
    {
        return self::base32Encode(random_bytes($length));
    }

    public function provisioningUri(string $secret, string $accountEmail): string
    {
        $config = config('security.two_factor');
        $issuer = rawurlencode($config['issuer']);
        $label = rawurlencode("{$config['issuer']}:{$accountEmail}");

        return "otpauth://totp/{$label}?secret={$secret}&issuer={$issuer}"
            . "&digits={$config['digits']}&period={$config['period_seconds']}";
    }

    /** Verifies a 6-digit code, tolerating ±1 time step (30s) of clock drift. */
    public function verify(string $secret, string $code): bool
    {
        $config = config('security.two_factor');
        $code = preg_replace('/\s+/', '', $code) ?? '';

        for ($drift = -1; $drift <= 1; $drift++) {
            $timeStep = (int) floor(time() / $config['period_seconds']) + $drift;
            if (hash_equals($this->generateCode($secret, $timeStep), $code)) {
                return true;
            }
        }

        return false;
    }

    private function generateCode(string $secret, int $timeStep): string
    {
        $config = config('security.two_factor');
        $key = self::base32Decode($secret);
        $timeBytes = pack('N*', 0, $timeStep); // 8-byte big-endian counter

        $hash = hash_hmac('sha1', $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);

        $code = $binary % (10 ** $config['digits']);

        return str_pad((string) $code, $config['digits'], '0', STR_PAD_LEFT);
    }

    /** @return string[] Plaintext recovery codes — show these to the user ONCE, only hashes are stored. */
    public function generateRecoveryCodes(int $userId): array
    {
        $count = config('security.two_factor.recovery_codes_count');
        $pdo = Database::connection();

        $pdo->prepare('DELETE FROM two_factor_recovery_codes WHERE user_id = :id')->execute(['id' => $userId]);

        $stmt = $pdo->prepare(
            'INSERT INTO two_factor_recovery_codes (user_id, code_hash, created_at) VALUES (:id, :hash, NOW())'
        );

        $codes = [];
        for ($i = 0; $i < $count; $i++) {
            $code = strtoupper(bin2hex(random_bytes(4))) . '-' . strtoupper(bin2hex(random_bytes(4)));
            $codes[] = $code;
            $stmt->execute(['id' => $userId, 'hash' => hash('sha256', $code)]);
        }

        return $codes;
    }

    public function verifyAndConsumeRecoveryCode(int $userId, string $code): bool
    {
        $pdo = Database::connection();
        $hash = hash('sha256', strtoupper(trim($code)));

        $stmt = $pdo->prepare(
            'SELECT id FROM two_factor_recovery_codes WHERE user_id = :id AND code_hash = :hash AND used_at IS NULL LIMIT 1'
        );
        $stmt->execute(['id' => $userId, 'hash' => $hash]);
        $row = $stmt->fetch();

        if ($row === false) {
            return false;
        }

        $pdo->prepare('UPDATE two_factor_recovery_codes SET used_at = NOW() WHERE id = :id')
            ->execute(['id' => $row['id']]);

        return true;
    }

    public function enable(int $userId, string $secret): void
    {
        Database::connection()
            ->prepare('UPDATE users SET two_factor_enabled = 1, two_factor_secret = :secret WHERE id = :id')
            ->execute(['secret' => $secret, 'id' => $userId]);
    }

    public function disable(int $userId): void
    {
        $pdo = Database::connection();
        $pdo->prepare('UPDATE users SET two_factor_enabled = 0, two_factor_secret = NULL WHERE id = :id')
            ->execute(['id' => $userId]);
        $pdo->prepare('DELETE FROM two_factor_recovery_codes WHERE user_id = :id')->execute(['id' => $userId]);
    }

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

        $output = '';
        foreach (str_split($bits, 5) as $chunk) {
            if (strlen($chunk) < 5) {
                $chunk = str_pad($chunk, 5, '0');
            }
            $output .= self::BASE32_ALPHABET[bindec($chunk)];
        }

        return $output;
    }

    private static function base32Decode(string $encoded): string
    {
        $encoded = strtoupper(rtrim($encoded, '='));
        $bits = '';

        foreach (str_split($encoded) as $char) {
            $pos = strpos(self::BASE32_ALPHABET, $char);
            if ($pos === false) {
                continue;
            }
            $bits .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
        }

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

        return $binary;
    }
}
