<?php

use App\Jobs\FetchNostrProfileJob;
use App\Models\LoginKey;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use swentel\nostr\Event\Event as NostrEvent;
use swentel\nostr\Key\Key as NostrKey;
use swentel\nostr\Sign\Sign as NostrSign;

/**
 * Build a NIP-55-style signed login event for a given k1 challenge, as an
 * Android signer (e.g. Amber) would return it to the callback URL.
 *
 * @return array{0: array<string, mixed>, 1: string}
 */
function makeSignedMobileNostrEvent(string $challenge): array
{
    $keyGen = new NostrKey;
    $privateKey = $keyGen->generatePrivateKey();
    $publicKey = $keyGen->getPublicKey($privateKey);

    $event = new NostrEvent;
    $event->setKind(22242)
        ->setCreatedAt(time())
        ->setContent('')
        ->setTags([['challenge', $challenge]]);

    (new NostrSign)->signEvent($event, $privateKey);

    $signed = [
        'id' => $event->getId(),
        'pubkey' => $event->getPublicKey(),
        'created_at' => $event->getCreatedAt(),
        'kind' => $event->getKind(),
        'tags' => $event->getTags(),
        'content' => $event->getContent(),
        'sig' => $event->getSignature(),
    ];

    return [$signed, $keyGen->convertPublicKeyToBech32($publicKey)];
}

it('renders the mobile login page for guests and stores the flow state in the session', function () {
    $response = $this->get('/auth/mobile?redirect_uri=einundzwanzig%3A%2F%2Fauth&device_name=Pixel%2010');

    $response->assertOk();

    expect(session('mobile_auth.redirect_uri'))->toBe('einundzwanzig://auth')
        ->and(session('mobile_auth.device_name'))->toBe('Pixel 10');
});

it('rejects redirect uris that are not whitelisted', function () {
    $this->get('/auth/mobile?redirect_uri=https%3A%2F%2Fevil.example%2Fphish')
        ->assertForbidden();
});

it('stores a login key and creates a user when the nostr signer callback verifies', function () {
    Queue::fake();

    $k1 = bin2hex(random_bytes(32));
    [$signedEvent, $npub] = makeSignedMobileNostrEvent($k1);

    $response = $this->get('/api/nostr-login-callback?k1='.$k1.'&event='.urlencode(json_encode($signedEvent)));

    $response->assertOk()->assertJson(['status' => 'OK']);

    $user = User::query()->where('nostr', $npub)->first();
    expect($user)->not->toBeNull();

    $loginKey = LoginKey::query()->where('k1', $k1)->first();
    expect($loginKey)->not->toBeNull()
        ->and($loginKey->user_id)->toBe($user->id);

    Queue::assertPushed(FetchNostrProfileJob::class);
});

it('rejects a nostr signer callback whose event is bound to a different challenge', function () {
    $k1 = bin2hex(random_bytes(32));
    [$signedEvent] = makeSignedMobileNostrEvent(bin2hex(random_bytes(32)));

    $response = $this->get('/api/nostr-login-callback?k1='.$k1.'&event='.urlencode(json_encode($signedEvent)));

    $response->assertBadRequest();
    expect(LoginKey::query()->where('k1', $k1)->exists())->toBeFalse();
});

it('completes the login via the path-based signer callback and redirects to the app handoff', function () {
    Queue::fake();

    $k1 = bin2hex(random_bytes(32));
    [$signedEvent, $npub] = makeSignedMobileNostrEvent($k1);

    // Amber appends the URL-encoded signed event after the trailing slash
    // and drops any query string from the callback URL.
    $response = $this
        ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']])
        ->get('/auth/mobile/signed/'.$k1.'/'.rawurlencode(json_encode($signedEvent)));

    $location = $response->headers->get('Location');
    expect($location)->toStartWith(route('auth.mobile.handoff').'?token=');

    $user = User::query()->where('nostr', $npub)->first();
    expect($user)->not->toBeNull();

    $token = $user->tokens()->first();
    expect($token)->not->toBeNull()
        ->and($token->name)->toBe('Pixel 10');

    expect(LoginKey::query()->where('k1', $k1)->exists())->toBeTrue();
    Queue::assertPushed(FetchNostrProfileJob::class);

    // The handoff fallback page offers the deep link behind a button.
    $this->get($location)
        ->assertOk()
        ->assertSee('einundzwanzig://auth?token=', false);
});

