<?php
/**
 * Offline geolocation — resolve a visitor's country from their IP at visit time,
 * then throw the IP away.
 *
 * Everything here is local: a compact IP-to-country database ships gzipped with
 * the plugin (includes/geo/*.dat.gz, DB-IP Lite, CC-BY) and is decompressed once
 * into uploads/ for fseek binary-search. No request ever leaves the server, and
 * the IP is never stored — country/region are resolved on the live request and
 * only the coarse result is kept (see kantan_track_visit).
 *
 * Region is intentionally NOT bundled: region-level data needs a ~70MB city
 * database that can't ship in a plugin. Sites that want region add one and hook
 * the 'kantan_geo_region' filter; by default region stays empty.
 */

if (!defined('ABSPATH')) {
    exit;
}

/** Records are fixed-width: [start][2-char country], sorted by start. */
const KANTAN_GEO_V4_REC = 6;  // 4-byte start + 2-byte country
const KANTAN_GEO_V6_REC = 10; // 8-byte start (top 64 bits) + 2-byte country

/** Where the decompressed .dat files live (writable, outside the plugin). */
function kantan_geo_data_dir() {
    $up = wp_upload_dir();
    return trailingslashit($up['basedir']) . 'kantan-geo';
}

/**
 * Decompress the bundled country DB into uploads/ if it isn't there yet (or the
 * bundled copy changed). Cheap no-op once unpacked. Runs on admin_init so the
 * one-time cost lands on an admin page load, never a visitor request.
 */
function kantan_geo_ensure_unpacked() {
    $stamp = 'v1'; // bump when the bundled .dat.gz files are refreshed
    if (get_option('kantan_geo_unpacked') === $stamp) {
        return true;
    }

    $dir = kantan_geo_data_dir();
    if (!wp_mkdir_p($dir)) {
        return false;
    }
    if (!function_exists('gzdecode')) {
        return false; // zlib absent; geo silently disabled
    }

    $src = KANTAN_ANALYTICS_PLUGIN_DIR . 'includes/geo/';
    foreach (array('country-v4.dat', 'country-v6.dat') as $name) {
        $gz = $src . $name . '.gz';
        if (!is_readable($gz)) {
            return false;
        }
        $data = gzdecode((string) file_get_contents($gz));
        if ($data === false) {
            return false;
        }
        // Atomic-ish: write to a temp file then rename.
        $dest = trailingslashit($dir) . $name;
        $tmp  = $dest . '.tmp';
        if (file_put_contents($tmp, $data) === false) {
            return false;
        }
        rename($tmp, $dest);
    }

    // Keep the folder private and non-listable.
    $idx = trailingslashit($dir) . 'index.php';
    if (!file_exists($idx)) {
        file_put_contents($idx, "<?php // Silence is golden.\n");
    }
    update_option('kantan_geo_unpacked', $stamp, false);
    return true;
}
add_action('admin_init', 'kantan_geo_ensure_unpacked');

/**
 * Binary-search a packed .dat file for the country covering $key.
 *
 * $key is the big-endian start bytes of the query IP (4 for v4, 8 for v6), so a
 * plain byte comparison (strcmp) matches unsigned numeric order — no int math,
 * no 32-bit overflow. Finds the greatest record start <= key and returns its
 * 2-char country, or '' if before the first record / unknown ('ZZ').
 */
function kantan_geo_search($file, $key, $rec_size) {
    $key_len = $rec_size - 2;
    $size    = @filesize($file);
    if (!$size || $size % $rec_size !== 0) {
        return '';
    }
    $fh = @fopen($file, 'rb');
    if (!$fh) {
        return '';
    }

    $lo = 0;
    $hi = intdiv($size, $rec_size) - 1;
    $found = '';
    while ($lo <= $hi) {
        $mid = intdiv($lo + $hi, 2);
        fseek($fh, $mid * $rec_size);
        $rec = fread($fh, $rec_size);
        if ($rec === false || strlen($rec) !== $rec_size) {
            break;
        }
        $start = substr($rec, 0, $key_len);
        if (strcmp($start, $key) <= 0) {
            $found = substr($rec, $key_len, 2);
            $lo = $mid + 1; // look for a later (greater) start still <= key
        } else {
            $hi = $mid - 1;
        }
    }
    fclose($fh);

    $found = strtoupper(trim($found));
    return ($found === '' || $found === 'ZZ') ? '' : $found;
}

