<?php

namespace Tests\Feature;

use App\Livewire\Auth\ForgotPassword;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Notifications\QueuedResetPassword;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;

/**
 * Hardening of the two low-severity account-enumeration *timing* side channels on the
 * forgot-password screen (the message-level leak is already closed via generic copy):
 *   (a) sendResetLink() must enqueue the reset mail (never send synchronously), so SMTP
 *       latency/exceptions can't distinguish a known address;
 *   (b) resetPassword() must spend comparable crypto time whether the account exists / has
 *       2FA or not, so the verification branch isn't a timing oracle.
 * These tests assert the *structural* equivalence of the branches (and that mail is queued),
 * not wall-clock timing (which would be flaky).
 */
class ForgotPasswordTimingTest extends TestCase
{
    use RefreshDatabase;

    private function twoFactorUser(string $email = 'admin@clusev.local'): User
    {
        return User::factory()->create([
            'email' => $email,
            'two_factor_secret' => (new Google2FA)->generateSecretKey(),
            'two_factor_confirmed_at' => now(),
        ]);
    }

    private function attemptReset(string $email, string $code = '123456'): Testable
    {
        return Livewire::test(ForgotPassword::class)
            ->set('email', $email)->set('code', $code)
            ->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123')
            ->call('resetPassword');
    }

    // ── (a) the reset-link mail is queued ────────────────────────────────
    public function test_reset_link_notification_is_queued(): void
    {
        config(['mail.default' => 'smtp']);
        Notification::fake();
        $user = User::factory()->create(['email' => 'admin@clusev.local']);

        Livewire::test(ForgotPassword::class)
            ->set('email', $user->email)
            ->call('sendResetLink')
            ->assertHasNoErrors();

        Notification::assertSentTo(
            $user,
            QueuedResetPassword::class,
            fn ($notification) => $notification instanceof ShouldQueue,
        );
    }

    public function test_send_link_swallows_transport_errors_and_stays_generic(): void
    {
        config(['mail.default' => 'smtp']);
        // No user exists → Password::sendResetLink hits the INVALID_USER path; the screen must
        // still report the generic message with no error, exactly like the existing-user case.
        Livewire::test(ForgotPassword::class)
            ->set('email', 'ghost@clusev.local')
            ->call('sendResetLink')
            ->assertHasNoErrors()
            ->assertDispatched('notify', message: __('auth.reset_link_sent'));
    }

    // ── (b) every invalid branch issues an identical query fingerprint ───
    public function test_invalid_branches_have_identical_query_fingerprints(): void
    {
        // A real 2FA account with a wrong code runs a uniform webauthn-existence probe + verifyTotp
        // (HMAC) + useRecoveryCode (a locked SELECT in a transaction). The no-account and no-2FA
        // branches must produce the SAME query fingerprint (users + webauthn_credentials counts) —
        // proving none of them returns early (faster), does heavier work like a cost-12 bcrypt
        // (slower), nor skips the webauthn lookup (an extra account-enumeration tell).
        $this->twoFactorUser('totp@clusev.local');
        User::factory()->create(['email' => 'plain@clusev.local']); // registered, no 2FA

        // registered with a security key only (no TOTP)
        $keyUser = User::factory()->create(['email' => 'key@clusev.local']);
        WebauthnCredential::create(['user_id' => $keyUser->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);

        $totp = $this->queryFingerprint(fn () => $this->attemptReset('totp@clusev.local', '000000')->assertHasErrors('code'));
        $plain = $this->queryFingerprint(fn () => $this->attemptReset('plain@clusev.local')->assertHasErrors('code'));
        $keyOnly = $this->queryFingerprint(fn () => $this->attemptReset('key@clusev.local')->assertHasErrors('code'));
        $unknown = $this->queryFingerprint(fn () => $this->attemptReset('ghost@clusev.local')->assertHasErrors('code'));

        $this->assertGreaterThan(1, $unknown['users'], 'no-account branch must do dummy verification work, not return early');
        $this->assertSame(1, $unknown['webauthn'], 'no-account branch must still issue the webauthn existence probe');
        $this->assertSame($totp, $unknown, 'unknown email must look identical to a real TOTP wrong-code attempt');
        $this->assertSame($totp, $plain, 'a no-2FA account must look identical to a real TOTP wrong-code attempt');
        $this->assertSame($totp, $keyOnly, 'a webauthn-only account must look identical to a real TOTP wrong-code attempt');
    }

    /** Count queries per table issued while running $action (one DB round-trip = one timing tell). */
    private function queryFingerprint(callable $action): array
    {
        DB::flushQueryLog();
        DB::enableQueryLog();
        $action();
        $queries = collect(DB::getQueryLog())->map(fn ($entry) => strtolower((string) $entry['query']));
        DB::disableQueryLog();

        return [
            'users' => $queries->filter(fn ($q) => str_contains($q, '"users"'))->count(),
            'webauthn' => $queries->filter(fn ($q) => str_contains($q, '"webauthn_credentials"'))->count(),
        ];
    }

    // ── structural equivalence of the invalid branches ──────────────────
    public function test_missing_and_no_2fa_branches_are_structurally_identical(): void
    {
        User::factory()->create(['email' => 'real@clusev.local']);

        $missing = $this->attemptReset('ghost@clusev.local');
        $no2fa = $this->attemptReset('real@clusev.local');

        foreach ([$missing, $no2fa] as $component) {
            $component->assertHasErrors('code');
            $component->assertNoRedirect();
        }
    }

    public function test_unknown_email_hits_the_rate_limiter_like_a_real_account(): void
    {
        RateLimiter::spy();

        $this->attemptReset('ghost@clusev.local')->assertHasErrors('code');

        // The no-account path must increment the limiter exactly like a real account would —
        // no fast, un-throttled enumeration loop.
        RateLimiter::shouldHaveReceived('hit')->atLeast()->once();
    }
}