it('exchanges a signed event for a token via the mobile token endpoint', function () {
    Queue::fake();

    $k1 = bin2hex(random_bytes(32));
    [$signedEvent, $npub] = makeSignedMobileNostrEvent($k1);

    $response = $this->postJson('/api/mobile/token', [
        'k1' => $k1,
        'event' => $signedEvent,
        'device_name' => 'Pixel 10',
    ]);

    $response->assertOk()->assertJsonStructure(['token', 'user' => ['id', 'name']]);

    $user = User::query()->where('nostr', $npub)->first();
    expect($user)->not->toBeNull()
        ->and($user->tokens()->first()->name)->toBe('Pixel 10');

    $this->getJson('/api/user', ['Authorization' => 'Bearer '.$response->json('token')])
        ->assertOk()
        ->assertJsonPath('id', $user->id);
});

it('rejects a token exchange with a mismatched challenge', function () {
    $k1 = bin2hex(random_bytes(32));
    [$signedEvent] = makeSignedMobileNostrEvent(bin2hex(random_bytes(32)));

    $this->postJson('/api/mobile/token', [
        'k1' => $k1,
        'event' => $signedEvent,
    ])->assertBadRequest();
});

it('rejects a path-based signer callback with a tampered challenge', function () {
    $k1 = bin2hex(random_bytes(32));
    [$signedEvent] = makeSignedMobileNostrEvent(bin2hex(random_bytes(32)));

    $this->get('/auth/mobile/signed/'.$k1.'/'.rawurlencode(json_encode($signedEvent)))
        ->assertRedirect(route('auth.mobile'));

    expect(LoginKey::query()->where('k1', $k1)->exists())->toBeFalse();
});

it('issues a token and redirects into the app when completing a verified login', function () {
    $user = User::factory()->create();
    $k1 = bin2hex(random_bytes(32));
    LoginKey::query()->create(['k1' => $k1, 'user_id' => $user->id]);

    $response = $this
        ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']])
        ->get('/auth/mobile/complete/'.$k1);

    $location = $response->headers->get('Location');
    expect($location)->toStartWith(route('auth.mobile.handoff').'?token=');

    $token = $user->tokens()->first();
    expect($token)->not->toBeNull()
        ->and($token->name)->toBe('Pixel 10');

    // The plain-text token handed to the app must authenticate API requests.
    $plainTextToken = urldecode(str($location)->after('?token=')->value());
    $this->getJson('/api/user', ['Authorization' => 'Bearer '.$plainTextToken])
        ->assertOk()
        ->assertJsonPath('id', $user->id);
});

it('redirects back to the mobile login when the k1 is unknown or expired', function () {
    $this->get('/auth/mobile/complete/'.bin2hex(random_bytes(32)))
        ->assertRedirect(route('auth.mobile'));
});

it('lets an already authenticated user connect the app and replaces tokens of the same device', function () {
    $user = User::factory()->create();
    $user->createToken('Pixel 10');

    $response = $this
        ->actingAs($user)
        ->withSession(['mobile_auth' => ['redirect_uri' => 'einundzwanzig://auth', 'device_name' => 'Pixel 10']])
        ->post('/auth/mobile/confirm');

    expect($response->headers->get('Location'))->toStartWith(route('auth.mobile.handoff').'?token=');
    expect($user->tokens()->where('name', 'Pixel 10')->count())->toBe(1);
});

it('shows the confirmation screen instead of the login methods for authenticated users', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->get('/auth/mobile')
        ->assertOk()
        ->assertSee(route('auth.mobile.confirm'));
});

it('returns the token owner profile on /api/user', function () {
    $user = User::factory()->create();
    Sanctum::actingAs($user);

    $this->getJson('/api/user')
        ->assertOk()
        ->assertJsonPath('id', $user->id)
        ->assertJsonPath('name', $user->name);
});

it('denies /api/user without a token', function () {
    $this->getJson('/api/user')->assertUnauthorized();
});
