<?php
/**
 * Pattern Matcher Controller
 * 
 * Handles custom pattern blocking (hostname, user_agent, referrer, ip_range)
 * Supports both wildcard and regex patterns
 * 
 * @package VMPFence
 * @subpackage Firewall\Controllers
 * @since 2.2.0
 */

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

class VMPFence_Pattern_Matcher {
    
    const CACHE_KEY = 'vmpfence_custom_patterns_grouped';
    const CACHE_DURATION = 300; // 5 minutes
    const HOSTNAME_CACHE_KEY = 'vmpfence_hostname_';
    const HOSTNAME_CACHE_DURATION = 3600; // 1 hour
    
    /**
     * Get active patterns from database (cached)
     * Returns grouped array by pattern type for performance
     */
    private static function get_active_patterns() {
        // Try cache first
        $patterns_grouped = wp_cache_get(self::CACHE_KEY);
        
        if ($patterns_grouped === false) {
            global $wpdb;
            $table = $wpdb->prefix . 'vmpfence_custom_patterns';
            
            // Check if table exists
            if ($wpdb->get_var("SHOW TABLES LIKE '$table'") != $table) {
                return array();
            }
            
            $patterns = $wpdb->get_results(
                "SELECT id, pattern_type, pattern_value, is_regex 
                 FROM $table 
                 WHERE is_active = 1 
                 ORDER BY pattern_type, id",
                ARRAY_A
            );
            
            // Group patterns by type for faster checking
            $patterns_grouped = array(
                'hostname' => array(),
                'user_agent' => array(),
                'referrer' => array(),
                'ip_range' => array()
            );
            
            foreach ($patterns as $pattern) {
                $type = $pattern['pattern_type'];
                if (isset($patterns_grouped[$type])) {
                    $patterns_grouped[$type][] = $pattern;
                }
            }
            
            // Cache for 5 minutes
            wp_cache_set(self::CACHE_KEY, $patterns_grouped, '', self::CACHE_DURATION);
        }
        
        return $patterns_grouped;
    }
    
    /**
     * Get cached hostname for an IP
     */
    private static function get_cached_hostname($ip) {
        $cache_key = self::HOSTNAME_CACHE_KEY . md5($ip);
        $hostname = wp_cache_get($cache_key);
        
        // Check if cache exists (false means not cached, null is a valid cached value)
        if ($hostname === false) {
            // Perform DNS lookup
            $hostname = @gethostbyaddr($ip);
            
            // If lookup fails or returns the IP itself, store empty string to avoid repeated lookups
            if ($hostname === $ip || $hostname === false) {
                $hostname = '';
            }
            
            // Cache for 1 hour (even if empty string)
            wp_cache_set($cache_key, $hostname, '', self::HOSTNAME_CACHE_DURATION);
        }
        
        // Return hostname or null if empty
        return ($hostname !== '') ? $hostname : null;
    }
    
