<?php

namespace App\Services;

use App\Models\EmailOtpModel;
use App\Support\EmailAddress;
use Config\Encryption;
use Config\Tieasy;
use RuntimeException;

class OtpService
{
    public const MSG_INVALID_EMAIL = 'Please enter a valid email address.';
    public const MSG_INCORRECT     = 'Incorrect OTP. Please try again.';
    public const MSG_EXPIRED       = 'This OTP has expired. Please request a new OTP.';
    public const MSG_TOO_MANY      = 'Too many incorrect attempts. Please resend OTP.';
    public const MSG_COOLDOWN      = 'Please wait before requesting another OTP.';
    public const MSG_RATE_LIMIT    = 'Too many OTP requests. Please try again later.';
    public const MSG_SEND_FAIL     = 'We could not send the OTP. Please try again.';

    public function __construct(
        private readonly EmailOtpModel $otps = new EmailOtpModel(),
        private readonly Tieasy $config = new Tieasy(),
        private readonly ?OtpMailerInterface $mailer = null,
    ) {
    }

    public function send(string $email, bool $isResend = false): array
    {
        $email = EmailAddress::normalize($email);
        if (! EmailAddress::isValid($email)) {
            return $this->fail(self::MSG_INVALID_EMAIL, 'invalid_email');
        }

        $mailer = $this->mailer();
        if (! $mailer->isConfigured()) {
            log_message('error', 'OTP mailer is not configured.');

            return $this->fail(self::MSG_SEND_FAIL, 'send_failure');
        }

        if ($this->sendCountInWindow($email) >= $this->config->otpMaxSendsPerWindow) {
            return $this->fail(self::MSG_RATE_LIMIT, 'rate_limited');
        }

        $latest = $this->latestRow($email);
        if ($latest !== null && $this->secondsSinceCreated($latest) < $this->config->otpResendCooldownSeconds) {
            return $this->fail(self::MSG_COOLDOWN, 'cooldown', [
                'retry_after' => $this->config->otpResendCooldownSeconds - $this->secondsSinceCreated($latest),
            ]);
        }

        $otp = $this->generateOtp();

        try {
            $mailer->send($email, $otp);
        } catch (RuntimeException $exception) {
            log_message('error', 'OTP email send failed: {0}', [$exception->getMessage()]);

            return $this->fail(self::MSG_SEND_FAIL, 'send_failure');
        }

        $this->invalidateActive($email);

        $this->otps->insert([
            'email'         => $email,
            'otp_hash'      => $this->hashOtp($otp),
            'attempt_count' => 0,
            'expires_at'    => date('Y-m-d H:i:s', time() + ($this->config->otpExpiryMinutes * 60)),
            'verified_at'   => null,
            'created_at'    => date('Y-m-d H:i:s'),
        ]);

        return [
            'ok'          => true,
            'email'       => $email,
            'masked'      => EmailAddress::mask($email),
            'cooldown'    => $this->config->otpResendCooldownSeconds,
            'is_resend'   => $isResend,
            'message'     => 'OTP sent.',
        ];
    }

    public function resend(string $email): array
    {
        return $this->send($email, true);
    }

    public function verify(string $email, string $otp): array
    {
        $email = EmailAddress::normalize($email);
        $otp   = trim($otp);

        if (! EmailAddress::isValid($email)) {
            return $this->fail(self::MSG_INVALID_EMAIL, 'invalid_email');
        }

        if (! preg_match('/^\d{' . $this->config->otpLength . '}$/', $otp)) {
            return $this->fail(self::MSG_INCORRECT, 'incorrect');
        }

        $row = $this->latestRow($email);
        if ($row === null) {
            return $this->fail(self::MSG_EXPIRED, 'expired');
        }

        if ((int) $row['attempt_count'] >= $this->config->otpMaxAttempts) {
            return $this->fail(self::MSG_TOO_MANY, 'too_many');
        }

        if (strtotime((string) $row['expires_at']) <= time()) {
            return $this->fail(self::MSG_EXPIRED, 'expired');
        }

        if ($row['verified_at'] !== null) {
            return $this->fail(self::MSG_EXPIRED, 'expired');
        }

        if (! $this->otpMatches((string) $row['otp_hash'], $otp)) {
            $attempts = (int) $row['attempt_count'] + 1;
            $update   = ['attempt_count' => $attempts];

            if ($attempts >= $this->config->otpMaxAttempts) {
                $update['expires_at'] = date('Y-m-d H:i:s', time() - 1);
                $this->otps->update($row['id'], $update);

                return $this->fail(self::MSG_TOO_MANY, 'too_many');
            }

            $this->otps->update($row['id'], $update);

            return $this->fail(self::MSG_INCORRECT, 'incorrect');
        }

        $this->otps->update($row['id'], [
            'verified_at'   => date('Y-m-d H:i:s'),
            'attempt_count' => (int) $row['attempt_count'],
        ]);

        return [
            'ok'      => true,
            'email'   => $email,
            'message' => 'Verified.',
        ];
    }

    public function cooldownRemaining(string $email): int
    {
        $email = EmailAddress::normalize($email);
        $row   = $this->latestRow($email);
        if ($row === null) {
            return 0;
        }

        $wait = $this->config->otpResendCooldownSeconds - $this->secondsSinceCreated($row);

        return max(0, $wait);
    }

    private function mailer(): OtpMailerInterface
    {
        return $this->mailer ?? service('otpMailer');
    }

    private function generateOtp(): string
    {
        return str_pad((string) random_int(0, (10 ** $this->config->otpLength) - 1), $this->config->otpLength, '0', STR_PAD_LEFT);
    }

    private function hashOtp(string $otp): string
    {
        return hash_hmac('sha256', $otp, $this->pepper());
    }

    private function otpMatches(string $hash, string $otp): bool
    {
        return hash_equals($hash, $this->hashOtp($otp));
    }

    private function pepper(): string
    {
        $key = (string) config(Encryption::class)->key;
        if ($key === '') {
            throw new RuntimeException('encryption.key is not configured.');
        }

        return $key;
    }

    private function latestRow(string $email): ?array
    {
        $row = $this->otps->builder()
            ->where('email', $email)
            ->orderBy('id', 'DESC')
            ->limit(1)
            ->get()
            ->getRowArray();

        return $row === null ? null : $row;
    }

    private function invalidateActive(string $email): void
    {
        $this->otps->builder()
            ->where('email', $email)
            ->where('verified_at', null)
            ->update(['expires_at' => date('Y-m-d H:i:s', time() - 1)]);
    }

    private function sendCountInWindow(string $email): int
    {
        $since = date('Y-m-d H:i:s', time() - ($this->config->otpSendWindowMinutes * 60));

        return (int) $this->otps->builder()
            ->where('email', $email)
            ->where('created_at >=', $since)
            ->countAllResults();
    }

    private function secondsSinceCreated(array $row): int
    {
        $created = strtotime((string) $row['created_at']) ?: 0;

        return max(0, time() - $created);
    }

    /**
     * @param array<string, mixed> $extra
     *
     * @return array<string, mixed>
     */
    private function fail(string $message, string $reason, array $extra = []): array
    {
        return array_merge([
            'ok'      => false,
            'error'   => $message,
            'reason'  => $reason,
            'message' => $message,
        ], $extra);
    }
}
