<?php
declare(strict_types=1);

/**
 * /includes/traffic_log.php
 * Логування трафіку в БД (traffic_hits) + GEO як в магазині (через кеш таблиці):
 * - Визначає IP (CF / reverse-proxy / REMOTE_ADDR)
 * - Визначає бот/не бот (простий детектор по UA)
 * - Для людей створює visitor_id cookie (для unique today)
 * - GEO:
 *   1) Cloudflare headers (якщо є)
 *   2) traffic_geo_cache / traffic_ip_geo (кеш)
 *   3) зовнішній geo lookup (ipwho.is -> fallback ip-api) з кешуванням
 *
 * ВАЖЛИВО (НОВЕ):
 * - 1 запис на добу на людину/бота (по visitor_id або по hash) — щоб refresh не накручував хіти.
 * - День рахуємо по Europe/Kyiv: 00:00–23:59.
 *
 * Не ламає сайт: всі помилки ковтаються.
 */

if (!defined('TRAFFIC_LOG_ENABLED'))          define('TRAFFIC_LOG_ENABLED', true);
if (!defined('TRAFFIC_LOG_GEO_ENABLED'))      define('TRAFFIC_LOG_GEO_ENABLED', true);
if (!defined('TRAFFIC_GEO_TTL_DAYS'))         define('TRAFFIC_GEO_TTL_DAYS', 30);
if (!defined('TRAFFIC_GEO_TIMEOUT'))          define('TRAFFIC_GEO_TIMEOUT', 1.2);
if (!defined('TRAFFIC_MAX_UA'))               define('TRAFFIC_MAX_UA', 512);
if (!defined('TRAFFIC_MAX_URI'))              define('TRAFFIC_MAX_URI', 512);
if (!defined('TRAFFIC_COUNT_ONCE_PER_DAY'))   define('TRAFFIC_COUNT_ONCE_PER_DAY', true);