    /**
     * Check if request matches any custom patterns
     * Collects ALL matching patterns before blocking to ensure accurate analytics
     */
    public static function check($request) {
        $patterns_grouped = self::get_active_patterns();
        
        if (empty($patterns_grouped)) {
            return; // No patterns configured
        }
        
        // Skip incrementing match counts for static resources (favicon, CSS, JS, images)
        // These are secondary requests that shouldn't count as separate attacks
        $is_static_resource = false;
        if (!empty($request['uri'])) {
            $static_extensions = array('.ico', '.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.eot');
            foreach ($static_extensions as $ext) {
                if (stripos($request['uri'], $ext) !== false) {
                    $is_static_resource = true;
                    break;
                }
            }
        }
        
        // Collect ALL matching patterns before blocking
        $all_matching_ids = array();
        $first_match = null;
        
        // 1. Check user_agent (fast, no DNS)
        if (!empty($patterns_grouped['user_agent']) && !empty($request['user_agent'])) {
            foreach ($patterns_grouped['user_agent'] as $pattern) {
                if (self::match_pattern($request['user_agent'], $pattern)) {
                    $all_matching_ids[] = $pattern['id'];
                    if ($first_match === null) {
                        $first_match = array('type' => 'user_agent', 'pattern' => $pattern);
                    }
                }
            }
        }
        
        // 2. Check referrer (fast, no DNS) - WordPress uses 'referer'
        if (!empty($patterns_grouped['referrer']) && !empty($request['referer'])) {
            foreach ($patterns_grouped['referrer'] as $pattern) {
                if (self::match_pattern($request['referer'], $pattern)) {
                    $all_matching_ids[] = $pattern['id'];
                    if ($first_match === null) {
                        $first_match = array('type' => 'referrer', 'pattern' => $pattern);
                    }
                }
            }
        }
        
        // 3. Check ip_range (fast, no DNS)
        if (!empty($patterns_grouped['ip_range']) && !empty($request['ip'])) {
            foreach ($patterns_grouped['ip_range'] as $pattern) {
                if (self::match_cidr($request['ip'], $pattern['pattern_value'])) {
                    $all_matching_ids[] = $pattern['id'];
                    if ($first_match === null) {
                        $first_match = array('type' => 'ip_range', 'pattern' => $pattern);
                    }
                }
            }
        }
        
        // 4. Check hostname (SLOW - DNS lookup, lazy resolution with caching)
        if (!empty($patterns_grouped['hostname']) && !empty($request['ip'])) {
            // Lazy resolution: only resolve hostname if hostname patterns exist
            $hostname = self::get_cached_hostname($request['ip']);
            
            if ($hostname !== null) {
                foreach ($patterns_grouped['hostname'] as $pattern) {
                    if (self::match_pattern($hostname, $pattern)) {
                        $all_matching_ids[] = $pattern['id'];
                        if ($first_match === null) {
                            $first_match = array('type' => 'hostname', 'pattern' => $pattern);
                        }
                    }
                }
            }
        }
        
        // If ANY patterns matched, increment ALL of them, then block
        if (!empty($all_matching_ids)) {
            if (!$is_static_resource) {
                self::batch_record_matches($all_matching_ids);
            }
            
            // Throw exception with first match details
            throw new VMPFence_Pattern_Block_Exception(
                'Request blocked by custom ' . $first_match['type'] . ' pattern',
                $first_match['type'],
                $first_match['pattern']['pattern_value'],
                $first_match['pattern']['id']
            );
        }
    }
    
    /**
     * Match a value against a pattern (wildcard or regex)
     */
    private static function match_pattern($value, $pattern) {
        if ($pattern['is_regex']) {
            // Regex pattern
            return @preg_match($pattern['pattern_value'], $value) === 1;
        } else {
            // Wildcard pattern - convert to regex
            $regex = self::wildcard_to_regex($pattern['pattern_value']);
            return @preg_match($regex, $value) === 1;
        }
    }
    
    /**
     * Convert wildcard pattern to regex
     * Example: *.example.com -> /^.*\.example\.com$/i
     */
    private static function wildcard_to_regex($pattern) {
        // Escape special regex characters except *
        $pattern = str_replace(
            array('\\', '^', '$', '.', '[', ']', '|', '(', ')', '?', '+', '{', '}'),
            array('\\\\', '\\^', '\\$', '\\.', '\\[', '\\]', '\\|', '\\(', '\\)', '\\?', '\\+', '\\{', '\\}'),
            $pattern
        );
        
        // Replace * with .*
        $pattern = str_replace('*', '.*', $pattern);
        
        // Add anchors and case-insensitive flag
        return '/^' . $pattern . '$/i';
    }
    
