<?php
/**
 * VirtualPatcher — Auto-generates WAF rules for known CVEs before a patch ships.
 *
 * When a virtual patch is applied:
 *   1. A row is inserted into ultraguard_attack_patterns (firewall WAF table)
 *      with pattern_name = 'CVE-{id}_virtual_patch', source = 'virtual_patch'.
 *   2. A row is inserted into ultraguard_virtual_patches linking the CVE to the
 *      new WAF rule.
 *   3. The vulnerability result row is updated: status = 'virtual_patched',
 *      virtual_patch_active = 1.
 *
 * The cleanup cron in VulnerabilityModule removes expired virtual patches and
 * deactivates their WAF rules when the component is updated past the CVE fix.
 *
 * @package UltraguardPro\modules\vulnerability
 */

namespace UltraguardPro\modules\vulnerability;

use UltraguardPro\core\DatabaseCrud;
use UltraguardPro\core\Logger;
use UltraguardPro\modules\vulnerability\db\VirtualPatchesTable;
use UltraguardPro\modules\vulnerability\db\VulnerabilityResultsTable;

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

class VirtualPatcher {

    /** Default virtual patch duration (30 days) — overridden per-CVE if needed. */
    private const DEFAULT_EXPIRY_DAYS = 30;

    /** @var VulnerabilityResultsTable */
    private VulnerabilityResultsTable $vuln_table;

    /** @var VirtualPatchesTable */
    private VirtualPatchesTable $patch_table;

    public function __construct(
        VulnerabilityResultsTable $vuln_table,
        VirtualPatchesTable $patch_table
    ) {
        $this->vuln_table  = $vuln_table;
        $this->patch_table = $patch_table;
    }

    // ── Public API ─────────────────────────────────────────────────────────────

    /**
     * Generate WAF rule data for a given vulnerability record.
     *
     * The rule uses a generic path+param scanner pattern. For well-known CVEs
     * this can be specialised; for unknown ones we fall back to a broad
     * component-name probe blocker.
     *
     * @param  array $vulnerability  Row from ultraguard_vulnerability_results.
     * @return array  WAF rule fields ready for insert into ultraguard_attack_patterns.
     */
    public function generate_rules_for_cve( array $vulnerability ): array {
        $cve_id  = sanitize_text_field( $vulnerability['cve_id']         ?? 'unknown' );
        $slug    = sanitize_text_field( $vulnerability['component_slug'] ?? '' );
        $type    = sanitize_text_field( $vulnerability['component_type'] ?? 'plugin' );
        $title   = sanitize_text_field( $vulnerability['title']          ?? '' );
        $severity = $vulnerability['severity'] ?? 'medium';

        // Build a conservative WAF pattern that blocks probe requests targeting
        // the component's path. This is intentionally broad — it blocks
        // any request whose query string or path contains the plugin/theme slug
        // combined with common exploit probe parameters.
        $slug_escaped = preg_quote( $slug, '/' );
        $pattern      = '/(?:' . $slug_escaped . '[^"\']*(?:eval|base64_decode|system|passthru|exec|phpinfo)|'
                      . '(?:wp-content\/(?:plugins|themes)\/' . $slug_escaped . ').*(?:\.\.|\/etc\/passwd|cmd=|shell=))/i';

        return [
            'pattern_name'    => 'CVE-' . $cve_id . '_virtual_patch',
            'pattern_type'    => 'regex',
            'pattern_value'   => $pattern,
            'attack_category' => 'virtual_patch',
            'severity'        => $severity,
            'is_active'       => 1,
        ];
    }

    /**
     * Apply a virtual patch for a vulnerability: create the WAF rule and link it.
     *
     * Idempotent — if a patch already exists for this CVE it is returned unchanged.
     *
     * @param  array $vulnerability  Row from ultraguard_vulnerability_results.
     * @return bool  True on success.
     */
    public function apply_virtual_patch( array $vulnerability ): bool {
        $cve_id   = sanitize_text_field( $vulnerability['cve_id']         ?? '' );
        $slug     = sanitize_text_field( $vulnerability['component_slug'] ?? '' );
        $vuln_id  = (int) ( $vulnerability['id'] ?? 0 );

        if ( ! $cve_id || ! $slug ) {
            Logger::warning( '[VirtualPatcher] apply_virtual_patch: missing cve_id or slug' );
            return false;
        }

        // Check for existing patch
        $existing = DatabaseCrud::get_row( 'ultraguard_virtual_patches', [ 'cve_id' => $cve_id ] );
        if ( $existing ) {
            return true; // Already patched
        }

        // 1. Insert WAF rule into attack_patterns
        $rule_data = $this->generate_rules_for_cve( $vulnerability );
        $waf_id    = DatabaseCrud::insert( 'ultraguard_attack_patterns', $rule_data );

        if ( ! $waf_id ) {
            Logger::error( '[VirtualPatcher] Failed to insert WAF rule', [ 'cve_id' => $cve_id ] );
            return false;
        }

        // 2. Insert virtual_patches row
        $version_range = $vulnerability['component_version'] ?? null;
        $expires_at    = gmdate( 'Y-m-d H:i:s', strtotime( '+' . self::DEFAULT_EXPIRY_DAYS . ' days' ) );

        $patch_id = $this->patch_table->insert( [
            'cve_id'                  => $cve_id,
            'component_slug'          => $slug,
            'component_version_range' => $version_range,
            'waf_rule_id'             => $waf_id,
            'applied_at'              => gmdate( 'Y-m-d H:i:s' ),
            'expires_at'              => $expires_at,
        ] );

        if ( ! $patch_id ) {
            // Roll back the WAF rule
            DatabaseCrud::delete( 'ultraguard_attack_patterns', [ 'id' => $waf_id ] );
            Logger::error( '[VirtualPatcher] Failed to insert virtual_patches row', [ 'cve_id' => $cve_id ] );
            return false;
        }

        // 3. Update vulnerability result status
        if ( $vuln_id ) {
            $this->vuln_table->update( $vuln_id, [
                'status'               => 'virtual_patched',
                'virtual_patch_active' => 1,
            ] );
        }

        do_action( 'ultraguard_virtual_patch_applied', $cve_id, $slug, $waf_id );

        Logger::info( '[VirtualPatcher] Virtual patch applied', [
            'cve_id'     => $cve_id,
            'slug'       => $slug,
            'waf_rule_id' => $waf_id,
        ] );

        return true;
    }

