<?php
/**
 * VMPFence Signature Loader (Singleton)
 * 
 * Loads malware signatures from JSON file once and shares across all classes
 * 
 * 
 * @package VMPFence
 * @subpackage Security
 * @since 1.0.0
 */

if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}

class VMPFence_Signature_Loader {
    
    /**
     * Singleton instance
     */
    private static $instance = null;
    
    /**
     * Loaded signatures data
     */
    private static $signatures = null;
    
    /**
     * JSON file path
     */
    private $json_file_path;
    
    /**
     * PHP array file path (opcache-backed)
     * @since 2.2.4
     */
    private $php_file_path;
    
    /**
     * Get singleton instance
     */
    public static function get_instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    /**
     * Private constructor (singleton pattern)
     * 
     * @since 1.0.0
     * @since 2.2.4 Lazy loading: constructor no longer calls load_signatures().
     *              Signatures load on first get_signatures() / get() call.
     */
    private function __construct() {
        $this->json_file_path = plugin_dir_path(__FILE__) . '../data/malware-signatures.json';
        $this->php_file_path  = plugin_dir_path(__FILE__) . '../data/malware-signatures.php';
        // Lazy loading: do NOT call $this->load_signatures() here.
        // Signatures will be loaded on first access via get_signatures() or get().
    }
    
    /**
     * Load signatures from PHP array file (opcache) or JSON fallback
     * 
     * @since 1.0.0
     * @since 2.2.4 Prefers PHP array file via include() for opcache-backed loading.
     *              Falls back to JSON file for pre-migration or corrupted PHP file.
     */
    private function load_signatures() {
        // Already loaded, skip
        if (self::$signatures !== null) {
            return;
        }
        
        // Large signature files (101K+ sigs, ~26 MB JSON) need ~130 MB peak to decode.
        // AJAX requests default to WP_MEMORY_LIMIT (often 128 MB on hosts with low
        // php.ini memory_limit), which causes OOM. Raise to 512 MB for safe headroom.
        // Only takes effect when current limit is lower; harmless if already higher.
        @ini_set('memory_limit', '512M'); // phpcs:ignore WordPress.PHP.IniSet.memory_limit_Blacklisted
        
        // Strategy 1: PHP array file (opcache-backed, fastest)
        if (file_exists($this->php_file_path)) {
            $data = @include $this->php_file_path;
            if (is_array($data) && isset($data['signatures'])) {
                self::$signatures = $data['signatures'];
                return;
            }
            // PHP file exists but is corrupt — fall through to JSON fallback
        }
        
        // Strategy 2: JSON file (pre-migration or PHP file corrupt)
        if (!file_exists($this->json_file_path)) {
            // Neither file exists — create from encoded data
            $this->create_json_from_encoded();
        }
        
        // Check again after creation attempt
        if (!file_exists($this->json_file_path)) {
            self::$signatures = [];
            return;
        }
        
        // Read JSON file
        $json_content = file_get_contents($this->json_file_path);
        
        if ($json_content === false) {
            self::$signatures = [];
            return;
        }
        
        // Decode JSON
        $data = json_decode($json_content, true);
        
        if (json_last_error() !== JSON_ERROR_NONE) {
            self::$signatures = [];
            return;
        }
        
        // Store signatures
        self::$signatures = isset($data['signatures']) ? $data['signatures'] : [];
    }
    
    /**
     * Create JSON file from encoded data
     */
    private function create_json_from_encoded() {
        $encoded_file = plugin_dir_path(__FILE__) . '../data/signatures-encoded.php';
        
        if (!file_exists($encoded_file)) {
            // error_log('VMPFence: Encoded signatures file not found: ' . $encoded_file);
            return false;
        }
        
        // Read the file content
        $content = file_get_contents($encoded_file);
        if ($content === false) {
            // error_log('VMPFence: Failed to read encoded signatures file');
            return false;
        }
        
        // Extract base64 data from PHP file using multiple patterns
        $encoded_data = $this->extract_base64_from_php($content);
        
        if (empty($encoded_data)) {
            // error_log('VMPFence: Failed to extract base64 data from encoded file');
            return false;
        }
        
        // Remove any whitespace from base64 string
        $encoded_data = preg_replace('/\s+/', '', $encoded_data);
        
        // Decode the data with strict mode
        $json_content = base64_decode($encoded_data, true);
        if ($json_content === false) {
            // error_log('VMPFence: Failed to decode signatures data - invalid base64');
            return false;
        }
        
        // Verify it's valid JSON before writing
        $test_decode = json_decode($json_content, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            // error_log('VMPFence: Decoded data is not valid JSON: ' . json_last_error_msg());
            return false;
        }
        
        // Create the JSON file
        $result = file_put_contents($this->json_file_path, $json_content);
        
        if ($result === false) {
            // error_log('VMPFence: Failed to write malware signatures JSON file');
            return false;
        }
        
        // error_log('VMPFence: Successfully created malware signatures JSON file from encoded data');
        return true;
    }
    
