<?php

namespace App\Services\Devices;

use App\Models\LoginSession;
use App\Models\UserDevice;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Str;

/**
 * Deciding whether a sign-in comes from somewhere this account has been before.
 *
 * The whole value of a new-device warning is that it is rare. A warning that
 * fires on ordinary behaviour is worse than none: it trains the recipient to
 * delete it, and the one that matters goes with it. So the two obvious signals
 * are deliberately not used.
 *
 * NOT the IP address. It changes on every train, every café, every mobile
 * handover, and for anyone behind a VPN it is not theirs to begin with.
 *
 * NOT the user-agent string. Chrome ships a new version roughly every four
 * weeks and the string changes each time — twelve warnings a year, per
 * customer, describing nothing.
 *
 * A device is a long-lived signed cookie holding a random token. Present and
 * known for this identity: known. Absent or unknown: new. Nothing else is
 * consulted, and the honest cost is that clearing cookies, a private window or
 * a second browser each read as a new device — which is correct, because as far
 * as anything here can tell, they are.
 */
final class DeviceRecognition
{
    /** Not httpOnly by accident — nothing in the browser needs to read this. */
    public const COOKIE = 'clupilot_device';

    /**
     * 400 days, because that is the ceiling browsers enforce.
     *
     * Chrome caps any cookie lifetime at 400 days and silently shortens
     * anything longer, so asking for more only makes the code disagree with
     * the browser about when the warning comes back.
     */
    private const LIFETIME_DAYS = 400;

    /**
     * Record this sign-in, and say whether it deserves a warning.
     *
     * @param  string  $guard  'web' or 'operator' — the guard IS the identity
     *                         type here, operators and customers being two
     *                         tables and two guards (R21).
     */
    public function recognise(Authenticatable $user, string $guard, Request $request): DeviceOutcome
    {
        $id = (int) $user->getAuthIdentifier();

        // Whether this account has ever signed in anywhere, asked BEFORE the
        // row below exists. Warning somebody about the very first device they
        // have ever used is telling them they just did the thing they just did.
        $firstEver = ! UserDevice::query()
            ->where('guard', $guard)
            ->where('authenticatable_id', $id)
            ->exists();

        // The cookie's token is reused when it is already there, even if it is
        // unknown for THIS identity: on a shared browser two people hold two
        // device rows against one token, and issuing a fresh token for the
        // second would orphan the first — whose owner then gets a new-device
        // warning on their own machine the next time they sign in.
        $token = $this->tokenFrom($request) ?? Str::random(48);
        $hash = hash('sha256', $token);

        $device = UserDevice::query()
            ->where('guard', $guard)
            ->where('authenticatable_id', $id)
            ->where('token_hash', $hash)
            ->first();

        $isNew = $device === null;

        if ($isNew) {
            $device = new UserDevice([
                'guard' => $guard,
                'authenticatable_id' => $id,
                'token_hash' => $hash,
            ]);
        }

        // Recomputed every time, on a known device too: a browser upgrade
        // should relabel the row, not raise an alarm. It can only be safely
        // recomputed because the name identifies nothing.
        $device->name = DeviceName::from((string) $request->userAgent());
        $device->last_ip = $request->ip();
        $device->last_seen_at = now();
        $device->save();

        // Re-issued on every sign-in, known device included, so that somebody
        // who signs in regularly never falls off the end of the 400 days and
        // gets warned about the machine on their own desk.
        Cookie::queue(Cookie::make(
            self::COOKIE,
            $token,
            self::LIFETIME_DAYS * 24 * 60,
            secure: $request->isSecure(),
            httpOnly: true,
            sameSite: 'lax',
        ));

        return new DeviceOutcome(
            device: $device,
            isNew: $isNew,
            // A first device is new but is not news.
            shouldWarn: $isNew && ! $firstEver,
        );
    }

    /**
     * Attach the current session to a device, and remember it by uuid.
     *
     * The uuid goes into the session payload rather than the row being keyed by
     * the session id, because Laravel rotates that id on sign-in and on every
     * regenerate(). A row keyed by it stops describing anything within a
     * request or two — and the list would then offer somebody sessions that no
     * longer exist alongside the one they are sitting in.
     */
    public function attachSession(UserDevice $device, Request $request): LoginSession
    {
        $session = LoginSession::create([
            'device_id' => $device->id,
            'session_id' => $request->hasSession() ? $request->session()->getId() : null,
            'ip_address' => $request->ip(),
            'last_seen_at' => now(),
        ]);

        if ($request->hasSession()) {
            $request->session()->put(LoginSession::KEY, $session->uuid);
        }

        return $session;
    }

    private function tokenFrom(Request $request): ?string
    {
        $value = $request->cookie(self::COOKIE);

        // Cookies are encrypted by the framework, so anything that survives
        // decryption was issued here — but a truncated or replayed value is
        // still not a token, and a lookup on nonsense must not match a row.
        return is_string($value) && strlen($value) >= 32 ? $value : null;
    }
}
