<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall\GeoIP;

use SiteFort\Includes\GuardedDataFile;
use SiteFort\Includes\ProxyPresets;
use SiteFort\Includes\RequestProvenance;

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

/**
 * GeoIP lookup service.
 *
 * Resolves IP addresses to country codes using two sources (in order):
 *
 * 1. Trusted Cloudflare CF-IPCountry header (zero cost, only when request
 *    provenance proves Cloudflare originated the header)
 * 2. MaxMind GeoLite2-Country MMDB (local binary lookup, no external calls)
 *
 * If neither source is available, lookups return null and geo features
 * (country blocking) are simply unavailable. No external API calls are made.
 *
 */
class GeoIPService {

    /**
     * Path to the MaxMind MMDB database file.
     */
    private const MMDB_PATH = '/sitefort-data/geoip/GeoLite2-Country.mmdb.php';
    private const LEGACY_MMDB_PATH = '/sitefort-data/geoip/GeoLite2-Country.mmdb';

    /**
     * MMDB metadata marker.
     */
    private const MMDB_METADATA_MARKER = "\xab\xcd\xefMaxMind.com";

    /**
     * Full path to the MMDB file.
     *
     * @var string
     */
    private string $mmdb_path;

    /**
     * MMDB file handle (opened once, kept for the request lifetime).
     *
     * @var resource|null
     */
    private $mmdb_handle = null;

    /**
     * Parsed MMDB metadata.
     *
     * @var array|null
     */
    private ?array $mmdb_metadata = null;

    /**
     * Whether the MMDB file has been tested and is usable.
     *
     * @var bool|null
     */
    private ?bool $mmdb_usable = null;

    /**
     * MMDB search tree size (in bytes).
     *
     * @var int
     */
    private int $mmdb_search_tree_size = 0;

    /**
     * Cached data section (loaded once per request for pointer resolution).
     *
     * @var string|null
     */
    private ?string $mmdb_data_section = null;

    /**
     * Payload offset for PHP-guarded MMDB containers.
     *
     * @var int
     */
    private int $mmdb_payload_offset = 0;

    /**
     * MMDB payload size excluding any guard prefix.
     *
     * @var int
     */
    private int $mmdb_payload_size = 0;

    public function __construct() {
        $this->refresh_mmdb_path();
    }

    private function refresh_mmdb_path(): void {
        $guarded_path = WP_CONTENT_DIR . self::MMDB_PATH;
        $legacy_path  = WP_CONTENT_DIR . self::LEGACY_MMDB_PATH;

        $this->mmdb_path = is_file( $guarded_path ) ? $guarded_path : $legacy_path;
    }

    /**
     * Destructor: close the MMDB file handle if open.
     */
    public function __destruct() {
        if ( $this->mmdb_handle !== null ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the persistent MMDB handle for this request.
            fclose( $this->mmdb_handle );
            $this->mmdb_handle = null;
        }
    }

    /**
     * Look up country information for an IP address.
     *
     * Strategy: Cloudflare header first (free, no I/O), then local MaxMind
     * MMDB. Returns null if neither source is available.
     *
     * @param string $ip The IP address to look up.
     * @return array{country_code: string, country_name: string}|null Country data or null if lookup fails.
     */
    public function lookup( string $ip ): ?array {
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return null;
        }

        // 1. Trusted Cloudflare CF-IPCountry header (present on verified CF requests).
        $cf_country = $this->lookup_via_cloudflare();
        if ( $cf_country !== null ) {
            return $cf_country;
        }

