<?php

declare(strict_types=1);

namespace PpPhp\Core\Analytics;

/**
 * Work out the browser, OS and device from a request — accurately.
 *
 * Primary source: User-Agent Client Hints (the Sec-CH-UA-* headers). They are the
 * standardised, harder-to-spoof successor to User-Agent sniffing and carry exact
 * browser + version, OS + version, a mobile flag and even the device model. Only
 * Chromium browsers send them, so we fall back to parsing the User-Agent string
 * for Safari, Firefox and old clients.
 *
 * Honest limits, on purpose:
 *   • There is no "laptop vs desktop" — the web cannot tell them apart. We report
 *     mobile / tablet / desktop (and Chromebook), which IS derivable.
 *   • There is no MAC address on a web request; a router discards it at the first
 *     hop. Nothing here pretends otherwise.
 *
 * @phpstan-type Device array{browser:string,browser_version:string,platform:string,platform_version:string,device:string,device_model:string,engine:string,is_bot:bool,via:string}
 */
final class DeviceDetector
{
    /**
     * @param array<string,mixed> $server $_SERVER
     * @return Device
     */
    public static function detect(array $server): array
    {
        $ua = (string) ($server['HTTP_USER_AGENT'] ?? '');
        $bot = self::isBot($ua);

        // Prefer Client Hints when the browser sent them.
        $ch = self::fromClientHints($server, $ua);
        $base = $ch ?? self::fromUserAgent($ua);
        $base['is_bot'] = $bot;
        return $base;
    }

    public static function isBot(string $ua): bool
    {
        if ($ua === '') return true;                       // no UA is not a person
        return (bool) preg_match(
            '/bot|crawl|spider|slurp|bingpreview|facebookexternalhit|headless|curl|wget|python-requests|'
            . 'go-http|java\/|node-fetch|axios|okhttp|scrapy|ahrefs|semrush|mj12|dotbot|petalbot|yandex|'
            . 'duckduck|phantom|monitor|uptime|pingdom|lighthouse|gtmetrix|censys|masscan|zgrab/i',
            $ua
        );
    }

    // ── Client Hints ──────────────────────────────────────────────────

    /**
     * @param array<string,mixed> $server
     * @return Device|null  null when the browser sent no usable hints
     */
    private static function fromClientHints(array $server, string $ua): ?array
    {
        $brands = (string) ($server['HTTP_SEC_CH_UA'] ?? '');
        $platform = trim((string) ($server['HTTP_SEC_CH_UA_PLATFORM'] ?? ''), " \"");
        if ($brands === '' && $platform === '') return null;

        $fullList = (string) ($server['HTTP_SEC_CH_UA_FULL_VERSION_LIST'] ?? '');
        [$browser, $version] = self::pickBrand($fullList !== '' ? $fullList : $brands);
        if ($browser === '') return null;                  // only grease brands — unusable

        $mobile = trim((string) ($server['HTTP_SEC_CH_UA_MOBILE'] ?? '')) === '?1';
        $model  = trim((string) ($server['HTTP_SEC_CH_UA_MODEL'] ?? ''), " \"");
        $pver   = trim((string) ($server['HTTP_SEC_CH_UA_PLATFORM_VERSION'] ?? ''), " \"");

        $osName = self::normalisePlatform($platform);
        $osVer  = self::osVersion($osName, $pver);

        // Device class: the mobile flag plus a couple of reliable signals.
        if ($osName === 'Android' && !$mobile) {
            $device = 'tablet';
        } elseif ($osName === 'iOS' && stripos($ua, 'iPad') !== false) {
            $device = 'tablet';
        } elseif ($mobile) {
            $device = 'mobile';
        } elseif ($osName === 'ChromeOS') {
            $device = 'desktop';
        } else {
            $device = 'desktop';
        }

        return [
            'browser'          => $browser,
            'browser_version'  => self::majorVersion($version),
            'platform'         => $osName,
            'platform_version' => $osVer,
            'device'           => $device,
            'device_model'     => substr($model, 0, 80),
            'engine'           => self::engineFor($browser),
            'is_bot'           => false,
            'via'              => 'client-hints',
        ];
    }

    /**
     * Pick the real browser brand from a Sec-CH-UA brand list, skipping the
     * "GREASE" placeholder brands and preferring a specific brand over Chromium.
     *
     * @return array{0:string,1:string} [browserName, version]
     */
    private static function pickBrand(string $list): array
    {
        if (!preg_match_all('/"([^"]+)";\s*v="([^"]+)"/', $list, $m, PREG_SET_ORDER)) {
            return ['', ''];
        }
        $known = [
            'Microsoft Edge' => 'Edge', 'Google Chrome' => 'Chrome', 'Chromium' => 'Chromium',
            'Opera' => 'Opera', 'Opera GX' => 'Opera', 'Brave' => 'Brave', 'Vivaldi' => 'Vivaldi',
            'Samsung Internet' => 'Samsung Internet', 'Yandex' => 'Yandex', 'Arc' => 'Arc',
        ];
        $chromium = ['', ''];
        foreach ($m as $pair) {
            $brand = trim($pair[1]);
            $ver   = trim($pair[2]);
            // Skip GREASE brands like "Not;A=Brand", "Not/A)Brand", "Not_A Brand".
            if (stripos($brand, 'Brand') !== false && stripos($brand, 'Not') !== false) continue;
            if (isset($known[$brand])) {
                if ($known[$brand] === 'Chromium') { $chromium = ['Chromium', $ver]; continue; }
                return [$known[$brand], $ver];             // a specific brand wins
            }
        }
        return $chromium[0] !== '' ? $chromium : ['', ''];
    }

