<?php

namespace App\Jobs;

use App\Models\QrCodeScan;
use App\Services\QrCode\Contracts\GeoLocator;
use App\Services\QrCode\IpAnonymizer;
use DeviceDetector\DeviceDetector;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;

/**
 * Enriches a {@see QrCodeScan} with device and (best-effort) geo data,
 * off the hot redirect path.
 *
 * Idempotent and safe to re-run: it always re-derives fields from the
 * scan's stored `user_agent`/`ip_address` rather than accumulating state,
 * and re-running it simply overwrites `enriched_at` with a newer timestamp.
 */
class EnrichQrCodeScan implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Number of times the job may be attempted.
     */
    public int $tries = 3;

    /**
     * Backoff (seconds) between retry attempts.
     *
     * @return array<int, int>
     */
    public function backoff(): array
    {
        return [10, 30, 60];
    }

    public function __construct(public readonly int $qrCodeScanId)
    {
        //
    }

    public function handle(GeoLocator $geoLocator): void
    {
        $scan = QrCodeScan::query()->find($this->qrCodeScanId);

        if ($scan === null) {
            return;
        }

        $this->fillDeviceData($scan);
        $this->fillGeoData($scan, $geoLocator);

        $scan->enriched_at = Carbon::now();
        $scan->save();
    }

    /**
     * Link-preview crawlers that DeviceDetector does not report as bots.
     *
     * DeviceDetector correctly flags facebookexternalhit, Slackbot,
     * Twitterbot, TelegramBot, LinkedInBot and Googlebot. It classifies
     * WhatsApp's preview fetcher as a mobile *app* instead, because the
     * same "WhatsApp/x.y.z" token is used by the real client. Sharing a
     * QR link over WhatsApp would therefore book a phantom scan for every
     * message it appears in, which matters here because WhatsApp is the
     * primary sharing channel for this business.
     *
     * Matched case-insensitively against the raw user agent.
     *
     * @var list<string>
     */
    private const ADDITIONAL_BOT_PATTERNS = [
        'WhatsApp/',
        'SkypeUriPreview',
        'Discordbot',
        'redditbot',
        'Applebot',
        'vkShare',
        'Iframely',
        'Embedly',
    ];

    private function fillDeviceData(QrCodeScan $scan): void
    {
        $userAgent = (string) $scan->user_agent;

        $detector = new DeviceDetector($userAgent);
        $detector->parse();

        $scan->is_bot = $detector->isBot() || $this->looksLikeLinkPreviewBot($userAgent);
        $scan->device_type = $this->nullIfUnknown($detector->getDeviceName());
        $scan->browser = $this->nullIfUnknown($detector->getClient('name'));
        $scan->platform = $this->nullIfUnknown($detector->getOs('name'));
    }

    /**
     * Catch link-preview crawlers DeviceDetector reports as regular clients.
     */
    private function looksLikeLinkPreviewBot(string $userAgent): bool
    {
        foreach (self::ADDITIONAL_BOT_PATTERNS as $pattern) {
            if (stripos($userAgent, $pattern) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Normalize a DeviceDetector attribute value to null when it couldn't
     * be determined. `getClient()`/`getOs()` are typed to also allow an
     * array return (when called with no attribute), which can't happen
     * here since we always pass one, but is handled defensively anyway.
     * An empty string or DeviceDetector's own `"UNK"` sentinel both mean
     * "unknown".
     *
     * @param  array<string, mixed>|string|null  $value
     */
    private function nullIfUnknown(array|string|null $value): ?string
    {
        if (! is_string($value)) {
            return null;
        }

        return in_array($value, ['', DeviceDetector::UNKNOWN], true) ? null : $value;
    }

    private function fillGeoData(QrCodeScan $scan, GeoLocator $geoLocator): void
    {
        $ip = $scan->ip_address;

        if ($ip === null) {
            return;
        }

        // Every scan in local dev originates from a private/loopback
        // address; the lookup is guaranteed useless there, so it's skipped
        // rather than burning the request budget on it.
        if (IpAnonymizer::isPrivateOrReserved($ip)) {
            return;
        }

        // Note: `ip_address` may already be truncated to a /24 (IPv4) or
        // /48 (IPv6) by the time this runs (see IpAnonymizer), so the
        // resolved geo data is approximate — city-level accuracy is not
        // guaranteed by design, not a bug.
        $geo = $geoLocator->locate($ip);

        if ($geo === null) {
            return;
        }

        $scan->country_code = $geo['country_code'];
        $scan->country = $geo['country'];
        $scan->region = $geo['region'];
        $scan->city = $geo['city'];
        $scan->latitude = $geo['latitude'];
        $scan->longitude = $geo['longitude'];
    }
}