    /**
     * Extract base64 string from PHP file content
     * 
     * 
     * @param string $content PHP file content
     * @return string|null Base64 encoded string or null if not found
     */
    private function extract_base64_from_php($content) {
        // Pattern 1: return 'base64string';
        if (preg_match("/return\s+['\"]([A-Za-z0-9+\/=\s]+)['\"]/s", $content, $matches)) {
            return preg_replace('/\s+/', '', $matches[1]);
        }
        
        // Pattern 2: $var = 'base64string';
        if (preg_match("/=\s+['\"]([A-Za-z0-9+\/=\s]+)['\"]/s", $content, $matches)) {
            return preg_replace('/\s+/', '', $matches[1]);
        }
        
        // Pattern 3: Any long base64 string in quotes
        if (preg_match("/['\"]([A-Za-z0-9+\/=\s]{100,})['\"]/s", $content, $matches)) {
            return preg_replace('/\s+/', '', $matches[1]);
        }
        
        // Pattern 4: Try to find any long base64-like string
        $content_no_php = preg_replace('/<\?php.*?\?>/s', '', $content);
        $content_no_comments = preg_replace('/\/\*.*?\*\//s', '', $content_no_php);
        $content_no_comments = preg_replace('/\/\/.*?\n/', '', $content_no_comments);
        
        if (preg_match_all('/[A-Za-z0-9+\/=]{100,}/', $content_no_comments, $matches)) {
            // Return the longest match
            $longest = '';
            foreach ($matches[0] as $match) {
                if (strlen($match) > strlen($longest)) {
                    $longest = $match;
                }
            }
            return $longest;
        }
        
        return null;
    }
    
    /**
     * Get all signatures (triggers lazy load on first call)
     * 
     * @since 1.0.0
     * @since 2.2.4 Lazy loading: signatures load on first call to get_signatures() or get().
     * @return array All signatures data
     */
    public function get_signatures() {
        if (self::$signatures === null) {
            $this->load_signatures();
        }
        return self::$signatures;
    }
    
    /**
     * Get specific signature category (triggers lazy load on first call)
     * 
     * @since 1.0.0
     * @since 2.2.4 Lazy loading: signatures load on first call to get_signatures() or get().
     * @param string $key Signature category key
     * @return array Signature data for the category
     */
    public function get($key) {
        if (self::$signatures === null) {
            $this->load_signatures();
        }
        return isset(self::$signatures[$key]) ? self::$signatures[$key] : [];
    }
    
    /**
     * Check if signatures are loaded
     * 
     * @return bool True if signatures loaded successfully
     */
    public function is_loaded() {
        return !empty(self::$signatures);
    }
    
    /**
     * Get loaded signature category count (requires full load)
     * 
     * Returns the number of top-level signature categories (~20 keys like
     * 'high_risk_functions', 'encoding_patterns', etc.) — NOT the total
     * individual signature count (101K). For the total count without
     * triggering a full load, use the static VMPFence_Signature_Loader::get_count().
     *
     * @since 1.0.0
     * @since 2.2.4 Renamed from get_count() to avoid collision with new static get_count().
     * @return int Number of loaded signature categories
     */
    public function get_loaded_count() {
        if (self::$signatures === null) {
            return 0;
        }
        return count(self::$signatures);
    }
    
    /**
     * Get total signature count from metadata file (lightweight, no full load)
     * 
     * Reads from the tiny malware-signatures-meta.json sidecar file (~100 bytes)
     * instead of loading the full 26 MB signature database. Safe to call from
     * dashboard, AJAX handlers, or any non-scan path.
     *
     * @since 2.2.4
     * @return int Total number of individual signatures (e.g. 101029)
     */
    public static function get_count() {
        $meta_path = plugin_dir_path(__FILE__) . '../data/malware-signatures-meta.json';
        if (!file_exists($meta_path)) {
            // Fallback to wp_option if meta file doesn't exist yet (pre-migration)
            return (int) get_option('vmpfence_signature_count', 0);
        }
        $raw = file_get_contents($meta_path);
        if ($raw === false) {
            return (int) get_option('vmpfence_signature_count', 0);
        }
        $meta = json_decode($raw, true);
        return isset($meta['count']) ? (int) $meta['count'] : 0;
    }
    
