<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1\Auth;

use App\Enums\AuthEventType;
use App\Events\LoginSucceeded;
use App\Http\Controllers\Api\V1\Auth\Concerns\IssuesAuthentication;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\Auth\AuthEventRecorder;
use App\Services\Auth\TwoFactorAuthenticator;
use App\Support\Http\RequestSignature;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;

final class AuthenticatedSessionController extends Controller
{
    use IssuesAuthentication;

    /**
     * Failed attempts tolerated per email+IP pair before the pair is locked out
     * for a minute. Keyed on both so one attacker cannot lock a victim out of
     * their own account by hammering their address from elsewhere.
     */
    private const MAX_ATTEMPTS = 5;

    private const DECAY_SECONDS = 60;

    public function __construct(
        private readonly AuthEventRecorder $authEvents,
        private readonly TwoFactorAuthenticator $twoFactor,
        private readonly RequestSignature $signature,
    ) {}

    public function store(LoginRequest $request): JsonResponse
    {
        $email = $request->string('email')->toString();
        $throttleKey = $this->throttleKey($email, $request->ip());

        if (RateLimiter::tooManyAttempts($throttleKey, self::MAX_ATTEMPTS)) {
            $seconds = RateLimiter::availableIn($throttleKey);

            $this->authEvents->record(
                AuthEventType::AccountLocked,
                emailAttempted: $email,
                metadata: ['retry_after' => $seconds],
            );

            throw ValidationException::withMessages([
                'email' => [sprintf('Too many login attempts. Try again in %d seconds.', $seconds)],
            ])->status(Response::HTTP_TOO_MANY_REQUESTS);
        }

        $user = User::query()->where('email', $email)->first();

        /*
         * The password is checked even when no such user exists, against a
         * throwaway hash, so that a missing account and a wrong password take
         * the same time to answer. Otherwise response timing tells an attacker
         * which addresses are registered.
         */
        $passwordValid = $user !== null
            ? Hash::check($request->string('password')->toString(), $user->password)
            : Hash::check($request->string('password')->toString(), $this->dummyHash());

        if ($user === null || ! $passwordValid) {
            RateLimiter::hit($throttleKey, self::DECAY_SECONDS);

            $this->authEvents->record(
                AuthEventType::LoginFailed,
                user: $user,
                emailAttempted: $email,
                metadata: ['reason' => $user === null ? 'unknown_account' : 'bad_password'],
            );

            // One message for both cases, for the same reason.
            throw ValidationException::withMessages([
                'email' => ['These credentials do not match our records.'],
            ]);
        }

        if (! $user->canAuthenticate()) {
            $this->authEvents->record(
                AuthEventType::LoginFailed,
                user: $user,
                metadata: ['reason' => 'account_'.$user->status->value],
            );

            return response()->json([
                'message' => 'This account is not currently active. Contact support for assistance.',
                'status' => $user->status->value,
            ], Response::HTTP_FORBIDDEN);
        }

        // Second factor is demanded before any session or token is issued.
        if ($user->hasTwoFactorEnabled()) {
            $hasSecondFactor = $request->filled('two_factor_code') || $request->filled('recovery_code');

            if (! $hasSecondFactor) {
                $this->authEvents->record(AuthEventType::TwoFactorChallenged, $user);

                return response()->json([
                    'message' => 'Enter the code from your authenticator app to continue.',
                    'two_factor_required' => true,
                ], Response::HTTP_LOCKED);
            }

            $passed = $this->twoFactor->challenge(
                $user,
                $request->input('two_factor_code'),
                $request->input('recovery_code'),
            );

            if (! $passed) {
                RateLimiter::hit($throttleKey, self::DECAY_SECONDS);

                throw ValidationException::withMessages([
                    'two_factor_code' => ['That code is not valid. Check your authenticator app and try again.'],
                ]);
            }
        }

        RateLimiter::clear($throttleKey);

        $token = $this->issueAuthentication($request, $user, $request->boolean('remember'));

        if ($token !== null) {
            $this->authEvents->record(AuthEventType::TokenCreated, $user, metadata: [
                'device_name' => $request->string('device_name')->toString(),
            ]);
        }

        $user->forceFill([
            'last_login_at' => now(),
            'last_login_ip' => $request->ip(),
        ])->save();

        $this->authEvents->record(AuthEventType::LoginSuccess, $user);

        /*
         * Risk assessment happens off the request path.
         *
         * It reads login history, may write a fraud flag and may send mail --
         * none of which the person signing in should wait for, and none of which
         * should be able to fail their sign-in. The request signature is
         * computed here, at the edge, because the listener runs without a
         * Request.
         *
         * Dispatched after the LoginSuccess event is recorded, so the history the
         * assessor reads includes this sign-in.
         */
        LoginSucceeded::dispatch(
            $user,
            $request->ip(),
            $this->signature->countryCode($request),
            $this->signature->fingerprint($request),
            $request->userAgent(),
        );

        return response()->json([
            'message' => 'Signed in.',
            'user' => UserResource::make($user->load(['profile', 'sellerProfile', 'verifications'])),
            'token' => $token,
        ]);
    }

    /** Signs out of the current session, or revokes the presented token. */
    public function destroy(Request $request): JsonResponse
    {
        $user = $request->user();

        if ($user !== null) {
            $token = $user->currentAccessToken();

            if ($token !== null && method_exists($token, 'delete')) {
                $token->delete();
                $this->authEvents->record(AuthEventType::TokenRevoked, $user);
            } else {
                Auth::guard('web')->logout();
                $request->session()->invalidate();
                $request->session()->regenerateToken();
            }

            $this->authEvents->record(AuthEventType::Logout, $user);
        }

        return response()->json(['message' => 'Signed out.']);
    }

    private function throttleKey(string $email, ?string $ip): string
    {
        return 'login:'.Str::lower($email).'|'.($ip ?? 'unknown');
    }

    /**
     * A stable bcrypt hash of a value nothing will ever match, used purely to
     * spend comparable time on the unknown-account path.
     */
    private function dummyHash(): string
    {
        return '$2y$12$Ku0MRRVXKYFJEBmT8AjIYugFJ5ttQzVoAqYPTiu.uEBnDe3.Jd1c.';
    }
}
