<?php
/**
 * Free geolocation: bundled IP-to-country resolver.
 *
 * Ships a compact, gzipped IPv4+IPv6 country table (built from the DB-IP Lite
 * dataset, CC-BY-4.0) under data/geoip-country.bin.gz. On first use it inflates
 * once into wp-content/uploads/shieldforge/ and answers lookups via a seek-based
 * binary search — the full table is never loaded into memory.
 *
 * IP-to-country lookups (country_for_ip) are pure local, no network calls, no
 * raw IP retained. The one exception is site_country()'s optional auto-detect,
 * which is opt-in and off by default — see its docblock. Adapted from the
 * Brainwerk SEO Suite's Seoforge_Geo (same bundled dataset).
 *
 * 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 Security Suite
 */

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

class Shieldforge_Geo {

    const BIN             = 'geoip-country.bin';
    const GZ              = 'geoip-country.bin.gz';
    const UNKNOWN         = 'ZZ';
    const SITE_CC_OPTION      = 'sf_site_cc';
    const SITE_CC_OVERRIDE    = 'sf_site_cc_override';
    const SITE_CC_AUTODETECT  = 'sf_site_cc_autodetect';

    /** In-memory memo of the site's own country for this request. */
    private static $site_cc_memo = null;

    /**
     * In-memory fallback copy of the inflated DB for this request, used only
     * when neither the uploads cache dir nor the system temp dir is writable.
     */
    private static $mem_buf = null;

    /**
     * Common ISO-3166 alpha-2 -> English short name, for admin-UI labels.
     * Not exhaustive — covers the countries that show up in real-world attack
     * traffic and site hosting; unknown codes fall back to the raw code.
     */
    private static $country_names = array(
        'US' => 'USA', 'CN' => 'China', 'RU' => 'Russland', 'DE' => 'Deutschland',
        'FR' => 'Frankreich', 'GB' => 'Vereinigtes Königreich', 'NL' => 'Niederlande',
        'BR' => 'Brasilien', 'IN' => 'Indien', 'VN' => 'Vietnam', 'ID' => 'Indonesien',
        'UA' => 'Ukraine', 'PL' => 'Polen', 'TR' => 'Türkei', 'KR' => 'Südkorea',
        'JP' => 'Japan', 'CA' => 'Kanada', 'IT' => 'Italien', 'ES' => 'Spanien',
        'RO' => 'Rumänien', 'IR' => 'Iran', 'SG' => 'Singapur', 'HK' => 'Hongkong',
        'TW' => 'Taiwan', 'SE' => 'Schweden', 'CH' => 'Schweiz', 'AT' => 'Österreich',
        'BE' => 'Belgien', 'AU' => 'Australien', 'ZA' => 'Südafrika', 'MX' => 'Mexiko',
        'AR' => 'Argentinien', 'TH' => 'Thailand', 'PH' => 'Philippinen', 'PK' => 'Pakistan',
        'BD' => 'Bangladesch', 'EG' => 'Ägypten', 'NG' => 'Nigeria', 'SA' => 'Saudi-Arabien',
        'IL' => 'Israel', 'NZ' => 'Neuseeland', 'IE' => 'Irland', 'DK' => 'Dänemark',
        'NO' => 'Norwegen', 'FI' => 'Finnland', 'CZ' => 'Tschechien', 'HU' => 'Ungarn',
        'GR' => 'Griechenland', 'PT' => 'Portugal', 'BG' => 'Bulgarien', 'LT' => 'Litauen',
        'LV' => 'Lettland', 'EE' => 'Estland', 'SK' => 'Slowakei', 'HR' => 'Kroatien',
        'RS' => 'Serbien', 'ME' => 'Montenegro', 'MD' => 'Moldau', 'BY' => 'Belarus',
        'KZ' => 'Kasachstan', 'UZ' => 'Usbekistan', 'MA' => 'Marokko', 'DZ' => 'Algerien',
        'TN' => 'Tunesien', 'CO' => 'Kolumbien', 'CL' => 'Chile', 'PE' => 'Peru',
        'VE' => 'Venezuela', 'ZZ' => 'Unbekannt',
    );

    /** English/German short name for a 2-letter ISO country code, or the code itself if unmapped. */
    public static function country_name( $cc ) {
        $cc = strtoupper( trim( (string) $cc ) );
        return isset( self::$country_names[ $cc ] ) ? self::$country_names[ $cc ] : $cc;
    }

