<?php
/**
 * WAF Controller
 *
 * @package VMPFence
 * @since 2.0.0
 */

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

/**
 * WAF Controller Class
 *
 * @since 2.0.0
 */
class VMPFence_WAF_Controller {

    /**
     * Scores accumulated for current request
     *
     * @var array
     */
    private $scores = array();

    /**
     * Allowlist cache
     * @var array
     */
    private $allowlist_cache = array();

    /**
     * Violations detected in current request
     *
     * @var array
     */
    private $violations = array();

    /**
     * Evaluate request against WAF rules
     *
     * @since 2.0.0
     * @param array $request Request data (IP, params, etc.)
     * @return array Action result with keys: action, violations, score, scores_by_category
     */
    public function evaluate_request($request) {
        // Reset scores and violations for this request
        $this->scores = array();
        $this->violations = array();
        $this->allowlist_cache = array();
        
        // Get all enabled rules
        $rules = VMPFence_WAF_Rule::get_enabled_rules();

        //Get allowlist cache once
        $this->allowlist_cache = VMPFence_Param_Allowlist::get_allowlist_cache();
        if (empty($rules)) {

            return array(
                'action' => 'log',
                'violations' => array(),
                'score' => 0,
                'scores_by_category' => array(),
            );
        }

        // Check all rules
        $this->check_all_rules($rules, $request);

        // Determine action based on violations
        $total_score = array_sum(array_map('array_sum', $this->scores));
        
        // Check if any whitelist rule matched first
        $is_whitelisted = false;
        foreach ($this->violations as $violation) {
            if (isset($violation['whitelist']) && $violation['whitelist'] == 1) {
                $is_whitelisted = true;
                break;
            }
        }
        
        // Check if any violation requires blocking (action = 'block')
        $should_block = false;
        foreach ($this->violations as $violation) {
            if (isset($violation['action']) && $violation['action'] === 'block' && 
                (!isset($violation['whitelist']) || $violation['whitelist'] != 1)) {
                $should_block = true;
                break;
            }
        }
        
        // Determine final action
        if ($is_whitelisted) {
            $action = 'allow';
        } elseif ($should_block) {
            $action = 'block';  // Block only if a 'block' action rule violated
        } elseif (!empty($this->violations)) {
            $action = 'log';    // Log-only violations don't block
        } else {
            $action = 'allow';
        }

        return array(
            'action' => $action,
            'violations' => $this->violations,
            'score' => $total_score,
            'scores_by_category' => $this->get_scores_by_category(),
        );
    }

    /**
     * Check all rules against request
     *
     * @since 2.0.0
     * @param array $rules WAF rules
     * @param array $request Request data
     * @return void
     */
    private function check_all_rules($rules, $request) {
        foreach ($rules as $rule) {
            $this->check_rule($rule, $request);
        }
    }

