<?php
/**
 * IP Range Checker
 *
 * Handles IP address range validation including single IPs, CIDR notation,
 * bracket ranges, and dash ranges.
 *
 * @package VMPFence
 * @since 2.0.0
 */

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

/**
 * IP Range Class
 *
 * @since 2.0.0
 */
class VMPFence_IP_Range {

    /**
     * IP range string (e.g., "192.168.1.1", "192.168.1.0/24", "192.168.1.[1-100]")
     *
     * @var string
     */
    private $range_string;

    /**
     * Constructor
     *
     * @since 2.0.0
     * @param string $range_string IP range in supported format
     */
    public function __construct($range_string = null) {
        $this->range_string = $this->sanitize_range($range_string);
    }

    /**
     * Check if IP address is within this range
     *
     * @since 2.0.0
     * @param string $ip IP address to check
     * @return bool True if IP is in range, false otherwise
     */
    public function is_ip_in_range($ip) {
        if (empty($this->range_string) || empty($ip)) {
            return false;
        }

        // Remove whitespace
        $ip = trim($ip);
        $range = trim($this->range_string);

        // CIDR range -- 127.0.0.1/24
        if (strpos($range, '/') !== false) {
            $result = $this->cidr_contains_ip($range, $ip);
            return $result;
        }
        
        // Bracketed range -- 127.0.0.[1-100]
        if (strpos($range, '[') !== false) {
            $result = $this->bracket_range_contains_ip($range, $ip);
            return $result;
        }
        
        // Dash range -- 127.0.0.1-127.0.0.100
        if (strpos($range, '-') !== false && $this->is_dash_range($range)) {
            $result = $this->dash_range_contains_ip($range, $ip);
            return $result;
        }

        // Single IP - exact match
        $normalized_ip = $this->normalize_ip($ip);
        $normalized_range = $this->normalize_ip($range);
        $result = $normalized_ip === $normalized_range;
        return $result;
    }

    /**
     * Check if IP is within CIDR range
     *
     * @since 2.0.0
     * @param string $cidr CIDR notation (e.g., "192.168.1.0/24")
     * @param string $ip IP address to check
     * @return bool
     */
    private function cidr_contains_ip($cidr, $ip) {
        // Validate CIDR format
        if (strpos($cidr, '/') === false) {
            return false;
        }
        
        $parts = explode('/', $cidr);
        if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) {
            return false;
        }
        
        list($subnet, $mask) = $parts;
        
        // Validate mask
        $mask = intval($mask);
        if ($mask < 0 || $mask > 128) {
            return false;
        }

        // Convert IPs to binary
        $ip_binary = @inet_pton($ip);
        $subnet_binary = @inet_pton($subnet);

        if ($ip_binary === false || $subnet_binary === false) {
            return false;
        }

        // Determine IP version
        $ip_version = strlen($ip_binary) === 4 ? 4 : 6;
        $max_bits = $ip_version === 4 ? 32 : 128;

        // Validate mask for IP version
        if ($mask > $max_bits) {
            return false;
        }

        // Create netmask
        $netmask = str_repeat('f', $mask >> 2);
        switch ($mask & 3) {
            case 3:
                $netmask .= 'e';
                break;
            case 2:
                $netmask .= 'c';
                break;
            case 1:
                $netmask .= '8';
                break;
        }
        $netmask = str_pad($netmask, $max_bits >> 2, '0');
        $netmask = pack('H*', $netmask);

