<?php
/**
 * Free geolocation: bundled IP-to-country resolver.
 *
 * Ships a compact IPv4+IPv6 country table (built from the DB-IP Lite dataset,
 * CC-BY-4.0) under data/geoip-country.dat, read directly from the plugin
 * directory and answered via a seek-based binary search — the full table is
 * never loaded into memory.
 *
 * Works on egress-locked sites (no external download) and fills the `country`
 * field of every visit. Pro (Seoforge_Pro_Geo, MaxMind City) runs first at a
 * higher priority and additionally supplies region + city.
 *
 * Binary format ("SFG1"):
 *   magic    4   "SFG1"
 *   v4_count u32 BE
 *   v6_count u32 BE
 *   dict_len u16 BE        number of 2-char country codes
 *   dict     dict_len*2    ASCII codes; id = index
 *   v4_starts v4_count*4   u32 BE, ascending range starts
 *   v4_ids    v4_count*1   index into dict
 *   v6_starts v6_count*16  u128 BE, ascending range starts
 *   v6_ids    v6_count*1   index into dict
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Geo {

    const BIN        = 'geoip-country.dat';
    /** Vintage of the bundled DB-IP Lite dataset (for the UI/credits). */
    const DATA_MONTH = '2026-06';

    public static function register( Seoforge_Loader $loader ) {
        // Priority 20: Pro geo (region/city) hooks at 10 and may already set the
        // country; we only fill the country when nothing else did.
        $loader->add_filter( 'seoforge_visit_geo', __CLASS__, 'fill', 20, 2 );
    }

    /**
     * Filter callback. Fills $geo['country'] when still empty.
     *
     * @param array  $geo array{country:string,region:string,city:string}
     * @param string $ip  raw (un-anonymised) client IP
     * @return array
     */
    public static function fill( $geo, $ip ) {
        if ( ! is_array( $geo ) ) {
            $geo = array( 'country' => '', 'region' => '', 'city' => '' );
        }
        if ( empty( $geo['country'] ) ) {
            $cc = self::country( (string) $ip );
            if ( '' !== $cc ) {
                $geo['country'] = $cc;
            }
        }
        return $geo;
    }

    /**
     * Regional-indicator flag emoji for an ISO alpha-2 code (e.g. "AT" -> 🇦🇹).
     * Returns '' for anything that is not two A–Z letters.
     */
    public static function flag( $code ) {
        $code = strtoupper( trim( (string) $code ) );
        if ( ! preg_match( '/^[A-Z]{2}$/', $code ) ) {
            return '';
        }
        $c1 = 0x1F1E6 + ( ord( $code[0] ) - 65 );
        $c2 = 0x1F1E6 + ( ord( $code[1] ) - 65 );
        return self::utf8( $c1 ) . self::utf8( $c2 );
    }

    /** Encode a Unicode code point as UTF-8 without relying on mbstring. */
    private static function utf8( $cp ) {
        if ( $cp < 0x80 ) {
            return chr( $cp );
        }
        if ( $cp < 0x800 ) {
            return chr( 0xC0 | ( $cp >> 6 ) ) . chr( 0x80 | ( $cp & 0x3F ) );
        }
        if ( $cp < 0x10000 ) {
            return chr( 0xE0 | ( $cp >> 12 ) ) . chr( 0x80 | ( ( $cp >> 6 ) & 0x3F ) ) . chr( 0x80 | ( $cp & 0x3F ) );
        }
        return chr( 0xF0 | ( $cp >> 18 ) ) . chr( 0x80 | ( ( $cp >> 12 ) & 0x3F ) ) . chr( 0x80 | ( ( $cp >> 6 ) & 0x3F ) ) . chr( 0x80 | ( $cp & 0x3F ) );
    }

    /** Absolute path of the bundled country DB inside the plugin. */
    public static function bin_path() {
        return SEOFORGE_PATH . 'data/' . self::BIN;
    }

    /** True when the resolver has a usable database available. */
    public static function is_available() {
        return '' !== self::ensure_db();
    }

    /** Returns the path to the bundled .dat, or '' if it's missing/corrupt. */
    private static function ensure_db() {
        $path = self::bin_path();
        return ( file_exists( $path ) && filesize( $path ) > 1024 ) ? $path : '';
    }

    /**
     * Resolve an IP to an ISO-3166 alpha-2 country code, or '' if unknown.
     */
    public static function country( $ip ) {
        $ip = trim( (string) $ip );
        if ( '' === $ip || ! function_exists( 'inet_pton' ) ) {
            return '';
        }
        $packed = @inet_pton( $ip );
        if ( false === $packed ) {
            return '';
        }
        $path = self::ensure_db();
        if ( '' === $path ) {
            return '';
        }
        $fh = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
        if ( ! $fh ) {
            return '';
        }
        $cc = '';
        try {
            if ( 'SFG1' !== fread( $fh, 4 ) ) {
                fclose( $fh );
                return '';
            }
            $hdr  = unpack( 'Nv4/Nv6', (string) fread( $fh, 8 ) );
            $v4   = (int) $hdr['v4'];
            $v6   = (int) $hdr['v6'];
            $dlen = (int) unpack( 'n', (string) fread( $fh, 2 ) )[1];
            $dict = (string) fread( $fh, $dlen * 2 );

            $v4_starts = 4 + 8 + 2 + $dlen * 2;

            if ( 4 === strlen( $packed ) ) {
                $key = (int) unpack( 'N', $packed )[1];
                $idx = self::bsearch_u32( $fh, $v4_starts, $v4, $key );
                if ( $idx >= 0 ) {
                    fseek( $fh, $v4_starts + $v4 * 4 + $idx );
                    $cc = substr( $dict, ord( (string) fread( $fh, 1 ) ) * 2, 2 );
                }
            } else {
                $v6_starts = $v4_starts + $v4 * 4 + $v4;
                $idx       = self::bsearch_u128( $fh, $v6_starts, $v6, $packed );
                if ( $idx >= 0 ) {
                    fseek( $fh, $v6_starts + $v6 * 16 + $idx );
                    $cc = substr( $dict, ord( (string) fread( $fh, 1 ) ) * 2, 2 );
                }
            }
        } catch ( \Throwable $e ) {
            $cc = '';
        }
        fclose( $fh );

        $cc = strtoupper( $cc );
        // ZZ = DB-IP's "reserved / unknown" marker.
        return ( '' === $cc || 'ZZ' === $cc ) ? '' : $cc;
    }

    /** Greatest index whose 4-byte start <= $key (uint32), or -1. */
    private static function bsearch_u32( $fh, $base, $n, $key ) {
        $lo = 0;
        $hi = $n - 1;
        $res = -1;
        while ( $lo <= $hi ) {
            $mid = intdiv( $lo + $hi, 2 );
            fseek( $fh, $base + $mid * 4 );
            $v = (int) unpack( 'N', (string) fread( $fh, 4 ) )[1];
            if ( $v <= $key ) {
                $res = $mid;
                $lo  = $mid + 1;
            } else {
                $hi = $mid - 1;
            }
        }
        return $res;
    }

    /** Greatest index whose 16-byte start <= $key16 (big-endian), or -1. */
    private static function bsearch_u128( $fh, $base, $n, $key16 ) {
        $lo = 0;
        $hi = $n - 1;
        $res = -1;
        while ( $lo <= $hi ) {
            $mid = intdiv( $lo + $hi, 2 );
            fseek( $fh, $base + $mid * 16 );
            $v = (string) fread( $fh, 16 );
            // Binary big-endian strings compare correctly as unsigned via strcmp.
            if ( strcmp( $v, $key16 ) <= 0 ) {
                $res = $mid;
                $lo  = $mid + 1;
            } else {
                $hi = $mid - 1;
            }
        }
        return $res;
    }
}
