<?php
/**
 * IP-to-country lookup against a pre-built static binary DB.
 *
 * Source data: DB-IP Country Lite CSV (https://db-ip.com/db/lite.php),
 * licensed CC-BY-4.0, free monthly updates, no signup required.
 *
 * Build pipeline (see /tmp/build_ip2country.php on the server):
 *   1. Download dbip-country-lite-YYYY-MM.csv.gz
 *   2. gunzip
 *   3. Run build_ip2country.php → emits two fixed-width binary files at
 *      /var/www/instinctor.com/data/ip2country-v{4,6}.bin
 *   4. Each row sorted by start-IP, fixed width (10 / 34 bytes), enabling
 *      O(log n) binary search via fseek+fread.
 *
 * Lookup is read-only and stateless. The lookup files live OUTSIDE the
 * plugin directory so plugin updates don't ship the 15 MB payload and so
 * deploy doesn't blow them away.
 *
 * Used as a fallback by AnalyticsService when the visitor request has no
 * `CF-IPCountry` header (i.e. when the site isn't behind Cloudflare).
 * If both CF header AND the binary lookup miss, country stays ''.
 *
 * @package Instinctor\Core
 * @since   2.1.1184
 */

declare(strict_types=1);

namespace Instinctor\Core;

final class IpToCountry
{
    /** @var string */
    private const V4_PATH = '/var/www/instinctor.com/data/ip2country-v4.bin';
    /** @var string */
    private const V6_PATH = '/var/www/instinctor.com/data/ip2country-v6.bin';

    /** @var int Bytes per row: 4 (start) + 4 (end) + 2 (country) = 10. */
    private const V4_ROW = 10;
    /** @var int Bytes per row: 16 (start) + 16 (end) + 2 (country) = 34. */
    private const V6_ROW = 34;

    /**
     * Resolve an IP address to a 2-letter ISO 3166-1 alpha-2 country code.
     *
     * @param string $ip Dotted-decimal IPv4 or colon-hex IPv6 address.
     * @return string 2-letter code (e.g. "US"), or "" on miss / unparseable input.
     */
    public static function lookup(string $ip): string
    {
        $ip = trim($ip);
        if ($ip === '') return '';

        // Strip an IPv4-mapped IPv6 prefix so we hit the smaller, faster v4 file.
        if (str_starts_with($ip, '::ffff:') && strpos($ip, '.') !== false) {
            $ip = substr($ip, 7);
        }

        $isV6 = strpos($ip, ':') !== false;

        if ($isV6) {
            return self::lookupV6($ip);
        }
        return self::lookupV4($ip);
    }

    private static function lookupV4(string $ip): string
    {
        $num = ip2long($ip);
        if ($num === false) return '';
        // Cast to unsigned 32-bit so the comparisons below work for IPs >= 128.0.0.0
        // (ip2long returns signed int on 32-bit PHP builds — defensive).
        $num &= 0xFFFFFFFF;

        return self::binarySearch(self::V4_PATH, self::V4_ROW, 4, $num, false);
    }

    private static function lookupV6(string $ip): string
    {
        $bin = @inet_pton($ip);
        if ($bin === false || strlen($bin) !== 16) return '';
        return self::binarySearch(self::V6_PATH, self::V6_ROW, 16, $bin, true);
    }

    /**
     * Binary-search a fixed-row-width sorted file for the row whose
     * [start, end] range contains $needle. Returns the 2-byte country at the
     * end of that row, or "" on miss.
     *
     * Algorithm: find rightmost row where row.start <= needle (upper-bound -1).
     * Then verify needle <= row.end. If the file is internally well-formed
     * (DB-IP guarantees non-overlapping sorted ranges), this is the only
     * candidate row.
     *
     * @param string $path     Absolute path to the binary file.
     * @param int    $rowSize  Bytes per row.
     * @param int    $keySize  Bytes per IP key (4 for v4, 16 for v6).
     * @param int|string $needle  The IP to look up: int for v4, 16-byte binary for v6.
     * @param bool   $isBin    True if $needle is binary (v6 path).
     */
    private static function binarySearch(string $path, int $rowSize, int $keySize, $needle, bool $isBin): string
    {
        $size = @filesize($path);
        if ($size === false || $size === 0 || ($size % $rowSize) !== 0) return '';
        $rowCount = intdiv($size, $rowSize);

        $fh = @fopen($path, 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- binary IP-to-country DB opened for random-access (fseek) reads; WP_Filesystem has no seek/partial-read API
        if (!$fh) return '';

        try {
            // Find rightmost row where row.start <= needle.
            $lo = 0; $hi = $rowCount - 1;
            while ($lo < $hi) {
                $mid = intdiv($lo + $hi + 1, 2);
                $start = self::readKey($fh, $mid * $rowSize, $keySize, $isBin);
                if (self::cmp($start, $needle, $isBin) <= 0) {
                    $lo = $mid;
                } else {
                    $hi = $mid - 1;
                }
            }
            // Read the candidate row; verify the IP falls within [start, end].
            fseek($fh, $lo * $rowSize);
            $row = fread($fh, $rowSize); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- binary record read at a computed offset
            if (strlen($row) !== $rowSize) return '';

            if ($isBin) {
                $start = substr($row, 0, 16);
                $end   = substr($row, 16, 16);
                $cc    = substr($row, 32, 2);
                if (strcmp($start, $needle) <= 0 && strcmp($needle, $end) <= 0) {
                    return ($cc === 'ZZ') ? '' : $cc;
                }
            } else {
                $start = unpack('N', substr($row, 0, 4))[1];
                $end   = unpack('N', substr($row, 4, 4))[1];
                $cc    = substr($row, 8, 2);
                if ($start <= $needle && $needle <= $end) {
                    return ($cc === 'ZZ') ? '' : $cc;
                }
            }
            return '';
        } finally {
            fclose($fh); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the binary DB handle
        }
    }

    /**
     * Read a $keySize-byte key from $offset. Returns int (v4) or raw string (v6).
     *
     * @return int|string
     */
    private static function readKey($fh, int $offset, int $keySize, bool $isBin)
    {
        fseek($fh, $offset);
        $buf = fread($fh, $keySize); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- binary key read at a computed offset
        if ($isBin) {
            return $buf;
        }
        return unpack('N', $buf)[1];
    }

    /**
     * Compare two keys. For v4 (int): standard <=>. For v6 (16-byte big-endian
     * binary): strcmp() — binary-safe and equivalent to numeric compare because
     * fixed-width big-endian binary strings sort the same way as their integer
     * values.
     *
     * @param int|string $a
     * @param int|string $b
     */
    private static function cmp($a, $b, bool $isBin): int
    {
        if ($isBin) return strcmp((string)$a, (string)$b);
        return $a <=> $b;
    }
}