    /**
     * Check single rule against request
     *
     * @since 2.0.0
     * @since 2.2.2 Added special handling for SCANNER category rules, URI path checking for path-based categories
     * @param array $rule WAF rule
     * @param array $request Request data
     * @return void
     */
    private function check_rule($rule, $request) {
        // Get request parameters to check
        $params = isset($request['params']) ? $request['params'] : array();
        
        // Get request path for allowlist check
        $path = isset($request['path']) ? $request['path'] : '/';
        
        // Get URI for path-based rule checking (configuration file access, etc.)
        $uri = isset($request['uri']) ? $request['uri'] : '';

        // SPECIAL HANDLING: SCANNER category - check User-Agent header directly
        // Scanner detection rules need to match against User-Agent, not request parameters
        if (strtoupper($rule['category']) === 'SCANNER') {
            $user_agent = isset($request['user_agent']) ? $request['user_agent'] : '';
            
            // Check if User-Agent is allowlisted for this scanner rule
            if ($this->is_param_allowlisted_in_cache($rule['rule_id'], $path, 'HTTP_USER_AGENT')) {
                return; // Skip this scanner rule - allowlisted
            }
            
            if (!empty($user_agent) && $this->match_pattern($rule['pattern'], $user_agent)) {
                // Scanner detected via User-Agent
                $violation = array(
                    'rule_id' => $rule['rule_id'],
                    'category' => $rule['category'],
                    'severity' => $rule['severity'],
                    'param_key' => 'HTTP_USER_AGENT',
                    'param_value' => $user_agent,
                    'score' => isset($rule['score']) ? $rule['score'] : 50,
                    'action' => isset($rule['action']) ? $rule['action'] : 'block',
                    'whitelist' => isset($rule['whitelist']) ? $rule['whitelist'] : 0,
                    'description' => isset($rule['description']) ? $rule['description'] : 'Scanner detected',
                );

                $this->violations[] = $violation;

                // Accumulate score
                $action = isset($rule['action']) ? $rule['action'] : 'block';
                $whitelist = isset($rule['whitelist']) ? $rule['whitelist'] : 0;
                $score = isset($rule['score']) ? $rule['score'] : 50;

                if ($action === 'block' && $whitelist == 0) {
                    $this->accumulate_score('HTTP_USER_AGENT', $rule['category'], $score);
                }
            }
            
            return; // Skip parameter checking for SCANNER rules
        }

        // CRITICAL FIX: Check URI path for PATH-RELEVANT categories only
        // Only check URI for categories that are path-based (avoid false positives from SQLi/XSS rules)
        // Categories: LFI, TRAVERSAL, RFI, CONFIGURATION, WORDPRESS, AUTHENTICATION (config files, path traversal, WP-specific paths)
        // Excluded: SQLi, XSS, SCANNER (parameter/header-only), GENERIC (too broad), RCE (too broad)
        $category_upper = strtoupper($rule['category']);
        $uri_check_categories = array('LFI', 'TRAVERSAL', 'RFI', 'CONFIGURATION', 'WORDPRESS', 'AUTHENTICATION');
        
        if (in_array($category_upper, $uri_check_categories) && (!empty($uri) || !empty($path))) {
            $check_uri = !empty($uri) ? $uri : $path;
            
            // Check if URI is allowlisted for this rule
            if (!$this->is_param_allowlisted_in_cache($rule['rule_id'], $path, 'REQUEST_URI')) {
                if ($this->match_pattern($rule['pattern'], $check_uri)) {
                    // URI path matched - path-based attack detected
                    $violation = array(
                        'rule_id' => $rule['rule_id'],
                        'category' => $rule['category'],
                        'severity' => $rule['severity'],
                        'param_key' => 'REQUEST_URI',
                        'param_value' => $check_uri,
                        'score' => isset($rule['score']) ? $rule['score'] : 100,
                        'action' => isset($rule['action']) ? $rule['action'] : 'block',
                        'whitelist' => isset($rule['whitelist']) ? $rule['whitelist'] : 0,
                        'description' => isset($rule['description']) ? $rule['description'] : 'Path-based attack detected',
                    );

                    $this->violations[] = $violation;

                    // Accumulate score
                    $action = isset($rule['action']) ? $rule['action'] : 'block';
                    $whitelist = isset($rule['whitelist']) ? $rule['whitelist'] : 0;
                    $score = isset($rule['score']) ? $rule['score'] : 100;

                    if ($action === 'block' && $whitelist == 0) {
                        $this->accumulate_score('REQUEST_URI', $rule['category'], $score);
                    }
                    
                    // DON'T return - continue to check parameters (for multi-vector attacks)
                }
            }
        }

        // Check each parameter (for non-SCANNER rules)
        foreach ($params as $param_key => $param_value) {
            // Check if this parameter is allowlisted for this rule
            
            if($this->is_param_allowlisted_in_cache($rule['rule_id'], $path, $param_key)) {
                continue; // Skip this rule for this parameter
            }

            if ($this->match_pattern($rule['pattern'], $param_value)) {
                // Pattern matched
                $violation = array(
                    'rule_id' => $rule['rule_id'],
                    'category' => $rule['category'],
                    'severity' => $rule['severity'],
                    'param_key' => $param_key,
                    'param_value' => $param_value,
                    'score' => isset($rule['score']) ? $rule['score'] : 0,
                    'action' => isset($rule['action']) ? $rule['action'] : 'log',
                    'whitelist' => isset($rule['whitelist']) ? $rule['whitelist'] : 0,
                    'description' => isset($rule['description']) ? $rule['description'] : '',
                );

                $this->violations[] = $violation;

                // Accumulate score only if action is 'block' and not whitelist
                $action = isset($rule['action']) ? $rule['action'] : 'log';
                $whitelist = isset($rule['whitelist']) ? $rule['whitelist'] : 0;
                $score = isset($rule['score']) ? $rule['score'] : 0;
                
                if ($action === 'block' && $whitelist == 0) {
                    $this->accumulate_score($param_key, $rule['category'], $score);
                }
            }
        }
    }

