#!/usr/local/bin/php
<?php

// Define UDP packet start and end symbols.
define('UDP_START', '<');
define('UDP_END', '>');

// Set PHP timezone based on the container's TZ environment variable for logging
$server_timezone = getenv('TZ') ?: 'UTC';
date_default_timezone_set($server_timezone);

// Helper function to get UTC timestamp regardless of server timezone
function getUTCTime() {
    // Always return UTC timestamp regardless of server timezone setting
    $utc_tz = new DateTimeZone('UTC');
    $utc_time = new DateTime('now', $utc_tz);
    return $utc_time->getTimestamp();
}

// Helper function to format date in UTC regardless of server timezone
function dateUTC($format, $timestamp = null) {
    if ($timestamp === null) $timestamp = getUTCTime();
    $utc_tz = new DateTimeZone('UTC');
    $utc_time = new DateTime('@' . $timestamp);
    $utc_time->setTimezone($utc_tz);
    return $utc_time->format($format);
}

// Determine the base directory of the script.
$base_dir = dirname(__FILE__);

// Read the UDP port from the environment variable or default to 2342.
$udp_port = getenv('UDP_PORT') ?: 2342;

// Maximum UDP packet size.
$max_packet_size = 1400;
define('REQUEST_ID_PATTERN', '/^[A-Za-z0-9_-]{1,64}$/');

$list_chunk_payload_size = max(128, min(
    intval(getenv('LIST_CHUNK_SIZE') ?: 512),
    $max_packet_size - 96
));

$list_challenge_window = max(10, intval(getenv('LIST_CHALLENGE_WINDOW') ?: 60));
$list_challenge_secret = getenv('LIST_CHALLENGE_SECRET') ?: hash('sha256', __FILE__ . php_uname());

$geoip_cache_ttl = max(60, intval(getenv('GEOIP_CACHE_TTL') ?: 86400));
$geoip_cache_max_entries = max(100, intval(getenv('GEOIP_CACHE_MAX_ENTRIES') ?: 10000));

$rate_limit_tracker_max_entries = max(1000, intval(getenv('RATE_LIMIT_TRACKER_MAX_ENTRIES') ?: 10000));
$global_limit_window = max(1, intval(getenv('GLOBAL_LIMIT_WINDOW') ?: 1));
$global_request_limit = max(1, intval(getenv('GLOBAL_REQUEST_LIMIT') ?: 500));
$global_list_limit = max(1, intval(getenv('GLOBAL_LIST_LIMIT') ?: 30));
$global_ext_geoip_limit = max(1, intval(getenv('GLOBAL_EXT_GEOIP_LIMIT') ?: 30));
$global_geoip_limit = max(1, intval(getenv('GLOBAL_GEOIP_LIMIT') ?: 100));
$global_info_all_limit = max(1, intval(getenv('GLOBAL_INFO_ALL_LIMIT') ?: 100));

$request_windows = array();
$geoip_cache = array();
$ext_geoip_cache = array();

// ---------------------------------------------------------
// Helper: isInternalIP()
// Checks if the given IP is in a private, loopback, or link-local range.
function isInternalIP($ip) {
    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
        return false; // Not a valid IP, treat as external.
    }

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

    if (strlen($packed) === 4) {
        $octets = unpack('C4', $packed);
        if ($octets[1] === 10) return true;
        if ($octets[1] === 127) return true;
        if ($octets[1] === 192 && $octets[2] === 168) return true;
        if ($octets[1] === 172 && $octets[2] >= 16 && $octets[2] <= 31) return true;
        if ($octets[1] === 169 && $octets[2] === 254) return true;
        return false;
    }

    if ($packed === inet_pton('::1')) {
        return true;
    }

    $bytes = unpack('C16', $packed);
    $first = $bytes[1];
    $second = $bytes[2];

    // Unique local addresses fc00::/7
    if (($first & 0xfe) === 0xfc) {
        return true;
    }

    // Link-local unicast fe80::/10
    if ($first === 0xfe && ($second & 0xc0) === 0x80) {
        return true;
    }

    // IPv4-mapped IPv6 addresses ::ffff:x.x.x.x
    $ipv4_mapped_prefix = substr($packed, 0, 12);
    if ($ipv4_mapped_prefix === str_repeat("\x00", 10) . "\xff\xff") {
        $mapped_ipv4 = inet_ntop(substr($packed, 12, 4));
        return isInternalIP($mapped_ipv4);
    }

    return false;
}

// ---------------------------------------------------------
// Data Loading Functions (posixinfo & zone1970.tab)
// ---------------------------------------------------------
$tz = array();

// Load posixinfo into the $tz array.
$posixinfo_path = $base_dir . '/posixinfo';
if ($file = fopen($posixinfo_path, "r")) {
    while (!feof($file)) {
        $line = fgets($file);
        if (preg_match("/^(.*?) (.*?)$/", $line, $matches) && trim($matches[1]) !== "" && trim($matches[2]) !== "") {
            $tz[] = array("olson" => trim($matches[1]), "posix" => trim($matches[2]));
        }
    }
    fclose($file);
}

// Load zone1970.tab and enrich the $tz array.
$zone1970_path = $base_dir . '/download/zone1970.tab';
if ($file = fopen($zone1970_path, "r")) {
    while (!feof($file)) {
        $line = fgets($file);
        if (!empty($line) && $line[0] != "#") {
            $columns = explode("\t", $line);
            if (count($columns) >= 3) {
                $countries = explode(",", $columns[0]);
                for ($n = 0; $n < count($countries); $n++) {
                    $country = strtoupper(trim($countries[$n])); // Normalize country codes.
                    $insert_at = -1;
                    $posix = "";
                    for ($m = 0; $m < count($tz); $m++) {
                        if (trim($tz[$m]["olson"]) == trim($columns[2])) {
                            $posix = $tz[$m]["posix"];
                            if (!isset($tz[$m]["country"])) {
                                $insert_at = $m;
                            }
                        }
                    }
                    if ($insert_at == -1) {
                        $insert_at = count($tz);
                        $tz[] = array();
                    }
                    $tz[$insert_at]["country"] = $country;
                    $tz[$insert_at]["coordinates"] = $columns[1];
                    $tz[$insert_at]["olson"] = trim($columns[2]);
                    if ($posix != "") {
                        $tz[$insert_at]["posix"] = $posix;
                    }
                    if (isset($columns[3])) {
                        $tz[$insert_at]["comments"] = $columns[3];
                    }
                }
            }
        }
    }
    fclose($file);
}