/**
 * ISO 3166-1 alpha-2 country for an IP, from the offline DB. '' if unknown,
 * private/reserved, or the DB isn't available.
 */
function kantan_geo_lookup_country($ip) {
    $ip = trim((string) $ip);
    if ($ip === '') {
        return '';
    }
    // Don't waste a lookup on non-routable addresses (LAN, loopback).
    if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
        return '';
    }
    $bin = @inet_pton($ip);
    if ($bin === false) {
        return '';
    }

    $dir = trailingslashit(kantan_geo_data_dir());
    if (strlen($bin) === 4) {
        return kantan_geo_search($dir . 'country-v4.dat', $bin, KANTAN_GEO_V4_REC);
    }
    // IPv6: match on the top 64 bits, the granularity the DB is packed at.
    return kantan_geo_search($dir . 'country-v6.dat', substr($bin, 0, 8), KANTAN_GEO_V6_REC);
}

/**
 * Resolve geo for the current visit from a live IP.
 *
 *   country : CF-IPCountry header when behind Cloudflare (free, exact), else the
 *             bundled offline DB.
 *   region  : empty unless a site provides city-level data via the
 *             'kantan_geo_region' filter (BYO — see includes/geo.php header).
 *
 * @param string $ip Live client IP (already resolved, about to be discarded).
 * @return array{country:string, region:string}
 */
function kantan_resolve_geo($ip) {
    $country = '';

    // Cloudflare sets CF-IPCountry to a 2-letter code (or "XX"/"T1"). Trust it
    // only when the request actually came through a CF edge.
    if (!empty($_SERVER['HTTP_CF_IPCOUNTRY']) && function_exists('kantan_cloudflare_ranges')) {
        $remote = isset($_SERVER['REMOTE_ADDR']) ? trim(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']))) : '';
        if ($remote !== '' && kantan_ip_in_ranges($remote, kantan_cloudflare_ranges())) {
            $cc = strtoupper(sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_IPCOUNTRY'])));
            if (preg_match('/^[A-Z]{2}$/', $cc) && $cc !== 'XX' && $cc !== 'T1') {
                $country = $cc;
            }
        }
    }

    if ($country === '') {
        $country = kantan_geo_lookup_country($ip);
    }

    // Region: opt-in, BYO. A site with a city DB returns e.g. "California" / "CA".
    $region = (string) apply_filters('kantan_geo_region', '', $ip, $country);
    $region = sanitize_text_field(substr($region, 0, 100));

    return array('country' => $country, 'region' => $region);
}

/**
 * Flag emoji for an ISO alpha-2 code, built from the two regional-indicator
 * symbols (no images). Empty string for an invalid code.
 */
function kantan_country_flag($code) {
    $code = strtoupper(trim((string) $code));
    if (!preg_match('/^[A-Z]{2}$/', $code)) {
        return '';
    }
    $a = 0x1F1E6 + (ord($code[0]) - 65);
    $b = 0x1F1E6 + (ord($code[1]) - 65);
    return mb_convert_encoding('&#' . $a . ';&#' . $b . ';', 'UTF-8', 'HTML-ENTITIES');
}

/**
 * Human country name for an ISO alpha-2 code. Uses the intl extension's
 * localized region names when available, otherwise returns the code unchanged.
 */
function kantan_country_name($code) {
    $code = strtoupper(trim((string) $code));
    if (!preg_match('/^[A-Z]{2}$/', $code)) {
        return $code;
    }
    if (class_exists('Locale')) {
        $name = Locale::getDisplayRegion('-' . $code, get_locale() ? substr(get_locale(), 0, 2) : 'en');
        if ($name && $name !== $code) {
            return $name;
        }
    }
    return $code;
}