    // ── User-Agent fallback ───────────────────────────────────────────

    /** @return Device */
    private static function fromUserAgent(string $ua): array
    {
        [$browser, $version] = self::uaBrowser($ua);
        [$platform, $pver]   = self::uaPlatform($ua);
        $model = self::uaModel($ua);

        $device = preg_match('/iPad|Tablet|(?=.*Android)(?!.*Mobile)/i', $ua) ? 'tablet'
                : (preg_match('/Mobi|Android|iPhone|iPod|Windows Phone/i', $ua) ? 'mobile' : 'desktop');

        return [
            'browser'          => $browser,
            'browser_version'  => $version,
            'platform'         => $platform,
            'platform_version' => $pver,
            'device'           => $device,
            'device_model'     => $model,
            'engine'           => self::engineFor($browser),
            'is_bot'           => false,
            'via'              => 'user-agent',
        ];
    }

    /** @return array{0:string,1:string} */
    private static function uaBrowser(string $ua): array
    {
        // Order matters: Edge/Opera masquerade as Chrome; Chrome carries "Safari".
        $patterns = [
            'Edge'              => '/Edg(?:e|iOS|A)?\/([\d.]+)/i',
            'Opera'             => '/(?:OPR|Opera)\/([\d.]+)/i',
            'Samsung Internet'  => '/SamsungBrowser\/([\d.]+)/i',
            'Vivaldi'           => '/Vivaldi\/([\d.]+)/i',
            'Yandex'            => '/YaBrowser\/([\d.]+)/i',
            'Firefox'           => '/(?:Firefox|FxiOS)\/([\d.]+)/i',
            'Chrome'            => '/(?:Chrome|CriOS)\/([\d.]+)/i',
            'Safari'            => '/Version\/([\d.]+).*Safari/i',
            'Internet Explorer' => '/(?:MSIE |rv:)([\d.]+).*Trident|Trident.*rv:([\d.]+)/i',
        ];
        foreach ($patterns as $name => $re) {
            if (preg_match($re, $ua, $mm)) {
                $ver = $mm[1] ?? ($mm[2] ?? '');
                return [$name, self::majorVersion($ver)];
            }
        }
        if (stripos($ua, 'Safari') !== false) return ['Safari', ''];
        return ['Other', ''];
    }

    /** @return array{0:string,1:string} */
    private static function uaPlatform(string $ua): array
    {
        if (preg_match('/Windows NT ([\d.]+)/i', $ua, $m)) {
            $map = ['10.0' => '10/11', '6.3' => '8.1', '6.2' => '8', '6.1' => '7'];
            return ['Windows', $map[$m[1]] ?? $m[1]];
        }
        if (preg_match('/Android ([\d.]+)/i', $ua, $m))        return ['Android', $m[1]];
        if (preg_match('/(?:iPhone|iPad|iPod).*?OS ([\d_]+)/i', $ua, $m)) return ['iOS', str_replace('_', '.', $m[1])];
        if (preg_match('/CrOS \S+ ([\d.]+)/i', $ua, $m))       return ['ChromeOS', $m[1]];
        if (preg_match('/Mac OS X ([\d_]+)/i', $ua, $m))       return ['macOS', str_replace('_', '.', $m[1])];
        if (stripos($ua, 'Linux') !== false)                    return ['Linux', ''];
        return ['Other', ''];
    }

    private static function uaModel(string $ua): string
    {
        if (preg_match('/iPhone/i', $ua)) return 'iPhone';
        if (preg_match('/iPad/i', $ua))   return 'iPad';
        // Android UAs carry the model between "; " and "/" or ")" after the build.
        if (preg_match('/Android [\d.]+;\s*([^;)]+?)\s*(?:Build|\))/i', $ua, $m)) {
            $model = trim($m[1]);
            if ($model !== '' && stripos($model, 'wv') !== 0 && strlen($model) <= 60) return $model;
        }
        return '';
    }

    // ── Shared helpers ────────────────────────────────────────────────

    private static function normalisePlatform(string $p): string
    {
        $p = trim($p, " \"");
        return match (strtolower($p)) {
            'windows'            => 'Windows',
            'android'            => 'Android',
            'ios'                => 'iOS',
            'macos', 'mac os x'  => 'macOS',
            'chrome os', 'chromeos' => 'ChromeOS',
            'linux'              => 'Linux',
            ''                   => 'Other',
            default              => ucfirst($p),
        };
    }

    /** Windows Client-Hints versions don't map linearly to the marketing name. */
    private static function osVersion(string $os, string $pver): string
    {
        $pver = trim($pver);
        if ($pver === '' || $pver === '0.0.0') return '';
        if ($os === 'Windows') {
            $major = (int) explode('.', $pver)[0];
            if ($major >= 13) return '11';
            if ($major >= 1)  return '10';
            return '7/8';
        }
        return self::majorVersion($pver);
    }

    private static function majorVersion(string $v): string
    {
        $v = trim($v);
        if ($v === '') return '';
        return explode('.', $v)[0];
    }

    private static function engineFor(string $browser): string
    {
        return match ($browser) {
            'Firefox'                                  => 'Gecko',
            'Safari'                                   => 'WebKit',
            'Internet Explorer'                        => 'Trident',
            'Other'                                    => '',
            default                                    => 'Blink',
        };
    }
}