echo "Data read \n";

// ------------------------
// Performance Optimization: Create indexes for faster lookups
// ------------------------
$country_index = array();
$olson_index = array();

foreach ($tz as $index => $entry) {
    // Index by country code for faster country lookups
    if (isset($entry["country"])) {
        if (!isset($country_index[$entry["country"]])) {
            $country_index[$entry["country"]] = array();
        }
        $country_index[$entry["country"]][] = $index;
    }
    
    // Index by uppercase olson name for faster string searches
    if (isset($entry["olson"])) {
        $olson_upper = strtoupper($entry["olson"]);
        if (!isset($olson_index[$olson_upper])) {
            $olson_index[$olson_upper] = array();
        }
        $olson_index[$olson_upper][] = $index;
    }
}

echo "Indexes created for " . count($tz) . " timezones\n";

function trimAssociativeArrayByAge(&$items, $max_entries) {
    if (count($items) <= $max_entries) {
        return;
    }

    uasort($items, function ($a, $b) {
        $a_time = is_array($a) && isset($a['stored_at']) ? $a['stored_at'] : (is_array($a) && isset($a['timestamp']) ? $a['timestamp'] : $a);
        $b_time = is_array($b) && isset($b['stored_at']) ? $b['stored_at'] : (is_array($b) && isset($b['timestamp']) ? $b['timestamp'] : $b);
        return $a_time <=> $b_time;
    });

    while (count($items) > $max_entries) {
        array_shift($items);
    }
}

function trimMapByOldestValueTimestamp(&$items, $max_entries) {
    if (count($items) <= $max_entries) {
        return;
    }

    uasort($items, function ($a, $b) {
        $a_time = !empty($a) ? min($a) : 0;
        $b_time = !empty($b) ? min($b) : 0;
        return $a_time <=> $b_time;
    });

    while (count($items) > $max_entries) {
        array_shift($items);
    }
}

function cleanupCacheEntries(&$cache, $ttl, $max_entries) {
    $now = getUTCTime();
    foreach ($cache as $key => $entry) {
        if (!isset($entry['stored_at']) || ($now - $entry['stored_at']) > $ttl) {
            unset($cache[$key]);
        }
    }
    trimAssociativeArrayByAge($cache, $max_entries);
}

function cacheGet(&$cache, $key, $ttl, $max_entries) {
    cleanupCacheEntries($cache, $ttl, $max_entries);
    if (!isset($cache[$key])) {
        return null;
    }
    if ((getUTCTime() - $cache[$key]['stored_at']) > $ttl) {
        unset($cache[$key]);
        return null;
    }
    return $cache[$key]['value'];
}

function cacheSet(&$cache, $key, $value, $max_entries) {
    $cache[$key] = array(
        'stored_at' => getUTCTime(),
        'value' => $value
    );
    trimAssociativeArrayByAge($cache, $max_entries);
}

function allowGlobalRate($bucket, $limit, $window_seconds) {
    global $request_windows, $rate_limit_tracker_max_entries;

    $now = getUTCTime();
    if (!isset($request_windows[$bucket])) {
        $request_windows[$bucket] = array();
    }

    $request_windows[$bucket] = array_values(array_filter(
        $request_windows[$bucket],
        function ($timestamp) use ($now, $window_seconds) {
            return ($now - $timestamp) < $window_seconds;
        }
    ));

    if (count($request_windows[$bucket]) >= $limit) {
        return false;
    }

    $request_windows[$bucket][] = $now;
    trimMapByOldestValueTimestamp($request_windows, $rate_limit_tracker_max_entries);
    return true;
}

function createListChallengeToken($query, $request_id, $remote_ip) {
    global $list_challenge_secret, $list_challenge_window;

    $window = intdiv(getUTCTime(), $list_challenge_window);
    $payload = $remote_ip . '|' . $request_id . '|' . strtolower(trim($query)) . '|' . $window;
    return $window . '.' . hash_hmac('sha256', $payload, $list_challenge_secret);
}

function validateListChallengeToken($token, $query, $request_id, $remote_ip) {
    global $list_challenge_secret, $list_challenge_window;

    if (!preg_match('/^(\d+)\.([a-f0-9]{64})$/', $token, $matches)) {
        return false;
    }

    $window = intval($matches[1]);
    $provided_hash = $matches[2];
    $current_window = intdiv(getUTCTime(), $list_challenge_window);

    if ($window < ($current_window - 1) || $window > $current_window) {
        return false;
    }

    $payload = $remote_ip . '|' . $request_id . '|' . strtolower(trim($query)) . '|' . $window;
    $expected_hash = hash_hmac('sha256', $payload, $list_challenge_secret);
    return hash_equals($expected_hash, $provided_hash);
}

function getHttpHeaderValue($headers, $name) {
    if (preg_match('/^' . preg_quote($name, '/') . ':\s*(.+)$/mi', $headers, $matches)) {
        return trim($matches[1]);
    }
    return null;
}

