<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class TotpService
{
    private const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

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

    public function code(string $secret, ?int $timestamp = null): string
    {
        $counter = intdiv($timestamp ?? time(), 30);
        $key = $this->base32Decode($secret);
        $binaryCounter = pack('N2', 0, $counter);
        $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);

        return str_pad((string) ($value % 1000000), 6, '0', STR_PAD_LEFT);
    }

    public function verify(string $secret, string $code, int $window = 1, ?int $timestamp = null): bool
    {
        $candidate = preg_replace('/\D/', '', $code) ?? '';
        if (strlen($candidate) !== 6) {
            return false;
        }

        $now = $timestamp ?? time();
        for ($step = -$window; $step <= $window; $step++) {
            if (hash_equals($this->code($secret, $now + ($step * 30)), $candidate)) {
                return true;
            }
        }

        return false;
    }

    public function provisioningUri(User $user, string $secret): string
    {
        $issuer = (string) config('app.name', 'Microfinance');
        $identity = $user->username ?: $user->email ?: ('user-'.$user->getKey());
        $label = rawurlencode($issuer.':'.$identity);

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

    /** @return array{plain: array<int,string>, hashes: array<int,string>} */
    public function generateRecoveryCodes(int $count = 8): array
    {
        $plain = [];
        for ($i = 0; $i < $count; $i++) {
            $plain[] = Str::upper(Str::random(4).'-'.Str::random(4).'-'.Str::random(4));
        }

        return [
            'plain' => $plain,
            'hashes' => array_map(fn (string $code): string => $this->recoveryHash($code), $plain),
        ];
    }

    public function consumeRecoveryCode(User $user, string $code): bool
    {
        $candidate = $this->recoveryHash($code);
        $consumed = DB::transaction(function () use ($user, $candidate): bool {
            $locked = User::query()->lockForUpdate()->find($user->getKey());
            if (! $locked) {
                return false;
            }

            $hashes = array_values($locked->two_factor_recovery_codes ?? []);
            foreach ($hashes as $index => $hash) {
                if (is_string($hash) && hash_equals($hash, $candidate)) {
                    unset($hashes[$index]);
                    $locked->forceFill(['two_factor_recovery_codes' => array_values($hashes)])->save();

                    return true;
                }
            }

            return false;
        });

        if ($consumed) {
            $user->refresh();
        }

        return $consumed;
    }

    private function recoveryHash(string $code): string
    {
        $normalized = Str::upper(preg_replace('/[^A-Z0-9]/i', '', $code) ?? '');

        return hash('sha256', $normalized);
    }

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

        $encoded = '';
        foreach (str_split($bits, 5) as $chunk) {
            $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
            $encoded .= self::ALPHABET[bindec($chunk)];
        }

        return $encoded;
    }

    private function base32Decode(string $secret): string
    {
        $normalized = Str::upper(preg_replace('/[^A-Z2-7]/i', '', $secret) ?? '');
        if ($normalized === '') {
            throw new \InvalidArgumentException('Invalid TOTP secret.');
        }

        $bits = '';
        foreach (str_split($normalized) as $char) {
            $position = strpos(self::ALPHABET, $char);
            if ($position === false) {
                throw new \InvalidArgumentException('Invalid TOTP secret.');
            }
            $bits .= str_pad(decbin($position), 5, '0', STR_PAD_LEFT);
        }

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

        return $decoded;
    }
}
