<?php

namespace App\Domain\Identity\Services;

use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Helpers\CredentialNormalizer;
use App\Domain\WhatsApp\Services\WhatsAppService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;

/**
 * One-time codes over WhatsApp, for the one place they are genuinely needed:
 * proving a stranger's phone number before anything is written into `people`
 * or `participants`.
 *
 * The flow this replaces was a case study in how not to do it. Its `verify()`
 * accepted the constant `'0000'` whenever a system setting said 'demo' — which
 * was the seeded default — and resolved *any* active user by phone, academy
 * owners included, then minted a token with `mobile:*`. And in the non-demo
 * mode it generated a code, cached it, and **never sent it anywhere**, so
 * turning the bypass off locked everybody out rather than securing anything.
 *
 * So, deliberately:
 *
 *  - **No bypass exists.** Not behind a flag, not in an environment, not for
 *    testing. A code is sent or the flow fails.
 *  - **Fail closed.** If WhatsApp is not configured, this refuses rather than
 *    storing a code nobody can receive.
 *  - **Only the hash is stored**, with a bounded attempt count, in a table
 *    rather than the cache — a code you cannot audit is a code you cannot
 *    investigate.
 */
class PhoneVerificationService
{
    private const CODE_TTL_MINUTES = 10;

    private const MAX_ATTEMPTS = 5;

    /** Six digits is the usual shape; the attempt cap is what makes it safe. */
    private const CODE_LENGTH = 6;

    public function __construct(private readonly WhatsAppService $whatsapp) {}

    /**
     * Send a code, or say why not.
     */
    public function send(int $academyId, string $phone, string $purpose = 'registration', ?string $ip = null): void
    {
        $normalized = CredentialNormalizer::phone($phone);

        if (! $normalized || strlen($normalized) < 10) {
            throw new DomainException('رقم الهاتف غير صالح');
        }

        // Two separate limits, because the two abuses are different: hammering
        // one number to burn its attempts, and walking a range of numbers.
        foreach ([
            'otp-send:phone:' . $normalized => 5,
            'otp-send:ip:' . ($ip ?: 'unknown') => 15,
        ] as $key => $limit) {
            if (RateLimiter::tooManyAttempts($key, $limit)) {
                throw new DomainException('محاولات كثيرة. حاول مرة أخرى بعد قليل.');
            }
        }

        $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);

        DB::transaction(function () use ($academyId, $normalized, $purpose, $code, $ip) {
            // Requesting again supersedes the previous code rather than leaving
            // several alive — enforced by a partial unique index as well.
            DB::table('otp_verifications')
                ->where('academy_id', $academyId)
                ->where('phone', $normalized)
                ->where('purpose', $purpose)
                ->whereNull('verified_at')
                ->delete();

            DB::table('otp_verifications')->insert([
                'academy_id' => $academyId,
                'phone' => $normalized,
                'code_hash' => hash('sha256', $code),
                'purpose' => $purpose,
                'attempts' => 0,
                'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
                'request_ip' => $ip,
                'created_at' => now(),
                'updated_at' => now(),
            ]);
        });

        try {
            $this->whatsapp->sendText(
                $normalized,
                __('رمز التحقق') . ': ' . $code . "\n\n"
                . __('صالح لمدة') . ' ' . self::CODE_TTL_MINUTES . ' ' . __('دقائق') . '. '
                . __('لا تشارك هذا الرمز مع أحد.')
            );
        } catch (\Throwable $e) {
            Log::error('OTP send failed', ['phone' => $normalized, 'error' => $e->getMessage()]);

            // The record goes with it. A code stored but never delivered is the
            // exact failure the old implementation shipped: a flow that looks
            // like it is working and locks everybody out.
            DB::table('otp_verifications')
                ->where('academy_id', $academyId)
                ->where('phone', $normalized)
                ->where('purpose', $purpose)
                ->whereNull('verified_at')
                ->delete();

            throw new DomainException('تعذّر إرسال رمز التحقق — تواصل مع الأكاديمية');
        }

        foreach (['otp-send:phone:' . $normalized, 'otp-send:ip:' . ($ip ?: 'unknown')] as $key) {
            RateLimiter::hit($key, 3600);
        }
    }

    /**
     * Check a code. Consumed on success; counted on failure.
     */
    public function verify(int $academyId, string $phone, string $code, string $purpose = 'registration'): bool
    {
        $normalized = CredentialNormalizer::phone($phone);

        if (! $normalized) {
            return false;
        }

        return DB::transaction(function () use ($academyId, $normalized, $code, $purpose) {
            $row = DB::table('otp_verifications')
                ->where('academy_id', $academyId)
                ->where('phone', $normalized)
                ->where('purpose', $purpose)
                ->whereNull('verified_at')
                ->lockForUpdate()
                ->first();

            if (! $row) {
                return false;
            }

            if ($row->attempts >= self::MAX_ATTEMPTS || now()->gt($row->expires_at)) {
                DB::table('otp_verifications')->where('id', $row->id)->delete();

                return false;
            }

            // hash_equals, not ===: a length-independent comparison here would
            // leak how much of the code was right.
            if (! hash_equals($row->code_hash, hash('sha256', trim($code)))) {
                DB::table('otp_verifications')->where('id', $row->id)
                    ->update(['attempts' => $row->attempts + 1, 'updated_at' => now()]);

                return false;
            }

            DB::table('otp_verifications')->where('id', $row->id)
                ->update(['verified_at' => now(), 'updated_at' => now()]);

            return true;
        });
    }

    /**
     * Whether a phone was verified recently enough to act on.
     *
     * Checked again at the moment a row is written, not merely at the moment
     * the member typed the code: a session that sat open for an hour is not
     * evidence of anything.
     */
    public function isVerified(int $academyId, string $phone, string $purpose = 'registration'): bool
    {
        $normalized = CredentialNormalizer::phone($phone);

        if (! $normalized) {
            return false;
        }

        return DB::table('otp_verifications')
            ->where('academy_id', $academyId)
            ->where('phone', $normalized)
            ->where('purpose', $purpose)
            ->whereNotNull('verified_at')
            ->where('verified_at', '>', now()->subMinutes(30))
            ->exists();
    }

    /** Housekeeping for the scheduler. */
    public function pruneExpired(): int
    {
        return DB::table('otp_verifications')
            ->where('expires_at', '<', now()->subDay())
            ->delete();
    }
}