function decodeChunkedHttpBody($body) {
    $decoded = '';
    $offset = 0;
    $body_length = strlen($body);

    while (true) {
        $line_end = strpos($body, "\r\n", $offset);
        if ($line_end === false) {
            return false;
        }

        $length_line = trim(substr($body, $offset, $line_end - $offset));
        $length_line = explode(';', $length_line, 2)[0];
        if ($length_line === '' || !ctype_xdigit($length_line)) {
            return false;
        }

        $chunk_length = hexdec($length_line);
        $offset = $line_end + 2;

        if ($chunk_length === 0) {
            return $decoded;
        }

        if (($offset + $chunk_length + 2) > $body_length) {
            return false;
        }

        $decoded .= substr($body, $offset, $chunk_length);
        $offset += $chunk_length;

        if (substr($body, $offset, 2) !== "\r\n") {
            return false;
        }
        $offset += 2;
    }
}

// ------------------------
// Wrapper functions that use indexes
// ------------------------
function handleCountryQueryWithIndex($query, $tz) {
    global $country_index, $olson_index;
    return handleCountryQuery($query, $tz, $country_index, $olson_index);
}

function handleStringQueryWithIndex($query, $tz) {
    global $olson_index;
    return handleStringQuery($query, $tz, $olson_index);
}

function resolveTimezoneQuery($query, $tz) {
    if (preg_match('/^[A-Z]{2}$/', strtoupper($query))) {
        return handleCountryQueryWithIndex(strtoupper($query), $tz);
    }
    return handleStringQueryWithIndex($query, $tz);
}

// ------------------------
// Reverse lookup functions for timezone info
// ------------------------
function getCountryCodeFromTimezone($timezone_name, $tz) {
    // Search through timezone data to find country code for this timezone
    foreach ($tz as $entry) {
        if (isset($entry['olson']) && $entry['olson'] === $timezone_name) {
            if (isset($entry['country'])) {
                return $entry['country'];
            }
        }
    }
    return 'unknown';
}

function getCityFromTimezone($timezone_name) {
    // Extract city from timezone name (e.g., "Asia/Dubai" -> "Dubai")
    $parts = explode('/', $timezone_name);
    if (count($parts) >= 2) {
        // Take the last part and clean it up
        $city = end($parts);
        // Replace underscores with spaces and handle special cases
        $city = str_replace('_', ' ', $city);
        return $city;
    }
    return 'unknown';
}

// ------------------------
// Helper: sendChunks()
// ------------------------
function sendChunks($data, $max_packet_size, $sock, $remote_ip, $remote_port) {
    sendChunksWithRequestId($data, $max_packet_size, $sock, $remote_ip, $remote_port, null);
}

function buildChunkPacket($request_id, $total_chunks, $chunk_index, $chunk) {
    $checksum = hash('crc32b', $chunk);
    $metadata = "RID=$request_id;TOT=$total_chunks;IDX=$chunk_index;LEN=" . strlen($chunk) . ";CRC=$checksum";
    return UDP_START . $metadata . "|" . $chunk . UDP_END;
}

