<?php

namespace Tests\Feature;

use App\Models\Asns;
use App\Models\Group;
use App\Models\GroupPermission;
use App\Models\ProductRights;
use App\Models\OrganisationExternalIdentities;
use App\Models\SecurityEvent;
use App\Notifications\SlackNotification;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use App\Models\Organisation;
use App\Models\Prefixes;
use App\Models\User;
use App\Models\FailedLogin;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\PersonalAccessToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;

class RouteSecurityTest extends TestCase
{

    use RefreshDatabase;

    private $organisations = [];
    private $users = [];
    private $asns = [];

    protected function setUp(): void {
        parent::setUp();
        $this->withoutExceptionHandling();

        // Setup organisations
        $this->organisations = [
            'A' => Organisation::create([ 'id' => 1, 'name' => 'A' ])
        ];

        // Setup groups and users
        $this->withoutMiddleware()->json('POST', '/api/admin/group', [ 'name' => 'Admin', 'scopes' => explode(' ', 'mercury.manage=1 network.redirect-view=1 network.redirect-write=1 goliath.l1.report=1 mitigation.attack.read=1 mitigation.attack.write=1 admin.mitigation.attack.read=1 admin.mitigation.attack.write=1 hook.view=1 hook.update=1 billing.admin.view=1 billing.admin.edit=1 provision.bgp.view=1 provision.bgp.write=1 goliath.l1.special=1 network.view=1 management.view=1 management.write=1 mitigation.view=1 mitigation.add=1 mitigation.edit=1 mitigation.management=1 mitigation.subtenancy=1 geoip.view=1 geoip.add=1 geoip.edit=1 geoip.management=1 geoip.subtenancy=1 goliath.edit-chain=1 goliath.view=1 goliath.management=1 goliath.edit-flags=1 goliath.edit-prefix=1 goliath.l1.view=1 goliath.l1.edit=1 goliath.l1.log=1 goliath.l1.manage=1 goliath.l2.view=1 goliath.l2.edit=1 goliath.l2.log=1 goliath.l2.manage=1 network.subtenant=1 network.rename=1 network.delete=1 network.manage=1 network.map=1 mercury.view=1 mercury.delete=1 compute.bgp.view=1 compute.bgp.tier1=1 compute.bgp.tier2=1 compute.bgp.tier3=1 compute.bgp.write=1 compute.bgp.delete=1 billing.view=1 billing.chart=1 goliath.l1.bulk-edit=1') ]);
        $this->withoutMiddleware()->json('POST', '/api/admin/group', [ 'name' => 'User', 'scopes' => explode(' ', 'goliath.l1.view=1 goliath.l1.edit=1 goliath.l1.log=1 goliath.l1.special=1 network.rename=1 network.map=1 goliath.l2.view=1 goliath.l2.edit=1 goliath.l2.log=1 network.redirect-view=1 network.redirect-write=1 organisation.manage=1 goliath.l1.report=1 mitigation.attack.read=1 mitigation.attack.write=1 mercury.view=1 mercury.delete=1 hook.view=1 hook.update=1 compute.bgp.view=1 compute.bgp.tier1=1 compute.bgp.tier2=1 compute.bgp.tier3=1 compute.bgp.write=1 compute.bgp.delete=1 goliath.l1.bulk-edit=1') ]);

        $default_user = [
            'phone'             => null,
            'password'          => ''
        ];

        $this->users = [
            'admin' => User::create([
                ...$default_user,
                'group_id'          => Group::where('name', '=', 'Admin')->get()->first()->id,
                'organisation_id'   => $this->organisations['A']->id,
                'name'              => 'Admin',
                'email'             => 'creatia+admin@globalsecurelayer.com'
            ]),
            'user' => User::create([
                ...$default_user,
                'group_id'          => Group::where('name', '=', 'User')->get()->first()->id,
                'organisation_id'   => $this->organisations['A']->id,
                'name'              => 'User',
                'email'             => 'creatia+user@globalsecurelayer.com'
            ])
        ];

        $this->app->instance('middleware.disable', false);

    }

    private function authUrl(string $path): string {
        return '/api/auth/' . ltrim($path, '/');
    }

    private function forgetAuth(): void {
        $this->app['auth']->forgetGuards();
    }