    /**
     * Get full sync metadata from sidecar file (lightweight, no full load)
     * 
     * Returns count, last_sync timestamp, has_premium flag, and file_format.
     * Falls back to wp_options if meta file doesn't exist.
     *
     * @since 2.2.4
     * @return array Associative array with keys: count, last_sync, has_premium, file_format
     */
    public static function get_sync_metadata() {
        $defaults = array(
            'count'       => 0,
            'last_sync'   => 0,
            'has_premium'  => false,
            'file_format' => 'json',
        );
        
        $meta_path = plugin_dir_path(__FILE__) . '../data/malware-signatures-meta.json';
        if (!file_exists($meta_path)) {
            // Fallback to wp_options for pre-migration installs
            return array(
                'count'       => (int) get_option('vmpfence_signature_count', 0),
                'last_sync'   => (int) get_option('vmpfence_last_signature_sync', 0),
                'has_premium'  => VMPFence_Config::get('scanner_license_tier', 'free') === 'premium',
                'file_format' => 'json',
            );
        }
        $raw = file_get_contents($meta_path);
        if ($raw === false) {
            return $defaults;
        }
        $meta = json_decode($raw, true);
        return is_array($meta) ? array_merge($defaults, $meta) : $defaults;
    }
    
    /**
     * Write metadata sidecar file
     * 
     * Called by sync pipeline AFTER the main signature file is successfully
     * written and renamed. Writes a tiny JSON file (~100 bytes) with count,
     * last_sync timestamp, has_premium flag, and file_format.
     *
     * @since 2.2.4
     * @param int    $count       Total number of individual signatures
     * @param string $tier        License tier ('free' or 'premium')
     * @param string $file_format Signature file format ('json' or 'php')
     * @return bool True on success, false on failure
     */
    public static function write_meta_file($count, $tier = 'free', $file_format = 'php') {
        $meta_path = plugin_dir_path(__FILE__) . '../data/malware-signatures-meta.json';
        $meta = array(
            'count'       => (int) $count,
            'last_sync'   => time(),
            'has_premium'  => ($tier === 'premium'),
            'file_format' => $file_format,
        );
        $json = wp_json_encode($meta);
        if ($json === false) {
            return false;
        }
        $result = file_put_contents($meta_path, $json, LOCK_EX);
        return ($result !== false);
    }

    /**
     * Reload signatures (for testing/debugging)
     */
    public function reload() {
        self::$signatures = null;
        $this->load_signatures();
    }
    
    /**
     * Ensure signature files exist on disk (for activation hook)
     * 
     * With lazy loading, get_instance() no longer triggers load_signatures()
     * which is the method that calls create_json_from_encoded() when the file
     * is missing. This method explicitly checks and creates the file if needed,
     * without loading the full 26 MB into memory.
     *
     * @since 2.2.4
     * @return bool True if signature file exists (or was created), false on failure
     */
    public function ensure_signatures_exist() {
        $php_file = plugin_dir_path(__FILE__) . '../data/malware-signatures.php';
        $json_file = $this->json_file_path;
        
        // If either file format exists, we're good
        if (file_exists($php_file) || file_exists($json_file)) {
            return true;
        }
        
        // Neither file exists — create from encoded data
        return $this->create_json_from_encoded();
    }
    
    /**
     * Sync signatures from server
     * 
     * @deprecated 2.2.3 Use VMPFence_Signature_Sync::sync_from_api() instead.
     *             This method loads ALL signatures into memory and will OOM on
     *             large databases (100K+). Kept only to avoid fatal errors if
     *             called from third-party code. Will be removed in 3.0.
     *
     * @since 1.0.0
     * @return bool True on success, false on failure
     */
    public function sync_from_server() {
        _deprecated_function(__METHOD__, '2.2.3', 'VMPFence_Signature_Sync::sync_from_api()');
        // Get API key from plugin settings
        // API key is stored in VMPFence_Config, not WordPress options
        $api_key = VMPFence_Config::get('central_api_key', '');
        if (empty($api_key)) {
            VMPFence_Secure_Logger::warning("Cannot sync signatures - no API key configured. Complete setup wizard or generate API key.");
            return false;
        }
        
        // Get API client
        require_once plugin_dir_path(__FILE__) . '../lib/class-api-client.php';
        $api_client = new VMPFence_API_Client();
        
        // Check current version
        $current_version = get_option('vmpfence_signature_version', '0.0.0');
        
        // Fetch signatures from server
        $response = $api_client->get_malware_signatures($api_key, $current_version);
        
        if (is_wp_error($response)) {
            VMPFence_Secure_Logger::error("Signature sync failed", ['error' => $response->get_error_message()]);
            return false;
        }
        
        // Check if we got new signatures
        if (isset($response['signatures']) && !empty($response['signatures'])) {
            // Build new signatures JSON
            $new_signatures = array(
                'version' => isset($response['version']) ? $response['version'] : '1.0.0',
                'last_updated' => current_time('mysql'),
                'signatures' => $response['signatures']
            );
            
            // Save to JSON file
            $json_content = wp_json_encode($new_signatures, JSON_PRETTY_PRINT);
            $result = file_put_contents($this->json_file_path, $json_content);
            
            if ($result === false) {
                VMPFence_Secure_Logger::error("Failed to write updated signatures to file");
                return false;
            }
            
            // Update version in options
            update_option('vmpfence_signature_version', $response['version']);
            update_option('vmpfence_last_signature_sync', time());
            update_option('vmpfence_signature_count', isset($response['total_count']) ? $response['total_count'] : count($response['signatures']));
            
            // Reload signatures in memory
            $this->reload();
            return true;
        }
        
        // No updates available or same version
        update_option('vmpfence_last_signature_sync', time());
        VMPFence_Secure_Logger::info("Signatures already up to date");
        return true;
    }
    