function sendChunksWithRequestId($data, $max_packet_size, $sock, $remote_ip, $remote_port, $request_id = null) {
    global $list_chunk_payload_size;

    if ($request_id === null || $request_id === '') {
        if (!socket_sendto($sock, "ERROR LIST Missing request id", 100, 0, $remote_ip, $remote_port)) {
            error_log("Failed to send LIST request-id error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
        }
        return;
    }

    $chunks = str_split($data, $list_chunk_payload_size);
    $total_chunks = count($chunks);
    foreach ($chunks as $index => $chunk) {
        $packet = buildChunkPacket($request_id, $total_chunks, $index + 1, $chunk);
        if (!socket_sendto($sock, $packet, strlen($packet), 0, $remote_ip, $remote_port)) {
            error_log("Failed to send chunk " . ($index + 1) . "/$total_chunks to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
        }
    }
}

function parsePacketEnvelope($packet) {
    $parts = explode('#', trim($packet), 2);
    $query = trim($parts[0]);
    $metadata = array();

    if (count($parts) === 2 && $parts[1] !== '') {
        foreach (explode('&', $parts[1]) as $item) {
            if ($item === '') {
                continue;
            }
            $kv = explode('=', $item, 2);
            if (count($kv) === 2) {
                $metadata[strtolower(trim($kv[0]))] = trim($kv[1]);
            }
        }
    }

    return array($query, $metadata);
}

function getRequestIdFromMetadata($metadata) {
    if (!isset($metadata['rid'])) {
        return null;
    }

    $request_id = $metadata['rid'];
    if (!preg_match(REQUEST_ID_PATTERN, $request_id)) {
        return null;
    }

    return $request_id;
}

// ------------------------
// Generic Response Helper: processAndRespond()
// ------------------------
/**
 * processAndRespond
 *
 * @param string   $origin         Origin identifier (e.g., "GEOIP", "DIRECT", "EXT_GEOIP").
 * @param string   $queryForProcessing   The query passed to the processor.
 * @param string   $logQuery       The query string used in logs (can include extra info such as IP).
 * @param callable $processor      A callback function that accepts ($query, $tz) and returns:
 *                                   - On success: an associative array with keys 'olson' and 'posix'
 *                                   - On failure: false, or an array with an 'error' key ('not_found', 'multiple', or 'internal')
 * @param array    $tz             Timezone data array.
 * @param resource $sock           The UDP socket.
 * @param string   $remote_ip      Remote IP address.
 * @param int      $remote_port    Remote port.
 * @param string   $logstart       Log prefix string.
 */
function processAndRespond($origin, $queryForProcessing, $logQuery, $processor, $tz, $sock, $remote_ip, $remote_port, $logstart) {
    $result = call_user_func($processor, $queryForProcessing, $tz);
    if ($result === false) {
        $errorMessage = "ERROR Timezone Not Found";
        echo "$logstart ERR $origin: $errorMessage for $logQuery\n";
        if (!socket_sendto($sock, $errorMessage, strlen($errorMessage), 0, $remote_ip, $remote_port)) {
            error_log("Failed to send error message to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
        }
        return;
    }
    if (is_array($result) && isset($result['error'])) {
        if ($result['error'] === 'internal') {
            $errorMessage = "ERROR {$origin} Internal IP";
        } else if ($result['error'] === 'not_found') {
            $errorMessage = "ERROR Country Not Found";
        } else {
            $errorMessage = "ERROR Country Spans Multiple Timezones";
        }
        echo "$logstart ERR $origin: $errorMessage for $logQuery\n";
        if (!socket_sendto($sock, $errorMessage, strlen($errorMessage), 0, $remote_ip, $remote_port)) {
            error_log("Failed to send error message to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
        }
        return;
    }
    echo "$logstart OK $origin: $logQuery -> " . $result['olson'] . " " . $result['posix'] . "\n";
    $response = "OK " . $result['olson'] . " " . $result['posix'];
    if (!socket_sendto($sock, $response, strlen($response), 0, $remote_ip, $remote_port)) {
        error_log("Failed to send response to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
    }
}

// ------------------------
// Query Handling Functions (Processing Only)
// ------------------------

// EXT_GEOIP: Returns full decoded JSON from external service, or an array with error 'internal' if IP is internal, or false if lookup fails.
function handleExtGeoip($query, $remote_ip) {
    global $ext_geoip_cache, $geoip_cache_ttl, $geoip_cache_max_entries;

    $parts = preg_split('/\s+/', $query);
    $ip = isset($parts[1]) ? $parts[1] : $remote_ip;
    
    // Strict IP validation for security
    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
        error_log("EXT_GEOIP: Invalid IP format: $ip");
        return false;
    }
    
    if (isInternalIP($ip)) {
        return array("error" => "internal");
    }

    $cache_key = 'ext:' . $ip;
    $cached = cacheGet($ext_geoip_cache, $cache_key, $geoip_cache_ttl, $geoip_cache_max_entries);
    if ($cached !== null) {
        return $cached;
    }
    
    // Use environment variables for API configuration
    $geoip_host = getenv('GEOIP_API_HOST') ?: 'geoip-api';
    $geoip_port = getenv('GEOIP_API_PORT') ?: '8080';
    $geoip_timeout = max(1, intval(getenv('GEOIP_API_TIMEOUT') ?: 2));
    $geoip_header_limit = max(1024, intval(getenv('GEOIP_API_HEADER_LIMIT') ?: 8192));
    $geoip_body_limit = max(4096, intval(getenv('GEOIP_API_BODY_LIMIT') ?: 65536));
    $path = '/' . rawurlencode($ip);

    $connection = @fsockopen($geoip_host, intval($geoip_port), $errno, $errstr, $geoip_timeout);
    if (!$connection) {
        error_log("EXT_GEOIP: Connection failed to $geoip_host:$geoip_port - [$errno] $errstr");
        return false;
    }

    stream_set_timeout($connection, $geoip_timeout);

    $request = "GET $path HTTP/1.1\r\n";
    $request .= "Host: $geoip_host\r\n";
    $request .= "Connection: close\r\n\r\n";

    if (fwrite($connection, $request) === false) {
        fclose($connection);
        error_log("EXT_GEOIP: Failed to write request for IP $ip");
        return false;
    }

    $result = '';
    $headers = '';
    $body = '';
    $header_complete = false;
    while (!feof($connection)) {
        $chunk = fread($connection, 4096);
        if ($chunk === false) {
            fclose($connection);
            error_log("EXT_GEOIP: Failed to read response for IP $ip");
            return false;
        }

        $result .= $chunk;
        $meta = stream_get_meta_data($connection);
        if ($meta['timed_out']) {
            fclose($connection);
            error_log("EXT_GEOIP: Request timed out for IP $ip");
            return false;
        }

        if (!$header_complete) {
            $header_end = strpos($result, "\r\n\r\n");
            if ($header_end !== false) {
                $headers = substr($result, 0, $header_end);
                $body = substr($result, $header_end + 4);
                $header_complete = true;
                if (strlen($headers) > $geoip_header_limit) {
                    fclose($connection);
                    error_log("EXT_GEOIP: Header too large for IP $ip");
                    return false;
                }
            } elseif (strlen($result) > $geoip_header_limit) {
                fclose($connection);
                error_log("EXT_GEOIP: Header too large for IP $ip");
                return false;
            }
        } else {
            $body = substr($result, strlen($headers) + 4);
        }

        if ($header_complete && strlen($body) > $geoip_body_limit) {
            fclose($connection);
            error_log("EXT_GEOIP: Body too large for IP $ip");
            return false;
        }
    }
    fclose($connection);

    if (!$header_complete) {
        error_log("EXT_GEOIP: Invalid HTTP response for IP $ip");
        return false;
    }

    if (!preg_match('#^HTTP/\d+\.\d+\s+200\b#', $headers)) {
        $status_line = strtok($headers, "\r\n");
        error_log("EXT_GEOIP: Non-200 response for IP $ip from $geoip_host:$geoip_port - $status_line");
        return false;
    }

    $transfer_encoding = getHttpHeaderValue($headers, 'Transfer-Encoding');
    if ($transfer_encoding !== null && stripos($transfer_encoding, 'chunked') !== false) {
        $decoded_body = decodeChunkedHttpBody($body);
        if ($decoded_body === false) {
            error_log("EXT_GEOIP: Failed to decode chunked response for IP $ip from $geoip_host:$geoip_port");
            return false;
        }
        $body = $decoded_body;
    }

    $geoData = json_decode($body, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        $preview = substr($body, 0, 200);
        error_log("EXT_GEOIP: JSON decode failed for IP $ip from $geoip_host:$geoip_port - " . json_last_error_msg() . " -- body preview: " . $preview);
        return false;
    }

    if (is_array($geoData) && isset($geoData["timezone"]) && is_string($geoData["timezone"]) && $geoData["timezone"] !== '') {
        cacheSet($ext_geoip_cache, $cache_key, $geoData, $geoip_cache_max_entries);
        return $geoData;
    }

    $keys = is_array($geoData) ? implode(',', array_keys($geoData)) : gettype($geoData);
    error_log("EXT_GEOIP: Response missing timezone for IP $ip from $geoip_host:$geoip_port - keys/type: $keys");
    return false;
}

// GEOIP: Returns the two-letter country code from local geoip lookup, or an array with error 'internal' if IP is internal, or false if lookup fails.
function handleGeoip($query, $remote_ip) {
    global $geoip_cache, $geoip_cache_ttl, $geoip_cache_max_entries;

    $parts = preg_split('/\s+/', $query);
    $ip = isset($parts[1]) ? $parts[1] : $remote_ip;
    
    // Strict IP validation for security
    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
        error_log("GEOIP: Invalid IP format: $ip");
        return false;
    }
    
    if (isInternalIP($ip)) {
        return array("error" => "internal");
    }

    $cache_key = 'geo:' . $ip;
    $cached = cacheGet($geoip_cache, $cache_key, $geoip_cache_ttl, $geoip_cache_max_entries);
    if ($cached !== null) {
        return $cached;
    }
    
    // Escape IP for shell command to prevent injection
    $geoip_lookup_timeout = max(1, intval(getenv('GEOIP_LOOKUP_TIMEOUT') ?: 2));
    $result = exec("timeout " . $geoip_lookup_timeout . " geoiplookup " . escapeshellarg($ip));
    if (preg_match("/: ([A-Z]{2}),/", $result, $matches)) {
        cacheSet($geoip_cache, $cache_key, $matches[1], $geoip_cache_max_entries);
        return $matches[1];
    }
    return false;
}

// GETIP: Returns the client's IP address, or false.
function handleGetip($remote_ip) {
    return $remote_ip ? $remote_ip : false;
}

// Country Query: For a two-letter country code, apply adjustments and lookup.
// For "DE" or "IE", delegate to handleStringQuery with the corresponding Olson value.
// For "UK", convert to "GB". Otherwise, search for a unique match in $tz.
// On success, returns ['olson'=>..., 'posix'=>...]. On error, returns an array with key 'error' set to 'not_found' or 'multiple'.
function handleCountryQuery($query, $tz, $country_index, $olson_index = null) {
    $upper = strtoupper($query);
    if ($upper === "DE") {
        return handleStringQuery("EUROPE/BERLIN", $tz, $olson_index);
    }
    if ($upper === "IE") {
        return handleStringQuery("EUROPE/DUBLIN", $tz, $olson_index);
    }
    if ($upper === "UK") {
        $upper = "GB";
    }
    
    // Use index for faster lookup
    if (!isset($country_index[$upper])) {
        return array('error' => 'not_found');
    }
    
    $matches = $country_index[$upper];
    $num_matches = count($matches);
    
    if ($num_matches === 1) {
        $entry = $tz[$matches[0]];
        return array('olson' => $entry['olson'], 'posix' => $entry['posix']);
    } else {
        return array('error' => 'multiple');
    }
}

// String Query: Searches for time zones matching the query (substring search on Olson).
// Returns an associative array with keys 'olson' and 'posix', or false.
function handleStringQuery($query, $tz, $olson_index = null) {
    $query_upper = strtoupper($query);
    
    // Try exact match first if index is available
    if ($olson_index !== null && isset($olson_index[$query_upper])) {
        $entry = $tz[$olson_index[$query_upper][0]];
        $posix = $entry["posix"];
        $olson = $entry["olson"];
        if ($olson == "Europe/Dublin") {  // Special case.
            $posix = "GMT0IST,M3.5.0/1,M10.5.0";
        }
        return array('olson' => $olson, 'posix' => $posix);
    }
    
    // Fallback to substring search for partial matches
    foreach ($tz as $entry) {
        if (strpos(strtoupper($entry["olson"]), $query_upper) !== false) {
            $posix = $entry["posix"];
            $olson = $entry["olson"];
            if ($olson == "Europe/Dublin") {  // Special case.
                $posix = "GMT0IST,M3.5.0/1,M10.5.0";
            }
            return array('olson' => $olson, 'posix' => $posix);
        }
    }
    return false;
}

// ------------------------
// INFO Function
// ------------------------

$dst_cache = array();

function getTimezoneInfo($timezone_name, $posix_string, $geoip_data = null, $timestamp = null) {
    global $dst_cache;
    
    if ($timestamp === null) $timestamp = getUTCTime();
    
    try {
        $timezone = new DateTimeZone($timezone_name);
    } catch (Exception $e) {
        return false;
    }

    $utc_datetime = new DateTime('@' . $timestamp);
    $utc_datetime->setTimezone(new DateTimeZone('UTC'));

    $local_datetime = new DateTime('@' . $timestamp);
    $local_datetime->setTimezone($timezone);

    $real_utc_offset = $timezone->getOffset($utc_datetime);

    $offset_sign = ($real_utc_offset >= 0) ? '+' : '-';
    $offset_abs = abs($real_utc_offset);
    $offset_hours = intdiv($offset_abs, 3600);
    $offset_minutes = intdiv($offset_abs % 3600, 60);

    $year = intval($local_datetime->format('Y'));
    $year_start = gmmktime(0, 0, 0, 1, 1, $year - 1);
    $year_end = gmmktime(23, 59, 59, 12, 31, $year + 1);
    $cache_key = $timezone_name . ':' . $year;

    if (!isset($dst_cache[$cache_key])) {
        $dst_cache[$cache_key] = $timezone->getTransitions($year_start, $year_end);
    }

    $transitions = $dst_cache[$cache_key];
    $has_dst = false;
    foreach ($transitions as $transition) {
        if (!empty($transition['isdst'])) {
            $has_dst = true;
            break;
        }
    }

    $in_dst = $local_datetime->format('I') === '1';
    
    $info = array(
        'olson' => $timezone_name,
        'posix' => $posix_string,
        'utcoffset' => sprintf('%s%02d:%02d', $offset_sign, $offset_hours, $offset_minutes),
        'hasdst' => $has_dst ? 'true' : 'false',
        'indst' => $in_dst ? 'true' : 'false',
        'date' => $local_datetime->format('Y-m-d'),
        'day' => strtolower($local_datetime->format('l')),
        'currenttime' => $local_datetime->format('H:i:s'),
        'epoch' => $timestamp
    );
    
    if ($geoip_data && is_array($geoip_data)) {
        $info['city'] = isset($geoip_data['city']) ? $geoip_data['city'] : 'unknown';
        $info['countrycode'] = isset($geoip_data['country']) ? $geoip_data['country'] : 'unknown';
    } else {
        // Use reverse lookup from our timezone database
        global $tz;
        $info['city'] = getCityFromTimezone($timezone_name);
        $info['countrycode'] = getCountryCodeFromTimezone($timezone_name, $tz);
    }
    
    return $info;
}

function handleInfo($query, $remote_ip, $tz) {
    $parts = preg_split('/\s+/', trim($query));
    
    if (count($parts) < 2) {
        return array('error' => 'Missing infotype parameter');
    }
    
    $infotype = strtolower($parts[1]);
    $target = isset($parts[2]) ? $parts[2] : $remote_ip;
    
    $valid_infotypes = array('olson', 'posix', 'utcoffset', 'hasdst', 'indst', 'city', 'countrycode', 'date', 'day', 'currenttime', 'epoch', 'all');
    
    if (!in_array($infotype, $valid_infotypes)) {
        return array('error' => 'Invalid infotype: ' . $infotype);
    }
    
    $timezone_name = null;
    $posix_string = null;
    $geoip_data = null;
    
    if (filter_var($target, FILTER_VALIDATE_IP)) {
        $geoip_result = handleExtGeoip("EXT_GEOIP $target", $remote_ip);
        
        if ($geoip_result === false) {
            return array('error' => 'Failed to lookup timezone for IP: ' . $target);
        }
        
        if (is_array($geoip_result) && isset($geoip_result['error'])) {
            if ($geoip_result['error'] === 'internal') {
                return array('error' => 'Internal IP provided: ' . $target);
            }
            return array('error' => 'GeoIP lookup failed for IP: ' . $target);
        }
        
        $timezone_name = $geoip_result['timezone'];
        $geoip_data = $geoip_result;
        
        foreach ($tz as $entry) {
            if (isset($entry['olson']) && $entry['olson'] === $timezone_name) {
                $posix_string = $entry['posix'];
                break;
            }
        }
        
    } else {
        $lookup_result = resolveTimezoneQuery($target, $tz);

        if ($lookup_result === false) {
            return array('error' => 'Unknown timezone: ' . $target);
        }

        if (is_array($lookup_result) && isset($lookup_result['error'])) {
            if ($lookup_result['error'] === 'not_found') {
                return array('error' => 'Unknown timezone: ' . $target);
            }
            if ($lookup_result['error'] === 'multiple') {
                return array('error' => 'Country spans multiple timezones: ' . $target);
            }
            return array('error' => 'Failed to resolve timezone: ' . $target);
        }

        $timezone_name = $lookup_result['olson'];
        $posix_string = $lookup_result['posix'];
    }
    
    if (!$posix_string) {
        return array('error' => 'No POSIX data found for timezone: ' . $timezone_name);
    }
    
    $info = getTimezoneInfo($timezone_name, $posix_string, $geoip_data);
    
    if (!$info) {
        return array('error' => 'Failed to calculate timezone information');
    }
    
    if ($infotype === 'all') {
        return array('success' => 'all', 'data' => $info);
    } else {
        if (!isset($info[$infotype])) {
            return array('error' => 'Information not available: ' . $infotype);
        }
        return array('success' => $infotype, 'value' => $info[$infotype]);
    }
}

// ------------------------
// handleLstFileRequest
// ------------------------
function handleLstFileRequest($query, $base_dir, $max_packet_size, $remote_ip, $remote_port, $sock, $logstart, $request_id = null) {
    $requested_file = strtolower($query);
    $dir_path = $base_dir . '/timezones/';
    $files = scandir($dir_path);
    $lst_file_name = null;
    foreach ($files as $file) {
        if (strtolower($file) === $requested_file) {
            $lst_file_name = $file;
            break;
        }
    }
    if ($lst_file_name) {
        echo "$logstart OK LIST: $lst_file_name\n";
        $lst_file_path = $dir_path . $lst_file_name;
        $lst_data = file_get_contents($lst_file_path);
        sendChunksWithRequestId($lst_data, $max_packet_size, $sock, $remote_ip, $remote_port, $request_id);
    } else {
        echo "$logstart ERR LIST: $requested_file not found\n";
        if (!socket_sendto($sock, "ERROR LST File Not Found", 100, 0, $remote_ip, $remote_port)) {
            error_log("Failed to send LST error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
        }
    }
}

// ------------------------
// Create UDP Socket and Main Loop (Reporting & Dispatch)
// ------------------------
if (!($sock = socket_create(AF_INET, SOCK_DGRAM, 0))) {
    $errorcode = socket_last_error();
    $errormsg = socket_strerror($errorcode);
    die("Couldn't create socket: [$errorcode] $errormsg\n");
}
echo "Socket created \n";

if (!socket_bind($sock, "0.0.0.0", $udp_port)) {
    $errorcode = socket_last_error();
    $errormsg = socket_strerror($errorcode);
    die("Could not bind socket: [$errorcode] $errormsg\n");
}
echo "Socket bind OK on port $udp_port\n";

// Check rate limiting configuration
$rate_limiting_enabled = getenv('RATE_LIMITING_ENABLED') !== 'false';
echo "Rate limiting: " . ($rate_limiting_enabled ? "enabled" : "disabled") . "\n";

$last_ask = array();

// ------------------------
// Helper: cleanupOldConnections()
// ------------------------
function cleanupOldConnections(&$last_ask, $max_age = 3600) {
    $current_time = getUTCTime();
    $cleaned = 0;
    foreach ($last_ask as $ip => $timestamp) {
        if ($current_time - $timestamp > $max_age) {
            unset($last_ask[$ip]);
            $cleaned++;
        }
    }
    if ($cleaned > 0) {
        echo "Cleaned up $cleaned old connection records\n";
    }
}

$request_count = 0;

while (1) {
    socket_recvfrom($sock, $packet, 512, 0, $remote_ip, $remote_port);
    list($query, $packet_metadata) = parsePacketEnvelope($packet);
    $request_id = getRequestIdFromMetadata($packet_metadata);
    $request_token = isset($packet_metadata['token']) ? $packet_metadata['token'] : null;
    $is_list_query = stripos($query, "LIST") === 0;
    $is_valid_list_followup = $is_list_query
        && $request_id !== null
        && $request_token !== null
        && validateListChallengeToken($request_token, $query, $request_id, $remote_ip);
    $logstart = date("D, d M Y H:i:s") . "Z -- $remote_ip:$remote_port --";

    // Cleanup old connections every 100 requests
    if (++$request_count % 100 === 0) {
        cleanupOldConnections($last_ask);
    }

    if ($rate_limiting_enabled) {
        // Determine allowed interval.
        $allowed_interval = $is_list_query ? 1 : 3;
        if (!$is_valid_list_followup) {
            if (isset($last_ask[$remote_ip]) && $last_ask[$remote_ip] > getUTCTime() - $allowed_interval) {
                continue;
            }
            $last_ask[$remote_ip] = getUTCTime();
            trimAssociativeArrayByAge($last_ask, $rate_limit_tracker_max_entries);
        }
    }

    if (!$is_valid_list_followup && !allowGlobalRate('global', $global_request_limit, $global_limit_window)) {
        if (!socket_sendto($sock, "ERROR Server Busy", 100, 0, $remote_ip, $remote_port)) {
            error_log("Failed to send global rate-limit error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
        }
        continue;
    }

    // ---------- EXT_GEOIP handling ----------
    if (stripos($query, "EXT_GEOIP") === 0) {
        if (!allowGlobalRate('ext_geoip', $global_ext_geoip_limit, $global_limit_window)) {
            if (!socket_sendto($sock, "ERROR EXT_GEOIP Rate Limited", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send EXT_GEOIP rate-limit error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        $parts = preg_split('/\s+/', $query);
        $lookupIP = isset($parts[1]) ? $parts[1] : $remote_ip;
        $geoData = handleExtGeoip($query, $remote_ip);
        if (is_array($geoData) && isset($geoData["error"]) && $geoData["error"] === "internal") {
            echo "$logstart ERR EXT_GEOIP: Internal IP $lookupIP provided\n";
            if (!socket_sendto($sock, "ERROR EXT_GEOIP Internal IP", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send EXT_GEOIP error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        if ($geoData === false) {
            echo "$logstart ERR EXT_GEOIP: Unable to determine timezone for IP $lookupIP\n";
            if (!socket_sendto($sock, "ERROR EXT_GEOIP Failed", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send EXT_GEOIP error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        $olson = $geoData["timezone"];
        processAndRespond("EXT_GEOIP", $olson, $olson . " [$lookupIP]", 'handleStringQueryWithIndex', $tz, $sock, $remote_ip, $remote_port, $logstart);
        continue;
    }

    // ---------- LIST File Request ----------
    if ($is_list_query) {
        if (!$is_valid_list_followup && !allowGlobalRate('list', $global_list_limit, $global_limit_window)) {
            if (!socket_sendto($sock, "ERROR LIST Rate Limited", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send LIST rate-limit error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        $parts = preg_split('/\s+/', $query);
        if (count($parts) >= 2) {
            $list_name = strtolower($parts[1]) . '.lst';
            if ($request_id === null) {
                echo "$logstart ERR LIST: Missing request id\n";
                if (!socket_sendto($sock, "ERROR LIST Missing request id", 100, 0, $remote_ip, $remote_port)) {
                    error_log("Failed to send LIST request-id error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
                }
                continue;
            }

            if (!$is_valid_list_followup) {
                $challenge = createListChallengeToken($query, $request_id, $remote_ip);
                $challenge_response = "LIST CHALLENGE " . $challenge;
                echo "$logstart CHALLENGE LIST: $list_name\n";
                if (!socket_sendto($sock, $challenge_response, strlen($challenge_response), 0, $remote_ip, $remote_port)) {
                    error_log("Failed to send LIST challenge to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
                }
                continue;
            }

            handleLstFileRequest($list_name, $base_dir, $max_packet_size, $remote_ip, $remote_port, $sock, $logstart, $request_id);
        } else {
            echo "$logstart ERR LIST: Missing list name\n";
            if (!socket_sendto($sock, "ERROR LIST Missing list name", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send LIST error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
        }
        continue;
    }

    // ---------- HEALTHCHECK (silent, localhost only) ----------
    if (stripos($query, "HEALTHCHECK") === 0) {
        // Only allow from localhost/loopback addresses
        if ($remote_ip === "127.0.0.1" || $remote_ip === "::1" || $remote_ip === "localhost") {
            // Silent healthcheck - no logging, no rate limiting
            $parts = preg_split('/\s+/', $query);
            $full_check = isset($parts[1]) && strtoupper($parts[1]) === "FULL";
            
            if ($full_check) {
                // Full health check - test all major functions
                $health_tests = array(
                    'string' => handleStringQueryWithIndex("Europe/Berlin", $tz),
                    'country' => handleCountryQueryWithIndex("DE", $tz),
                    'getip' => handleGetip($remote_ip),
                    'info' => handleInfo("INFO utcoffset Europe/Berlin", $remote_ip, $tz)
                );
                
                $failed_tests = array();
                foreach ($health_tests as $test_name => $result) {
                    if ($result === false || (is_array($result) && isset($result['error']))) {
                        $failed_tests[] = $test_name;
                    }
                }
                
                if (empty($failed_tests)) {
                    $response = "OK FULL ALL_TESTS_PASSED";
                } else {
                    $response = "ERROR FULL FAILED:" . implode(',', $failed_tests);
                }
            } else {
                // Basic health check - test core functions
                $berlin_result = handleStringQueryWithIndex("Europe/Berlin", $tz);
                $de_result = handleCountryQueryWithIndex("DE", $tz);
                
                if ($berlin_result !== false && !isset($berlin_result['error']) && 
                    $de_result !== false && !isset($de_result['error'])) {
                    $response = "OK CORE_FUNCTIONS_WORKING";
                } else {
                    $response = "ERROR CORE_FUNCTIONS_FAILED";
                }
            }
            
            if (!socket_sendto($sock, $response, strlen($response), 0, $remote_ip, $remote_port)) {
                error_log("Failed to send HEALTHCHECK response to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
        } else {
            // Reject healthcheck from non-localhost
            echo "$logstart ERR HEALTHCHECK: Access denied from $remote_ip\n";
            if (!socket_sendto($sock, "ERROR HEALTHCHECK Access denied", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send HEALTHCHECK error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
        }
        continue;
    }

    // ---------- GETIP ----------
    if ($query === "GETIP") {
        $ipResult = handleGetip($remote_ip);
        if ($ipResult === false) {
            echo "$logstart ERR GETIP: Could not retrieve IP\n";
            if (!socket_sendto($sock, "ERROR GETIP Failed", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send GETIP error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        echo "$logstart OK GETIP: $ipResult\n";
        if (!socket_sendto($sock, $ipResult, strlen($ipResult), 0, $remote_ip, $remote_port)) {
            error_log("Failed to send GETIP response to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
        }
        continue;
    }

    // ---------- GEOIP handling ----------
    if (stripos($query, "GEOIP") === 0) {
        if (!allowGlobalRate('geoip', $global_geoip_limit, $global_limit_window)) {
            if (!socket_sendto($sock, "ERROR GEOIP Rate Limited", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send GEOIP rate-limit error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        $parts = preg_split('/\s+/', $query);
        $lookupIP = isset($parts[1]) ? $parts[1] : $remote_ip;
        $countryCode = handleGeoip($query, $remote_ip);
        if (is_array($countryCode) && isset($countryCode["error"]) && $countryCode["error"] === "internal") {
            echo "$logstart ERR GEOIP: Internal IP $lookupIP provided\n";
            if (!socket_sendto($sock, "ERROR GEOIP Internal IP", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send GEOIP error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        if ($countryCode === false) {
            echo "$logstart ERR GEOIP: Lookup failed for IP $lookupIP\n";
            if (!socket_sendto($sock, "ERROR GEOIP Lookup Failed", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send GEOIP error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        processAndRespond("GEOIP", $countryCode, $countryCode . " [$lookupIP]", 'handleCountryQueryWithIndex', $tz, $sock, $remote_ip, $remote_port, $logstart);
        continue;
    }

    // ---------- INFO handling ----------
    if (stripos($query, "INFO") === 0) {
        $info_is_all = preg_match('/^INFO\s+all(\s|$)/i', $query) === 1;
        if ($info_is_all && !allowGlobalRate('info_all', $global_info_all_limit, $global_limit_window)) {
            if (!socket_sendto($sock, "ERROR INFO Rate Limited", 100, 0, $remote_ip, $remote_port)) {
                error_log("Failed to send INFO rate-limit error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        $info_result = handleInfo($query, $remote_ip, $tz);
        
        if (isset($info_result['error'])) {
            $errorMessage = "ERROR " . $info_result['error'];
            echo "$logstart ERR INFO: $errorMessage\n";
            if (!socket_sendto($sock, $errorMessage, strlen($errorMessage), 0, $remote_ip, $remote_port)) {
                error_log("Failed to send INFO error to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
            continue;
        }
        
        // Extract target for logging
        $parts = preg_split('/\s+/', trim($query));
        $infotype = strtolower($parts[1]);
        $target = isset($parts[2]) ? $parts[2] : $remote_ip;
        $log_query = "$infotype [$target]";
        
        if ($info_result['success'] === 'all') {
            $all_data = "INFO OK all\n";
            foreach ($info_result['data'] as $key => $value) {
                $all_data .= $key . ":" . $value . ";\n";
            }
            echo "$logstart OK INFO: $log_query -> all data\n";
            if ($request_id !== null) {
                sendChunksWithRequestId($all_data, $max_packet_size, $sock, $remote_ip, $remote_port, $request_id);
            } else if (!socket_sendto($sock, $all_data, strlen($all_data), 0, $remote_ip, $remote_port)) {
                error_log("Failed to send INFO all response to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
        } else {
            // Single value response - simplified format
            $response = "INFO OK " . $info_result['value'];
            echo "$logstart OK INFO: $log_query -> " . $info_result['value'] . "\n";
            if (!socket_sendto($sock, $response, strlen($response), 0, $remote_ip, $remote_port)) {
                error_log("Failed to send INFO response to $remote_ip:$remote_port - " . socket_strerror(socket_last_error()));
            }
        }
        continue;
    }

    // ---------- Direct Query (User Input) ----------
    if (preg_match('/^[A-Z]{2}$/', strtoupper($query))) {
        processAndRespond("COUNTRY", strtoupper($query), strtoupper($query), 'handleCountryQueryWithIndex', $tz, $sock, $remote_ip, $remote_port, $logstart);
    } else {
        processAndRespond("STRING QUERY", $query, $query, 'handleStringQueryWithIndex', $tz, $sock, $remote_ip, $remote_port, $logstart);
    }
}
?>
