<?php

declare(strict_types=1);

namespace PpPhp\Core\Analytics;

use PpPhp\Core\DB;
use PpPhp\Core\PlatformSettings;

/**
 * Turn an IP address into a real location — accurately, and without leaking every
 * visitor to a third party.
 *
 * Resolution order (first hit wins, most-private first):
 *   1. A local .mmdb database in storage/geoip/ (DB-IP Lite, IP2Location LITE, or
 *      MaxMind GeoLite2 — all the same format). Offline, sub-millisecond, ~99%
 *      accurate at country level. This is the primary source.
 *   2. A CDN country header (Cloudflare & friends), when the site sits behind one.
 *   3. An opt-in HTTP API (ipwho.is), called at most ONCE per unique IP and cached
 *      in pp_geoip_cache. Off by default — the owner turns it on knowingly.
 *   4. Unknown. We never guess a country from the browser locale again — a wrong
 *      answer (Nepal shown as the US) is worse than an honest "Unknown".
 *
 * @phpstan-type Geo array{country:string,country_name:string,city:string,region:string,latitude:?float,longitude:?float,source:string,accuracy:string}
 */
final class GeoIp
{
    public const SETTING = 'platform.geoip';
    private const CACHE_TABLE = 'pp_geoip_cache';
    private const CACHE_TTL_DAYS = 30;

    /** @var array<string,array<string,mixed>> per-request memo, keyed by IP */
    private static array $memo = [];
    private static ?MmdbReader $reader = null;
    private static bool $readerTried = false;
    private static ?MmdbReader $asnReader = null;
    private static bool $asnTried = false;

    // ── Settings ──────────────────────────────────────────────────────

    /** @return array{db_path:string,api_enabled:bool,api_provider:string} */
    public static function settings(): array
    {
        $s = [];
        try {
            $raw = PlatformSettings::get(self::SETTING);
            if (is_array($raw)) $s = $raw;
        } catch (\Throwable $e) { /* no DB yet */ }

        return [
            'db_path'      => (string) ($s['db_path'] ?? ''),          // '' = auto-locate
            'api_enabled'  => (bool) ($s['api_enabled'] ?? false),
            'api_provider' => (string) ($s['api_provider'] ?? 'ipwho.is'),
        ];
    }

    public static function saveSettings(array $in): bool
    {
        try {
            return PlatformSettings::set(self::SETTING, [
                'db_path'      => trim((string) ($in['db_path'] ?? '')),
                'api_enabled'  => !empty($in['api_enabled']),
                'api_provider' => in_array(($in['api_provider'] ?? ''), ['ipwho.is', 'ip-api.com'], true)
                                    ? $in['api_provider'] : 'ipwho.is',
            ]);
        } catch (\Throwable $e) {
            return false;
        }
    }

    // ── Public resolution ─────────────────────────────────────────────

    /**
     * Resolve one IP to a location. Never throws.
     *
     * @param array<string,mixed> $server $_SERVER, for CDN headers (optional)
     * @return Geo
     */
    public static function resolve(string $ip, array $server = []): array
    {
        $ip = trim($ip);
        if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) {
            return self::unknown('none');
        }
        if (isset(self::$memo[$ip])) return self::$memo[$ip];