    /**
     * Remove a virtual patch when the component has been updated past the fix version.
     *
     * Deactivates the WAF rule (sets is_active=0) and removes the patch record.
     *
     * @param  string $cve_id       CVE identifier.
     * @param  string $new_version  New installed version (must be >= patched_in_version).
     */
    public function remove_patch_when_fixed( string $cve_id, string $new_version ): void {
        $patch = DatabaseCrud::get_row( 'ultraguard_virtual_patches', [ 'cve_id' => $cve_id ] );
        if ( ! $patch ) {
            return;
        }

        $patch = (array) $patch;

        // Deactivate the WAF rule
        if ( ! empty( $patch['waf_rule_id'] ) ) {
            DatabaseCrud::update(
                'ultraguard_attack_patterns',
                [ 'is_active' => 0 ],
                [ 'id' => (int) $patch['waf_rule_id'] ]
            );
        }

        // Delete the patch record
        DatabaseCrud::delete( 'ultraguard_virtual_patches', [ 'cve_id' => $cve_id ] );

        // Update vulnerability result
        DatabaseCrud::update(
            'ultraguard_vulnerability_results',
            [ 'status' => 'patched', 'virtual_patch_active' => 0 ],
            [ 'cve_id' => $cve_id ]
        );

        do_action( 'ultraguard_virtual_patch_removed', $cve_id, $new_version );

        Logger::info( '[VirtualPatcher] Virtual patch removed (component updated)', [
            'cve_id'      => $cve_id,
            'new_version' => $new_version,
        ] );
    }

    /**
     * Return all active (non-expired) virtual patches with their linked WAF rule info.
     *
     * @return array
     */
    public function get_active_patches(): array {
        return $this->patch_table->get_active_patches();
    }

    /**
     * Sync patches from the API: applies virtual patches for all open critical/high
     * vulnerabilities that don't yet have a patch and have no available fix.
     *
     * @return int  Count of new patches applied.
     */
    public function sync_patches_from_api(): int {
        $open_vulns = $this->vuln_table->get_by(
            [ 'status' => 'open', 'is_exploitable' => 1 ],
            200,
            'cvss_score',
            'DESC'
        );

        $applied = 0;
        foreach ( $open_vulns as $vuln ) {
            $vuln = (array) $vuln;

            // Only auto-patch high/critical with no available fix version
            if ( ! in_array( $vuln['severity'] ?? '', [ 'high', 'critical' ], true ) ) {
                continue;
            }
            if ( ! empty( $vuln['patched_in_version'] ) ) {
                continue; // Update is available — don't auto-patch, notify instead
            }
            if ( empty( $vuln['cve_id'] ) ) {
                continue;
            }

            if ( $this->apply_virtual_patch( $vuln ) ) {
                $applied++;
            }
        }

        return $applied;
    }

    /**
     * Remove all expired virtual patches (expires_at < NOW()).
     * Called by the daily cron.
     */
    public function cleanup_expired_patches(): void {
        $expired = DatabaseCrud::get_records_before_datetime(
            'ultraguard_virtual_patches',
            'expires_at',
            gmdate( 'Y-m-d H:i:s' ),
            [],
            500,
            'expires_at',
            'ASC',
            ARRAY_A,
            true
        );

        foreach ( $expired as $patch ) {
            if ( ! empty( $patch['waf_rule_id'] ) ) {
                DatabaseCrud::update(
                    'ultraguard_attack_patterns',
                    [ 'is_active' => 0 ],
                    [ 'id' => (int) $patch['waf_rule_id'] ]
                );
            }
            DatabaseCrud::delete( 'ultraguard_virtual_patches', [ 'id' => (int) $patch['id'] ] );

            // Revert vuln status back to open so admin is re-notified
            DatabaseCrud::update(
                'ultraguard_vulnerability_results',
                [ 'status' => 'open', 'virtual_patch_active' => 0 ],
                [ 'cve_id' => $patch['cve_id'] ]
            );
        }

        if ( count( $expired ) > 0 ) {
            Logger::info( '[VirtualPatcher] Cleaned up expired patches', [ 'count' => count( $expired ) ] );
        }
    }
}
