<?php

declare(strict_types=1);

namespace App\Services\Auth;

use App\Enums\AuthEventType;
use App\Models\User;
use App\Services\Audit\AuditEvent;
use App\Services\Audit\AuditRecorder;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Google2FA;

/**
 * Time-based one-time password enrolment and verification.
 *
 * Two-factor is optional per the spec, but the pieces that make it trustworthy
 * are not: the shared secret is only persisted once the user has proved they
 * can generate a code from it, recovery codes are stored hashed so a database
 * read cannot bypass the second factor, and each recovery code is consumed on
 * use.
 */
final class TwoFactorAuthenticator
{
    private const RECOVERY_CODE_COUNT = 8;

    public function __construct(
        private readonly Google2FA $google2fa,
        private readonly AuditRecorder $audit,
        private readonly AuthEventRecorder $authEvents,
    ) {}

    /**
     * Begin enrolment: generate a secret and hand back the provisioning data.
     *
     * The secret is stored but `two_factor_confirmed_at` stays null, so the
     * second factor is not yet enforced. A user who abandons setup half-way is
     * not locked out of their own account.
     */
    public function beginEnrolment(User $user): TwoFactorEnrolment
    {
        $secret = $this->google2fa->generateSecretKey(32);

        $user->two_factor_secret = $secret;
        $user->two_factor_recovery_codes = null;
        $user->two_factor_confirmed_at = null;
        $user->save();

        return new TwoFactorEnrolment(
            secret: $secret,
            otpauthUri: $this->otpauthUri($user, $secret),
            qrCodeSvg: $this->qrCode($this->otpauthUri($user, $secret)),
        );
    }

    /**
     * Finish enrolment by verifying a code the user's app produced.
     *
     * Returns the plaintext recovery codes -- the only time they are ever
     * available, since only their hashes are kept.
     *
     * @return list<string>|null Null when the submitted code is wrong.
     */
    public function confirmEnrolment(User $user, string $code): ?array
    {
        if ($user->two_factor_secret === null) {
            return null;
        }

        if (! $this->verifyTotp($user->two_factor_secret, $code)) {
            $this->authEvents->record(AuthEventType::TwoFactorFailed, $user, metadata: ['stage' => 'enrolment']);

            return null;
        }

        $plain = $this->generateRecoveryCodes();

        $user->two_factor_recovery_codes = array_map(
            fn (string $recovery): string => Hash::make($recovery),
            $plain,
        );
        $user->two_factor_confirmed_at = now();
        $user->save();

        $this->audit->record(AuditEvent::USER_TWO_FACTOR_ENABLED, $user, actor: $user);
        $this->authEvents->record(AuthEventType::TwoFactorEnabled, $user);

        return $plain;
    }

    public function disable(User $user): void
    {
        $user->two_factor_secret = null;
        $user->two_factor_recovery_codes = null;
        $user->two_factor_confirmed_at = null;
        $user->save();

        $this->audit->record(AuditEvent::USER_TWO_FACTOR_DISABLED, $user, actor: $user);
        $this->authEvents->record(AuthEventType::TwoFactorDisabled, $user);
    }

    /**
     * Check a login-time second factor, accepting either a TOTP code or one
     * unused recovery code.
     */
    public function challenge(User $user, ?string $totpCode, ?string $recoveryCode): bool
    {
        if (! $user->hasTwoFactorEnabled()) {
            return true;
        }

        if ($totpCode !== null && $totpCode !== '' && $this->verifyTotp($user->two_factor_secret, $totpCode)) {
            return true;
        }

        if ($recoveryCode !== null && $recoveryCode !== '' && $this->consumeRecoveryCode($user, $recoveryCode)) {
            return true;
        }

        $this->authEvents->record(AuthEventType::TwoFactorFailed, $user, metadata: ['stage' => 'login']);

        return false;
    }

    /**
     * Verify a TOTP code against the secret.
     *
     * A one-window tolerance either side accommodates clock skew between the
     * server and the user's phone without meaningfully widening the window an
     * attacker has to guess in.
     */
    private function verifyTotp(?string $secret, string $code): bool
    {
        if ($secret === null) {
            return false;
        }

        $code = preg_replace('/\s+/', '', $code) ?? '';

        if (! preg_match('/^\d{6}$/', $code)) {
            return false;
        }

        return $this->google2fa->verifyKey($secret, $code, window: 1);
    }

    /**
     * Spend a recovery code.
     *
     * Each code works once: it is removed from the stored set as soon as it
     * matches, so a code read from a screenshot cannot be reused.
     */
    private function consumeRecoveryCode(User $user, string $candidate): bool
    {
        $hashes = $user->two_factor_recovery_codes ?? [];
        $candidate = trim($candidate);

        foreach ($hashes as $index => $hash) {
            if (Hash::check($candidate, $hash)) {
                unset($hashes[$index]);
                $user->two_factor_recovery_codes = array_values($hashes);
                $user->save();

                $this->authEvents->record(AuthEventType::TwoFactorChallenged, $user, metadata: [
                    'method' => 'recovery_code',
                    'remaining' => count($hashes),
                ]);

                return true;
            }
        }

        return false;
    }

    /**
     * Issue a fresh set of recovery codes, invalidating the previous set.
     *
     * @return list<string>
     */
    public function regenerateRecoveryCodes(User $user): array
    {
        $plain = $this->generateRecoveryCodes();

        $user->two_factor_recovery_codes = array_map(
            fn (string $recovery): string => Hash::make($recovery),
            $plain,
        );
        $user->save();

        return $plain;
    }

    public function remainingRecoveryCodeCount(User $user): int
    {
        return count($user->two_factor_recovery_codes ?? []);
    }

    /** @return list<string> */
    private function generateRecoveryCodes(): array
    {
        $codes = [];

        for ($i = 0; $i < self::RECOVERY_CODE_COUNT; $i++) {
            $codes[] = Str::lower(Str::random(5)).'-'.Str::lower(Str::random(5));
        }

        return $codes;
    }

    private function otpauthUri(User $user, string $secret): string
    {
        return $this->google2fa->getQRCodeUrl(
            (string) config('app.name'),
            $user->email,
            $secret,
        );
    }

    /** Rendered server-side so the secret never has to reach a third party. */
    private function qrCode(string $uri): string
    {
        $writer = new Writer(new ImageRenderer(
            new RendererStyle(size: 240, margin: 0),
            new SvgImageBackEnd,
        ));

        return $writer->writeString($uri);
    }
}
