<?php

namespace Tests\Feature;

use App\Livewire\Modals\RecoveryCodes;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\TestCase;

class RecoveryCodesModalTest extends TestCase
{
    use RefreshDatabase;

    public function test_manage_view_without_fresh_flag_dispatches_nothing_and_hides_codes(): void
    {
        $user = User::factory()->create();
        $codes = $user->replaceRecoveryCodes();

        // Opened from "Backup-Codes verwalten" with no fresh-generation flag: never reveal.
        // No reveal event fires, and the codes never appear in the server-rendered HTML.
        Livewire::actingAs($user)->test(RecoveryCodes::class)
            ->assertNotDispatched('reveal-codes')
            ->assertDontSee($codes[0])
            ->assertDontSee($codes[7])
            ->assertSee(__('auth.recovery_hidden_notice'));
    }

    public function test_fresh_flag_dispatches_codes_once_and_keeps_them_out_of_the_snapshot(): void
    {
        $user = User::factory()->create();
        $codes = $user->replaceRecoveryCodes();
        session(['2fa.codes_fresh' => now()->timestamp]);

        // The fresh flag delivers the codes via a one-time transient event — never as a
        // property and never in the server-rendered HTML, so they are absent from the snapshot.
        $component = Livewire::actingAs($user)->test(RecoveryCodes::class)
            ->assertDispatched('reveal-codes', codes: $codes)
            ->assertDontSee($codes[0])
            ->assertDontSee($codes[7]);

        // Prove the codes are absent from the serialized snapshot itself — the signed
        // wire:snapshot blob a client could capture and replay — not just from visible HTML.
        $this->assertSnapshotHidesCodes($codes, $component);

        // pull() forgets the flag — it is single-use.
        $this->assertFalse(session()->has('2fa.codes_fresh'));
    }

    public function test_a_stale_codes_fresh_flag_reveals_nothing(): void
    {
        $user = User::factory()->create();
        $codes = $user->replaceRecoveryCodes();

        // A reveal flag older than the 10-minute window (e.g. enrollment whose modal never
        // opened) must not reveal the codes when the modal is later mounted.
        session(['2fa.codes_fresh' => now()->subMinutes(11)->timestamp]);
        Livewire::actingAs($user)->test(RecoveryCodes::class)
            ->assertNotDispatched('reveal-codes')
            ->assertDontSee($codes[0]);

        // The stale flag is still consumed (pulled), so it cannot trigger a later reveal.
        $this->assertFalse(session()->has('2fa.codes_fresh'));
    }

    public function test_replayed_or_stale_snapshot_renders_no_codes(): void
    {
        $user = User::factory()->create();
        $codes = $user->replaceRecoveryCodes();
        session(['2fa.codes_fresh' => now()->timestamp]);

        // Legitimate one-time reveal: consumes the flag, fires the event once, renders no codes.
        $component = Livewire::actingAs($user)->test(RecoveryCodes::class)
            ->assertDispatched('reveal-codes', codes: $codes)
            ->assertDontSee($codes[0]);

        // The captured snapshot carries no codes — there is nothing in it to replay.
        $this->assertSnapshotHidesCodes($codes, $component);

        // Replaying that snapshot = a later roundtrip that hydrates the same state. mount()
        // does not re-run on hydrate, so no reveal-codes event fires and no codes appear; the
        // re-serialized snapshot stays clean too.
        $component->call('$refresh')
            ->assertNotDispatched('reveal-codes')
            ->assertDontSee($codes[0]);
        $this->assertSnapshotHidesCodes($codes, $component);

        // A fresh mount from the now-consumed flag (the state a replayed snapshot starts from)
        // dispatches nothing and renders nothing — codes cannot be re-revealed.
        Livewire::actingAs($user)->test(RecoveryCodes::class)
            ->assertNotDispatched('reveal-codes')
            ->assertDontSee($codes[0]);
    }

    public function test_regenerate_dispatches_the_new_codes_without_rendering_them(): void
    {
        $user = User::factory()->create();
        $old = $user->replaceRecoveryCodes();

        // Manage view (no flag) → nothing dispatched → regenerate → the NEW set is dispatched once.
        $component = Livewire::actingAs($user)->test(RecoveryCodes::class)
            ->assertNotDispatched('reveal-codes')
            ->call('regenerate');

        $new = $user->fresh()->recoveryCodes();
        $this->assertNotEquals($old, $new);

        // New set delivered via the transient event; neither the old nor the new set is in the HTML.
        $component->assertDispatched('reveal-codes', codes: $new)
            ->assertDontSee($old[0])
            ->assertDontSee($new[0]);

        // The regenerated set is absent from the serialized snapshot too.
        $this->assertSnapshotHidesCodes($new, $component);

        // Regenerate also grants exactly one download of the fresh set.
        $this->assertTrue(session()->has('2fa.download_grant'));
    }

    public function test_download_requires_a_fresh_grant(): void
    {
        $user = User::factory()->create();
        $user->replaceRecoveryCodes();

        // Without a fresh-generation grant, a direct hit of the download route is forbidden,
        // so stored codes can never be re-downloaded by an authed user at will.
        $this->actingAs($user)
            ->post(route('two-factor.recovery.download'))
            ->assertForbidden();

        // With a fresh one-time grant (a timestamp set when codes are freshly generated/
        // regenerated) the download succeeds exactly once.
        session(['2fa.download_grant' => now()->timestamp]);
        $this->actingAs($user)
            ->post(route('two-factor.recovery.download'))
            ->assertOk()
            ->assertDownload('clusev-recovery-codes.txt');

        // The grant is single-use: pull() consumed it, so an immediate re-download 403s.
        $this->actingAs($user)
            ->post(route('two-factor.recovery.download'))
            ->assertForbidden();
    }

    public function test_a_stale_download_grant_is_rejected(): void
    {
        $user = User::factory()->create();
        $user->replaceRecoveryCodes();

        // A grant older than the 10-minute window is refused, so an unused grant cannot
        // linger and be replayed later in the authenticated session.
        session(['2fa.download_grant' => now()->subMinutes(11)->timestamp]);
        $this->actingAs($user)
            ->post(route('two-factor.recovery.download'))
            ->assertForbidden();

        // A non-timestamp (legacy bool) grant is likewise refused.
        session(['2fa.download_grant' => true]);
        $this->actingAs($user)
            ->post(route('two-factor.recovery.download'))
            ->assertForbidden();

        // A future-dated grant is refused too (only [now-600s, now] is valid).
        session(['2fa.download_grant' => now()->addDay()->timestamp]);
        $this->actingAs($user)
            ->post(route('two-factor.recovery.download'))
            ->assertForbidden();

        // A grant within the window still works.
        session(['2fa.download_grant' => now()->timestamp]);
        $this->actingAs($user)
            ->post(route('two-factor.recovery.download'))
            ->assertOk();
    }

    public function test_old_recovery_route_is_gone(): void
    {
        $this->assertFalse(Route::has('two-factor.recovery'));
        $this->assertTrue(Route::has('two-factor.recovery.download'));
    }

    /**
     * Assert that NONE of the given recovery codes appear anywhere in the component's
     * serialized snapshot — the signed wire:snapshot blob a client could capture and replay.
     *
     * @param  array<int, string>  $codes
     * @param  Testable  $component
     */
    private function assertSnapshotHidesCodes(array $codes, $component): void
    {
        $snapshot = json_encode($component->snapshot);

        foreach ($codes as $code) {
            $this->assertStringNotContainsString($code, $snapshot);
        }
    }
}
