<?php

use App\Services\OtpMailer;
use App\Services\OtpService;
use App\Services\AuthSessionService;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use Config\Email as EmailConfig;
use Config\Session;
use Tests\Support\Auth\ArrayOtpMailer;
use Tests\Support\Auth\AuthTestTrait;
use Tests\Support\Auth\FailingOtpMailer;

/**
 * @internal
 */
final class OtpServiceTest extends CIUnitTestCase
{
    use DatabaseTestTrait;
    use AuthTestTrait;

    protected $namespace = 'App';
    protected $refresh   = true;
    protected $migrate   = true;
    protected $seed      = '';

    private ArrayOtpMailer $mailer;
    private OtpService $otp;

    protected function setUp(): void
    {
        parent::setUp();
        $this->mailer = new ArrayOtpMailer();
        $this->otp    = new OtpService(mailer: $this->mailer);
    }

    public function testInvalidEmailRejected(): void
    {
        $result = $this->otp->send('not-an-email');
        $this->assertFalse($result['ok']);
        $this->assertSame('invalid_email', $result['reason']);
        $this->assertSame(OtpService::MSG_INVALID_EMAIL, $result['message']);
        $this->assertSame([], $this->mailer->sent);
    }

    public function testOtpIsExactlySixDigitsAndNotStoredRaw(): void
    {
        $email  = 'otp-format@example.com';
        $result = $this->otp->send($email);
        $this->assertTrue($result['ok']);

        $code = $this->mailer->lastOtp();
        $this->assertNotNull($code);
        $this->assertMatchesRegularExpression('/^\d{6}$/', $code);

        $row = $this->latestOtpRow($email);
        $this->assertNotNull($row);
        $this->assertNotSame($code, $row['otp_hash']);
        $this->assertDoesNotMatchRegularExpression('/^\d{6}$/', (string) $row['otp_hash']);
        $this->assertSame(64, strlen((string) $row['otp_hash']));
        $this->assertSame(0, (int) $row['attempt_count']);

        $expires = strtotime((string) $row['expires_at']);
        $this->assertGreaterThan(time() + 9 * 60, $expires);
        $this->assertLessThanOrEqual(time() + 10 * 60 + 2, $expires);
    }

    public function testValidOtpAuthenticatesAndCannotBeReused(): void
    {
        $email = 'verify-ok@example.com';
        $this->otp->send($email);
        $code = $this->mailer->lastOtp();

        $first = $this->otp->verify($email, (string) $code);
        $this->assertTrue($first['ok']);

        $row = $this->latestOtpRow($email);
        $this->assertNotNull($row['verified_at']);

        $second = $this->otp->verify($email, (string) $code);
        $this->assertFalse($second['ok']);
        $this->assertSame('expired', $second['reason']);
    }

    public function testIncorrectOtpRejected(): void
    {
        $email = 'wrong-otp@example.com';
        $this->otp->send($email);

        $result = $this->otp->verify($email, '000000');
        $this->assertFalse($result['ok']);
        $this->assertSame('incorrect', $result['reason']);
        $this->assertSame(OtpService::MSG_INCORRECT, $result['message']);

        $tooShort = $this->otp->verify($email, '12345');
        $this->assertFalse($tooShort['ok']);
        $this->assertSame('incorrect', $tooShort['reason']);
    }

    public function testOtpExpiresAfterTenMinutes(): void
    {
        $email = 'expired-otp@example.com';
        $this->otp->send($email);
        $code = $this->mailer->lastOtp();
        $this->backdateLatestOtp($email, 11 * 60, 60);

        $result = $this->otp->verify($email, (string) $code);
        $this->assertFalse($result['ok']);
        $this->assertSame('expired', $result['reason']);
        $this->assertSame(OtpService::MSG_EXPIRED, $result['message']);
    }

    public function testMaximumFiveFailedAttemptsEnforced(): void
    {
        $email = 'attempts@example.com';
        $this->otp->send($email);
        $code = $this->mailer->lastOtp();

        for ($i = 1; $i <= 4; $i++) {
            $result = $this->otp->verify($email, '111111');
            $this->assertSame('incorrect', $result['reason'], "attempt {$i}");
        }

        $fifth = $this->otp->verify($email, '111111');
        $this->assertFalse($fifth['ok']);
        $this->assertSame('too_many', $fifth['reason']);
        $this->assertSame(OtpService::MSG_TOO_MANY, $fifth['message']);

        $blocked = $this->otp->verify($email, (string) $code);
        $this->assertFalse($blocked['ok']);
        $this->assertContains($blocked['reason'], ['too_many', 'expired']);
    }

    public function testResendCooldownEnforcedServerSide(): void
    {
        $email = 'cooldown@example.com';
        $this->assertTrue($this->otp->send($email)['ok']);

        $again = $this->otp->resend($email);
        $this->assertFalse($again['ok']);
        $this->assertSame('cooldown', $again['reason']);
        $this->assertSame(OtpService::MSG_COOLDOWN, $again['message']);
        $this->assertGreaterThan(0, $this->otp->cooldownRemaining($email));
        $this->assertCount(1, $this->mailer->sent);
    }