    /**
     * Check if IP matches CIDR range or IP range (supports both IPv4 and IPv6)
     */
    private static function match_cidr($ip, $cidr) {
        // Remove spaces for consistent parsing
        $cidr = str_replace(' ', '', $cidr);
        
        // Handle dash-separated range (e.g., "192.168.0.120-192.168.0.169")
        // Bug #26 & #28 fix: Only treat as range if both parts are valid IPs
        if (strpos($cidr, '-') !== false) {
            list($start_ip, $end_ip) = explode('-', $cidr, 2);
            
            // Bug #28 fix: Validate both parts are actual IPs before treating as range
            // This prevents hostnames like "192.my-server.com" from being matched as ranges
            if (filter_var($start_ip, FILTER_VALIDATE_IP) !== false && 
                filter_var($end_ip, FILTER_VALIDATE_IP) !== false) {
                
                // Both parts are valid IPs - proceed with range matching
                // Detect IP version
                $is_ipv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
                
                if ($is_ipv6) {
                    // IPv6 range matching
                    $ip_bin = inet_pton($ip);
                    $start_bin = inet_pton($start_ip);
                    $end_bin = inet_pton($end_ip);
                    
                    if ($ip_bin === false || $start_bin === false || $end_bin === false) {
                        return false;
                    }
                    
                    return strcmp($ip_bin, $start_bin) >= 0 && strcmp($ip_bin, $end_bin) <= 0;
                } else {
                    // IPv4 range matching
                    $ip_long = ip2long($ip);
                    $start_long = ip2long($start_ip);
                    $end_long = ip2long($end_ip);
                    
                    if ($ip_long === false || $start_long === false || $end_long === false) {
                        return false;
                    }
                    
                    return $ip_long >= $start_long && $ip_long <= $end_long;
                }
            }
            // If not a valid IP range, fall through to other checks
        }
        
        // Handle single IP (no slash)
        if (strpos($cidr, '/') === false) {
            return $ip === $cidr;
        }
        
        list($subnet, $mask) = explode('/', $cidr);
        
        // Detect IPv6
        $is_ipv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
        $max_mask = $is_ipv6 ? 128 : 32;
        
        // Validate mask
        if (!is_numeric($mask) || $mask < 0 || $mask > $max_mask) {
            return false;
        }
        
        if ($is_ipv6) {
            // IPv6 CIDR matching
            $ip_bin = inet_pton($ip);
            $subnet_bin = inet_pton($subnet);
            
            if ($ip_bin === false || $subnet_bin === false) {
                return false;
            }
            
            // Convert to binary string for bitwise comparison
            $ip_bits = '';
            $subnet_bits = '';
            for ($i = 0; $i < strlen($ip_bin); $i++) {
                $ip_bits .= str_pad(decbin(ord($ip_bin[$i])), 8, '0', STR_PAD_LEFT);
                $subnet_bits .= str_pad(decbin(ord($subnet_bin[$i])), 8, '0', STR_PAD_LEFT);
            }
            
            // Compare first $mask bits
            return substr($ip_bits, 0, $mask) === substr($subnet_bits, 0, $mask);
        } else {
            // IPv4 CIDR matching
            $ip_long = ip2long($ip);
            $subnet_long = ip2long($subnet);
            
            if ($ip_long === false || $subnet_long === false) {
                return false;
            }
            
            // Create mask
            $mask_long = -1 << (32 - (int)$mask);
            
            // Compare
            return ($ip_long & $mask_long) === ($subnet_long & $mask_long);
        }
    }
    
    /**
     * Batch record pattern matches (increment counters for multiple patterns)
     */
    private static function batch_record_matches($pattern_ids) {
        if (empty($pattern_ids)) {
            return;
        }
        
        global $wpdb;
        $table = $wpdb->prefix . 'vmpfence_custom_patterns';
        $ids_placeholder = implode(',', array_fill(0, count($pattern_ids), '%d'));
        
        $wpdb->query($wpdb->prepare(
            "UPDATE $table 
             SET match_count = match_count + 1, 
                 last_matched = %d 
             WHERE id IN ($ids_placeholder)",
            array_merge([time()], $pattern_ids)
        ));
    }
    
