<?php

namespace App\Livewire\Auth;

use App\Models\AuditEvent;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;

#[Layout('layouts.auth')]
class ForgotPassword extends Component
{
    /** Fixed throwaway base32 secret for the dummy TOTP verify on the no-user/no-2FA branch. */
    private const DUMMY_TOTP_SECRET = 'ABCDEFGHIJKLMNOP';

    public string $email = '';

    public string $code = '';

    public string $password = '';

    public string $password_confirmation = '';

    /** Reveals the secondary inline 2FA-proof form when a mailer is configured. */
    public bool $showCodeReset = false;

    /** True when a real mailer is configured (then the email-link option is offered too). */
    public function mailEnabled(): bool
    {
        return ! in_array(config('mail.default'), ['log', 'array', null], true);
    }

    /** Secondary path: email a reset link (only meaningful when a real mailer is set). */
    public function sendResetLink(): void
    {
        if (! $this->mailEnabled()) {
            return;
        }

        $this->validate(['email' => ['required', 'email']]);

        // The notification is queued (see User::sendPasswordResetNotification), so this only ever
        // pushes a job — no synchronous SMTP. Swallow any transport/queue error so that neither
        // latency nor an exception can distinguish a known address from an unknown one.
        try {
            \Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
        } catch (\Throwable $e) {
            // The client response stays identical regardless — but surface the failure to ops
            // (report() → logs), otherwise a broken queue/token store silently "succeeds" for
            // every address and a broken reset flow goes unnoticed.
            report($e);
        }

        // Generic — never reveal whether the email exists.
        $this->dispatch('notify', message: __('auth.reset_link_sent'));
    }

    /** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */
    public function resetPassword()
    {
        $this->validate([
            'email' => ['required', 'email'],
            'code' => ['required', 'string'],
            'password' => ['required', 'confirmed', Password::min(6)],
        ]);

        // Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset
        // path is a second TOTP/backup brute-force surface (email + code, no password), so a
        // distributed (multi-IP) attempt must be capped per account the same way the challenge
        // is. Both auto-expire — never a permanent lockout (clusev:reset-admin stays open).
        $email = strtolower($this->email);
        $key = 'forgot:'.md5($email.'|'.request()->ip());
        $acctKey = 'forgot-acct:'.md5($email);
        foreach ([[$key, 5], [$acctKey, 20]] as [$k, $max]) {
            if (RateLimiter::tooManyAttempts($k, $max)) {
                throw ValidationException::withMessages([
                    'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]),
                ]);
            }
        }

        $user = User::where('email', $this->email)->first();

        // Resolve 2FA status with a *uniform* lookup pattern: always exactly one
        // webauthn-existence query — for a TOTP user that wouldn't otherwise need it, and for a
        // missing account — so the presence/absence of that query can't tell accounts apart.
        $hasWebauthn = $user
            ? $user->hasWebauthnCredentials()
            : WebauthnCredential::whereKey(0)->exists();
        $hasTotp = (bool) $user?->hasTotp();
        $has2fa = $hasTotp || $hasWebauthn;

        if ($user && $has2fa) {
            // Keep the TOTP HMAC uniform: a webauthn-only account has no TOTP secret, so
            // verifyTotp() returns without hashing — spend an equivalent throwaway HMAC first so
            // this branch costs the same as a TOTP account would.
            if (! $hasTotp) {
                $this->dummyTotpVerify();
            }
            $ok = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code);
        } else {
            // No account, or one without 2FA: run the same HMAC + locked recovery lookup a real
            // wrong-code attempt does, so the verification path is not a timing/query oracle that
            // distinguishes a known address.
            $this->dummyTotpVerify();
            $this->dummyRecoveryLookup();
            $ok = false;
        }

        if (! $ok) {
            RateLimiter::hit($key, 60);     // 5 / minute (email+IP)
            RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent)
            // Generic message — never reveal whether the email exists.
            throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]);
        }

        RateLimiter::clear($key);
        RateLimiter::clear($acctKey);
        // Rotate remember_token too, so a stolen remember-me cookie can't survive the reset.
        $user->forceFill([
            'password' => Hash::make($this->password),
            'must_change_password' => false,
            'remember_token' => Str::random(60),
        ])->save();

        AuditEvent::create([
            'user_id' => $user->id,
            'actor' => $user->name ?? 'system',
            'action' => 'password.reset',
            'target' => $user->email,
            'ip' => request()->ip(),
        ]);

        session()->flash('status', __('auth.reset_done'));

        return $this->redirect(route('login'), navigate: true);
    }

    /**
     * Stand-in for User::verifyTotp() — one throwaway Google2FA HMAC against a fixed dummy secret,
     * so a request with no (or no TOTP) account spends the same crypto as a real wrong-code
     * attempt. It must NOT do heavier work than the real path (a cost-12 bcrypt here would make
     * this slower, re-opening the oracle in reverse and amplifying CPU DoS). Result is discarded.
     */
    private function dummyTotpVerify(): void
    {
        try {
            (new Google2FA)->verifyKey(self::DUMMY_TOTP_SECRET, preg_replace('/\s+/', '', $this->code) ?? '');
        } catch (\Throwable) {
            // ignore — verifyTotp swallows the same failure
        }
    }

    /**
     * Stand-in for User::useRecoveryCode() — a locked SELECT in a transaction + an in_array scan,
     * matching the dominant DB cost of the recovery path without touching a real row (id 0 never
     * exists, so nothing is locked). Exceptions are deliberately NOT caught: a DB failure must
     * surface here exactly as it would in the real path, otherwise an outage becomes an
     * error-response oracle (generic for an unknown address, 500 for a real account).
     */
    private function dummyRecoveryLookup(): void
    {
        DB::transaction(function (): void {
            $row = User::whereKey(0)->lockForUpdate()->first();
            in_array($this->code, $row?->recoveryCodes() ?? array_fill(0, 8, ''), true);
        });
    }

    public function render()
    {
        return view('livewire.auth.forgot-password')->title(__('auth.title_forgot'));
    }
}