    public function testResendCreatesNewOtpAndInvalidatesOld(): void
    {
        $email = 'resend@example.com';
        $this->otp->send($email);
        $old = $this->mailer->lastOtp();
        $this->backdateLatestOtp($email, 61);

        $resend = $this->otp->resend($email);
        $this->assertTrue($resend['ok']);
        $new = $this->mailer->lastOtp();
        $this->assertNotSame($old, $new);
        $this->assertMatchesRegularExpression('/^\d{6}$/', (string) $new);

        $oldResult = $this->otp->verify($email, (string) $old);
        $this->assertFalse($oldResult['ok']);

        $newResult = $this->otp->verify($email, (string) $new);
        $this->assertTrue($newResult['ok']);

        $rows = db_connect()->table('email_otps')->where('email', $email)->get()->getResultArray();
        $this->assertCount(2, $rows);
        foreach ($rows as $row) {
            $this->assertNotSame($old, $row['otp_hash']);
            $this->assertNotSame($new, $row['otp_hash']);
        }
    }

    public function testFiveSendsPerThirtyMinutesRateLimit(): void
    {
        $email = 'rate-limit@example.com';

        for ($i = 0; $i < 5; $i++) {
            if ($i > 0) {
                $this->backdateLatestOtp($email, 61);
            }
            $result = $this->otp->send($email);
            $this->assertTrue($result['ok'], "send {$i} should succeed");
        }

        $blocked = $this->otp->send($email);
        $this->assertFalse($blocked['ok']);
        $this->assertSame('rate_limited', $blocked['reason']);
        $this->assertSame(OtpService::MSG_RATE_LIMIT, $blocked['message']);
        $this->assertCount(5, $this->mailer->sent);
    }

    public function testSmtpFailureProducesFriendlyError(): void
    {
        $otp    = new OtpService(mailer: new FailingOtpMailer());
        $result = $otp->send('smtp-fail@example.com');
        $this->assertFalse($result['ok']);
        $this->assertSame('send_failure', $result['reason']);
        $this->assertSame(OtpService::MSG_SEND_FAIL, $result['message']);
        $this->assertStringNotContainsString('SMTP', $result['message']);
        $this->assertNull($this->latestOtpRow('smtp-fail@example.com'));
    }

    public function testMailboxMailerDeliversWithoutStoringRawOtp(): void
    {
        $config            = new EmailConfig();
        $config->protocol  = 'mailbox';
        $config->fromEmail = 'noreply@localhost';
        $config->fromName  = 'TIEASY';

        $otp   = new OtpService(mailer: new OtpMailer($config));
        $email = 'mailbox@example.com';
        $this->assertTrue($otp->send($email)['ok']);

        $files = glob(WRITEPATH . 'mailbox/*.txt') ?: [];
        $matchFile = null;
        foreach ($files as $file) {
            $contents = (string) file_get_contents($file);
            if (str_contains($contents, $email)) {
                $matchFile = $file;
                $body      = $contents;
                break;
            }
        }
        $this->assertNotNull($matchFile, 'Expected a mailbox file for ' . $email);
        $this->assertMatchesRegularExpression('/\b\d{6}\b/', $body);

        preg_match('/\b(\d{6})\b/', $body, $match);
        $code = $match[1];
        $row  = $this->latestOtpRow($email);
        $this->assertNotNull($row);
        $this->assertNotSame($code, $row['otp_hash']);
    }

    public function testNewEmailCreatesUserExistingNormalizedEmailDoesNotDuplicate(): void
    {
        $auth = new AuthSessionService();

        $first = $auth->identifyOrCreate('  New.User@Example.COM ');
        $this->assertIsArray($first);
        $this->assertSame('new.user@example.com', $first['email']);
        $this->assertSame(0, (int) $first['profile_onboarding_completed']);

        $second = $auth->identifyOrCreate('new.user@example.com');
        $this->assertSame($first['id'], $second['id']);

        $count = db_connect()->table('users')->where('email', 'new.user@example.com')->countAllResults();
        $this->assertSame(1, $count);
    }

    public function testSessionSavePathIsNeverUnderPublic(): void
    {
        $config = new Session();
        $this->assertNotSame('null', $config->savePath);
        $this->assertTrue(str_starts_with($config->savePath, WRITEPATH));
        $this->assertStringContainsString('session', $config->savePath);

        $public = realpath(FCPATH) ?: FCPATH;
        $this->assertFalse(str_starts_with(
            strtolower(str_replace('\\', '/', $config->savePath)),
            strtolower(str_replace('\\', '/', rtrim($public, '/\\'))) . '/',
        ));
        $this->assertDirectoryDoesNotExist(FCPATH . 'null');
    }
}