        // Skip private/reserved IPs for local database lookups.
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
            return null;
        }

        // 2. Local MaxMind MMDB database.
        if ( $this->is_mmdb_usable() ) {
            return $this->lookup_via_mmdb( $ip );
        }

        return null;
    }

    /**
     * Look up an arbitrary IP address without using request-scoped proxy headers.
     *
     * This is intended for admin diagnostic tools where the caller wants to
     * inspect a specific target IP rather than the current HTTP request.
     *
     * @param string $ip The IP address to look up.
     * @return array{country_code: string, country_name: string}|null
     */
    public function lookup_exact_ip( string $ip ): ?array {
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return null;
        }

        if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
            return null;
        }

        if ( ! $this->is_mmdb_usable() ) {
            return null;
        }

        return $this->lookup_via_mmdb( $ip );
    }

    /**
     * Check if the local MaxMind database is present and can be parsed.
     */
    public function has_usable_mmdb(): bool {
        return $this->is_mmdb_usable();
    }

    /**
     * Check whether this request has any origin-side country provider.
     *
     * Edge-only Cloudflare enforcement is not sufficient for origin fallback.
     */
    public function has_origin_country_provider(): bool {
        return $this->get_cloudflare_country_code() !== null || $this->has_usable_mmdb();
    }

    /**
     * Check if the MMDB database file exists and is readable.
     */
    public function has_mmdb(): bool {
        $this->refresh_mmdb_path();
        return is_file( $this->mmdb_path ) && is_readable( $this->mmdb_path );
    }

    // -------------------------------------------------------------------------
    // Cloudflare CF-IPCountry header
    // -------------------------------------------------------------------------

    /**
     * Read country from the Cloudflare CF-IPCountry header.
     *
     * CF sets this automatically for proxied requests. Values "XX"
     * (unknown) and "T1" (Tor) are not usable for normal country enforcement.
     *
     * @return array{country_code: string, country_name: string}|null
     */
    private function lookup_via_cloudflare(): ?array {
        $code = $this->get_cloudflare_country_code();
        if ( $code === null ) {
            return null;
        }

        return [
            'country_code' => $code,
            'country_name' => '',
        ];
    }

    /**
     * Return a validated Cloudflare country code for this request, but only when
     * request provenance proves the header came from Cloudflare.
     */
    private function get_cloudflare_country_code(): ?string {
        $provenance = RequestProvenance::resolve_current_request( null, [
            'cloudflare_ranges' => ProxyPresets::get_ranges( ProxyPresets::PRESET_CLOUDFLARE ),
        ] );

        if ( empty( $provenance['cloudflare_peer_verified'] ) || empty( $provenance['cloudflare_country_valid'] ) ) {
            return null;
        }

        $code = strtoupper( (string) ( $provenance['cf_ipcountry'] ?? '' ) );
        return strlen( $code ) === 2 ? $code : null;
    }

    // -------------------------------------------------------------------------
    // MMDB Reader (self-contained, minimal MaxMind MMDB binary parser)
    // -------------------------------------------------------------------------

    /**
     * Check if the MMDB file is usable (exists, readable, metadata parseable).
     *
     * @return bool
     */
    private function is_mmdb_usable(): bool {
        if ( $this->mmdb_usable !== null ) {
            return $this->mmdb_usable;
        }

        $this->mmdb_usable = false;

        if ( ! $this->has_mmdb() ) {
            return false;
        }

        try {
            $this->open_mmdb();
            if ( $this->mmdb_metadata !== null ) {
                $this->mmdb_usable = true;
            }
        } catch ( \Throwable $e ) {
            $this->mmdb_usable = false;
        }

        return $this->mmdb_usable;
    }

    /**
     * Open the MMDB file and parse metadata.
     */
    private function open_mmdb(): void {
        if ( $this->mmdb_handle !== null ) {
            return;
        }

        $this->refresh_mmdb_path();

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- MMDB parsing requires random-access binary reads against the local database file.
        $handle = fopen( $this->mmdb_path, 'rb' );
        if ( $handle === false ) {
            return;
        }

        $this->mmdb_handle = $handle;
        $this->mmdb_payload_offset = GuardedDataFile::payload_offset_for_path( $this->mmdb_path );

        // Find the metadata section by searching backwards for the marker.
        $raw_size = filesize( $this->mmdb_path );
        if ( $raw_size === false ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the MMDB handle on early size-detection failure.
            fclose( $handle );
            $this->mmdb_handle = null;
            return;
        }

        $file_size = max( 0, (int) $raw_size - $this->mmdb_payload_offset );
        $this->mmdb_payload_size = $file_size;

        if ( $file_size < 200 ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the MMDB handle when the file is too small to be valid.
            fclose( $handle );
            $this->mmdb_handle = null;
            return;
        }

        $marker_len = strlen( self::MMDB_METADATA_MARKER );

        // Read the last 128KB (metadata is at the end of the file).
        $search_size = min( $file_size, 131072 );
        fseek( $handle, $this->mmdb_payload_offset + $file_size - $search_size );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary tail read is required to locate the MMDB metadata marker.
        $tail = fread( $handle, $search_size );

        if ( $tail === false ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the MMDB handle after a failed tail read.
            fclose( $handle );
            $this->mmdb_handle = null;
            return;
        }

        // Find the LAST occurrence of the marker.
        $marker_pos = strrpos( $tail, self::MMDB_METADATA_MARKER );
        if ( $marker_pos === false ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the MMDB handle when the metadata marker is missing.
            fclose( $handle );
            $this->mmdb_handle = null;
            return;
        }

        // The metadata section starts after the marker.
        $metadata_offset = ( $file_size - $search_size ) + $marker_pos + $marker_len;

        // Read and decode the metadata.
        fseek( $handle, $this->mmdb_payload_offset + $metadata_offset );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary metadata read from the MMDB footer.
        $metadata_raw = fread( $handle, $file_size - $metadata_offset );
        if ( $metadata_raw === false || $metadata_raw === '' ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the MMDB handle after a failed metadata read.
            fclose( $handle );
            $this->mmdb_handle = null;
            return;
        }

        $offset   = 0;
        $metadata = $this->mmdb_decode( $metadata_raw, $offset );

        if ( ! is_array( $metadata ) || ! isset( $metadata['node_count'], $metadata['record_size'] ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the MMDB handle when parsed metadata is invalid.
            fclose( $handle );
            $this->mmdb_handle = null;
            return;
        }

        $this->mmdb_metadata         = $metadata;
        $this->mmdb_search_tree_size = (int) ( $metadata['node_count'] * $metadata['record_size'] * 2 / 8 );
    }

    /**
     * Look up an IP address in the MMDB database.
     *
     * @param string $ip The IP address.
     * @return array{country_code: string, country_name: string}|null
     */
    private function lookup_via_mmdb( string $ip ): ?array {
        if ( $this->mmdb_handle === null || $this->mmdb_metadata === null ) {
            return null;
        }

        try {
            $packed = inet_pton( $ip );
            if ( $packed === false ) {
                return null;
            }

            $ip_version = strlen( $packed ) === 4 ? 4 : 6;
            $bit_count  = $ip_version === 4 ? 32 : 128;

            $record_size  = (int) $this->mmdb_metadata['record_size'];
            $node_count   = (int) $this->mmdb_metadata['node_count'];
            $ip_version_db = (int) $this->mmdb_metadata['ip_version'];

            // If searching for IPv4 in an IPv6 database, skip the first 96 bits.
            $node = 0;
            if ( $ip_version === 4 && $ip_version_db === 6 ) {
                // Walk the tree for the first 96 zero bits (::ffff:0:0/96 mapping).
                for ( $i = 0; $i < 96; $i++ ) {
                    if ( $node >= $node_count ) {
                        break;
                    }
                    $node = $this->mmdb_read_node( $node, 0, $record_size );
                }
            }

            // Walk the search tree bit by bit.
            $bits = $this->ip_to_bits( $packed );

            for ( $i = 0; $i < $bit_count; $i++ ) {
                if ( $node >= $node_count ) {
                    break;
                }

                $bit  = $bits[ $i ] ?? 0;
                $node = $this->mmdb_read_node( $node, $bit, $record_size );
            }

            // Check if we hit a data record.
            if ( $node === $node_count ) {
                // Not found in tree.
                return null;
            }

            if ( $node <= $node_count ) {
                return null;
            }

            // The data section starts after the search tree + 16-byte separator.
            $data_section_start = $this->mmdb_search_tree_size + 16;
            $data_offset        = $node - $node_count - 16;

            // Read the entire data section so pointer offsets resolve correctly.
            // Pointers are relative to the data section start, so partial reads
            // would break pointer resolution. Country DBs have ~120 KB of data.
            if ( $this->mmdb_data_section === null ) {
                $file_size = $this->mmdb_payload_size;
                if ( $file_size <= 0 ) {
                    return null;
                }
                $data_section_length = $file_size - $data_section_start;
                fseek( $this->mmdb_handle, $this->mmdb_payload_offset + $data_section_start );
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Loads the MMDB data section once for pointer resolution.
                $this->mmdb_data_section = fread( $this->mmdb_handle, $data_section_length );
                if ( $this->mmdb_data_section === false || $this->mmdb_data_section === '' ) {
                    $this->mmdb_data_section = null;
                    return null;
                }
            }

            $offset = $data_offset;
            $record = $this->mmdb_decode( $this->mmdb_data_section, $offset );

            return $this->extract_country_from_record( $record );
        } catch ( \Throwable $e ) {
            return null;
        }
    }

    /**
     * Read a single node from the MMDB search tree.
     *
     * @param int $node_number The current node number.
     * @param int $bit         Which branch to follow (0 = left, 1 = right).
     * @param int $record_size The record size in bits (24, 28, or 32).
     * @return int The value of the selected record (next node number or data pointer).
     */
    private function mmdb_read_node( int $node_number, int $bit, int $record_size ): int {
        $node_byte_size = (int) ( $record_size * 2 / 8 );
        $offset         = $node_number * $node_byte_size;

        fseek( $this->mmdb_handle, $this->mmdb_payload_offset + $offset );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Random-access MMDB node read during binary tree traversal.
        $node_data = fread( $this->mmdb_handle, $node_byte_size );

        if ( $node_data === false || strlen( $node_data ) < $node_byte_size ) {
            return 0;
        }

        if ( $record_size === 24 ) {
            if ( $bit === 0 ) {
                // Left record: first 3 bytes.
                return ( ord( $node_data[0] ) << 16 ) | ( ord( $node_data[1] ) << 8 ) | ord( $node_data[2] );
            }
            // Right record: last 3 bytes.
            return ( ord( $node_data[3] ) << 16 ) | ( ord( $node_data[4] ) << 8 ) | ord( $node_data[5] );
        }

        if ( $record_size === 28 ) {
            $middle = ord( $node_data[3] );
            if ( $bit === 0 ) {
                // Left record: first 3 bytes + high nibble of middle byte.
                return ( ( $middle >> 4 ) << 24 )
                     | ( ord( $node_data[0] ) << 16 )
                     | ( ord( $node_data[1] ) << 8 )
                     | ord( $node_data[2] );
            }
            // Right record: low nibble of middle byte + last 3 bytes.
            return ( ( $middle & 0x0f ) << 24 )
                 | ( ord( $node_data[4] ) << 16 )
                 | ( ord( $node_data[5] ) << 8 )
                 | ord( $node_data[6] );
        }

        if ( $record_size === 32 ) {
            if ( $bit === 0 ) {
                $unpacked = unpack( 'N', substr( $node_data, 0, 4 ) );
                return $unpacked ? $unpacked[1] : 0;
            }
            $unpacked = unpack( 'N', substr( $node_data, 4, 4 ) );
            return $unpacked ? $unpacked[1] : 0;
        }

        return 0;
    }

    /**
     * Convert a packed IP address to an array of bits.
     *
     * @param string $packed Packed IP (4 or 16 bytes from inet_pton).
     * @return int[] Array of 0s and 1s.
     */
    private function ip_to_bits( string $packed ): array {
        $bits = [];
        for ( $i = 0, $len = strlen( $packed ); $i < $len; $i++ ) {
            $byte = ord( $packed[ $i ] );
            for ( $j = 7; $j >= 0; $j-- ) {
                $bits[] = ( $byte >> $j ) & 1;
            }
        }
        return $bits;
    }

    /**
     * Decode an MMDB data section value.
     *
     * Supports all MaxMind data types: pointer, string, double, bytes,
     * uint16, uint32, map, int32, uint64, uint128, array, boolean, float.
     *
     * @param string $data   The raw data bytes.
     * @param int    $offset Current offset (modified in-place).
     * @return mixed The decoded value.
     */
    private function mmdb_decode( string $data, int &$offset ) {
        if ( $offset >= strlen( $data ) ) {
            return null;
        }

        $ctrl_byte = ord( $data[ $offset ] );
        $offset++;

        $type = $ctrl_byte >> 5;

        // Extended type.
        if ( $type === 0 ) {
            if ( $offset >= strlen( $data ) ) {
                return null;
            }
            $type = ord( $data[ $offset ] ) + 7;
            $offset++;
        }

        // Pointer type.
        if ( $type === 1 ) {
            $pointer_size = ( ( $ctrl_byte >> 3 ) & 0x03 );
            $pointer      = 0;
            $base          = $ctrl_byte & 0x07;

            switch ( $pointer_size ) {
                case 0:
                    if ( $offset >= strlen( $data ) ) {
                        return null;
                    }
                    $pointer = ( $base << 8 ) | ord( $data[ $offset ] );
                    $offset++;
                    break;
                case 1:
                    if ( $offset + 1 >= strlen( $data ) ) {
                        return null;
                    }
                    $pointer = ( ( $base << 16 ) | ( ord( $data[ $offset ] ) << 8 ) | ord( $data[ $offset + 1 ] ) ) + 2048;
                    $offset += 2;
                    break;
                case 2:
                    if ( $offset + 2 >= strlen( $data ) ) {
                        return null;
                    }
                    $pointer = ( ( $base << 24 ) | ( ord( $data[ $offset ] ) << 16 ) | ( ord( $data[ $offset + 1 ] ) << 8 ) | ord( $data[ $offset + 2 ] ) ) + 526336;
                    $offset += 3;
                    break;
                case 3:
                    if ( $offset + 3 >= strlen( $data ) ) {
                        return null;
                    }
                    $pointer = ( ord( $data[ $offset ] ) << 24 ) | ( ord( $data[ $offset + 1 ] ) << 16 ) | ( ord( $data[ $offset + 2 ] ) << 8 ) | ord( $data[ $offset + 3 ] );
                    $offset += 4;
                    break;
            }

            // Resolve the pointer by decoding at the pointer offset.
            // Pointers reference positions in the data section.
            $ptr_offset = $pointer;
            return $this->mmdb_decode( $data, $ptr_offset );
        }

        // Determine the payload size.
        $size = $ctrl_byte & 0x1f;

        if ( $type !== 1 && $size >= 29 ) {
            if ( $size === 29 ) {
                if ( $offset >= strlen( $data ) ) {
                    return null;
                }
                $size = 29 + ord( $data[ $offset ] );
                $offset++;
            } elseif ( $size === 30 ) {
                if ( $offset + 1 >= strlen( $data ) ) {
                    return null;
                }
                $size = 285 + ( ord( $data[ $offset ] ) << 8 ) + ord( $data[ $offset + 1 ] );
                $offset += 2;
            } elseif ( $size === 31 ) {
                if ( $offset + 2 >= strlen( $data ) ) {
                    return null;
                }
                $size = 65821 + ( ord( $data[ $offset ] ) << 16 ) + ( ord( $data[ $offset + 1 ] ) << 8 ) + ord( $data[ $offset + 2 ] );
                $offset += 3;
            }
        }

        switch ( $type ) {
            case 2:  // string (UTF-8)
                return $this->mmdb_decode_string( $data, $offset, $size );
            case 3:  // double
                return $this->mmdb_decode_double( $data, $offset );
            case 4:  // bytes
                return $this->mmdb_decode_bytes( $data, $offset, $size );
            case 5:  // uint16
            case 6:  // uint32
            case 9:  // uint64
            case 10: // uint128
                return $this->mmdb_decode_uint( $data, $offset, $size );
            case 7:  // map
                return $this->mmdb_decode_map( $data, $offset, $size );
            case 8:  // int32
                return $this->mmdb_decode_int32( $data, $offset, $size );
            case 11: // array
                return $this->mmdb_decode_array( $data, $offset, $size );
            case 14: // boolean
                return $size !== 0;
            case 15: // float
                return $this->mmdb_decode_float( $data, $offset );
            default:
                return null;
        }
    }

    /**
     * Decode a UTF-8 string from MMDB data.
     */
    private function mmdb_decode_string( string $data, int &$offset, int $size ): string {
        if ( $size === 0 ) {
            return '';
        }
        $str     = substr( $data, $offset, $size );
        $offset += $size;
        return $str !== false ? $str : '';
    }

    /**
     * Decode a double (8-byte IEEE 754) from MMDB data.
     */
    private function mmdb_decode_double( string $data, int &$offset ): float {
        $bytes   = substr( $data, $offset, 8 );
        $offset += 8;
        $unpacked = unpack( 'E', $bytes );
        return $unpacked ? (float) $unpacked[1] : 0.0;
    }

    /**
     * Decode raw bytes from MMDB data.
     */
    private function mmdb_decode_bytes( string $data, int &$offset, int $size ): string {
        $bytes   = substr( $data, $offset, $size );
        $offset += $size;
        return $bytes !== false ? $bytes : '';
    }

    /**
     * Decode an unsigned integer from MMDB data.
     *
     * @return int|string
     */
    private function mmdb_decode_uint( string $data, int &$offset, int $size ) {
        if ( $size === 0 ) {
            return 0;
        }

        $bytes   = substr( $data, $offset, $size );
        $offset += $size;

        // For sizes up to 8 bytes, return an int. Larger values return a hex string.
        if ( $size <= 8 ) {
            $value = 0;
            for ( $i = 0; $i < $size; $i++ ) {
                $value = ( $value << 8 ) | ord( $bytes[ $i ] );
            }
            return $value;
        }

        // uint128 - return as hex string.
        return bin2hex( $bytes );
    }

    /**
     * Decode a signed 32-bit integer from MMDB data.
     */
    private function mmdb_decode_int32( string $data, int &$offset, int $size ): int {
        if ( $size === 0 ) {
            return 0;
        }

        $bytes = str_pad( substr( $data, $offset, $size ), 4, "\x00", STR_PAD_LEFT );
        $offset += $size;
        $unpacked = unpack( 'N', $bytes );

        if ( ! $unpacked ) {
            return 0;
        }

        $value = $unpacked[1];

        // Convert unsigned to signed.
        if ( $value >= 2147483648 ) {
            $value -= 4294967296;
        }

        return (int) $value;
    }

    /**
     * Decode a map (key-value pairs) from MMDB data.
     */
    private function mmdb_decode_map( string $data, int &$offset, int $size ): array {
        $map = [];
        for ( $i = 0; $i < $size; $i++ ) {
            $key         = $this->mmdb_decode( $data, $offset );
            $value       = $this->mmdb_decode( $data, $offset );
            if ( $key !== null ) {
                $map[ (string) $key ] = $value;
            }
        }
        return $map;
    }

    /**
     * Decode an array from MMDB data.
     */
    private function mmdb_decode_array( string $data, int &$offset, int $size ): array {
        $arr = [];
        for ( $i = 0; $i < $size; $i++ ) {
            $arr[] = $this->mmdb_decode( $data, $offset );
        }
        return $arr;
    }

    /**
     * Decode a float (4-byte IEEE 754) from MMDB data.
     */
    private function mmdb_decode_float( string $data, int &$offset ): float {
        $bytes   = substr( $data, $offset, 4 );
        $offset += 4;
        $unpacked = unpack( 'G', $bytes );
        return $unpacked ? (float) $unpacked[1] : 0.0;
    }

    /**
     * Extract country code and name from an MMDB data record.
     *
     * @param mixed $record The decoded MMDB data record.
     * @return array{country_code: string, country_name: string}|null
     */
    private function extract_country_from_record( $record ): ?array {
        if ( ! is_array( $record ) ) {
            return null;
        }

        $country_code = '';
        $country_name = '';

        // Standard GeoLite2-Country structure.
        if ( isset( $record['country']['iso_code'] ) ) {
            $country_code = (string) $record['country']['iso_code'];
        }

        if ( isset( $record['country']['names']['en'] ) ) {
            $country_name = (string) $record['country']['names']['en'];
        }

        // Fall back to registered_country if country is not set.
        if ( empty( $country_code ) && isset( $record['registered_country']['iso_code'] ) ) {
            $country_code = (string) $record['registered_country']['iso_code'];
        }
        if ( empty( $country_name ) && isset( $record['registered_country']['names']['en'] ) ) {
            $country_name = (string) $record['registered_country']['names']['en'];
        }

        if ( empty( $country_code ) ) {
            return null;
        }

        return [
            'country_code' => $country_code,
            'country_name' => $country_name,
        ];
    }
}