    /**
     * Regional-indicator flag emoji for a 2-letter ISO country code. Computed
     * algorithmically (each letter -> its regional-indicator symbol) so it
     * works for every valid code without a lookup table. '' for invalid input
     * or the ZZ/unknown placeholder.
     */
    public static function flag_emoji( $cc ) {
        $cc = strtoupper( trim( (string) $cc ) );
        if ( self::UNKNOWN === $cc || ! preg_match( '/^[A-Z]{2}$/', $cc ) ) {
            return '';
        }
        $out = '';
        foreach ( str_split( $cc ) as $letter ) {
            $out .= mb_chr( 127397 + ord( $letter ), 'UTF-8' );
        }
        return $out;
    }

    /** Absolute path of the inflated runtime DB inside uploads. */
    private static function bin_path() {
        $upload = wp_upload_dir();
        return trailingslashit( $upload['basedir'] ) . 'shieldforge/' . self::BIN;
    }

    /** Secondary on-disk cache location, tried if uploads/ isn't writable. */
    private static function temp_path() {
        return trailingslashit( sys_get_temp_dir() ) . 'shieldforge-' . self::BIN;
    }

    /** Read + gzinflate the bundled .gz source. '' if missing/corrupt. */
    private static function inflate_source() {
        $gz = SHIELDFORGE_PATH . 'data/' . self::GZ;
        if ( ! file_exists( $gz ) || ! function_exists( 'gzdecode' ) ) {
            return '';
        }
        $raw = gzdecode( (string) file_get_contents( $gz ) );
        if ( false === $raw || strlen( $raw ) < 1024 ) {
            return '';
        }
        return $raw;
    }

    /** Best-effort write of $data to $target, creating its parent dir if needed. */
    private static function try_cache_write( $target, $data ) {
        $dir = dirname( $target );
        if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
            return false;
        }
        return false !== @file_put_contents( $target, $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged -- optional cache write; failure is handled via the false-return check, warning suppression avoids noise on read-only filesystems.
    }

    /**
     * Open a readable handle to the inflated DB: a real file resource for the
     * common case (bundled .gz inflated once into uploads, or the system temp
     * dir as a secondary cache), or a Shieldforge_Mem_Handle wrapping an
     * in-memory buffer when neither location is writable. Returns null if the
     * bundled source is missing or corrupt.
     */
    private static function open_handle() {
        $path = self::bin_path();
        if ( file_exists( $path ) && filesize( $path ) > 1024 ) {
            $fh = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
            if ( $fh ) {
                return $fh;
            }
        }

        if ( null !== self::$mem_buf ) {
            return new Shieldforge_Mem_Handle( self::$mem_buf );
        }

        $raw = self::inflate_source();
        if ( '' === $raw ) {
            return null;
        }

        foreach ( array( $path, self::temp_path() ) as $target ) {
            if ( self::try_cache_write( $target, $raw ) ) {
                $fh = fopen( $target, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
                if ( $fh ) {
                    return $fh;
                }
            }
        }

        self::$mem_buf = $raw;
        return new Shieldforge_Mem_Handle( $raw );
    }

    /**
     * Resolve an IP to an ISO-3166 alpha-2 country code, UPPERCASE.
     * Returns 'ZZ' for unknown/private/reserved/invalid input. Pure local
     * database lookup — no network access, the raw IP never leaves this call.
     */
    public static function country_for_ip( $ip ) {
        $ip = trim( (string) $ip );
        if ( '' === $ip || ! function_exists( 'inet_pton' ) ) {
            return self::UNKNOWN;
        }
        if ( self::is_private_or_reserved( $ip ) ) {
            return self::UNKNOWN;
        }
        $packed = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed IP triggers a PHP warning; failure is handled via the false-return check below.
        if ( false === $packed ) {
            return self::UNKNOWN;
        }
        $fh = self::open_handle();
        if ( null === $fh ) {
            return self::UNKNOWN;
        }
        $cc = '';
        try {
            if ( 'SFG1' !== Shieldforge_Mem_Handle::read( $fh, 4 ) ) {
                Shieldforge_Mem_Handle::close( $fh );
                return self::UNKNOWN;
            }
            $hdr  = unpack( 'Nv4/Nv6', Shieldforge_Mem_Handle::read( $fh, 8 ) );
            $v4   = (int) $hdr['v4'];
            $v6   = (int) $hdr['v6'];
            $dlen = (int) unpack( 'n', Shieldforge_Mem_Handle::read( $fh, 2 ) )[1];
            $dict = Shieldforge_Mem_Handle::read( $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 ) {
                    Shieldforge_Mem_Handle::seek( $fh, $v4_starts + $v4 * 4 + $idx );
                    $cc = substr( $dict, ord( Shieldforge_Mem_Handle::read( $fh, 1 ) ) * 2, 2 );
                }
            } else {
                $v6_starts = $v4_starts + $v4 * 4 + $v4;
                $idx       = self::bsearch_u128( $fh, $v6_starts, $v6, $packed );
                if ( $idx >= 0 ) {
                    Shieldforge_Mem_Handle::seek( $fh, $v6_starts + $v6 * 16 + $idx );
                    $cc = substr( $dict, ord( Shieldforge_Mem_Handle::read( $fh, 1 ) ) * 2, 2 );
                }
            }
        } catch ( \Throwable $e ) {
            $cc = '';
        }
        Shieldforge_Mem_Handle::close( $fh );

        $cc = strtoupper( $cc );
        return ( 2 === strlen( $cc ) ) ? $cc : self::UNKNOWN;
    }

