<?php

namespace Tests\Feature;

use App\Models\FailedLogin;
use App\Models\Group;
use App\Models\Organisation;
use App\Models\SecurityEvent;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;

class MfaModuleTest extends TestCase
{

    use RefreshDatabase;

    private $organisations = [];
    private $manager_group_id;
    private $admin_group_id;
    private $google2fa;

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

        $this->google2fa = new Google2FA;

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

        $this->withoutMiddleware()->json('POST', '/api/admin/group', [ 'name' => 'Manager', 'scopes' => explode(' ', 'organisation.manage=1') ]);
        $this->withoutMiddleware()->json('POST', '/api/admin/group', [ 'name' => 'SysAdmin', 'scopes' => explode(' ', 'management.view=1 management.write=1') ]);
        $this->app->instance('middleware.disable', false);

        $this->manager_group_id = Group::where('name', '=', 'Manager')->first()->id;
        $this->admin_group_id = Group::where('name', '=', 'SysAdmin')->first()->id;
    }

    // --- Helpers ---

    private function makeUser(string $email, string $password, string $organisation = 'A', $group_id = null): User {
        $user = User::create([
            'phone'           => null,
            'password'        => '',
            'group_id'        => $group_id,
            'organisation_id' => $this->organisations[$organisation]->id,
            'name'            => 'Mfa',
            '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();
    }

    // Establishes a confirmed enrollment directly, decoupling the login tests from the enrollment endpoints.
    private function enableMfa(User $user): string {
        $secret = $this->google2fa->generateSecretKey(32);

        $user->totp_secret = $secret;
        $user->totp_confirmed_at = now();
        $user->totp_last_slice = null;
        $user->save();

        return $secret;
    }

    private function login(string $email, string $password, ?string $code = null) {
        $payload = [ 'email' => $email, 'password' => $password ];

        if($code !== null) {
            $payload['code'] = $code;
        }

        return $this->json('POST', '/api/auth/login', $payload);
    }

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

    private function clearLoginLimiter(): void {
        RateLimiter::clear('login-attempt:127.0.0.1');
    }

    private function codeAt(string $secret, int $offset): string {
        return $this->google2fa->oathTotp($secret, $this->google2fa->getTimestamp() + $offset);
    }

    // A six digit code guaranteed not to verify against any slice near the current window.
    private function invalidCodeFor(string $secret): string {
        $valid = [];
        $now = $this->google2fa->getTimestamp();

        for($i = -3; $i <= 3; $i++) {
            $valid[] = $this->google2fa->oathTotp($secret, $now + $i);
        }

        $candidate = 0;

        while(in_array(str_pad((string) $candidate, 6, '0', STR_PAD_LEFT), $valid, true)) {
            $candidate++;
        }

        return str_pad((string) $candidate, 6, '0', STR_PAD_LEFT);
    }

    // --- Enrollment ---

    public function testEnrollmentLifecycle(): void {
        $user = $this->makeUser('creatia+enroll@globalsecurelayer.com', 'correct-horse-battery');

        // Confirming without a setup in progress is refused.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/confirm', [ 'code' => '123456' ])
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'No MFA setup in progress' ]);

        // Setup hands back the secret and a provisioning URI for the SPA to render as a QR.
        $setup = $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/setup');
        $setup->assertStatus(200);

        $secret = $setup->json('secret');

        $this->assertNotEmpty($secret, 'Setup should return the generated secret.');
        $this->assertStringStartsWith('otpauth://totp/', $setup->json('otpauth_uri'));
        $this->assertStringContainsString($secret, $setup->json('otpauth_uri'));
        $this->assertStringContainsString(rawurlencode($user->email), $setup->json('otpauth_uri'));

        // Enrollment is pending, not enforced.
        $status = $this->actingAs($user, 'api')->json('GET', '/api/auth/mfa');
        $this->assertFalse($status->json('enabled'));
        $this->assertTrue($status->json('pending'));

        // A wrong code cannot confirm the enrollment.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/confirm', [ 'code' => $this->invalidCodeFor($secret) ])
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'Invalid MFA code' ]);

        $this->assertFalse($user->fresh()->mfaEnabled());

        // A malformed code is a validation failure, not a code failure.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/confirm', [ 'code' => 'abc' ])
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'Invalid Payload' ]);

        // The current code confirms and enables.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/confirm', [ 'code' => $this->google2fa->getCurrentOtp($secret) ])
            ->assertStatus(200)
            ->assertJson([ 'detail' => 'MFA enabled' ]);

        $status = $this->actingAs($user, 'api')->json('GET', '/api/auth/mfa');
        $this->assertTrue($status->json('enabled'));
        $this->assertFalse($status->json('pending'));

        $this->assertTrue(
            SecurityEvent::where('user_id', $user->id)->where('description', '=', 'MFA enabled')->exists(),
            'Enabling MFA should record a security event.'
        );

        // Re-running setup on an enabled account is refused - The factor must be proven via disable first.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/setup')
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'MFA already enabled' ]);
    }

    public function testSecretIsStoredEncrypted(): void {
        $user = $this->makeUser('creatia+crypt@globalsecurelayer.com', 'correct-horse-battery');

        $secret = $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/setup')->json('secret');

        $raw = DB::table('users')->where('id', $user->id)->value('totp_secret');

        $this->assertNotEmpty($raw);
        $this->assertStringNotContainsString($secret, $raw, 'The secret must not be readable in the raw column.');
        $this->assertSame($secret, $user->fresh()->totp_secret, 'The model should decrypt back to the plaintext secret.');

        // The secret is encrypted under the dedicated MFA key, not the application key.
        try {
            Crypt::decryptString($raw);
            $this->fail('The raw column must not be decryptable with the application key.');
        }
        catch(DecryptException $e) {
            $this->assertTrue(true);
        }
    }

    // --- Login enforcement ---

    public function testLoginUnchangedForUserWithoutMfa(): void {
        $this->makeUser('creatia+nomfa@globalsecurelayer.com', 'correct-horse-battery');

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

        $response->assertStatus(200);
        $this->assertFalse($response->json('mfa'), 'The login payload should report MFA as not enrolled.');
    }

    public function testLoginChallengesEnabledUser(): void {
        $user = $this->makeUser('creatia+challenge@globalsecurelayer.com', 'correct-horse-battery');
        $secret = $this->enableMfa($user);

        // First leg of the flow: valid password, no code - Challenged without penalty.
        $this->login('creatia+challenge@globalsecurelayer.com', 'correct-horse-battery')
            ->assertStatus(401)
            ->assertJson([ 'detail' => 'MFA code required' ]);

        $this->assertSame(0, FailedLogin::where('user_id', $user->id)->count(),
            'An absent code must not count towards the account lockout.');

        // A wrong code gets the same response but does count as a failed attempt.
        $this->login('creatia+challenge@globalsecurelayer.com', 'correct-horse-battery', $this->invalidCodeFor($secret))
            ->assertStatus(401)
            ->assertJson([ 'detail' => 'MFA code required' ]);

        $this->assertSame(1, FailedLogin::where('user_id', $user->id)->count(),
            'A wrong code must count towards the account lockout.');
        $this->assertTrue(
            SecurityEvent::where('user_id', $user->id)->where('description', '=', 'Failed MFA code')->exists(),
            'A wrong code should record a security event.'
        );

        // The valid code completes the login with a usable bearer.
        $response = $this->login('creatia+challenge@globalsecurelayer.com', 'correct-horse-battery', $this->google2fa->getCurrentOtp($secret));

        $response->assertStatus(200);
        $this->assertTrue($response->json('mfa'), 'The login payload should report MFA as enrolled.');

        $token = $response->json('access_token');
        $this->assertNotEmpty($token);

        $this->forgetAuth();
        $me = $this->withToken($token)->json('GET', '/api/auth/information');

        $me->assertStatus(200);
        $this->assertTrue($me->json('mfa'), 'The information payload should report MFA as enrolled.');
    }

    public function testUsedCodeCannotBeReplayed(): void {
        $user = $this->makeUser('creatia+replay@globalsecurelayer.com', 'correct-horse-battery');
        $secret = $this->enableMfa($user);

        $code = $this->google2fa->getCurrentOtp($secret);

        $this->login('creatia+replay@globalsecurelayer.com', 'correct-horse-battery', $code)->assertStatus(200);

        $this->assertNotNull($user->fresh()->totp_last_slice, 'The accepted timeslice should be persisted.');

        // The same code within its validity window is refused on a second use.
        $this->forgetAuth();
        $this->login('creatia+replay@globalsecurelayer.com', 'correct-horse-battery', $code)
            ->assertStatus(401)
            ->assertJson([ 'detail' => 'MFA code required' ]);
    }

    public function testDriftWindowBounds(): void {
        $user = $this->makeUser('creatia+drift@globalsecurelayer.com', 'correct-horse-battery');
        $secret = $this->enableMfa($user);

        // A wider window than the default keeps both assertions deterministic even if real time
        // crosses a 30 second slice boundary between minting a code here and verifying it there.
        config()->set('security.mfa_totp_window', 2);

        // A code from beyond the drift window is refused.
        $this->login('creatia+drift@globalsecurelayer.com', 'correct-horse-battery', $this->codeAt($secret, -5))
            ->assertStatus(401)
            ->assertJson([ 'detail' => 'MFA code required' ]);

        // A stale code within the drift window is tolerated.
        $this->login('creatia+drift@globalsecurelayer.com', 'correct-horse-battery', $this->codeAt($secret, -1))
            ->assertStatus(200);
    }

    public function testLockoutAfterRepeatedWrongCodes(): void {
        $user = $this->makeUser('creatia+codelock@globalsecurelayer.com', 'correct-horse-battery');
        $secret = $this->enableMfa($user);

        $threshold = (int) config('security.account_login_attempts');

        for($i = 0; $i < $threshold; $i++) {
            $this->login('creatia+codelock@globalsecurelayer.com', 'correct-horse-battery', $this->invalidCodeFor($secret))
                ->assertStatus(401);

            // The per-IP limiter would trip first - This test targets the per-account lockout.
            $this->clearLoginLimiter();
        }

        $this->assertSame($threshold, FailedLogin::where('user_id', $user->id)->count());

        // Even a valid password and code are refused once the account is locked out.
        $this->login('creatia+codelock@globalsecurelayer.com', 'correct-horse-battery', $this->google2fa->getCurrentOtp($secret))
            ->assertStatus(429)
            ->assertJson([ 'detail' => 'Attempts exceeded. Try again later' ]);
    }

    // --- Internal network bypass ---

    public function testBypassSkipsChallengeFromInternalBlock(): void {
        $user = $this->makeUser('creatia+bypass@globalsecurelayer.com', 'correct-horse-battery');
        $this->enableMfa($user);

        config()->set('security.mfa_bypass_blocks', [ '127.0.0.0/8' ]);

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

        $this->assertTrue(
            SecurityEvent::where('user_id', $user->id)->where('description', '=', 'MFA bypassed - internal network')->exists(),
            'Using the bypass should record a security event.'
        );
    }

    public function testBypassIgnoresNonMatchingBlocks(): void {
        $this->makeUser('creatia+noskip@globalsecurelayer.com', 'correct-horse-battery');
        $this->enableMfa(User::where('email', '=', 'creatia+noskip@globalsecurelayer.com')->first());

        config()->set('security.mfa_bypass_blocks', [ '10.0.0.0/8', '192.168.0.0/16' ]);

        $this->login('creatia+noskip@globalsecurelayer.com', 'correct-horse-battery')
            ->assertStatus(401)
            ->assertJson([ 'detail' => 'MFA code required' ]);
    }

    // --- Disable ---

    public function testDisableLifecycle(): void {
        $user = $this->makeUser('creatia+disable@globalsecurelayer.com', 'correct-horse-battery');

        // Disabling without an enrollment is refused.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/disable', [ 'code' => '123456' ])
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'MFA not enabled' ]);

        $secret = $this->enableMfa($user->fresh());

        // actingAs() pinned the pre-enrollment instance into the guard - Swap in the enabled one.
        $this->forgetAuth();
        $user = $user->fresh();

        // A wrong code cannot strip the factor.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/disable', [ 'code' => $this->invalidCodeFor($secret) ])
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'Invalid MFA code' ]);

        $this->assertTrue($user->fresh()->mfaEnabled());

        // The current code disables and clears the enrollment entirely.
        $this->actingAs($user, 'api')->json('POST', '/api/auth/mfa/disable', [ 'code' => $this->google2fa->getCurrentOtp($secret) ])
            ->assertStatus(200)
            ->assertJson([ 'detail' => 'MFA disabled' ]);

        $fresh = $user->fresh();
        $this->assertFalse($fresh->mfaEnabled());
        $this->assertNull($fresh->totp_secret);
        $this->assertNull($fresh->totp_last_slice);

        $this->assertTrue(
            SecurityEvent::where('user_id', $user->id)->where('description', '=', 'MFA disabled')->exists(),
            'Disabling MFA should record a security event.'
        );

        // Login is password-only again.
        $this->forgetAuth();
        $this->login('creatia+disable@globalsecurelayer.com', 'correct-horse-battery')->assertStatus(200);
    }

    // --- Admin reset ---

    public function testAdminResetClearsEnrollment(): void {
        Notification::fake();

        $manager = $this->makeUser('creatia+manager@globalsecurelayer.com', 'pw', 'A', $this->manager_group_id);
        $target = $this->makeUser('creatia+target@globalsecurelayer.com', 'pw', 'A');
        $outsider = $this->makeUser('creatia+outsider@globalsecurelayer.com', 'pw', 'B');

        $this->enableMfa($target);
        $this->enableMfa($outsider);

        // Runs through the real auth + bounce middleware - The manager group holds organisation.manage.
        $this->actingAs($manager, 'api')->json('DELETE', '/api/user/' . $target->id . '/mfa')
            ->assertStatus(200)
            ->assertJson([ 'detail' => 'MFA reset' ]);

        $fresh = $target->fresh();
        $this->assertFalse($fresh->mfaEnabled());
        $this->assertNull($fresh->totp_secret);

        $this->assertTrue(
            SecurityEvent::where('user_id', $manager->id)->where('description', '=', 'MFA reset for user')->exists(),
            'An admin reset should record a security event against the acting manager.'
        );

        // A target outside the manager's organisation is refused and untouched.
        $this->actingAs($manager, 'api')->json('DELETE', '/api/user/' . $outsider->id . '/mfa')
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'Invalid Payload' ]);

        $this->assertTrue($outsider->fresh()->mfaEnabled());
    }

    public function testAdminResetRequiresPermission(): void {
        Notification::fake();

        $plain = $this->makeUser('creatia+plain@globalsecurelayer.com', 'pw', 'A');
        $target = $this->makeUser('creatia+victim@globalsecurelayer.com', 'pw', 'A');

        $this->enableMfa($target);

        $this->actingAs($plain, 'api')->json('DELETE', '/api/user/' . $target->id . '/mfa')
            ->assertStatus(401)
            ->assertJson([ 'detail' => 'Insufficient Permissions' ]);

        $this->assertTrue($target->fresh()->mfaEnabled());
    }

    public function testSystemAdminResetClearsEnrollmentAcrossOrganisations(): void {
        Notification::fake();

        $admin = $this->makeUser('creatia+sysadmin@globalsecurelayer.com', 'pw', 'A', $this->admin_group_id);
        $target = $this->makeUser('creatia+elsewhere@globalsecurelayer.com', 'pw', 'B');

        $this->enableMfa($target);

        // The system administrator plane is not organisation-scoped - Org B is reachable from org A.
        $this->actingAs($admin, 'api')->json('DELETE', '/api/admin/user/' . $target->id . '/mfa')
            ->assertStatus(200)
            ->assertJson([ 'detail' => 'MFA reset' ]);

        $fresh = $target->fresh();
        $this->assertFalse($fresh->mfaEnabled());
        $this->assertNull($fresh->totp_secret);

        $this->assertTrue(
            SecurityEvent::where('user_id', $admin->id)->where('description', '=', 'MFA reset for user')->exists(),
            'A system admin reset should record a security event against the acting admin.'
        );

        // A nonexistent target is refused.
        $this->actingAs($admin, 'api')->json('DELETE', '/api/admin/user/999999/mfa')
            ->assertStatus(400)
            ->assertJson([ 'detail' => 'Invalid Payload' ]);
    }

    public function testSystemAdminResetRequiresManagementWrite(): void {
        Notification::fake();

        // An organisation manager holds organisation.manage but not management.write.
        $manager = $this->makeUser('creatia+orgmanager@globalsecurelayer.com', 'pw', 'A', $this->manager_group_id);
        $target = $this->makeUser('creatia+shielded@globalsecurelayer.com', 'pw', 'B');

        $this->enableMfa($target);

        $this->actingAs($manager, 'api')->json('DELETE', '/api/admin/user/' . $target->id . '/mfa')
            ->assertStatus(401)
            ->assertJson([ 'detail' => 'Insufficient Permissions' ]);

        $this->assertTrue($target->fresh()->mfaEnabled());
    }

    // --- User lists ---

    public function testUserListsReportMfaEnrollment(): void {
        $manager = $this->makeUser('creatia+lister@globalsecurelayer.com', 'pw', 'A', $this->manager_group_id);
        $enrolled = $this->makeUser('creatia+enrolled@globalsecurelayer.com', 'pw', 'A');
        $admin = $this->makeUser('creatia+overseer@globalsecurelayer.com', 'pw', 'A', $this->admin_group_id);

        $this->enableMfa($enrolled);

        // Organisation-scoped list.
        $rows = collect($this->actingAs($manager, 'api')->json('GET', '/api/user?draw=0&start=0&length=50')
            ->assertStatus(200)
            ->json('data'));

        $this->assertSame(1, $rows->firstWhere('email', 'creatia+enrolled@globalsecurelayer.com')['mfa'],
            'The org user list should flag an enrolled account.');
        $this->assertSame(0, $rows->firstWhere('email', 'creatia+lister@globalsecurelayer.com')['mfa'],
            'The org user list should flag an unenrolled account.');

        // System administrator list.
        $rows = collect($this->actingAs($admin, 'api')->json('GET', '/api/admin/user?draw=0&start=0&length=50')
            ->assertStatus(200)
            ->json('data'));

        $this->assertSame(1, $rows->firstWhere('email', 'creatia+enrolled@globalsecurelayer.com')['mfa'],
            'The admin user list should flag an enrolled account.');
        $this->assertSame(0, $rows->firstWhere('email', 'creatia+overseer@globalsecurelayer.com')['mfa'],
            'The admin user list should flag an unenrolled account.');
    }

}