    /**
     * Record pattern match (increment counter, update timestamp)
     * @deprecated Use batch_record_matches() for better performance
     */
    private static function record_match($pattern_id) {
        global $wpdb;
        $table = $wpdb->prefix . 'vmpfence_custom_patterns';
        
        $wpdb->query($wpdb->prepare(
            "UPDATE $table 
             SET match_count = match_count + 1, 
                 last_matched = %d 
             WHERE id = %d",
            time(),
            $pattern_id
        ));
    }
    
    /**
     * Post-hoc pattern check: Increment match count for all patterns that match a blocked request
     * Called when an IP is blocked (from blocked_ips table) to track pattern coverage
     * 
     * @param array $request Request data (ip, user_agent, referrer, etc.)
     * @return void
     */
    public static function increment_matching_patterns($request) {
        // Ensure we have at least an IP
        if (empty($request['ip'])) {
            return;
        }
        
        $ip = $request['ip'];
        
        // Get active patterns (cached)
        $patterns_grouped = self::get_active_patterns();
        
        // Find all patterns that match this request
        $matching_pattern_ids = array();
        
        // Check IP range patterns
        if (!empty($patterns_grouped['ip_range'])) {
            foreach ($patterns_grouped['ip_range'] as $pattern) {
                if (self::match_cidr($ip, $pattern['pattern_value'])) {
                    $matching_pattern_ids[] = $pattern['id'];
                }
            }
        }
        
        // Check hostname patterns (do reverse DNS lookup)
        if (!empty($patterns_grouped['hostname'])) {
            $hostname = self::get_cached_hostname($ip);
            
            if ($hostname) {
                foreach ($patterns_grouped['hostname'] as $pattern) {
                    // Build regex pattern
                    $regex = self::build_regex_pattern($pattern['pattern_value'], $pattern['is_regex']);
                    
                    if (@preg_match($regex, $hostname)) {
                        $matching_pattern_ids[] = $pattern['id'];
                    }
                }
            }
        }
        
        // Check user agent patterns
        if (!empty($patterns_grouped['user_agent']) && !empty($request['user_agent'])) {
            $user_agent = $request['user_agent'];
            
            foreach ($patterns_grouped['user_agent'] as $pattern) {
                // Build regex pattern
                $regex = self::build_regex_pattern($pattern['pattern_value'], $pattern['is_regex']);
                
                if (@preg_match($regex, $user_agent)) {
                    $matching_pattern_ids[] = $pattern['id'];
                }
            }
        }
        
        // Check referrer patterns
        if (!empty($patterns_grouped['referrer']) && !empty($request['referrer'])) {
            $referrer = $request['referrer'];
            
            foreach ($patterns_grouped['referrer'] as $pattern) {
                // Build regex pattern
                $regex = self::build_regex_pattern($pattern['pattern_value'], $pattern['is_regex']);
                
                if (@preg_match($regex, $referrer)) {
                    $matching_pattern_ids[] = $pattern['id'];
                }
            }
        }
        
        // Batch increment all matching patterns in one query
        if (!empty($matching_pattern_ids)) {
            global $wpdb;
            $table = $wpdb->prefix . 'vmpfence_custom_patterns';
            $ids_placeholder = implode(',', array_fill(0, count($matching_pattern_ids), '%d'));
            
            $wpdb->query($wpdb->prepare(
                "UPDATE $table 
                 SET match_count = match_count + 1, 
                     last_matched = %d 
                 WHERE id IN ($ids_placeholder)",
                array_merge([time()], $matching_pattern_ids)
            ));
        }
    }
    
    /**
     * Clear pattern cache (call when patterns are added/deleted/toggled)
     */
    public static function clear_cache() {
        wp_cache_delete(self::CACHE_KEY);
    }
}