    /**
     * This server's own country. Order of resolution:
     *   1. Admin override option (sf_site_cc_override), if set.
     *   2. Cached option (sf_site_cc), if present.
     *   3. Only if the admin opted in (sf_site_cc_autodetect = true): resolve
     *      the server's public IP via one outbound lookup, geolocate it
     *      locally, cache the result.
     *   4. Otherwise 'ZZ' — no network call made.
     */
    public static function site_country() {
        $override = Shieldforge_Settings::get( self::SITE_CC_OVERRIDE, '' );
        if ( is_string( $override ) && preg_match( '/^[A-Za-z]{2}$/', $override ) ) {
            return strtoupper( $override );
        }

        if ( null !== self::$site_cc_memo ) {
            return self::$site_cc_memo;
        }

        $cached = get_option( self::SITE_CC_OPTION, '' );
        if ( is_string( $cached ) && preg_match( '/^[A-Za-z]{2}$/', $cached ) ) {
            self::$site_cc_memo = strtoupper( $cached );
            return self::$site_cc_memo;
        }

        if ( ! Shieldforge_Settings::get( self::SITE_CC_AUTODETECT, false ) ) {
            self::$site_cc_memo = self::UNKNOWN;
            return self::$site_cc_memo;
        }

        $cc = self::UNKNOWN;
        $ip = self::lookup_public_ip();
        if ( '' !== $ip ) {
            $cc = self::country_for_ip( $ip );
        }

        update_option( self::SITE_CC_OPTION, $cc, false );
        self::$site_cc_memo = $cc;
        return $cc;
    }

    /** One outbound lookup for this server's own public IP. '' on failure. */
    private static function lookup_public_ip() {
        if ( ! function_exists( 'wp_remote_get' ) ) {
            return '';
        }
        $resp = wp_remote_get( 'https://api.ipify.org/', array(
            'timeout'   => 5,
            'sslverify' => true,
            'headers'   => array( 'User-Agent' => 'Brainwerk Security Suite/' . ( defined( 'SHIELDFORGE_VERSION' ) ? SHIELDFORGE_VERSION : '0' ) ),
        ) );
        if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
            return '';
        }
        $body = trim( (string) wp_remote_retrieve_body( $resp ) );
        return filter_var( $body, FILTER_VALIDATE_IP ) ? $body : '';
    }

    /** True for private/reserved/loopback/link-local IPv4+IPv6 ranges. */
    private static function is_private_or_reserved( $ip ) {
        return false === filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        );
    }

    /** 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 );
            Shieldforge_Mem_Handle::seek( $fh, $base + $mid * 4 );
            $v = (int) unpack( 'N', Shieldforge_Mem_Handle::read( $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 );
            Shieldforge_Mem_Handle::seek( $fh, $base + $mid * 16 );
            $v = Shieldforge_Mem_Handle::read( $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;
    }
}
