<?php

declare(strict_types=1);

namespace App\Http\Requests\Auth;

use App\Models\User;
use App\Services\AuditLogger;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

/**
 * Validates and performs a sign-in attempt.
 *
 * The attempt itself lives here rather than in the controller so that
 * rate limiting, credential checking and the audit entry are one unit that
 * cannot be partially applied.
 */
class LoginRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, array<int, mixed>>
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'string', 'email', 'max:160'],
            'password' => ['required', 'string'],
        ];
    }

    /**
     * Attempt to authenticate, or throw with a deliberately vague message.
     *
     * @throws ValidationException
     */
    public function authenticate(AuditLogger $auditLogger): User
    {
        $this->ensureIsNotRateLimited();

        $email = mb_strtolower((string) $this->string('email'));

        /*
         | `is_active` is part of the credentials, so a disabled account fails at
         | the database query and never reaches password verification. It also
         | means a disabled account and a wrong password are indistinguishable
         | from the outside, which is the point.
         */
        $attempted = Auth::attempt([
            'email' => $email,
            'password' => (string) $this->string('password'),
            'is_active' => true,
        ]);

        if (! $attempted) {
            RateLimiter::hit($this->throttleKey());

            $auditLogger->loginFailed($email);

            /*
             | One message for every failure mode: unknown address, wrong
             | password, disabled account. Anything more specific tells an
             | attacker which addresses are real.
             */
            throw ValidationException::withMessages([
                'email' => __('These credentials do not match our records.'),
            ]);
        }

        RateLimiter::clear($this->throttleKey());

        /** @var User $user */
        $user = Auth::user();

        return $user;
    }

    /**
     * @throws ValidationException
     */
    protected function ensureIsNotRateLimited(): void
    {
        $maxAttempts = (int) config('ams.security.login_max_attempts', 5);

        if (! RateLimiter::tooManyAttempts($this->throttleKey(), $maxAttempts)) {
            return;
        }

        $seconds = RateLimiter::availableIn($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => __('Too many sign-in attempts. Please try again in :seconds seconds.', [
                'seconds' => $seconds,
            ]),
        ]);
    }

    /**
     * Rate limit per address *and* per IP.
     *
     * Keying on the address alone would let one attacker lock a colleague out
     * of their own account; keying on IP alone would let a shared office IP be
     * locked by one person's typo.
     */
    protected function throttleKey(): string
    {
        return Str::transliterate(
            Str::lower((string) $this->string('email')).'|'.$this->ip(),
        );
    }
}