        // Skip lookups for private / reserved ranges — a LAN address has no country.
        $public = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);

        $geo = self::unknown('none');
        if ($public !== false) {
            $geo = self::fromDatabase($ip)
                ?? self::fromCdnHeader($server)
                ?? self::fromApi($ip)
                ?? self::unknown('none');
            // Network/ASN enrichment (organisation + hosting flag) when an ASN
            // database is present — "more accurate visitor info": tells apart a
            // real person's ISP from a datacenter / VPN / crawler.
            $asn = self::resolveAsn($ip);
            $geo['asn'] = $asn['asn'];
            $geo['org'] = $asn['org'];
            $geo['hosting'] = $asn['hosting'];
        } else {
            $geo = self::unknown('private');
        }

        return self::$memo[$ip] = $geo;
    }

    // ── Source 1: local database ──────────────────────────────────────

    /** @return Geo|null */
    private static function fromDatabase(string $ip): ?array
    {
        $reader = self::reader();
        if ($reader === null) return null;

        $rec = $reader->get($ip);
        if ($rec === null) return null;

        $country = strtoupper((string) ($rec['country']['iso_code'] ?? $rec['registered_country']['iso_code'] ?? ''));
        if ($country === '') return null;

        $name = (string) ($rec['country']['names']['en'] ?? $rec['registered_country']['names']['en'] ?? '');
        $city = (string) ($rec['city']['names']['en'] ?? '');
        $region = '';
        if (!empty($rec['subdivisions'][0]['names']['en'])) $region = (string) $rec['subdivisions'][0]['names']['en'];
        $lat = isset($rec['location']['latitude'])  ? (float) $rec['location']['latitude']  : null;
        $lon = isset($rec['location']['longitude']) ? (float) $rec['location']['longitude'] : null;

        return [
            'country'      => $country,
            'country_name' => $name !== '' ? $name : VisitorLog::countryName($country),
            'city'         => $city,
            'region'       => $region,
            'latitude'     => $lat,
            'longitude'    => $lon,
            'source'       => 'db',
            'accuracy'     => $city !== '' ? 'city' : 'country',
        ];
    }

    private static function reader(): ?MmdbReader
    {
        if (self::$readerTried) return self::$reader;
        self::$readerTried = true;
        $path = self::databasePath();
        self::$reader = $path !== '' ? MmdbReader::open($path) : null;
        return self::$reader;
    }

    /** The .mmdb in use: an explicit setting, else the newest file in storage/geoip. */
    public static function databasePath(): string
    {
        $explicit = self::settings()['db_path'];
        if ($explicit !== '' && is_file($explicit)) return $explicit;

        $dir = self::geoDir();
        if (!is_dir($dir)) return '';
        $best = '';
        $bestMtime = -1;
        foreach ((array) glob($dir . '/*.mmdb') as $f) {
            $name = strtolower(basename((string) $f));
            if (str_contains($name, 'asn')) continue;   // the ASN database is not a country/city DB
            $m = (int) @filemtime((string) $f);
            // Prefer a City database over Country when both exist (more detail).
            $score = (str_contains($name, 'city') ? 1_000_000_000 : 0) + $m;
            if ($score > $bestMtime) { $bestMtime = $score; $best = (string) $f; }
        }
        return $best;
    }

    private static function geoDir(): string
    {
        $base = defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        return rtrim((string) $base, '/') . '/storage/geoip';
    }

    // ── ASN / network enrichment ──────────────────────────────────────

    /**
     * The autonomous-system number + organisation for an IP, and whether it looks
     * like hosting/datacenter (a strong bot/VPN signal). Reads an ASN .mmdb from
     * storage/geoip if present; returns zeros otherwise.
     *
     * @return array{asn:int,org:string,hosting:bool}
     */
    public static function resolveAsn(string $ip): array
    {
        $reader = self::asnReader();
        if ($reader === null) return ['asn' => 0, 'org' => '', 'hosting' => false];
        $rec = $reader->get($ip);
        if ($rec === null) return ['asn' => 0, 'org' => '', 'hosting' => false];
        $org = (string) ($rec['autonomous_system_organization'] ?? $rec['as_organization'] ?? $rec['isp'] ?? '');
        return [
            'asn'     => (int) ($rec['autonomous_system_number'] ?? $rec['asn'] ?? 0),
            'org'     => substr($org, 0, 160),
            'hosting' => self::looksHosting($org),
        ];
    }

    private static function asnReader(): ?MmdbReader
    {
        if (self::$asnTried) return self::$asnReader;
        self::$asnTried = true;
        $dir = self::geoDir();
        $best = '';
        foreach ((array) glob($dir . '/*asn*.mmdb') as $f) {
            if ($best === '' || @filemtime((string) $f) > @filemtime($best)) $best = (string) $f;
        }
        self::$asnReader = $best !== '' ? MmdbReader::open($best) : null;
        return self::$asnReader;
    }

    /** Heuristic: does an AS organisation name read like a hosting/cloud provider? */
    private static function looksHosting(string $org): bool
    {
        if ($org === '') return false;
        return (bool) preg_match(
            '/\b(hosting|host|cloud|server|data ?center|datacenter|colo|vps|dedicated|amazon|aws|google|'
            . 'microsoft|azure|digitalocean|linode|akamai|cloudflare|fastly|ovh|hetzner|contabo|vultr|'
            . 'leaseweb|scaleway|oracle|alibaba|tencent|choopa|m247|cdn|proxy|vpn)\b/i',
            $org
        );
    }

    // ── Source 2: CDN header ──────────────────────────────────────────

    /**
     * @param array<string,mixed> $server
     * @return Geo|null
     */
    private static function fromCdnHeader(array $server): ?array
    {
        foreach (['HTTP_CF_IPCOUNTRY', 'HTTP_X_GEO_COUNTRY', 'HTTP_X_APPENGINE_COUNTRY', 'GEOIP_COUNTRY_CODE'] as $h) {
            $c = strtoupper(trim((string) ($server[$h] ?? '')));
            if (preg_match('/^[A-Z]{2}$/', $c) && $c !== 'XX' && $c !== 'T1') {
                return [
                    'country'      => $c,
                    'country_name' => VisitorLog::countryName($c),
                    'city'         => substr((string) ($server['HTTP_CF_IPCITY'] ?? ''), 0, 120),
                    'region'       => substr((string) ($server['HTTP_CF_REGION'] ?? ''), 0, 120),
                    'latitude'     => null,
                    'longitude'    => null,
                    'source'       => 'cdn',
                    'accuracy'     => 'country',
                ];
            }
        }
        return null;
    }

    // ── Source 3: opt-in HTTP API (cached per IP) ─────────────────────

    /** @return Geo|null */
    private static function fromApi(string $ip): ?array
    {
        $cfg = self::settings();
        if (!$cfg['api_enabled']) return null;

        $cached = self::cacheGet($ip);
        if ($cached !== null) {
            return $cached['country'] !== '' ? $cached : null;   // negative results are cached too
        }

        $geo = self::callApi($ip, $cfg['api_provider']);
        // Cache even a miss (empty country) so we do not re-hit the API for it.
        self::cachePut($ip, $geo ?? self::unknown('api'));
        return $geo;
    }

    /** @return Geo|null */
    private static function callApi(string $ip, string $provider): ?array
    {
        try {
            if ($provider === 'ip-api.com') {
                $url = 'http://ip-api.com/json/' . rawurlencode($ip) . '?fields=status,countryCode,country,regionName,city,lat,lon';
            } else {
                $url = 'https://ipwho.is/' . rawurlencode($ip) . '?fields=success,country_code,country,region,city,latitude,longitude';
            }
            $raw = self::httpGet($url);
            if ($raw === '') return null;
            $d = json_decode($raw, true);
            if (!is_array($d)) return null;

            if ($provider === 'ip-api.com') {
                if (($d['status'] ?? '') !== 'success') return null;
                $cc = strtoupper((string) ($d['countryCode'] ?? ''));
                if ($cc === '') return null;
                return self::apiGeo($cc, (string) ($d['country'] ?? ''), (string) ($d['city'] ?? ''),
                    (string) ($d['regionName'] ?? ''), $d['lat'] ?? null, $d['lon'] ?? null);
            }
            if (empty($d['success'])) return null;
            $cc = strtoupper((string) ($d['country_code'] ?? ''));
            if ($cc === '') return null;
            return self::apiGeo($cc, (string) ($d['country'] ?? ''), (string) ($d['city'] ?? ''),
                (string) ($d['region'] ?? ''), $d['latitude'] ?? null, $d['longitude'] ?? null);
        } catch (\Throwable $e) {
            return null;
        }
    }

    /** @return Geo */
    private static function apiGeo(string $cc, string $name, string $city, string $region, $lat, $lon): array
    {
        return [
            'country'      => $cc,
            'country_name' => $name !== '' ? $name : VisitorLog::countryName($cc),
            'city'         => substr($city, 0, 120),
            'region'       => substr($region, 0, 120),
            'latitude'     => $lat !== null ? (float) $lat : null,
            'longitude'    => $lon !== null ? (float) $lon : null,
            'source'       => 'api',
            'accuracy'     => $city !== '' ? 'city' : 'country',
        ];
    }

    private static function httpGet(string $url): string
    {
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 3,
                CURLOPT_TIMEOUT        => 5,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_USERAGENT      => 'PPPHP-Analytics/1',
            ]);
            $raw = curl_exec($ch);
            $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            unset($ch);
            return ($raw !== false && $code >= 200 && $code < 300) ? (string) $raw : '';
        }
        $ctx = stream_context_create(['http' => ['timeout' => 5, 'ignore_errors' => true]]);
        $raw = @file_get_contents($url, false, $ctx);
        return $raw !== false ? (string) $raw : '';
    }

    // ── API cache table ───────────────────────────────────────────────

    private static bool $cacheEnsured = false;

    private static function ensureCache(): void
    {
        if (self::$cacheEnsured) return;   // one DDL check per request, not per lookup
        self::$cacheEnsured = true;
        try {
            DB::Exec("CREATE TABLE IF NOT EXISTS `" . self::CACHE_TABLE . "` (
                `ip` VARCHAR(64) NOT NULL PRIMARY KEY,
                `country` CHAR(2) NOT NULL DEFAULT '',
                `country_name` VARCHAR(80) NOT NULL DEFAULT '',
                `city` VARCHAR(120) NOT NULL DEFAULT '',
                `region` VARCHAR(120) NOT NULL DEFAULT '',
                `latitude` DOUBLE NULL,
                `longitude` DOUBLE NULL,
                `fetched_at` DATETIME NOT NULL
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
        } catch (\Throwable $e) { /* best effort */ }
    }

    /** @return Geo|null */
    private static function cacheGet(string $ip): ?array
    {
        try {
            self::ensureCache();
            $row = DB::FetchAll(
                'SELECT * FROM `' . self::CACHE_TABLE . '` WHERE `ip` = ? AND `fetched_at` >= DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT 1',
                [$ip, self::CACHE_TTL_DAYS]
            );
            if (empty($row)) return null;
            $r = $row[0];
            return [
                'country'      => (string) $r['country'],
                'country_name' => (string) $r['country_name'],
                'city'         => (string) $r['city'],
                'region'       => (string) $r['region'],
                'latitude'     => isset($r['latitude']) ? (float) $r['latitude'] : null,
                'longitude'    => isset($r['longitude']) ? (float) $r['longitude'] : null,
                'source'       => 'api',
                'accuracy'     => ((string) $r['city']) !== '' ? 'city' : 'country',
            ];
        } catch (\Throwable $e) {
            return null;
        }
    }

    /** @param Geo $geo */
    private static function cachePut(string $ip, array $geo): void
    {
        try {
            self::ensureCache();
            DB::Query(
                'REPLACE INTO `' . self::CACHE_TABLE . '` (`ip`,`country`,`country_name`,`city`,`region`,`latitude`,`longitude`,`fetched_at`)
                 VALUES (?,?,?,?,?,?,?,NOW())',
                [$ip, $geo['country'], $geo['country_name'], $geo['city'], $geo['region'], $geo['latitude'], $geo['longitude']]
            );
        } catch (\Throwable $e) { /* best effort */ }
    }

    // ── Database install / update (owner action) ──────────────────────

    /**
     * Download the current DB-IP Lite country database (no registration needed),
     * unpack it, and atomically install it into storage/geoip/. Returns a
     * {success,message} the console can show verbatim.
     *
     * @return array{success:bool,message:string}
     */
    public static function updateDatabase(): array
    {
        $dir = self::geoDir();
        if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
            return ['success' => false, 'message' => 'Could not create storage/geoip (check permissions).'];
        }
        if (!is_writable($dir)) {
            return ['success' => false, 'message' => 'storage/geoip is not writable by the web server.'];
        }

        // DB-IP publishes a fresh file each month; the newest may be this month or
        // still last month early on. Try current, then previous.
        $errors = [];
        foreach (self::candidateDbipMonths() as $ym) {
            $url = "https://download.db-ip.com/free/dbip-country-lite-{$ym}.mmdb.gz";
            $gz  = self::httpGetBinary($url);
            if ($gz === '') { $errors[] = "{$ym}: not available"; continue; }
            $mmdb = @gzdecode($gz);
            if ($mmdb === false || strlen($mmdb) < 100000) { $errors[] = "{$ym}: bad archive"; continue; }

            // Validate it really parses before we swap it in.
            $tmp = $dir . '/.dbip-country-lite.tmp';
            if (@file_put_contents($tmp, $mmdb, LOCK_EX) === false) {
                return ['success' => false, 'message' => 'Could not write to storage/geoip.'];
            }
            $probe = MmdbReader::open($tmp);
            if ($probe === null || $probe->get('8.8.8.8') === null) {
                @unlink($tmp);
                $errors[] = "{$ym}: downloaded file did not parse";
                continue;
            }
            $dest = $dir . '/dbip-country-lite.mmdb';
            if (!@rename($tmp, $dest)) { @unlink($tmp); return ['success' => false, 'message' => 'Could not install the database file.']; }

            self::$reader = null; self::$readerTried = false;   // reset the cached reader
            $build = $probe->buildEpoch() ? date('Y-m-d', $probe->buildEpoch()) : $ym;
            $asn = self::installAsn($dir);                       // best-effort network DB too
            return ['success' => true, 'message' => "Installed {$probe->type()} (build {$build}, "
                . number_format(strlen($mmdb) / 1048576, 1) . ' MB).' . ($asn ? ' Network (ASN) database installed too.' : '')];
        }

        return ['success' => false, 'message' => 'Could not download a database. ' . implode('; ', $errors)
            . '. You can also place any .mmdb file in storage/geoip/ by hand.'];
    }

    /** Download + install the free DB-IP ASN Lite database. Best-effort. */
    private static function installAsn(string $dir): bool
    {
        foreach (self::candidateDbipMonths() as $ym) {
            $gz = self::httpGetBinary("https://download.db-ip.com/free/dbip-asn-lite-{$ym}.mmdb.gz");
            if ($gz === '') continue;
            $mmdb = @gzdecode($gz);
            if ($mmdb === false || strlen($mmdb) < 100000) continue;
            $tmp = $dir . '/.dbip-asn-lite.tmp';
            if (@file_put_contents($tmp, $mmdb, LOCK_EX) === false) return false;
            $probe = MmdbReader::open($tmp);
            if ($probe === null || $probe->get('8.8.8.8') === null) { @unlink($tmp); continue; }
            if (!@rename($tmp, $dir . '/dbip-asn-lite.mmdb')) { @unlink($tmp); return false; }
            self::$asnReader = null; self::$asnTried = false;
            return true;
        }
        return false;
    }

    /** @return array<int,string> YYYY-MM strings, current month then previous. */
    private static function candidateDbipMonths(): array
    {
        $now = time();
        $prev = strtotime('first day of last month', $now) ?: ($now - 30 * 86400);
        return [date('Y-m', $now), date('Y-m', $prev)];
    }

    private static function httpGetBinary(string $url): string
    {
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 8,
                CURLOPT_TIMEOUT        => 60,
                CURLOPT_FOLLOWLOCATION => true,
                CURLOPT_MAXREDIRS      => 3,
                CURLOPT_USERAGENT      => 'PPPHP-Analytics/1',
            ]);
            $raw = curl_exec($ch);
            $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            unset($ch);
            return ($raw !== false && $code >= 200 && $code < 300) ? (string) $raw : '';
        }
        $ctx = stream_context_create(['http' => ['timeout' => 60, 'follow_location' => 1]]);
        $raw = @file_get_contents($url, false, $ctx);
        return $raw !== false ? (string) $raw : '';
    }

    /**
     * What the console shows about the installed database.
     *
     * @return array{installed:bool,path:string,type:string,build:string,size_mb:string,stale:bool,checked:string}
     */
    public static function status(): array
    {
        $checked = (int) (self::meta('checked') ?: 0);
        $path = self::databasePath();
        if ($path === '' || !is_file($path)) {
            return ['installed' => false, 'path' => '', 'type' => '', 'build' => '', 'size_mb' => '',
                    'stale' => true, 'checked' => $checked ? date('Y-m-d H:i', $checked) : ''];
        }
        $reader = MmdbReader::open($path);
        return [
            'installed' => $reader !== null,
            'path'      => $path,
            'type'      => $reader?->type() ?? 'unreadable',
            'build'     => ($reader && $reader->buildEpoch()) ? date('Y-m-d', $reader->buildEpoch()) : '',
            'size_mb'   => number_format((int) @filesize($path) / 1048576, 1),
            'stale'     => self::isStale(),
            'checked'   => $checked ? date('Y-m-d H:i', $checked) : '',
            'asn'       => self::asnReader() !== null,
        ];
    }

    // ── Automatic refresh ─────────────────────────────────────────────

    private const META = 'platform.geoip_meta';
    /** How old the installed build may get before we refresh (DB-IP publishes monthly). */
    private const MAX_AGE_DAYS = 30;
    private const CHECK_EVERY = 86400;   // once per day

    /** True when the database is missing, unreadable, or older than MAX_AGE_DAYS. */
    public static function isStale(int $maxAgeDays = self::MAX_AGE_DAYS): bool
    {
        $path = self::databasePath();
        if ($path === '' || !is_file($path)) return true;
        $reader = MmdbReader::open($path);
        if ($reader === null) return true;
        $epoch = $reader->buildEpoch() ?: (int) @filemtime($path);
        return $epoch > 0 && (time() - $epoch) > $maxAgeDays * 86400;
    }

    /**
     * Piggybacked on a normal request: at most once a day, checks whether the
     * database needs refreshing and — crucially — runs the multi-MB download
     * AFTER the response is delivered, so no visitor ever waits for it. On a
     * server with no finish-request primitive it only self-heals a MISSING
     * database inline (a one-time cost) and otherwise leaves the refresh to cron
     * or the console button. Silent and non-fatal.
     */
    public static function tickIfDue(): void
    {
        try {
            $checked = (int) (self::meta('checked') ?: 0);
            if (time() - $checked < self::CHECK_EVERY) return;
            self::setMeta('checked', time());          // rate-limit: one request/day proceeds

            $path = self::databasePath();
            $missing = ($path === '' || !is_file($path));
            if (!$missing && !self::isStale()) return;

            // A non-blocking lock so two servers/requests don't download at once.
            $lock = @fopen(self::geoDir() . '/.update.lock', 'c');
            if ($lock && !@flock($lock, LOCK_EX | LOCK_NB)) { @fclose($lock); return; }

            $run = static function () use ($lock) {
                try { self::updateDatabase(); } catch (\Throwable $e) { /* logged by caller */ }
                if ($lock) { @flock($lock, LOCK_UN); @fclose($lock); }
            };

            if (function_exists('fastcgi_finish_request') || function_exists('litespeed_finish_request')) {
                register_shutdown_function(static function () use ($run) {
                    @ignore_user_abort(true);
                    if (function_exists('fastcgi_finish_request')) { @fastcgi_finish_request(); }
                    elseif (function_exists('litespeed_finish_request')) { @litespeed_finish_request(); }
                    $run();
                });
            } elseif ($missing) {
                $run();                                 // self-heal a missing DB inline (rare)
            } elseif ($lock) {
                @flock($lock, LOCK_UN); @fclose($lock);  // leave the refresh to cron/web-cron
            }
        } catch (\Throwable $e) { /* never break a page */ }
    }

    /** CLI/web-cron entry point: refresh only if stale (or $force). */
    public static function runScheduledUpdate(bool $force = false): array
    {
        if (!$force && !self::isStale()) {
            self::setMeta('checked', time());
            $st = self::status();
            return ['success' => true, 'message' => 'Geo database is current (' . ($st['type'] ?: 'installed')
                . ', build ' . ($st['build'] ?: '?') . '). No update needed.'];
        }
        $res = self::updateDatabase();
        self::setMeta('checked', time());
        return $res;
    }

    /** A dedicated secret for the geoip web-cron trigger (its own, per the cron-secret rule). */
    public static function cronSecret(): string
    {
        $s = (string) self::meta('cron_secret');
        if ($s === '') { $s = bin2hex(random_bytes(20)); self::setMeta('cron_secret', $s); }
        return $s;
    }

    // ── small settings-backed meta store ──────────────────────────────

    private static function meta(string $key): mixed
    {
        try {
            $bag = PlatformSettings::get(self::META);
            return is_array($bag) ? ($bag[$key] ?? null) : null;
        } catch (\Throwable $e) { return null; }
    }

    private static function setMeta(string $key, mixed $val): void
    {
        try {
            $bag = PlatformSettings::get(self::META);
            $bag = is_array($bag) ? $bag : [];
            $bag[$key] = $val;
            PlatformSettings::set(self::META, $bag);
        } catch (\Throwable $e) { /* best effort */ }
    }

    // ── helpers ───────────────────────────────────────────────────────

    /** @return Geo */
    private static function unknown(string $source): array
    {
        return [
            'country' => '', 'country_name' => '', 'city' => '', 'region' => '',
            'latitude' => null, 'longitude' => null, 'source' => $source, 'accuracy' => 'none',
            'asn' => 0, 'org' => '', 'hosting' => false,
        ];
    }
}