if (!function_exists('traffic_log_request')) {

function traffic_log_request(PDO $pdo, ?array $me = null): void
{
    if (!TRAFFIC_LOG_ENABLED) return;
    if (PHP_SAPI === 'cli') return;

    $uri = (string)($_SERVER['REQUEST_URI'] ?? '');
    if ($uri === '') return;

    // не логувати статику/сміття
    $u = parse_url($uri);
    $path = (string)($u['path'] ?? $uri);

    if (preg_match('~\.(css|js|map|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|eot|mp4|mp3|wav|ogg|webm|pdf|zip|rar|7z)$~i', $path)) return;
    if (str_starts_with($path, '/assets/')) return;

    $cols = traffic_th_columns($pdo);
    if (!$cols) return;

    $ip = traffic_client_ip();
    if ($ip === '') return;

    $ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
    $ua = mb_substr($ua, 0, TRAFFIC_MAX_UA);

    $ref = (string)($_SERVER['HTTP_REFERER'] ?? '');
    $ref = mb_substr($ref, 0, 512);

    $fullUri = mb_substr((string)($_SERVER['REQUEST_URI'] ?? ''), 0, TRAFFIC_MAX_URI);

    $isBot = traffic_is_bot($ua);
    $botName = $isBot ? traffic_bot_name($ua) : null;

    // visitor_id (для людей, щоб уникальні рахувались нормально)
    $visitorId = null;
    if (in_array('visitor_id', $cols, true)) {
        $visitorId = $isBot ? traffic_bot_visitor_id($ua, $botName, $ip) : traffic_human_visitor_id();
    }

    // ===== 1 запис на добу (00:00–23:59) =====
    if (TRAFFIC_COUNT_ONCE_PER_DAY) {
        $dayDate = date('Y-m-d'); // у start.php вже виставлено Europe/Kyiv
        $entityKey = traffic_daily_entity_key($isBot, $visitorId, $ip, $ua, $botName);

        if (!traffic_daily_allow($pdo, $dayDate, $isBot ? 1 : 0, $entityKey, $fullUri)) {
            return; // вже рахували сьогодні — не пишемо в traffic_hits
        }
    }

    // GEO з CF (якщо є)
    $cc   = traffic_cf_country();
    $city = traffic_cf_city();
    $region = traffic_cf_region();

    // GEO як в магазині (кеш + lookup), якщо CF не дав
    if (TRAFFIC_LOG_GEO_ENABLED && !$isBot) {
        $needGeo = (trim((string)$cc) === '' || trim((string)$city) === '');
        if ($needGeo) {
            $geo = traffic_geo_enrich($pdo, $ip);
            if ($geo) {
                if ($cc === null || trim((string)$cc) === '')      $cc   = $geo['country_code'] ?? $cc;
                if ($city === null || trim((string)$city) === '')  $city = $geo['city'] ?? $city;
                if ($region === null || trim((string)$region) === '') $region = $geo['region_name'] ?? $region;
            }
        }
    }

    // сформувати INSERT тільки по існуючих колонках
    $data = [];

    if (in_array('created_at', $cols, true)) $data['created_at'] = date('Y-m-d H:i:s');
    if (in_array('ip', $cols, true))         $data['ip'] = $ip;

    if (in_array('user_id', $cols, true)) {
        $data['user_id'] = isset($me['id']) ? (int)$me['id'] : null;
    }

    if (in_array('session_id', $cols, true)) {
        $sid = session_id();
        $data['session_id'] = ($sid !== '') ? $sid : null;
    }

    if (in_array('visitor_id', $cols, true)) {
        $data['visitor_id'] = $visitorId;
    }

    if (in_array('uri', $cols, true))        $data['uri'] = $fullUri;
    if (in_array('referrer', $cols, true))   $data['referrer'] = ($ref !== '' ? $ref : null);

    if (in_array('ua', $cols, true))         $data['ua'] = ($ua !== '' ? $ua : null);

    if (in_array('country_code', $cols, true)) $data['country_code'] = ($cc !== null && $cc !== '' ? strtoupper($cc) : null);
    if (in_array('region_code', $cols, true))  $data['region_code']  = ($region !== null && $region !== '' ? $region : null);
    if (in_array('city', $cols, true))         $data['city']         = ($city !== null && $city !== '' ? $city : null);

    if (in_array('is_bot', $cols, true))     $data['is_bot'] = $isBot ? 1 : 0;
    if (in_array('bot_name', $cols, true))   $data['bot_name'] = ($botName !== null && $botName !== '' ? $botName : null);

    $fields = [];
    $ph = [];
    $vals = [];
    foreach ($data as $k => $v) {
        if (!in_array($k, $cols, true)) continue;
        $fields[] = "`$k`";
        $ph[] = '?';
        $vals[] = $v;
    }
    if (!$fields) return;

    $sql = "INSERT INTO `traffic_hits` (" . implode(',', $fields) . ") VALUES (" . implode(',', $ph) . ")";
    $st = $pdo->prepare($sql);
    $st->execute($vals);
}

/* ===== columns cache ===== */
function traffic_th_columns(PDO $pdo): array
{
    static $cache = null;
    if (is_array($cache)) return $cache;

    try {
        $st = $pdo->query("SHOW COLUMNS FROM `traffic_hits`");
        $rows = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
        $cols = [];
        foreach ($rows as $r) {
            $cols[] = (string)($r['Field'] ?? '');
        }
        $cols = array_values(array_filter($cols, fn($x) => $x !== ''));
        $cache = $cols;
        return $cache;
    } catch (Throwable $e) {
        $cache = [];
        return [];
    }
}

/* ===== IP detect ===== */
function traffic_client_ip(): string
{
    $candidates = [
        'HTTP_CF_CONNECTING_IP',
        'HTTP_X_FORWARDED_FOR',
        'HTTP_X_REAL_IP',
        'REMOTE_ADDR',
    ];

    foreach ($candidates as $k) {
        $v = (string)($_SERVER[$k] ?? '');
        if ($v === '') continue;

        if ($k === 'HTTP_X_FORWARDED_FOR') {
            $parts = array_map('trim', explode(',', $v));
            foreach ($parts as $p) {
                if ($p !== '' && filter_var($p, FILTER_VALIDATE_IP)) return $p;
            }
        } else {
            if (filter_var($v, FILTER_VALIDATE_IP)) return $v;
        }
    }
    return '';
}

function traffic_is_public_ip(string $ip): bool
{
    if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;
    return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}

/* ===== bot detect ===== */
function traffic_is_bot(string $ua): bool
{
    $ua = strtolower($ua);
    if ($ua === '') return false;

    $needles = [
        'bot','spider','crawl','slurp','bingpreview','facebookexternalhit','facebookcatalog','telegrambot','whatsapp',
        'discordbot','vkshare','okhttp','python-requests','java/','go-http-client','wget','curl','headless',
        'lighthouse','pagespeed','yandex','googlebot','bingbot','duckduckbot','baiduspider','semrush','ahrefs','mj12bot'
    ];

    foreach ($needles as $n) {
        if (str_contains($ua, $n)) return true;
    }
    return false;
}

function traffic_bot_name(string $ua): string
{
    $uaL = strtolower($ua);

    $map = [
        'googlebot' => 'Googlebot',
        'bingbot' => 'Bingbot',
        'yandex' => 'Yandex',
        'baiduspider' => 'Baidu',
        'duckduckbot' => 'DuckDuckBot',
        'facebookexternalhit' => 'Facebook',
        'telegrambot' => 'Telegram',
        'ahrefs' => 'Ahrefs',
        'semrush' => 'Semrush',
        'mj12bot' => 'Majestic',
    ];

    foreach ($map as $k => $name) {
        if (str_contains($uaL, $k)) return $name;
    }
    return 'Bot';
}

/* ===== visitor id ===== */
function traffic_human_visitor_id(): string
{
    $cookieName = 'visitor_id';

    $existing = $_COOKIE[$cookieName] ?? '';
    $existing = is_string($existing) ? trim($existing) : '';

    // приймаємо і старі 32-символьні, і нові 40
    if ($existing !== '' && preg_match('/^[a-f0-9]{32,40}$/i', $existing)) {
        return strtolower($existing);
    }

    // 40 hex (20 bytes) як в char(40)
    $id = bin2hex(random_bytes(20));

    if (PHP_VERSION_ID >= 70300) {
        setcookie($cookieName, $id, [
            'expires' => time() + 3600 * 24 * 365,
            'path' => '/',
            'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    } else {
        setcookie($cookieName, $id, time() + 3600 * 24 * 365, '/');
    }

    $_COOKIE[$cookieName] = $id;
    return $id;
}

function traffic_bot_visitor_id(string $ua, ?string $botName, string $ip): string
{
    $base = ($botName ?? 'bot') . '|' . $ua . '|' . $ip;
    return substr(hash('sha256', $base), 0, 40);
}

/* ===== daily once-per-day gate ===== */
function traffic_daily_entity_key(bool $isBot, ?string $visitorId, string $ip, string $ua, ?string $botName): string
{
    if ($isBot) {
        // щоб один бот не накручував — стабільний ключ
        $key = 'b:' . substr(hash('sha256', ($botName ?? 'bot') . '|' . $ua . '|' . $ip), 0, 72);
        return $key;
    }

    if ($visitorId !== null && $visitorId !== '') {
        return 'h:' . substr($visitorId, 0, 76);
    }

    // fallback якщо cookie раптом нема
    return 'h:ip:' . substr(hash('sha256', $ip), 0, 72);
}

function traffic_daily_ensure_table(PDO $pdo): void
{
    static $done = false;
    if ($done) return;
    $done = true;

    try {
        $pdo->exec("
            CREATE TABLE IF NOT EXISTS `traffic_daily_seen` (
              `day_date` date NOT NULL,
              `is_bot` tinyint(1) NOT NULL DEFAULT 0,
              `entity_key` varchar(80) COLLATE utf8mb4_unicode_ci NOT NULL,
              `first_seen_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
              `last_uri` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              PRIMARY KEY (`day_date`,`is_bot`,`entity_key`),
              KEY `idx_first_seen` (`first_seen_at`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ");
    } catch (Throwable $e) {
        // якщо нема прав — просто не робимо gate (але у нас він потрібен)
    }
}

function traffic_daily_allow(PDO $pdo, string $dayDate, int $isBot, string $entityKey, string $lastUri): bool
{
    traffic_daily_ensure_table($pdo);

    // якщо таблиця не створилась (нема прав), не блокуємо
    // (інакше трафік взагалі перестане писатись)
    try {
        $st = $pdo->prepare("
            INSERT IGNORE INTO `traffic_daily_seen` (`day_date`,`is_bot`,`entity_key`,`first_seen_at`,`last_uri`)
            VALUES (?, ?, ?, ?, ?)
        ");
        $st->execute([$dayDate, $isBot, $entityKey, date('Y-m-d H:i:s'), mb_substr($lastUri, 0, 255)]);
        return ($st->rowCount() === 1); // 1 = перший раз сьогодні, 0 = вже було
    } catch (Throwable $e) {
        return true; // щоб не “вбити” логування
    }
}

/* ===== Cloudflare GEO headers (якщо є) ===== */
function traffic_cf_country(): ?string
{
    $v = (string)($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '');
    $v = trim($v);
    return $v !== '' ? $v : null;
}

function traffic_cf_city(): ?string
{
    $v = (string)($_SERVER['HTTP_CF_IPCITY'] ?? '');
    $v = trim($v);
    return $v !== '' ? $v : null;
}

function traffic_cf_region(): ?string
{
    $v = (string)($_SERVER['HTTP_CF_REGION'] ?? '');
    $v = trim($v);
    return $v !== '' ? $v : null;
}

/* ===== GEO as in shop: cache tables + external lookup ===== */
function traffic_ip_hash(string $ip): string
{
    $salt = (string)($_SERVER['HTTP_HOST'] ?? 'vmeregi');
    return hash('sha256', $ip . '|' . $salt);
}

function traffic_geo_ensure_tables(PDO $pdo): void
{
    static $done = false;
    if ($done) return;
    $done = true;

    try {
        $pdo->exec("
            CREATE TABLE IF NOT EXISTS `traffic_geo_cache` (
              `ip_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL,
              `ip` varchar(45) COLLATE utf8mb4_unicode_ci NOT NULL,
              `country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `region_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `city_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `source` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
              PRIMARY KEY (`ip_hash`),
              KEY `idx_cc` (`country_code`),
              KEY `idx_region` (`region_name`),
              KEY `idx_updated` (`updated_at`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ");

        $pdo->exec("
            CREATE TABLE IF NOT EXISTS `traffic_ip_geo` (
              `ip` varchar(45) COLLATE utf8mb4_unicode_ci NOT NULL,
              `country_code` char(2) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `country_name` varchar(80) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `region_name` varchar(80) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `region_code` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `city` varchar(80) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
              `lat` decimal(10,6) DEFAULT NULL,
              `lon` decimal(10,6) DEFAULT NULL,
              `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
              PRIMARY KEY (`ip`),
              KEY `idx_country` (`country_code`),
              KEY `idx_region` (`region_name`),
              KEY `idx_updated` (`updated_at`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
        ");
    } catch (Throwable $e) {
        // якщо нема прав — просто мовчки не робимо автосоздання
    }
}

function traffic_geo_enrich(PDO $pdo, string $ip): ?array
{
    if (!traffic_is_public_ip($ip)) return null;

    traffic_geo_ensure_tables($pdo);

    $ipHash = traffic_ip_hash($ip);
    $ttlDays = (int)TRAFFIC_GEO_TTL_DAYS;

    // 1) traffic_geo_cache by ip_hash
    try {
        $st = $pdo->prepare("
            SELECT country_code, region_name, city_name, updated_at
            FROM traffic_geo_cache
            WHERE ip_hash = ?
            LIMIT 1
        ");
        $st->execute([$ipHash]);
        $r = $st->fetch(PDO::FETCH_ASSOC);
        if ($r) {
            $okTime = true;
            if (!empty($r['updated_at'])) {
                $st2 = $pdo->prepare("SELECT (updated_at >= (NOW() - INTERVAL {$ttlDays} DAY)) FROM traffic_geo_cache WHERE ip_hash=? LIMIT 1");
                $st2->execute([$ipHash]);
                $okTime = (bool)$st2->fetchColumn();
            }

            $cc = trim((string)($r['country_code'] ?? ''));
            $city = trim((string)($r['city_name'] ?? ''));
            $region = trim((string)($r['region_name'] ?? ''));

            if (($cc !== '' || $city !== '') && $okTime) {
                return [
                    'country_code' => ($cc !== '' ? $cc : null),
                    'city' => ($city !== '' ? $city : null),
                    'region_name' => ($region !== '' ? $region : null),
                    'source' => 'cache',
                ];
            }
        }
    } catch (Throwable $e) {}

    // 2) traffic_ip_geo by ip
    try {
        $st = $pdo->prepare("
            SELECT country_code, region_name, region_code, city, updated_at
            FROM traffic_ip_geo
            WHERE ip = ?
            LIMIT 1
        ");
        $st->execute([$ip]);
        $r = $st->fetch(PDO::FETCH_ASSOC);
        if ($r) {
            $okTime = true;
            if (!empty($r['updated_at'])) {
                $st2 = $pdo->prepare("SELECT (updated_at >= (NOW() - INTERVAL {$ttlDays} DAY)) FROM traffic_ip_geo WHERE ip=? LIMIT 1");
                $st2->execute([$ip]);
                $okTime = (bool)$st2->fetchColumn();
            }

            $cc = trim((string)($r['country_code'] ?? ''));
            $city = trim((string)($r['city'] ?? ''));
            $region = trim((string)($r['region_name'] ?? ''));
            $region2 = trim((string)($r['region_code'] ?? ''));

            if (($cc !== '' || $city !== '') && $okTime) {
                return [
                    'country_code' => ($cc !== '' ? $cc : null),
                    'city' => ($city !== '' ? $city : null),
                    'region_name' => ($region !== '' ? $region : ($region2 !== '' ? $region2 : null)),
                    'source' => 'ip_geo',
                ];
            }
        }
    } catch (Throwable $e) {}

    // 3) external lookup + save to caches
    $geo = traffic_geo_fetch_external($ip);
    if (!$geo) return null;

    $cc = trim((string)($geo['country_code'] ?? ''));
    $city = trim((string)($geo['city'] ?? ''));
    $regionName = trim((string)($geo['region_name'] ?? ''));
    $regionCode = trim((string)($geo['region_code'] ?? ''));
    $src = (string)($geo['source'] ?? 'ext');

    try {
        $st = $pdo->prepare("
            INSERT INTO traffic_geo_cache (ip_hash, ip, country_code, region_name, city_name, source)
            VALUES (?, ?, ?, ?, ?, ?)
            ON DUPLICATE KEY UPDATE
                ip=VALUES(ip),
                country_code=VALUES(country_code),
                region_name=VALUES(region_name),
                city_name=VALUES(city_name),
                source=VALUES(source),
                updated_at=CURRENT_TIMESTAMP
        ");
        $st->execute([
            $ipHash,
            $ip,
            ($cc !== '' ? strtoupper($cc) : null),
            ($regionName !== '' ? $regionName : ($regionCode !== '' ? $regionCode : null)),
            ($city !== '' ? $city : null),
            $src
        ]);
    } catch (Throwable $e) {}

    try {
        $st = $pdo->prepare("
            INSERT INTO traffic_ip_geo (ip, country_code, country_name, region_name, region_code, city, lat, lon)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            ON DUPLICATE KEY UPDATE
                country_code=VALUES(country_code),
                country_name=VALUES(country_name),
                region_name=VALUES(region_name),
                region_code=VALUES(region_code),
                city=VALUES(city),
                lat=VALUES(lat),
                lon=VALUES(lon),
                updated_at=CURRENT_TIMESTAMP
        ");
        $st->execute([
            $ip,
            ($cc !== '' ? strtoupper($cc) : null),
            ($geo['country_name'] ?? null),
            ($regionName !== '' ? $regionName : null),
            ($regionCode !== '' ? $regionCode : null),
            ($city !== '' ? $city : null),
            ($geo['lat'] ?? null),
            ($geo['lon'] ?? null),
        ]);
    } catch (Throwable $e) {}

    return [
        'country_code' => ($cc !== '' ? strtoupper($cc) : null),
        'city' => ($city !== '' ? $city : null),
        'region_name' => ($regionName !== '' ? $regionName : ($regionCode !== '' ? $regionCode : null)),
        'source' => $src,
    ];
}

function traffic_geo_fetch_external(string $ip): ?array
{
    // 1) ipwho.is
    $a = traffic_http_json("https://ipwho.is/" . rawurlencode($ip), TRAFFIC_GEO_TIMEOUT);
    if (is_array($a) && isset($a['success']) && $a['success'] === true) {
        $cc = (string)($a['country_code'] ?? '');
        $city = (string)($a['city'] ?? '');
        $region = (string)($a['region'] ?? '');
        $regionCode = (string)($a['region_code'] ?? '');

        if (trim($cc) !== '' || trim($city) !== '') {
            return [
                'country_code' => ($cc !== '' ? $cc : null),
                'country_name' => ($a['country'] ?? null),
                'region_name'  => ($region !== '' ? $region : null),
                'region_code'  => ($regionCode !== '' ? $regionCode : null),
                'city'         => ($city !== '' ? $city : null),
                'lat'          => ($a['latitude'] ?? null),
                'lon'          => ($a['longitude'] ?? null),
                'source'       => 'ipwhois',
            ];
        }
    }

    // 2) ip-api fallback
    $b = traffic_http_json("http://ip-api.com/json/" . rawurlencode($ip) . "?fields=status,message,country,countryCode,region,regionName,city,lat,lon", TRAFFIC_GEO_TIMEOUT);
    if (is_array($b) && (($b['status'] ?? '') === 'success')) {
        $cc = (string)($b['countryCode'] ?? '');
        $city = (string)($b['city'] ?? '');
        $regionName = (string)($b['regionName'] ?? '');
        $regionCode = (string)($b['region'] ?? '');

        if (trim($cc) !== '' || trim($city) !== '') {
            return [
                'country_code' => ($cc !== '' ? $cc : null),
                'country_name' => ($b['country'] ?? null),
                'region_name'  => ($regionName !== '' ? $regionName : null),
                'region_code'  => ($regionCode !== '' ? $regionCode : null),
                'city'         => ($city !== '' ? $city : null),
                'lat'          => ($b['lat'] ?? null),
                'lon'          => ($b['lon'] ?? null),
                'source'       => 'ipapi',
            ];
        }
    }

    return null;
}

function traffic_http_json(string $url, float $timeoutSec): ?array
{
    $timeoutSec = max(0.5, (float)$timeoutSec);

    if (function_exists('curl_init')) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_CONNECTTIMEOUT => (int)ceil($timeoutSec),
            CURLOPT_TIMEOUT => (int)ceil($timeoutSec),
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_USERAGENT => 'VMEREGI/1.0 (+traffic-geo)',
        ]);
        $body = curl_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($body === false || $code < 200 || $code >= 300) return null;

        $j = json_decode((string)$body, true);
        return is_array($j) ? $j : null;
    }

    $ctx = stream_context_create([
        'http' => [
            'timeout' => $timeoutSec,
            'header' => "User-Agent: VMEREGI/1.0 (+traffic-geo)\r\n",
        ],
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
        ],
    ]);

    $body = @file_get_contents($url, false, $ctx);
    if ($body === false) return null;

    $j = json_decode((string)$body, true);
    return is_array($j) ? $j : null;
}

} // endif function_exists
