<?php

namespace Tests\Feature;

use App\Helpers\Security;
use App\Models\ApiScope;
use App\Models\ApiToken;
use App\Models\ApiTokenBearer;
use App\Models\ApiTokenPermission;
use App\Models\ApiTokenWhitelist;
use App\Models\Group;
use App\Models\Organisation;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
use Tests\TestCase;

class TokenMiddlewareCacheTest extends TestCase
{

    use RefreshDatabase;

    private $organisation = null;
    private $token = null;
    private $secret = 'an-extremely-secret-token-value1';

    protected function setUp(): void
    {
        parent::setUp();

        // Clear anything the middleware may have cached in previous runs - Token ids repeat across runs.
        foreach([ 'api-bearer-*', 'api-token-*', 'api-perms-*', 'api-whitelist-*' ] as $pattern) {
            foreach(Redis::keys($pattern) as $key) {
                // Keys arrive prefixed from KEYS but DEL applies the prefix itself, so strip it off.
                Redis::del(Str::after($key, config('database.redis.options.prefix')));
            }
        }

        $this->organisation = Organisation::create([ 'id' => 1, 'name' => 'Organisation #1' ]);

        $this->token = ApiToken::create([
            'name'              => 'Test Token',
            'identifier'        => 'test-identifier-1234567890123456',
            'token'             => Hash::make($this->secret),
            'active'            => 1,
            'organisation_id'   => $this->organisation->id
        ]);

        ApiTokenWhitelist::create([ 'address' => '0.0.0.0', 'token_id' => $this->token->id, 'organisation_id' => $this->organisation->id ]);

        ApiTokenPermission::create([
            'api_token_id'  => $this->token->id,
            'api_scopes_id' => ApiScope::create([ 'name' => 'goliath' ])->id
        ]);
    }

    private function issueBearer(): string
    {
        $response = $this->postJson('/api/auth/issue', [
            'identifier'    => $this->token->identifier,
            'token'         => $this->secret
        ]);

        $response->assertStatus(200);

        return $response->json('bearer');
    }

    private function bearerRequest(string $_bearer): Request
    {
        return Request::create('/api/anything', 'GET', [], [], [], [ 'HTTP_AUTHORIZATION' => "Bearer $_bearer" ]);
    }

    public function testValidationPopulatesCacheAndWarmPathSkipsTheDatabase(): void
    {
        /**
         * Scenarios:
         *      - A first validation resolves from the database and leaves the bearer, token and permission caches populated.
         *      - A second validation is served entirely from Redis - No database queries at all.
         *      - A permission the token does not hold still fails while warm.
         */

        $bearer = $this->issueBearer();

        [$returned_bearer, $token_id, $valid] = Security::valid_bearer_token($this->bearerRequest($bearer), ['goliath']);

        $this->assertTrue($valid);
        $this->assertEquals($bearer, $returned_bearer);
        $this->assertEquals($this->token->id, $token_id);

        $this->assertEquals(1, Redis::exists("api-bearer-$bearer"));
        $this->assertEquals(1, Redis::exists("api-token-{$this->token->id}"));
        $this->assertEquals(1, Redis::exists("api-perms-{$this->token->id}-" . md5('goliath')));

        // The bearer resolution must not be allowed to outlive the bearer itself.
        $this->assertLessThanOrEqual(300, Redis::ttl("api-bearer-$bearer"));

        DB::flushQueryLog();
        DB::enableQueryLog();

        [$_, $__, $still_valid] = Security::valid_bearer_token($this->bearerRequest($bearer), ['goliath']);

        $this->assertTrue($still_valid);
        $this->assertCount(0, DB::getQueryLog(), 'The warm validation path should not touch the database.');

        DB::disableQueryLog();

        // A scope the token doesn't hold is still refused when everything else is cached.
        [$_, $__, $denied] = Security::valid_bearer_token($this->bearerRequest($bearer), ['not-a-real-scope']);

        $this->assertFalse($denied);
    }

    public function testRevocationInvalidatesTheCacheImmediately(): void
    {
        /**
         * Scenarios:
         *      - Revoking a bearer removes its cached resolution, so the very next validation fails.
         */

        $bearer = $this->issueBearer();

        [$_, $__, $valid] = Security::valid_bearer_token($this->bearerRequest($bearer), ['goliath']);
        $this->assertTrue($valid);

        $this->withHeaders([ 'Authorization' => "Bearer $bearer" ])->postJson('/api/auth/revoke')->assertStatus(200);

        $this->assertEquals(0, Redis::exists("api-bearer-$bearer"));

        [$state, $__, $valid_after] = Security::valid_bearer_token($this->bearerRequest($bearer), ['goliath']);

        $this->assertFalse($valid_after);
        $this->assertEquals('INVALID', $state);
    }

    public function testTokenDeletionInvalidatesTheCacheImmediately(): void
    {
        /**
         * Scenarios:
         *      - Deleting the API token drops the cached existence check, so a still-cached bearer goes INACTIVE at once.
         */

        $group = Group::create([ 'name' => 'User' ]);

        $user = User::create([
            'phone'             => null,
            'password'          => '',
            'group_id'          => $group->id,
            'organisation_id'   => $this->organisation->id,
            'name'              => 'User #1',
            'email'             => 'creatia+1@globalsecurelayer.com'
        ]);

        $bearer = $this->issueBearer();

        [$_, $__, $valid] = Security::valid_bearer_token($this->bearerRequest($bearer), ['goliath']);
        $this->assertTrue($valid);

        $this->actingAs($user);

        $response = (new \App\Http\Controllers\Token\Controller)->delete_token(new Request, $this->token->id);
        $this->assertEquals(200, $response->getStatusCode());

        $this->assertEquals(0, Redis::exists("api-token-{$this->token->id}"));

        [$state, $__, $valid_after] = Security::valid_bearer_token($this->bearerRequest($bearer), ['goliath']);

        $this->assertFalse($valid_after);
        $this->assertEquals('INACTIVE', $state);
    }

    public function testExpiredBearersStillReportExpired(): void
    {
        /**
         * Scenarios:
         *      - An expired bearer takes the EXPIRED branch and never enters the cache.
         */

        $bearer = Str::random(32);

        ApiTokenBearer::create([
            'bearer'        => $bearer,
            'expiration_at' => Carbon::now()->subMinute(),
            'token_id'      => $this->token->id
        ]);

        [$state, $__, $valid] = Security::valid_bearer_token($this->bearerRequest($bearer), ['goliath']);

        $this->assertFalse($valid);
        $this->assertEquals('EXPIRED', $state);
        $this->assertEquals(0, Redis::exists("api-bearer-$bearer"));
    }
}