        // Compare network portions
        return ($ip_binary & $netmask) === ($subnet_binary & $netmask);
    }

    /**
     * Check if IP is within bracket range
     *
     * Supports both IPv4 and IPv6 bracket notation:
     * - IPv4: 192.168.1.[1-100]
     * - IPv6: 2a03:2880:f001:[0-ffff]:[0-ffff]:[0-ffff]:[0-ffff]:[0-ffff]
     *
     * @since 2.0.0
     * @param string $range Bracket range (e.g., "192.168.1.[1-100]" or "2a03:2880:f001:[0-ffff]::[0-ffff]")
     * @param string $ip IP address to check
     * @return bool
     */
    private function bracket_range_contains_ip($range, $ip) {
        // Detect IP version
        $is_ipv4_range = strpos($range, '.') !== false;
        $is_ipv4_ip = strpos($ip, '.') !== false;
        
        // Check if range has bracket notation
        if (!preg_match('/\[[\da-fA-F]+\-[\da-fA-F]+\]/', $range)) {
            return false;
        }
        
        // IPv4 bracket range
        if ($is_ipv4_range && $is_ipv4_ip) {
            return $this->bracket_range_ipv4($range, $ip);
        }
        
        // IPv6 bracket range
        if (!$is_ipv4_range && !$is_ipv4_ip) {
            return $this->bracket_range_ipv6($range, $ip);
        }
        
        // Mismatched IP versions
        return false;
    }
    
    /**
     * Check IPv4 bracket range
     *
     * @since 2.0.0
     * @param string $range IPv4 bracket range (e.g., "192.168.1.[1-100]")
     * @param string $ip IPv4 address to check
     * @return bool
     */
    private function bracket_range_ipv4($range, $ip) {
        // Handle IPv4-mapped-IPv6
        if (preg_match('/:ffff:([^:]+)$/i', $range, $matches)) {
            $range = $matches[1];
        }
        if (preg_match('/:ffff:([^:]+)$/i', $ip, $matches)) {
            $ip = $matches[1];
        }

        $ip_parts = explode('.', $ip);
        $range_parts = explode('.', $range);

        if (count($range_parts) !== 4 || count($ip_parts) !== 4) {
            return false;
        }

        // Compare each octet
        for ($i = 0; $i < 4; $i++) {
            if (preg_match('/^\[(\d+)\-(\d+)\]$/', $range_parts[$i], $matches)) {
                // This octet is a range
                $min = intval($matches[1]);
                $max = intval($matches[2]);
                $octet = intval($ip_parts[$i]);

                if ($octet < $min || $octet > $max) {
                    return false; // IP octet outside range
                }
            } else {
                // This octet must match exactly
                if ($range_parts[$i] !== $ip_parts[$i]) {
                    return false;
                }
            }
        }

        return true; // All octets match
    }
    
    /**
     * Check IPv6 bracket range
     *
     * @since 2.0.0
     * @param string $range IPv6 bracket range (e.g., "2a03:2880:f001:[0-ffff]:[0-ffff]:[0-ffff]:[0-ffff]:[0-ffff]")
     * @param string $ip IPv6 address to check
     * @return bool
     */
    private function bracket_range_ipv6($range, $ip) {
        // Expand compressed IPv6 addresses
        $ip_expanded = $this->expand_ipv6($ip);
        $range_expanded = $this->expand_ipv6_range($range);
        
        if ($ip_expanded === false || $range_expanded === false) {
            return false;
        }
        
        // Split into hextets (16-bit segments)
        $ip_parts = explode(':', $ip_expanded);
        $range_parts = explode(':', $range_expanded);
        
        if (count($range_parts) !== 8 || count($ip_parts) !== 8) {
            return false;
        }
        
        // Compare each hextet
        for ($i = 0; $i < 8; $i++) {
            if (preg_match('/^\[([0-9a-fA-F]+)\-([0-9a-fA-F]+)\]$/i', $range_parts[$i], $matches)) {
                // This hextet is a range
                $min = hexdec($matches[1]);
                $max = hexdec($matches[2]);
                $hextet = hexdec($ip_parts[$i]);
                
                if ($hextet < $min || $hextet > $max) {
                    return false; // IP hextet outside range
                }
            } else {
                // This hextet must match exactly (case-insensitive)
                if (strtolower($range_parts[$i]) !== strtolower($ip_parts[$i])) {
                    return false;
                }
            }
        }
        
        return true; // All hextets match
    }
    
    /**
     * Expand compressed IPv6 address to full format
     *
     * @since 2.0.0
     * @param string $ip IPv6 address (may be compressed)
     * @return string|false Expanded IPv6 address or false on error
     */
    private function expand_ipv6($ip) {
        // Validate IPv6
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) {
            return false;
        }
        
        // Convert to binary and back to get full format
        $binary = @inet_pton($ip);
        if ($binary === false) {
            return false;
        }
        
        // Unpack binary to 8 x 16-bit segments
        $unpacked = unpack('n8', $binary);
        
        // Convert each segment to 4-digit hex
        $parts = array();
        foreach ($unpacked as $segment) {
            $parts[] = str_pad(dechex($segment), 4, '0', STR_PAD_LEFT);
        }
        
        return implode(':', $parts);
    }
    
    /**
     * Expand IPv6 range with bracket notation
     *
     * @since 2.0.0
     * @param string $range IPv6 range (may be compressed with ::)
     * @return string|false Expanded range or false on error
     */
    private function expand_ipv6_range($range) {
        // Check if range contains ::
        if (strpos($range, '::') === false) {
            // No compression - just validate format
            $parts = explode(':', $range);
            if (count($parts) !== 8) {
                return false;
            }
            return $range;
        }
        
        // Split by ::
        $sides = explode('::', $range);
        if (count($sides) > 2) {
            return false; // Multiple :: not allowed
        }
        
        $left = $sides[0] ? explode(':', $sides[0]) : array();
        $right = isset($sides[1]) && $sides[1] ? explode(':', $sides[1]) : array();
        
        // Calculate how many zero groups to add
        $missing = 8 - (count($left) + count($right));
        
        if ($missing < 0) {
            return false; // Too many segments
        }
        
        // Build expanded address
        $expanded = array_merge(
            $left,
            array_fill(0, $missing, '0'),
            $right
        );
        
        // Ensure we have exactly 8 segments
        if (count($expanded) !== 8) {
            return false;
        }
        
        return implode(':', $expanded);
    }

    /**
     * Check if IP is within dash range
     *
     * @since 2.0.0
     * @param string $range Dash range (e.g., "192.168.1.1-192.168.1.100")
     * @param string $ip IP address to check
     * @return bool
     */
    private function dash_range_contains_ip($range, $ip) {
        $parts = explode('-', $range);
        if (count($parts) !== 2) {
            return false;
        }

        $start_ip = trim($parts[0]);
        $end_ip = trim($parts[1]);

        // Convert to binary for comparison
        $ip_binary = @inet_pton($ip);
        $start_binary = @inet_pton($start_ip);
        $end_binary = @inet_pton($end_ip);

        if ($ip_binary === false || $start_binary === false || $end_binary === false) {
            return false;
        }

        // Ensure same IP version
        if (strlen($ip_binary) !== strlen($start_binary) || strlen($ip_binary) !== strlen($end_binary)) {
            return false;
        }

        // Binary string comparison
        return $ip_binary >= $start_binary && $ip_binary <= $end_binary;
    }

    /**
     * Check if string is a dash range format
     *
     * @since 2.0.0
     * @param string $range Range string
     * @return bool
     */
    private function is_dash_range($range) {
        $parts = explode('-', $range);
        if (count($parts) !== 2) {
            return false;
        }

        $start = trim($parts[0]);
        $end = trim($parts[1]);

        // Both parts must be valid IPs
        return filter_var($start, FILTER_VALIDATE_IP) !== false &&
               filter_var($end, FILTER_VALIDATE_IP) !== false;
    }

    /**
     * Normalize IP address
     *
     * @since 2.0.0
     * @param string $ip IP address
     * @return string Normalized IP
     */
    private function normalize_ip($ip) {
        $binary = @inet_pton($ip);
        if ($binary === false) {
            return $ip;
        }
        return @inet_ntop($binary) ?: $ip;
    }

    /**
     * Sanitize IP range string
     *
     * @since 2.0.0
     * @param string $range IP range string
     * @return string Sanitized range
     */
    private function sanitize_range($range) {
        if (empty($range)) {
            return '';
        }

        // Remove whitespace
        $range = trim($range);

        // Remove common invalid characters
        $range = preg_replace('/[^\d\.\:\[\]\-\/a-fA-F,]/', '', $range);

        return $range;
    }

    /**
     * Get the range string
     *
     * @since 2.0.0
     * @return string
     */
    public function get_range_string() {
        return $this->range_string;
    }

    /**
     * Set the range string
     *
     * @since 2.0.0
     * @param string $range_string IP range
     */
    public function set_range_string($range_string) {
        $this->range_string = $this->sanitize_range($range_string);
    }
}