    private function getProtectedRoutes($prefix = null, $not_prefix = null): array {
        $routes = [];

        foreach (Route::getRoutes() as $route) {
            if ($this->hasPermissionMiddleware($route->middleware())) {

                if(($prefix === null && $not_prefix === null) || ($prefix !== null && str_contains($route->action['prefix'], "api/$prefix/") || ($not_prefix !== null && !str_contains($route->action['prefix'], "$not_prefix/")))) {
                    $routes[] = [ ...$route->action, 'uri' => $route->uri, 'method' => $route->methods[0] ];
                }

            }
        }

        return $routes;
    }

    private function hasPermissionMiddleware($middlewares): bool {
        $permissionMiddlewares = ['bounce'];
        
        foreach ($middlewares as $middleware) {
            if (is_string($middleware)) {
                foreach ($permissionMiddlewares as $pm) {
                    if (str_contains($middleware, $pm)) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    private function attemptRouteAccess($user, $route, $method): array {

        // Safe replaces are somewhat necessary for slugs - Generic catch all at the end, but specific ones are before.

        if($route === 'api/admin/goliath/report/period/{_type}') {
            $route = str_replace('{_type}', 'daily', $route);
        }

        if($route === 'api/admin/goliath/report/period/{_year}/{_type}/{_period}') {
            $route = str_replace('{_year}/{_type}/{_period}', '2025/daily/200', $route);
        }

        $route = preg_replace('/\{.+\}/', 1, $route);
        
        $response = $this->actingAs($user, 'api')->json($method, $route);

        return [ $response->status(), $response->json()['detail'] ];

    }

    // This checks that admin routes all correctly bounce
    public function testAdminRoutes() {

        $protected_routes = $this->getProtectedRoutes('admin');

        foreach($protected_routes as $i => $x) {

            $method = $x['method'];
            $uri = $x['uri'];

            [$status, $info] = $this->attemptRouteAccess($this->users['user'], $uri, $method);

            $this->assertTrue($info === 'Insufficient Permissions' && $status === 401, "Route $method $uri should have been rejected.");
        }

    }

    // This checks that user routes do not access any admin resources

    public function testUserRoutesNotAdmin() {

        $protected_routes = $this->getProtectedRoutes(null, 'admin');

        foreach($protected_routes as $i => $x) {

            $method = $x['method'];
            $uri = $x['uri'];
            $controller  = $x['controller'];
            
            $this->assertTrue(!str_contains($controller, 'Admin') && !str_contains($controller, 'admin'), "Route $method $uri points at $controller when it should not.");
        }

    }

    // Registers a throwaway route guarded by the bounce middleware and returns its URI.
    private function registerBounceRoute(string $permissions, string $method = 'GET'): string {
        $uri = 'test-bounce/' . Str::random(16);
        Route::match([$method], $uri, fn () => response()->json(['detail' => 'PASSED'], 200))
            ->middleware('bounce:' . $permissions);
        return $uri;
    }

    // A scope the user actually holds lets the request through.
    public function testBouncerAllowsUserWithGrantedPermission() {
        $uri = $this->registerBounceRoute('goliath.l1.edit');

        $response = $this->actingAs($this->users['user'], 'api')->json('GET', $uri);

        $response->assertStatus(200);
        $this->assertSame('PASSED', $response->json('detail'));
    }

    // A scope the user lacks is rejected with the standard payload.
    public function testBouncerRejectsUserWithoutPermission() {
        $uri = $this->registerBounceRoute('mercury.manage'); // admin-only

        $response = $this->actingAs($this->users['user'], 'api')->json('GET', $uri);

        $response->assertStatus(401);
        $this->assertSame('Insufficient Permissions', $response->json('detail'));
    }

    // Multiple required scopes are AND, not OR: missing one is a reject.
    public function testBouncerRequiresEveryListedPermission() {
        $uri = $this->registerBounceRoute('goliath.l1.edit,mercury.manage');

        $this->actingAs($this->users['user'], 'api')->json('GET', $uri)->assertStatus(401);
        $this->actingAs($this->users['admin'], 'api')->json('GET', $uri)->assertStatus(200);
    }

    // A user with no group is denied regardless of the scope asked for.
    public function testBouncerRejectsUserWithoutGroup() {
        $orphan = User::create([
            'phone'           => null,
            'password'        => '',
            'group_id'        => null,
            'organisation_id' => $this->organisations['A']->id,
            'name'            => 'Orphan',
            'email'           => 'creatia+orphan@globalsecurelayer.com',
        ]);

        $uri = $this->registerBounceRoute('goliath.l1.view');

        $this->actingAs($orphan, 'api')->json('GET', $uri)->assertStatus(401);
    }

    // A granted scope still gets blocked when its conditional product right is absent,
    // and clears once the org holds that right.
    public function testBouncerEnforcesConditionalProductRight() {
        GroupPermission::where('group_id', $this->users['user']->group_id)
            ->where('permission', 'goliath.l1.edit')
            ->update(['conditional' => 'ddos-advanced']);

        $uri = $this->registerBounceRoute('goliath.l1.edit');

        $this->actingAs($this->users['user'], 'api')->json('GET', $uri)->assertStatus(401);

        ProductRights::forceCreate([
            'organisation_id' => $this->users['user']->organisation_id,
            'name'            => 'ddos-advanced',
        ]);

        $this->actingAs($this->users['user'], 'api')->json('GET', $uri)->assertStatus(200);
    }

    // Same shape for the identifier gate against external identities.
    public function testBouncerEnforcesIdentifierExternalIdentity() {
        GroupPermission::where('group_id', $this->users['user']->group_id)
            ->where('permission', 'goliath.l1.view')
            ->update(['identifier' => 'gsl-tenant']);

        $uri = $this->registerBounceRoute('goliath.l1.view');

        $this->actingAs($this->users['user'], 'api')->json('GET', $uri)->assertStatus(401);

        OrganisationExternalIdentities::forceCreate([
            'organisation_id' => $this->users['user']->organisation_id,
            'key'             => 'gsl-tenant',
            'identity'        => 1
        ]);

        $this->actingAs($this->users['user'], 'api')->json('GET', $uri)->assertStatus(200);
    }

    // A successful non-GET pass records a security event.
    public function testBouncerLogsSecurityEventOnSuccessfulWrite() {
        Notification::fake();
        $uri = $this->registerBounceRoute('goliath.l1.edit', 'POST');

        $this->actingAs($this->users['user'], 'api')
            ->json('POST', $uri, ['foo' => 'bar'])
            ->assertStatus(200);

        $this->assertTrue(
            SecurityEvent::where('user_id', $this->users['user']->id)
                ->where('description', 'like', 'Successful pass%')->exists(),
            'Expected a success SecurityEvent to be recorded.'
        );
    }

    // Passwords in the payload are obscured, not stored.
    public function testBouncerObscuresPasswordInSecurityEvent() {
        Notification::fake();
        $uri = $this->registerBounceRoute('goliath.l1.edit', 'POST');

        $this->actingAs($this->users['user'], 'api')->json('POST', $uri, ['password' => 'hunter2']);

        $event = SecurityEvent::where('user_id', $this->users['user']->id)
            ->where('description', 'like', 'Successful pass%')->first();

        $this->assertNotNull($event);
        $this->assertSame('"OBSCURED FOR SECURITY"', $event->payload);
    }

    // A rejection records a null-user event and fires the Slack alert.
    public function testBouncerRecordsFailureAndAlertsOnReject() {
        Notification::fake();
        $uri = $this->registerBounceRoute('mercury.manage', 'POST');

        $this->actingAs($this->users['user'], 'api')
            ->json('POST', $uri, ['foo' => 'bar'])
            ->assertStatus(401);

        $this->assertTrue(
            SecurityEvent::whereNull('user_id')
                ->where('description', 'like', 'Failed pass%')->exists(),
            'Expected a failure SecurityEvent to be recorded.'
        );

        Notification::assertSentOnDemand(SlackNotification::class);
    }

    // --- Lifecycle helpers (real login, no actingAs) ---

    private function makeUserWithPassword(string $email, string $password): User {
        $user = User::create([
            'phone'           => null,
            'password'        => '',
            'group_id'        => $this->users['user']->group_id,
            'organisation_id' => $this->organisations['A']->id,
            'name'            => 'Lifecycle',
            'email'           => $email,
        ]);

        // Write the hash straight to the column so this works whether or not the User
        // model uses Laravel's 'hashed' password cast (avoids accidental double-hashing).
        DB::table('users')->where('id', $user->id)->update(['password' => bcrypt($password)]);

        return $user->fresh();
    }

    private function login(string $email, string $password) {
        return $this->json('POST', $this->authUrl('login'), ['email' => $email, 'password' => $password]);
    }

    // Login issues a usable bearer token — the path actingAs() never walks.
    // Doubles as proof the guard's driver is really 'sanctum' (a session driver would
    // never authenticate a bearer token) and that sanctum.expiration isn't 0/null.
    public function testLoginIssuesUsableBearerTokenWithFutureExpiry() {
        $this->withExceptionHandling();

        $this->makeUserWithPassword('creatia+login@globalsecurelayer.com', 'correct-horse-battery');

        $token = $this->login('creatia+login@globalsecurelayer.com', 'correct-horse-battery')
            ->assertStatus(200)
            ->json('access_token');

        $this->assertNotEmpty($token, 'Login did not return an access_token.');

        // Future expiry guards the sanctum.expiration => 0/null landmine (dead-on-arrival tokens).
        $row = PersonalAccessToken::findToken($token);
        $this->assertNotNull($row, 'Issued token was not found in personal_access_tokens.');
        $this->assertTrue(
            $row->expires_at !== null && $row->expires_at->isFuture(),
            'Issued token has no future expiry — check sanctum.expiration is a positive integer.'
        );

        // It must actually authenticate a protected route via the bearer header.
        $this->withToken($token)->json('GET', '/api/auth/information')->assertStatus(200);
    }

    // Wrong password is rejected and records a FailedLogin row for the known user.
    public function testLoginRejectsInvalidCredentialsAndRecordsFailedLogin() {
        $user = $this->makeUserWithPassword('creatia+badpass@globalsecurelayer.com', 'right-password');

        $this->login('creatia+badpass@globalsecurelayer.com', 'wrong-password')
            ->assertStatus(401)
            ->assertJson(['detail' => 'Invalid credentials']);

        $this->assertSame(1, FailedLogin::where('user_id', $user->id)->count(),
            'A failed login attempt should have been recorded.');
    }

    // Unknown email is rejected without recording a FailedLogin (enumeration resistance).
    public function testLoginDoesNotRecordFailedLoginForUnknownEmail() {
        $this->login('creatia+ghost@globalsecurelayer.com', 'whatever')
            ->assertStatus(401)
            ->assertJson(['detail' => 'Invalid credentials']);

        $this->assertSame(0, FailedLogin::count(), 'No FailedLogin should exist for an unknown email.');
    }

    // Per-account lockout short-circuits before credentials are checked: a correct
    // password still returns 429 once the account is over the failed-attempt threshold.
    public function testAccountLockoutTakesPrecedenceOverValidCredentials() {
        $user = $this->makeUserWithPassword('creatia+locked@globalsecurelayer.com', 'right-password');

        $threshold = (int) config('security.account_login_attempts');
        for ($i = 0; $i < $threshold; $i++) {
            FailedLogin::create(['ip' => '127.0.0.1', 'user_agent' => 'phpunit', 'user_id' => $user->id]);
        }

        $this->login('creatia+locked@globalsecurelayer.com', 'right-password')
            ->assertStatus(429)
            ->assertJson(['detail' => 'Attempts exceeded. Try again later']);
    }

    public function testExpiredTokenIsRejected() {
        $this->withExceptionHandling();

        $user  = $this->makeUserWithPassword('creatia+expiry@globalsecurelayer.com', 'pw');
        $token = $user->createToken('api', ['*'], now()->addMinutes(5))->plainTextToken;

        $this->withToken($token)->json('GET', '/api/auth/information')->assertStatus(200);

        $this->travel(10)->minutes();

        $this->forgetAuth();   // <-- force re-resolution against the now-expired token
        $this->withToken($token)->json('GET', '/api/auth/information')->assertStatus(401);
    }

    public function testRefreshIssuesNewTokenAndRevokesOld() {
        $this->withExceptionHandling();

        $this->makeUserWithPassword('creatia+refresh@globalsecurelayer.com', 'pw');
        $old = $this->login('creatia+refresh@globalsecurelayer.com', 'pw')->json('access_token');

        $new = $this->withToken($old)->json('GET', '/api/auth/refresh')   // <-- path fixed
            ->assertStatus(200)
            ->json('access_token');

        $this->assertNotEmpty($new);
        $this->assertNotSame($old, $new, 'Refresh must return a different token.');

        $this->forgetAuth();
        $this->withToken($new)->json('GET', '/api/auth/information')->assertStatus(200);

        $this->forgetAuth();
        $this->withToken($old)->json('GET', '/api/auth/information')->assertStatus(401);
    }

    public function testLogoutRevokesCurrentToken() {
        $this->withExceptionHandling();

        $this->makeUserWithPassword('creatia+logout@globalsecurelayer.com', 'pw');
        $token = $this->login('creatia+logout@globalsecurelayer.com', 'pw')->json('access_token');

        $this->withToken($token)->json('GET', '/api/auth/logout')->assertStatus(200);

        $this->forgetAuth();
        $this->withToken($token)->json('GET', '/api/auth/information')->assertStatus(401);
    }

}
