<?php

declare(strict_types=1);

namespace Apexl\Tests\User\FeatureTests;

use apexl\Io\modules\user\entities\TwoFactorMethodEntity;
use apexl\Io\modules\user\entities\userEntity;
use apexl\Io\modules\user\exceptions\InvalidTwoFactorCodeException;
use apexl\Io\modules\user\exceptions\TwoFactorException;
use apexl\Io\modules\user\exceptions\TwoFactorLockedException;
use apexl\Io\modules\user\services\twoFactorAuth\TwoFactorAuthService;
use apexl\Io\TestInfrastructure\FeatureTest;
use Apexl\Tests\User\Helpers\ManagesTwoFactorConfig;
use PHPUnit\Framework\Attributes\Test;
use RobThree\Auth\Providers\Qr\BaconQrCodeProvider;
use RobThree\Auth\TwoFactorAuth;
use function apexl\Io\container;

final class TwoFactorAuthServiceTest extends FeatureTest
{
    use ManagesTwoFactorConfig;

    protected function tearDown(): void
    {
        $this->restoreTwoFactorConfig();
        parent::tearDown();
    }

    private function service(): TwoFactorAuthService
    {
        return container()->get(TwoFactorAuthService::class);
    }

    /**
     * Create a user with guaranteed-unique email + nfc_key so parallel factory
     * users in the shared test harness never collide on those unique indexes.
     */
    private function makeUser(array $attrs = []): userEntity
    {
        $user = userEntity::factory()->create([
            'email' => $this->faker->unique()->safeEmail(),
            'nfc_key' => $this->faker->unique()->uuid(),
            // Pin the flag explicitly: the shared harness's fresh-user rows are
            // otherwise non-deterministic on this newly-added column.
            'two_factor_enabled' => false,
            ...$attrs,
        ]);
        $this->assertNotNull($user->id, 'harness produced a null-id user');

        return $user;
    }

    private function codeFor(string $secret): string
    {
        return new TwoFactorAuth(new BaconQrCodeProvider(4, '#fff', '#000', 'svg'), 'x')
            ->getCode($secret, time());
    }

    /** Full enrolment: begin + confirm; returns [user, secret]. */
    private function enrol(userEntity $user): string
    {
        $setup = $this->service()->beginSetup($user);
        $this->service()->completeSetup($user, $this->codeFor($setup->secret));

        return $setup->secret;
    }

    #[Test]
    public function setupIsRefusedWhenModeIsOff(): void
    {
        $this->setTwoFactorMode('off');
        $user = $this->makeUser();

        $this->expectException(TwoFactorException::class);
        $this->service()->beginSetup($user);
    }

    #[Test]
    public function beginSetupCreatesAnInactiveEncryptedMethod(): void
    {
        $this->setTwoFactorMode('optional');
        $user = $this->makeUser();

        $setup = $this->service()->beginSetup($user);

        $this->assertSame('totp', $setup->type);
        $this->assertNotEmpty($setup->secret);
        $this->assertStringStartsWith('otpauth://totp/', $setup->provisioningUri);
        $this->assertStringStartsWith('data:image/svg+xml', $setup->qrCodeDataUri);

        // Persisted, inactive, and NOT storing the secret in plaintext.
        $method = TwoFactorMethodEntity::fromPrimaryKey((int) $setup->methodId);
        $this->assertFalse((bool) $method->is_active);
        $this->assertNotSame($setup->secret, $method->secret);
        $this->assertFalse($this->service()->isEnabledForUser($user));
    }