    /**
     * Match pattern against value
     *
     * @since 2.0.0
     * @since 2.2.2 -- Added regex delimiters if pattern doesn't have them.
     * @param string $pattern Regex pattern (without delimiters) or delimited regex
     * @param string|array $value Value to check (can be string or array)
     * @return bool True if pattern matches
     */
    private function match_pattern($pattern, $value) {
        // Handle array values by checking each element recursively
        if (is_array($value)) {
            foreach ($value as $item) {
                if ($this->match_pattern($pattern, $item)) {
                    return true;
                }
            }
            return false;
        }

        // Convert to string if not already
        $value = (string) $value;

        // Add regex delimiters if pattern doesn't have them
        // Check if pattern has proper regex delimiters (start AND end with same delimiter)
        $delimiters = array('/', '#', '~', '@', '%', '!', ';', '|', '`', '{');
        $has_delimiters = false;
        foreach ($delimiters as $delim) {
            // Pattern must START with delimiter and END with delimiter+optional flags (i, s, m, etc.)
            if (strpos($pattern, $delim) === 0 && preg_match('/' . preg_quote($delim, '/') . '[imsxADSUXJu]*$/', $pattern)) {
                $has_delimiters = true;
                break;
            }
        }
        
        // If no delimiters, add them with case-insensitive flag (default for bot patterns)
        if (!$has_delimiters) {
            // Escape forward slashes in the pattern before wrapping with / delimiters
            $pattern = '/' . str_replace('/', '\/', $pattern) . '/i';
        }

        try {
            set_error_handler(function() {}, E_WARNING);
            
            // Check the original value
            $result = preg_match($pattern, $value);
            
            // Also check URL-encoded version (double-encoded attacks)
            if ($result !== 1) {
                $encoded_value = urlencode($value);
                $result = preg_match($pattern, $encoded_value);
            }
            
            // Also check URL-decoded version (in case of double encoding)
            if ($result !== 1) {
                $decoded_value = urldecode($value);
                if ($decoded_value !== $value) {
                    $result = preg_match($pattern, $decoded_value);
                }
            }
            
            restore_error_handler();

            return $result === 1;
        } catch (Exception $e) {
            // Invalid regex pattern - log and return false
            return false;
        }
    }

    /**
     * Accumulate score for parameter and category
     *
     * @since 2.0.0
     * @param string $param_key Parameter key
     * @param string $category Category
     * @param int $score Score to add
     * @return void
     */
    private function accumulate_score($param_key, $category, $score) {
        if (!isset($this->scores[$param_key])) {
            $this->scores[$param_key] = array();
        }

        if (!isset($this->scores[$param_key][$category])) {
            $this->scores[$param_key][$category] = 0;
        }

        $this->scores[$param_key][$category] += $score;
    }

    /**
     * Check if request should be blocked
     *
     * @since 2.0.0
     * @return string|false Highest scoring category or false
     */
    private function should_block() {
        $threshold = VMPFence_Config::get('waf_blocking_threshold', 100);

        $category_scores = $this->get_scores_by_category();

        foreach ($category_scores as $category => $score) {
            if ($score >= $threshold) {
                return $category;
            }
        }

        return false;
    }

    /**
     * Get scores grouped by category
     *
     * @since 2.0.0
     * @return array
     */
    private function get_scores_by_category() {
        $category_scores = array();

        foreach ($this->scores as $param_scores) {
            foreach ($param_scores as $category => $score) {
                if (!isset($category_scores[$category])) {
                    $category_scores[$category] = 0;
                }
                $category_scores[$category] += $score;
            }
        }

        return $category_scores;
    }

    /**
     * Get highest scoring category
     *
     * @since 2.0.0
     * @return string|null
     */
    public function get_highest_category() {
        $category_scores = $this->get_scores_by_category();

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

        arsort($category_scores);
        return key($category_scores);
    }

    /**
     * Check if a parameter is allowlisted in the cache for a specific rule and path
     * 
     * @since 2.0.0
     * @param string $rule_id WAF rule ID
     * @param string $path URL path
     * @param string $param_key Parameter key
     * @return bool True if allowlisted
     */
    private function is_param_allowlisted_in_cache($rule_id, $path, $param_key) {
        $composite_key = VMPFence_Param_Allowlist::generate_composite_key($path, $param_key);

        //Check if entry exist in the cache
        if (!isset($this->allowlist_cache[$composite_key])) {
            return false;
        }
        $entry = $this->allowlist_cache[$composite_key];

        // Check if allowlisted for all rules
        if (isset($entry['all'])) {
            if (!$entry['all']['disabled']) {
                return true; // Not disabled = allowlisted
            }
        }
        
        // Check if allowlisted for specific rule
        if (isset($entry[$rule_id])) {
            if (!$entry[$rule_id]['disabled']) {
                return true; // Not disabled = allowlisted
            }
        }
        return false;
    }
}