    /**
     * Schedule automatic signature sync
     * 
     * @deprecated 2.2.3 Use VMPFence_Signature_Scheduler::schedule_sync() instead.
     *             This method schedules the orphan 'vmpfence_sync_signatures' hook
     *             which has no registered callback. The active scheduler uses
     *             'vmpfence_hourly_signature_sync'. Will be removed in 3.0.
     *
     * @since 1.0.0
     * @return void
     */
    public function schedule_auto_sync() {
        _deprecated_function(__METHOD__, '2.2.3', 'VMPFence_Signature_Scheduler::schedule_sync()');
        if (!wp_next_scheduled('vmpfence_sync_signatures')) {
            // Get sync frequency from options (default: twice daily)
            $frequency = get_option('vmpfence_signature_sync_frequency', 'twicedaily');
            
            // Schedule the event
            wp_schedule_event(time(), $frequency, 'vmpfence_sync_signatures');
            VMPFence_Secure_Logger::info("Scheduled automatic signature sync", ['frequency' => $frequency]);
        }
    }
    
    /**
     * Unschedule automatic signature sync
     * 
     * @deprecated 2.2.3 Use VMPFence_Signature_Scheduler::unschedule_sync() instead.
     *             Will be removed in 3.0.
     *
     * @since 1.0.0
     * @return void
     */
    public function unschedule_auto_sync() {
        _deprecated_function(__METHOD__, '2.2.3', 'VMPFence_Signature_Scheduler::unschedule_sync()');
        $timestamp = wp_next_scheduled('vmpfence_sync_signatures');
        if ($timestamp) {
            wp_unschedule_event($timestamp, 'vmpfence_sync_signatures');
            VMPFence_Secure_Logger::info("Unscheduled automatic signature sync");
        }
    }
    
    /**
     * Get sync status information
     * 
     * Returns current signature version, last sync time, and count
     *
     * @since 1.0.0
     * @since 2.2.4 Updated to include next scheduled sync time and sync frequency from scheduler.
     * @return array Sync status data
     */
    public function get_sync_status() {
        // Use the active scheduler hook (vmpfence_hourly_signature_sync)
        // instead of the orphan legacy hook (vmpfence_sync_signatures)
        $next_sync = false;
        if (class_exists('VMPFence_Signature_Scheduler')) {
            $next_sync = VMPFence_Signature_Scheduler::get_next_sync_time();
        }

        return array(
            'version' => get_option('vmpfence_signature_version', 'Unknown'),
            'last_sync' => get_option('vmpfence_last_signature_sync', 0),
            'last_sync_human' => $this->get_last_sync_human(),
            'signature_count' => get_option('vmpfence_signature_count', 0),
            'next_sync' => $next_sync,
            'sync_frequency' => 'hourly' // Scheduler uses hourly interval
        );
    }
    
    /**
     * Get human-readable last sync time
     *
     * @since 1.0.0
     * @since 2.2.4 Updated to use time() for correct UTC comparison instead of current_time('timestamp').
     * @return string Human-readable time string
     */
    private function get_last_sync_human() {
        $last_sync = get_option('vmpfence_last_signature_sync', 0);
        if ($last_sync === 0) {
            return 'Never';
        }
        // Use time() (UTC) to match the UTC timestamp stored by sync_from_api().
        // current_time('timestamp') is local-time-shifted and would produce a
        // wrong "ago" offset equal to the site's GMT offset.
        return human_time_diff($last_sync, time()) . ' ago';
    }
}