    #[Test]
    public function completeSetupActivatesFlagsTheUserAndIssuesRecoveryCodes(): void
    {
        $this->setTwoFactorMode('optional');
        $user = $this->makeUser();

        $setup = $this->service()->beginSetup($user);
        $recoveryCodes = $this->service()->completeSetup($user, $this->codeFor($setup->secret));

        $this->assertCount(8, $recoveryCodes);
        $this->assertSame($recoveryCodes, array_unique($recoveryCodes));

        $fresh = userEntity::fromPrimaryKey($user->id);
        $this->assertTrue((bool) $fresh->two_factor_enabled);
        $this->assertNotNull($fresh->two_factor_confirmed_at);
        $this->assertTrue($this->service()->isEnabledForUser($fresh));
    }

    #[Test]
    public function completeSetupRejectsAWrongCodeAndDoesNotActivate(): void
    {
        $this->setTwoFactorMode('optional');
        $user = $this->makeUser();
        $this->service()->beginSetup($user);

        $this->expectException(InvalidTwoFactorCodeException::class);
        try {
            $this->service()->completeSetup($user, '000000');
        } finally {
            // The meaningful signal: a rejected setup leaves NO active method,
            // so the user is never gated regardless of the raw flag.
            $fresh = userEntity::fromPrimaryKey($user->id);
            $this->assertFalse($this->service()->hasActiveMethod($fresh));
            $this->assertFalse($this->service()->isEnabledForUser($fresh));
        }
    }

    #[Test]
    public function challengeCodeVerifiesAndWrongCodesAreRejected(): void
    {
        $this->setTwoFactorMode('optional');
        $user = $this->makeUser();
        $secret = $this->enrol($user);

        $this->assertTrue($this->service()->verifyChallengeCode($user, $this->codeFor($secret)));
        $this->assertFalse($this->service()->verifyChallengeCode($user, '000000'));
    }

    #[Test]
    public function tooManyFailuresLockTheMethod(): void
    {
        $this->setTwoFactorConfig([
            'auth.two_factor.mode' => 'optional',
            'auth.two_factor.lockout_max_attempts' => 3,
            'auth.two_factor.lockout_minutes' => 15,
        ]);
        $user = $this->makeUser();
        $secret = $this->enrol($user);

        // 2 wrong attempts stay under the limit.
        $this->assertFalse($this->service()->verifyChallengeCode($user, '000000'));
        $this->assertFalse($this->service()->verifyChallengeCode($user, '000000'));

        // The 3rd trips the lockout.
        try {
            $this->service()->verifyChallengeCode($user, '000000');
            $this->fail('Expected lockout');
        } catch (TwoFactorLockedException) {
            // expected
        }

        // Even a CORRECT code is refused while the method is locked.
        $this->expectException(TwoFactorLockedException::class);
        $this->service()->verifyChallengeCode($user, $this->codeFor($secret));
    }

    #[Test]
    public function recoveryCodesAreSingleUse(): void
    {
        $this->setTwoFactorMode('optional');
        $user = $this->makeUser();
        $setup = $this->service()->beginSetup($user);
        $codes = $this->service()->completeSetup($user, $this->codeFor($setup->secret));

        $first = $codes[0];
        $this->assertTrue($this->service()->consumeRecoveryCode($user, $first));
        // Second use of the same code must fail (burned).
        $this->assertFalse($this->service()->consumeRecoveryCode($user, $first));
        // A different, still-unused code works.
        $this->assertTrue($this->service()->consumeRecoveryCode($user, $codes[1]));
    }

    #[Test]
    public function adminResetClearsMethodsRecoveryCodesAndFlags(): void
    {
        $this->setTwoFactorMode('enforced');
        $user = $this->makeUser();
        $this->enrol($user);
        $this->assertTrue($this->service()->isEnabledForUser(userEntity::fromPrimaryKey($user->id)));

        $this->service()->reset(userEntity::fromPrimaryKey($user->id), actorId: 1);

        $fresh = userEntity::fromPrimaryKey($user->id);
        $this->assertFalse((bool) $fresh->two_factor_enabled);
        $this->assertNull($fresh->two_factor_confirmed_at);
        $this->assertFalse($this->service()->hasActiveMethod($fresh));
    }
}
