<?php
/**
 * AntivirusAjax — Standalone AJAX handler class for the Antivirus module.
 *
 * Every handler follows the same security pattern:
 *   1. check_ajax_referer()
 *   2. current_user_can( 'manage_options' )
 *   3. Sanitise input
 *   4. Business logic
 *   5. wp_send_json_success / wp_send_json_error
 *
 * Registered wp_ajax_ actions:
 *   ultraguard_antivirus_start_scan
 *   ultraguard_antivirus_scan_status
 *   ultraguard_antivirus_stop_scan
 *   ultraguard_antivirus_check_active_scan
 *   ultraguard_antivirus_get_results
 *   ultraguard_antivirus_quarantine_file
 *   ultraguard_antivirus_restore_file
 *   ultraguard_antivirus_delete_quarantine
 *   ultraguard_antivirus_mark_false_positive
 *   ultraguard_antivirus_get_quarantine
 *   ultraguard_antivirus_get_vulnerabilities
 *   ultraguard_antivirus_run_vuln_scan
 *   ultraguard_antivirus_patch_vulnerability
 *   ultraguard_antivirus_ignore_vulnerability
 *   ultraguard_antivirus_update_signatures
 *   ultraguard_antivirus_export_report
 *   ultraguard_antivirus_clear_results
 *   ultraguard_save_antivirus_settings
 *   ultraguard_antivirus_scan_single_file
 *
 * @package UltraguardPro\modules\antivirus\ajax
 */

namespace UltraguardPro\modules\antivirus\ajax;

use UltraguardPro\core\DatabaseCrud;

use UltraguardPro\Plugin;
use UltraguardPro\modules\antivirus\AntivirusEngine;
use UltraguardPro\modules\core\ModuleAjaxSettingsTrait;
use UltraguardPro\support\AjaxResponseBuilder;

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

// phpcs:disable WordPress.Security.NonceVerification.Missing -- Antivirus AJAX handlers verify nonce centrally via $this->verify().

class AntivirusAjax {

    use ModuleAjaxSettingsTrait;

    private const NONCE_ACTION = 'ultraguard_admin_nonce';

    private Plugin $plugin;

    public function __construct( Plugin $plugin ) {
        $this->plugin = $plugin;
    }

    /**
     * Register all AJAX actions via wp_ajax_{action}.
     */
    public function register(): void {
        $actions = [
            'ultraguard_antivirus_start_scan'          => 'handle_start_scan',
            'ultraguard_antivirus_scan_chunk'          => 'handle_scan_chunk',
            'ultraguard_antivirus_scan_status'         => 'handle_scan_status',
            'ultraguard_antivirus_stop_scan'           => 'handle_stop_scan',
            'ultraguard_antivirus_check_active_scan'   => 'handle_check_active_scan',
            'ultraguard_antivirus_get_results'         => 'handle_get_results',
            'ultraguard_antivirus_quarantine_file'     => 'handle_quarantine_file',
            'ultraguard_antivirus_restore_file'        => 'handle_restore_file',
            'ultraguard_antivirus_delete_quarantine'   => 'handle_delete_quarantine',
            'ultraguard_antivirus_mark_false_positive' => 'handle_mark_false_positive',
            'ultraguard_antivirus_get_quarantine'      => 'handle_get_quarantine',
            'ultraguard_antivirus_get_vulnerabilities' => 'handle_get_vulnerabilities',
            'ultraguard_antivirus_run_vuln_scan'       => 'handle_run_vuln_scan',
            'ultraguard_antivirus_patch_vulnerability' => 'handle_patch_vulnerability',
            'ultraguard_antivirus_ignore_vulnerability'=> 'handle_ignore_vulnerability',
            'ultraguard_antivirus_update_signatures'   => 'handle_update_signatures',
            'ultraguard_antivirus_export_report'       => 'handle_export_report',
            'ultraguard_antivirus_clear_results'       => 'handle_clear_results',
            'ultraguard_save_antivirus_settings'       => 'handle_save_settings',
            'ultraguard_antivirus_scan_single_file'    => 'handle_scan_single_file',
            // Section A — Malware Cleaner
            'ultraguard_antivirus_clean_file'          => 'handle_clean_file',
            // Row-level record mutations (table action buttons)
            'ultraguard_antivirus_delete_scan_result'     => 'handle_delete_scan_result',
            'ultraguard_antivirus_edit_signature'         => 'handle_edit_signature',
            'ultraguard_antivirus_delete_signature'       => 'handle_delete_signature',
            'ultraguard_antivirus_edit_quarantine_notes'  => 'handle_edit_quarantine_notes',
            // Legacy action aliases for backward-compat
            'ultraguard_start_scan'                    => 'handle_start_scan',
            'ultraguard_scan_chunk'                    => 'handle_scan_chunk',
            'ultraguard_get_scan_status'               => 'handle_scan_status',
            'ultraguard_stop_scan'                     => 'handle_stop_scan',
            'ultraguard_quarantine_file'               => 'handle_quarantine_file',
            'ultraguard_restore_file'                  => 'handle_restore_file',
            'ultraguard_update_signatures'             => 'handle_update_signatures',
            'ultraguard_scan_single_file'              => 'handle_scan_single_file',
            'ultraguard_export_scan_report'            => 'handle_export_report',
            'ultraguard_check_active_scan'             => 'handle_check_active_scan',
            // ── Realtime fallback polling (used by ultraguard-realtime.js when SSE unavailable) ──
            'ultraguard_rt_antivirus_status'           => 'handle_rt_antivirus_status',
            'ultraguard_rt_file_scanner_status'        => 'handle_rt_file_scanner_status',
        ];

        foreach ( $actions as $action => $method ) {
            add_action( 'wp_ajax_' . $action, [ $this, $method ] );
        }
    }

    // ── Scan management ───────────────────────────────────────────────────────

    /**
     * Start a new scan session.
     */
    public function handle_start_scan(): void {
        $this->verify();

        // Free plan: enforce 48-hour cooldown between manual scans
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            $last_scan = (int) get_option( 'ultraguard_av_last_manual_scan', 0 );
            $cooldown  = 48 * HOUR_IN_SECONDS;
            if ( $last_scan && ( time() - $last_scan ) < $cooldown ) {
                $next_available = $last_scan + $cooldown;
                $diff           = $next_available - time();
                $hours          = (int) floor( $diff / HOUR_IN_SECONDS );
                $minutes        = (int) floor( ( $diff % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS );
                $time_str       = $hours > 0
                    /* translators: %1$d: integer, %2$d: integer */
                    ? sprintf( __( '%1$dh %2$dm', 'ultraguard-security-suite' ), $hours, $minutes )
                    /* translators: %d: integer */
                    : sprintf( __( '%dm', 'ultraguard-security-suite' ), $minutes );

                \UltraguardPro\support\AjaxResponseBuilder::make()
                    ->error( sprintf(
                        /* translators: Placeholder values are inserted into this string. */
                        __( 'Free plan scans are limited to once every 48 hours. Next scan available in: %s. Upgrade to Pro for unlimited scheduled scans.', 'ultraguard-security-suite' ),
                        $time_str
                    ), 'scan_cooldown' )
                    ->addNotification(
                        /* translators: %s: string */
                        sprintf( __( 'Next scan available in %s. Upgrade to Pro to scan anytime.', 'ultraguard-security-suite' ), $time_str ),
                        'warning',
                        8000
                    )
                    ->addData( 'upgrade_url', \UltraguardPro\support\UpgradeManager::upgrade_page_url() )
                    ->addData( 'next_scan_in_seconds', $diff )
                    ->send( 429 );
                return;
            }
            // Record this scan attempt so the cooldown can be enforced going forward
            update_option( 'ultraguard_av_last_manual_scan', time(), false );
        }

        $scan_type   = sanitize_key( wp_unslash( $_POST['scan_type']   ?? 'quick'  ) );
        $custom_path = isset( $_POST['custom_path'] )
            ? sanitize_text_field( wp_unslash( $_POST['custom_path'] ) )
            : '';

        // Validate scan type
        if ( ! in_array( $scan_type, [ 'quick', 'full', 'custom', 'scheduled' ], true ) ) {
            $scan_type = 'quick';
        }

        $scan_id = 'scan_' . time() . '_' . wp_generate_password( 8, false );

        // Guard: refuse to start if a scan is already running
        $existing = get_option( 'ultraguard_active_scan', [] );
        if ( ! empty( $existing['scan_id'] ) && ( $existing['status'] ?? '' ) === 'running' ) {
            AjaxResponseBuilder::make()->error( __( 'A scan is already in progress. Stop it first.', 'ultraguard-security-suite' ) )->addNotification( __( 'A scan is already in progress.', 'ultraguard-security-suite' ), 'warning' )->send();
            return;
        }

        $scan_table = $this->get_table( 'scan_results' );
        $sig_table  = $this->get_table( 'malware_signatures' );
        $quar_table = $this->get_table( 'quarantine' );

        if ( ! $scan_table || ! $sig_table || ! $quar_table ) {
            AjaxResponseBuilder::make()->error( __( 'DB tables unavailable.', 'ultraguard-security-suite' ) )->addNotification( __( 'DB tables unavailable.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        $engine = new AntivirusEngine(
            $scan_table,
            $sig_table,
            $quar_table,
            $this->get_engine_settings(),
            $scan_id
        );
        $engine->start_chunked_scan( [
            'scan_id'     => $scan_id,
            'scan_type'   => $scan_type,
            'custom_path' => $custom_path,
        ] );

        update_option( 'ultraguard_active_scan', [
            'scan_id'       => $scan_id,
            'scan_type'     => $scan_type,
            'custom_path'   => $custom_path,
            'status'        => 'running',
            'started_at'    => current_time( 'mysql', true ),
            'progress'      => 0,
            'current_file'  => '',
            'files_scanned' => 0,
            'threats_found' => 0,
        ], false );

        wp_schedule_single_event( time() + 2, 'ultraguard_av_scan_chunk' );

        // Force WP-Cron to fire immediately — critical on local/low-traffic sites
        // where the scheduled event would otherwise only run on the next page visit.
        if ( function_exists( 'spawn_cron' ) ) {
            spawn_cron();
        }

        do_action( 'ultraguard_antivirus_scan_started', $scan_id, $scan_type );

        /* translators: %s: string */
        $start_msg = sprintf( __( '%s scan started successfully.', 'ultraguard-security-suite' ), ucfirst( $scan_type ) );
        AjaxResponseBuilder::make()
            ->success( $start_msg )
            ->addData( 'scan_id', $scan_id )
            ->addNotification( $start_msg, 'info' )
            ->send();
    }

    /**
     * Process one scan chunk directly from the browser polling loop.
     */
    public function handle_scan_chunk(): void {
        $this->verify();

        $scan_table = $this->get_table( 'scan_results' );
        $sig_table  = $this->get_table( 'malware_signatures' );
        $quar_table = $this->get_table( 'quarantine' );

        if ( ! $scan_table || ! $sig_table || ! $quar_table ) {
            AjaxResponseBuilder::make()->error( __( 'DB tables unavailable.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        $scan_id = (string) ( get_option( 'ultraguard_active_scan', [] )['scan_id'] ?? '' );
        $engine  = new AntivirusEngine( $scan_table, $sig_table, $quar_table, $this->get_engine_settings(), $scan_id );
        $result  = $engine->process_chunk();
        $state   = $engine->get_progress();
        $total   = (int) ( $state['total'] ?? 0 );
        $done    = ! empty( $result['done'] );
        $pct     = $done ? 100 : ( $total > 0 ? min( 99, (int) floor( ( (int) ( $state['scanned'] ?? 0 ) / $total ) * 100 ) ) : 0 );

        update_option( 'ultraguard_active_scan', array_merge( get_option( 'ultraguard_active_scan', [] ), [
            'status'        => $done ? 'complete' : 'running',
            'progress'      => $pct,
            'files_scanned' => (int) ( $state['scanned'] ?? 0 ),
            'threats_found' => (int) ( $state['found'] ?? 0 ),
            'current_file'  => (string) ( $state['current_file'] ?? '' ),
            'total_files'   => $total,
            'completed_at'  => $done ? current_time( 'mysql', true ) : null,
        ] ), false );

        AjaxResponseBuilder::make()->success()->mergeData( get_option( 'ultraguard_active_scan', [] ) )->send();
    }

    /**
     * Return the status and progress of an active or recent scan.
     */
    public function handle_scan_status(): void {
        $this->verify();

        $scan_id     = sanitize_text_field( wp_unslash( $_POST['scan_id'] ?? '' ) );
        $active_scan = get_option( 'ultraguard_active_scan', [] );
        $scan_table  = $this->get_table( 'scan_results' );
        $sig_table   = $this->get_table( 'malware_signatures' );
        $quar_table  = $this->get_table( 'quarantine' );

        if ( $scan_table && $sig_table && $quar_table ) {
            $engine   = new AntivirusEngine( $scan_table, $sig_table, $quar_table, $this->get_engine_settings(), (string) ( $active_scan['scan_id'] ?? '' ) );
            $progress = $engine->get_progress();

            if ( is_array( $progress ) && ! empty( $progress['scan_id'] ) ) {
                $total       = (int) ( $progress['total'] ?? 0 );
                $active_scan = array_merge( $active_scan, [
                    'status'        => (string) ( $progress['status'] ?? ( $active_scan['status'] ?? 'idle' ) ),
                    'files_scanned' => (int) ( $progress['scanned'] ?? 0 ),
                    'threats_found' => (int) ( $progress['found'] ?? 0 ),
                    'current_file'  => (string) ( $progress['current_file'] ?? '' ),
                    'total_files'   => $total,
                    'progress'      => ( $active_scan['status'] ?? '' ) === 'complete'
                        ? 100
                        : ( $total > 0 ? min( 100, (int) floor( ( (int) ( $progress['scanned'] ?? 0 ) / $total ) * 100 ) ) : (int) ( $active_scan['progress'] ?? 0 ) ),
                ] );
            }
        }

        if ( $scan_id && ( $active_scan['scan_id'] ?? '' ) !== $scan_id ) {
            // Scan was stopped and the option deleted — return a clean cancelled
            // signal so JS stops polling gracefully instead of hitting an error.
            AjaxResponseBuilder::make()
                ->success()
                ->addData( 'scan_id',  $scan_id )
                ->addData( 'status',   'cancelled' )
                ->addData( 'progress', 0 )
                ->send();
            return;
        }

        // Auto-cancel stale scans: running + started >10 min ago + still at <=5% progress
        $active_scan = $this->maybe_cancel_stale_scan( $active_scan );

        AjaxResponseBuilder::make()->success()->mergeData( (array) $active_scan )->send();
    }

    /**
     * Stop a running scan and fully clear the active-scan record.
     *
     * We delete the option entirely rather than just setting status='cancelled'
     * for three reasons:
     *   1. Any subsequent get_option call (cron, another AJAX) returns [] so
     *      nothing can accidentally overwrite cancelled back to running.
     *   2. process_scan() reads [] → scan_id mismatch → exits immediately.
     *   3. PHP page render reads [] → $is_running = false → panel stays hidden
     *      on every future page load without extra stale-detection logic.
     */
    public function handle_stop_scan(): void {
        $this->verify();

        $scan_id     = sanitize_text_field( wp_unslash( $_POST['scan_id'] ?? '' ) );
        $active_scan = get_option( 'ultraguard_active_scan', [] );
        $scan_table  = $this->get_table( 'scan_results' );
        $sig_table   = $this->get_table( 'malware_signatures' );
        $quar_table  = $this->get_table( 'quarantine' );

        // Match: no scan_id supplied (cancel anything), or the id matches.
        $matches = empty( $scan_id )
            || ( $active_scan['scan_id'] ?? '' ) === $scan_id;

        if ( $matches && ! empty( $active_scan ) ) {
            if ( $scan_table && $sig_table && $quar_table ) {
                $engine = new AntivirusEngine( $scan_table, $sig_table, $quar_table, $this->get_engine_settings(), (string) ( $active_scan['scan_id'] ?? '' ) );
                $engine->cancel_scan();
            }

            // Delete the option completely so no stale data can resurface.
            delete_option( 'ultraguard_active_scan' );

            // Bust persistent object-cache (Redis/Memcached) explicitly.
            // update_option normally handles this, but delete_option does not
            // always propagate to all cache layers on every host configuration.
            wp_cache_delete( 'ultraguard_active_scan', 'options' );
            wp_cache_delete( 'notoptions', 'options' ); // flush "not found" cache
        }

        // Always return success — if there was nothing to stop, that's fine too.
        AjaxResponseBuilder::make()->success( __( 'Scan stopped.', 'ultraguard-security-suite' ) )->addNotification( __( 'Scan stopped.', 'ultraguard-security-suite' ), 'info' )->send();
    }

    /**
     * Return whether a scan is currently active.
     */
    public function handle_check_active_scan(): void {
        $this->verify();

        $active = get_option( 'ultraguard_active_scan', [] );
        // Apply stale detection before reporting to JS
        $active    = $this->maybe_cancel_stale_scan( $active );
        $is_active = ! empty( $active['scan_id'] ) && ( $active['status'] ?? '' ) === 'running';

        AjaxResponseBuilder::make()
            ->success()
            ->addData( 'active_scan', $is_active )
            ->addData( 'scan_id',     $is_active ? $active['scan_id'] : null )
            ->addData( 'scan_data',   $is_active ? $active            : null )
            ->send();
    }

    /**
     * Scan a single file on-demand and return the result.
     */
    public function handle_scan_single_file(): void {
        $this->verify();

        $path = sanitize_text_field( wp_unslash( $_POST['file_path'] ?? '' ) );

        if ( ! $path || ! file_exists( $path ) ) {
            AjaxResponseBuilder::make()->error( __( 'File not found.', 'ultraguard-security-suite' ) )->addNotification( __( 'File not found.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        // Path traversal guard — must be inside ABSPATH
        $realpath = realpath( $path );
        $absbase  = realpath( ABSPATH );
        if ( ! $realpath || ! str_starts_with( $realpath, $absbase ) ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid file path.', 'ultraguard-security-suite' ) )->addNotification( __( 'Invalid file path.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        $scan_table = $this->get_table( 'scan_results' );
        $sig_table  = $this->get_table( 'malware_signatures' );
        $quar_table = $this->get_table( 'quarantine' );

        if ( ! $scan_table || ! $sig_table || ! $quar_table ) {
            AjaxResponseBuilder::make()->error( __( 'DB tables unavailable.', 'ultraguard-security-suite' ) )->addNotification( __( 'DB tables unavailable.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        $engine = new AntivirusEngine(
            $scan_table,
            $sig_table,
            $quar_table,
            $this->get_engine_settings(),
            'single_' . time()
        );

        $result = $engine->scan_single_file( $realpath );
        AjaxResponseBuilder::make()->success()->mergeData( (array) $result )->send();
    }

    // ── Results ───────────────────────────────────────────────────────────────

    /**
     * Paginated scan results, optionally filtered by status and scan_id.
     */
    public function handle_get_results(): void {
        $this->verify();

        $table   = $this->get_table( 'scan_results' );
        $page    = max( 1, absint( $_POST['page']    ?? 1 ) );
        $status  = sanitize_key( wp_unslash( $_POST['status']   ?? ''  ) );
        $scan_id = sanitize_text_field( wp_unslash( $_POST['scan_id'] ?? '' ) );

        if ( ! $table ) {
            AjaxResponseBuilder::make()->success()->addData( 'records', [] )->send();
            return;
        }

        $conds = [];
        if ( $status  ) { $conds['status']  = $status;  }
        if ( $scan_id ) { $conds['scan_id'] = $scan_id; }

        AjaxResponseBuilder::make()->success()->mergeData( (array) $table->paginate( $page, 50, $conds, 'created_at', 'DESC' ) )->send();
    }

    /**
     * Mark a scan result as a false positive.
     */
    public function handle_mark_false_positive(): void {
        $this->verify();

        $id       = absint( $_POST['result_id'] ?? 0 );
        $modal_id = sanitize_key( wp_unslash( $_POST['modal_id'] ?? ''  ) );
        $table    = $this->get_table( 'scan_results' );

        if ( ! $id ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid result ID.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( ! $table || ! $table->mark_as_false_positive( $id ) ) {
            AjaxResponseBuilder::make()->error( __( 'Could not update record.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        $row_sel     = '#ug-row-' . $id;
        $response = AjaxResponseBuilder::make()
            ->success( __( 'Marked as false positive.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'Marked as false positive.', 'ultraguard-security-suite' ), 'success' )
            ->addRemove( $row_sel, true, 250 );

        if ( '' !== $modal_id ) {
            $response->closeModal( $modal_id );
        }

        $this->apply_antivirus_dom_updates( $response );
        $response->send();
    }

    /**
     * Delete all clean scan records to free database space.
     */
    public function handle_clear_results(): void {
        $this->verify();

        // Route through DatabaseCrud — handles prepared statements + cache invalidation.
        DatabaseCrud::delete( 'ultraguard_scan_results', [ 'status' => 'clean' ] );

        AjaxResponseBuilder::make()
            ->success( __( 'Clean results cleared.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'Clean scan results cleared successfully.', 'ultraguard-security-suite' ), 'success' )
            ->addReload( 1500 )
            ->send();
    }

    // ── Quarantine ────────────────────────────────────────────────────────────

    /**
     * Move an infected file to quarantine.
     */
    public function handle_quarantine_file(): void {
        $this->verify();

        $result_id  = absint( $_POST['result_id'] ?? 0 );
        $modal_id   = sanitize_key( wp_unslash( $_POST['modal_id'] ?? ''  ) );
        $scan_table = $this->get_table( 'scan_results' );
        $quar_table = $this->get_table( 'quarantine' );

        if ( ! $result_id || ! $scan_table || ! $quar_table ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid request.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        $result = $scan_table->get( $result_id );
        if ( empty( $result['file_path'] ) ) {
            AjaxResponseBuilder::make()->error( __( 'Scan result not found.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        $src  = $result['file_path'];
        $dest = $this->make_quarantine_path( $src );

        if ( ! file_exists( $src ) ) {
            // File already gone — mark the record and update the row UI
            $scan_table->update( $result_id, [ 'status' => 'quarantined' ] );
            $row_sel = '#ug-row-' . $result_id;
            $response = AjaxResponseBuilder::make()
                ->success( __( 'File already moved — record updated.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Source file not found. Record marked as quarantined.', 'ultraguard-security-suite' ), 'warning' )
                ->addRemove( $row_sel, true, 250 );

            if ( '' !== $modal_id ) {
                $response->closeModal( $modal_id );
            }

            $this->apply_antivirus_dom_updates( $response );
            $response->send();
            return;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic move into quarantine within the local filesystem.
        if ( ! @rename( $src, $dest ) ) {
            AjaxResponseBuilder::make()
                ->error( __( 'Could not move file to quarantine. Check file permissions.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Quarantine failed — check server file permissions.', 'ultraguard-security-suite' ), 'error' )
                ->send();
            return;
        }

        $quar_table->insert( [
            'original_path'   => $src,
            'quarantine_path' => $dest,
            'file_hash'       => $result['file_hash']    ?? '',
            'file_size'       => (int) ( $result['file_size'] ?? 0 ),
            'threat_type'     => $result['threat_type']  ?? '',
            'threat_level'    => $result['threat_level'] ?? 'medium',
        ] );

        $scan_table->update( $result_id, [ 'status' => 'quarantined' ] );

        // Two tables were mutated — flush both so any subsequent page load or
        // AJAX poll sees the updated counts and statuses immediately.
        $scan_table->clear_all_caches();
        $quar_table->clear_all_caches();

        do_action( 'ultraguard_antivirus_file_quarantined', $src, $dest );

        $row_sel = '#ug-row-' . $result_id;
        $response = AjaxResponseBuilder::make()
            ->success( __( 'File quarantined successfully.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'File quarantined successfully.', 'ultraguard-security-suite' ), 'success' )
            ->addRemove( $row_sel, true, 250 );

        if ( '' !== $modal_id ) {
            $response->closeModal( $modal_id );
        }

        $this->apply_antivirus_dom_updates( $response );
        $response->send();
    }

    /**
     * Restore a quarantined file to its original location.
     */
    public function handle_restore_file(): void {
        $this->verify();

        $id     = absint( $_POST['quarantine_id'] ?? 0 );
        $reason = sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) );
        $table  = $this->get_table( 'quarantine' );

        if ( ! $id || ! $table ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid request.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        $item = $table->get( $id );
        if ( empty( $item['quarantine_path'] ) ) {
            AjaxResponseBuilder::make()->error( __( 'Quarantine record not found.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( ! file_exists( $item['quarantine_path'] ) ) {
            $table->restore_file( $id, $reason );
            $row_sel = '#ug-row-' . $id;
            AjaxResponseBuilder::make()
                ->success( __( 'File not found on disk — record updated.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Quarantined file not found on disk. Record marked as restored.', 'ultraguard-security-suite' ), 'warning' )
                ->addUpdate( $row_sel . ' .ultraguard-cell-status',
                    '<span class="ultraguard-cell-inner success">' . esc_html__( 'Restored', 'ultraguard-security-suite' ) . '</span>'
                )
                ->emptyElement( $row_sel . ' .ultraguard-cell-actions' )
                ->send();
            return;
        }

        // Ensure target directory exists
        $target_dir = dirname( $item['original_path'] );
        if ( ! is_dir( $target_dir ) ) {
            AjaxResponseBuilder::make()
                ->error( __( 'Target directory does not exist. Cannot restore.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Restore failed — target directory missing.', 'ultraguard-security-suite' ), 'error' )
                ->send();
            return;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic restore from quarantine within the local filesystem.
        if ( ! @rename( $item['quarantine_path'], $item['original_path'] ) ) {
            AjaxResponseBuilder::make()
                ->error( __( 'Could not restore file. Check directory permissions.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Restore failed — check directory permissions.', 'ultraguard-security-suite' ), 'error' )
                ->send();
            return;
        }

        $table->restore_file( $id, $reason );

        // restore_file() calls update() which triggers invalidate_caches_after_update,
        // but flush explicitly here too so the stat cards on the quarantine page
        // refresh without waiting for the TTL window.
        $table->clear_all_caches();

        do_action( 'ultraguard_antivirus_file_restored', $item['original_path'] );

        $row_sel = '#ug-row-' . $id;
        AjaxResponseBuilder::make()
            ->success( __( 'File restored to its original location.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'File restored successfully.', 'ultraguard-security-suite' ), 'success' )
            ->addUpdate( $row_sel . ' .ultraguard-cell-status',
                '<span class="ultraguard-cell-inner success">' . esc_html__( 'Restored', 'ultraguard-security-suite' ) . '</span>'
            )
            ->emptyElement( $row_sel . ' .ultraguard-cell-actions' )
            ->send();
    }

    /**
     * Permanently delete a quarantined file from disk and mark record as deleted.
     */
    public function handle_delete_quarantine(): void {
        $this->verify();

        $id    = absint( $_POST['quarantine_id'] ?? 0 );
        $table = $this->get_table( 'quarantine' );

        if ( ! $id || ! $table ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid request.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( ! $table->permanently_delete( $id ) ) {
            AjaxResponseBuilder::make()
                ->error( __( 'Delete failed. File may already be deleted.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Delete failed — file may already be removed.', 'ultraguard-security-suite' ), 'error' )
                ->send();
            return;
        }

        // Slide the entire row out of the table — no page reload needed
        AjaxResponseBuilder::make()
            ->success( __( 'File permanently deleted.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'File permanently deleted.', 'ultraguard-security-suite' ), 'success' )
            ->addRemove( '#ug-row-' . $id, true, 400 )
            ->send();
    }

    /**
     * Paginated quarantine list with stats.
     */
    public function handle_get_quarantine(): void {
        $this->verify();

        $table  = $this->get_table( 'quarantine' );
        $page   = max( 1, absint( $_POST['page']   ?? 1 ) );
        $status = sanitize_key( wp_unslash( $_POST['status'] ?? 'active'  ) );

        if ( ! $table ) {
            AjaxResponseBuilder::make()->success()->addData( 'records', [] )->addData( 'stats', [] )->send();
            return;
        }

        $result          = $table->paginate( $page, 50, [ 'status' => $status ], 'created_at', 'DESC' );
        $result['stats'] = $table->get_quarantine_stats();

        AjaxResponseBuilder::make()->success()->mergeData( (array) $result )->send();
    }

    // ── Vulnerabilities ───────────────────────────────────────────────────────

    /**
     * Paginated vulnerability list with summary counts.
     */
    public function handle_get_vulnerabilities(): void {
        $this->verify();

        $table    = $this->get_table( 'vulnerability_scans' );
        $page     = max( 1, absint( $_POST['page']     ?? 1 ) );
        $severity = sanitize_key( wp_unslash( $_POST['severity'] ?? ''  ) );

        if ( ! $table ) {
            AjaxResponseBuilder::make()->success()->addData( 'records', [] )->addData( 'summary', [] )->send();
            return;
        }

        $conds = [];
        if ( $severity ) { $conds['severity'] = $severity; }

        $result             = $table->paginate( $page, 50, $conds, 'severity', 'DESC' );
        $result['summary']  = $table->get_vulnerability_summary();

        AjaxResponseBuilder::make()->success()->mergeData( (array) $result )->send();
    }

    /**
     * Schedule a background vulnerability scan.
     */
    public function handle_run_vuln_scan(): void {
        $this->verify();

        wp_schedule_single_event( time() + 2, 'ultraguard_antivirus_run_vuln_scan' );

        AjaxResponseBuilder::make()->success( __( 'Vulnerability scan queued. Results will appear shortly.', 'ultraguard-security-suite' ) )->addNotification( __( 'Vulnerability scan queued.', 'ultraguard-security-suite' ), 'success' )->send();
    }

    /**
     * Mark a vulnerability as patched.
     */
    public function handle_patch_vulnerability(): void {
        $this->verify();

        $id    = absint( $_POST['vuln_id'] ?? 0 );
        $table = $this->get_table( 'vulnerability_scans' );

        if ( ! $id || ! $table ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid request.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( $table->mark_patched( $id ) ) {
            AjaxResponseBuilder::make()->success( __( 'Marked as patched.', 'ultraguard-security-suite' ) )->addNotification( __( 'Marked as patched.', 'ultraguard-security-suite' ), 'success' )->send();
        } else {
            AjaxResponseBuilder::make()->error( __( 'Could not update vulnerability.', 'ultraguard-security-suite' ) )->addNotification( __( 'Could not update vulnerability.', 'ultraguard-security-suite' ), 'error' )->send();
        }
    }

    /**
     * Mark a vulnerability as a false positive / ignored.
     */
    public function handle_ignore_vulnerability(): void {
        $this->verify();

        $id    = absint( $_POST['vuln_id'] ?? 0 );
        $table = $this->get_table( 'vulnerability_scans' );

        if ( ! $id || ! $table ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid request.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        $table->update( $id, [ 'status' => 'false_positive' ] );
        AjaxResponseBuilder::make()->success( __( 'Vulnerability ignored.', 'ultraguard-security-suite' ) )->addNotification( __( 'Vulnerability ignored.', 'ultraguard-security-suite' ), 'info' )->send();
    }

    // ── Row-level record mutations (table action buttons) ─────────────────────

    /**
     * Hard-delete a single scan result log entry.
     * POST: result_id (int)
     */
    public function handle_delete_scan_result(): void {
        $this->verify();

        $id       = absint( $_POST['result_id'] ?? 0 );
        $modal_id = sanitize_key( wp_unslash( $_POST['modal_id'] ?? ''  ) );
        $table    = $this->get_table( 'scan_results' );

        if ( ! $id ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid result ID.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( $table && $table->delete( $id ) ) {
            $response = AjaxResponseBuilder::make()
                ->success( __( 'Scan result log entry deleted.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Log entry deleted.', 'ultraguard-security-suite' ), 'success' )
                ->addRemove( '#ug-row-' . $id, true, 250 );

            if ( '' !== $modal_id ) {
                $response->closeModal( $modal_id );
            }

            $this->apply_antivirus_dom_updates( $response );
            $response->send();
        } else {
            AjaxResponseBuilder::make()->error( __( 'Could not delete record.', 'ultraguard-security-suite' ) )->send();
        }
    }

    /**
     * Update editable fields on a malware signature record.
     * POST: sig_id (int), name (string), threat_level (string), is_active (0|1)
     */
    public function handle_edit_signature(): void {
        $this->verify();

        $id           = absint( $_POST['sig_id']       ?? 0 );
        $modal_id     = sanitize_key( wp_unslash( $_POST['modal_id'] ?? ''  ) );
        $name         = sanitize_text_field( wp_unslash( $_POST['name']         ?? '' ) );
        $threat_level = sanitize_key( wp_unslash( $_POST['threat_level'] ?? 'medium'  ) );
        $is_active    = filter_var( wp_unslash( $_POST['is_active'] ?? '1' ), FILTER_VALIDATE_BOOLEAN ) ? 1 : 0;

        if ( ! $id ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid signature ID.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( empty( $name ) ) {
            AjaxResponseBuilder::make()->error( __( 'Signature name is required.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        $valid_levels = [ 'low', 'medium', 'high', 'critical' ];
        if ( ! in_array( $threat_level, $valid_levels, true ) ) {
            $threat_level = 'medium';
        }

        $table = $this->get_table( 'malware_signatures' );
        if ( $table && $table->update( $id, [
            'name'         => $name,
            'threat_level' => $threat_level,
            'is_active'    => $is_active,
        ] ) ) {
            $level_class  = 'critical' === $threat_level ? 'danger' : 'warning';
            $active_text  = $is_active ? __( 'Active', 'ultraguard-security-suite' ) : __( 'Disabled', 'ultraguard-security-suite' );
            $active_class = $is_active ? 'success' : 'secondary';
            $row_sel      = '#ug-sig-' . $id;

            $response = AjaxResponseBuilder::make()
                ->success( __( 'Signature updated.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Saved!', 'ultraguard-security-suite' ), 'success' )
                ->addUpdate( $row_sel . ' .ultraguard-cell-name', esc_html( $name ), 'text' )
                ->addUpdate(
                    $row_sel . ' .ultraguard-cell-level',
                    '<span class="ultraguard-cell-inner ' . esc_attr( $level_class ) . '">' . esc_html( ucfirst( $threat_level ) ) . '</span>'
                )
                ->addUpdate(
                    $row_sel . ' .ultraguard-cell-active',
                    '<span class="ultraguard-cell-inner ' . esc_attr( $active_class ) . '">' . esc_html( $active_text ) . '</span>'
                );

            if ( '' !== $modal_id ) {
                $response->closeModal( $modal_id );
            }

            $this->apply_antivirus_dom_updates( $response );
            $response->send();
        } else {
            AjaxResponseBuilder::make()->error( __( 'Could not update signature.', 'ultraguard-security-suite' ) )->send();
        }
    }

    /**
     * Hard-delete a malware signature record.
     * POST: sig_id (int)
     */
    public function handle_delete_signature(): void {
        $this->verify();

        $id       = absint( $_POST['sig_id'] ?? 0 );
        $modal_id = sanitize_key( wp_unslash( $_POST['modal_id'] ?? ''  ) );
        $table    = $this->get_table( 'malware_signatures' );

        if ( ! $id ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid signature ID.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( $table && $table->delete( $id ) ) {
            $response = AjaxResponseBuilder::make()
                ->success( __( 'Signature deleted.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Signature deleted.', 'ultraguard-security-suite' ), 'success' )
                ->addRemove( '#ug-sig-' . $id, true, 250 );

            if ( '' !== $modal_id ) {
                $response->closeModal( $modal_id );
            }

            $this->apply_antivirus_dom_updates( $response );
            $response->send();
        } else {
            AjaxResponseBuilder::make()->error( __( 'Could not delete signature.', 'ultraguard-security-suite' ) )->send();
        }
    }

    /**
     * Update the admin notes field on a quarantine record.
     * POST: quarantine_id (int), notes (string)
     */
    public function handle_edit_quarantine_notes(): void {
        $this->verify();

        $id    = absint( $_POST['quarantine_id'] ?? 0 );
        $notes = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) );
        $table = $this->get_table( 'quarantine' );

        if ( ! $id ) {
            AjaxResponseBuilder::make()->error( __( 'Invalid quarantine ID.', 'ultraguard-security-suite' ) )->send();
            return;
        }

        if ( $table && $table->update( $id, [ 'notes' => $notes ] ) ) {
            AjaxResponseBuilder::make()
                ->success( __( 'Notes saved.', 'ultraguard-security-suite' ) )
                ->addNotification( __( 'Notes saved!', 'ultraguard-security-suite' ), 'success' )
                ->send();
        } else {
            AjaxResponseBuilder::make()->error( __( 'Could not update notes.', 'ultraguard-security-suite' ) )->send();
        }
    }

    // ── Signatures ────────────────────────────────────────────────────────────

    /**
     * Re-seed the malware signature database from the bundled JSON file.
     * Forces a full reseed so any signatures added to the JSON since the
     * last run are picked up immediately without needing a plugin update.
     */
    public function handle_update_signatures(): void {
        $this->verify();

        $sig_table = $this->get_table( 'malware_signatures' );

        if ( ! $sig_table ) {
            AjaxResponseBuilder::make()->error( __( 'Signature table unavailable.', 'ultraguard-security-suite' ) )->addNotification( __( 'Signature table unavailable.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        $seeder = new \UltraguardPro\modules\antivirus\SignatureSeeder( $sig_table );
        $result = $seeder->force_reseed();

        if ( $result['error'] ) {
            AjaxResponseBuilder::make()
                /* translators: %s: string */
                ->error( sprintf( __( 'Signature update failed: %s', 'ultraguard-security-suite' ), esc_html( $result['error'] ) ) )
                /* translators: %s: string */
                ->addNotification( sprintf( __( 'Signature update failed: %s', 'ultraguard-security-suite' ), esc_html( $result['error'] ) ), 'error' )
                ->send();
            return;
        }

        $total = $sig_table->count( [ 'is_active' => 1 ] );

        do_action( 'ultraguard_antivirus_signatures_updated', $result );

        $sig_msg = sprintf(
            /* translators: Placeholder values are inserted into this string. */
            __( 'Signatures updated. %1$d new definitions added, %2$d already present.', 'ultraguard-security-suite' ),
            $result['inserted'],
            $result['skipped']
        );
        AjaxResponseBuilder::make()
            ->success( $sig_msg )
            ->addData( 'total_active',   $total )
            ->addData( 'new_signatures', $result['inserted'] )
            ->addData( 'db_version',     $result['version'] )
            ->addNotification( __( 'Signature database updated successfully.', 'ultraguard-security-suite' ), 'success' )
            ->updateText( '#ultraguard-av-sig-total', (string) $total )
            ->updateText( '#ug-av-active-signatures-count .ultraguard-stat-value', (string) $total, 'text' )
            ->updateText( '#ultraguard-av-sig-version', esc_html( $result['version'] ?? '' ) )
            ->send();
    }

    // ── Settings ──────────────────────────────────────────────────────────────

    /**
     * Every field this module owns, with type and constraints.
     * This is the single source of truth — Settings class has zero knowledge of these.
     */
    private function field_schema(): array {
        return [
            'antivirus_enabled'                 => [ 'type' => 'bool' ],
            'antivirus_scan_php'                => [ 'type' => 'bool' ],
            'antivirus_scan_js'                 => [ 'type' => 'bool' ],
            'antivirus_scan_html'               => [ 'type' => 'bool' ],
            'antivirus_scan_uploads'            => [ 'type' => 'bool' ],
            'antivirus_auto_quarantine'         => [ 'type' => 'bool' ],
            'antivirus_scheduled_scan'          => [ 'type' => 'bool' ],
            'antivirus_notify_on_threat'        => [ 'type' => 'bool' ],
            'antivirus_notify_on_quarantine'    => [ 'type' => 'bool' ],
            'antivirus_notify_on_vulnerability' => [ 'type' => 'bool' ],
            'antivirus_scan_depth'     => [ 'type' => 'select', 'options' => [ 'quick', 'normal', 'deep' ],                  'default' => 'normal' ],
            'antivirus_scan_frequency' => [ 'type' => 'select', 'options' => [ 'hourly', 'twicedaily', 'daily', 'weekly' ], 'default' => 'daily'  ],
            'antivirus_max_file_size_mb' => [ 'type' => 'int',      'min' => 1,  'max' => 100, 'default' => 10 ],
            'antivirus_notify_email'     => [ 'type' => 'email',    'allow_empty' => true, 'default' => '' ],
            'antivirus_wpvulndb_api_key' => [ 'type' => 'text',     'default' => '' ],
            'antivirus_exclude_paths'    => [ 'type' => 'textarea', 'default' => '' ],
        ];
    }

    public function handle_save_settings(): void {
        $this->verify();

        $data = $this->sanitize_fields( (array) wp_unslash( $_POST ), $this->field_schema() );
        $ok   = $this->plugin->settings->save_module_settings( 'antivirus', $data );

        if ( ! $ok ) {
            AjaxResponseBuilder::make()->error( __( 'Failed to save antivirus settings.', 'ultraguard-security-suite' ) )->addNotification( __( 'Failed to save antivirus settings.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        do_action( 'ultraguard_antivirus_settings_saved' );
        wp_clear_scheduled_hook( 'ultraguard_antivirus_scheduled_scan' );
        if ( ! empty( $data['antivirus_scheduled_scan'] ) ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, $data['antivirus_scan_frequency'] ?? 'daily', 'ultraguard_antivirus_scheduled_scan' );
        }

        AjaxResponseBuilder::make()
            ->success( __( 'Antivirus settings saved.', 'ultraguard-security-suite' ) )
            ->addNotification( __( 'Antivirus settings saved.', 'ultraguard-security-suite' ), 'success' )
            ->send();
    }

    // ── Export ────────────────────────────────────────────────────────────────

    /**
     * Export infected scan results to a CSV file and return the download URL.
     */
    public function handle_export_report(): void {
        $this->verify();

        $scan_table = $this->get_table( 'scan_results' );
        if ( ! $scan_table ) {
            AjaxResponseBuilder::make()->error( __( 'Scan results table unavailable.', 'ultraguard-security-suite' ) )->addNotification( __( 'Scan results table unavailable.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        $upload_dir = wp_upload_dir();
        $export_dir = trailingslashit( $upload_dir['basedir'] ) . 'ultraguard-security-suite/exports/';
        wp_mkdir_p( $export_dir );

        // Protect the export directory
        $htaccess = $export_dir . '.htaccess';
        if ( ! file_exists( $htaccess ) ) {
            file_put_contents( $htaccess, "Deny from all\nOptions -Indexes\n" );
        }

        $filename = 'antivirus-report-' . gmdate( 'Y-m-d-His' ) . '.csv';
        $filepath = $export_dir . $filename;

        $rows = $scan_table->get_by( [ 'status' => 'infected' ], 5000, 'created_at', 'DESC' );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming CSV export to a plugin-owned temporary file.
        $fp = fopen( $filepath, 'w' );
        if ( ! $fp ) {
            AjaxResponseBuilder::make()->error( __( 'Could not create export file.', 'ultraguard-security-suite' ) )->addNotification( __( 'Could not create export file.', 'ultraguard-security-suite' ), 'error' )->send();
            return;
        }

        fputcsv( $fp, [ 'File Path', 'Status', 'Threat Type', 'Threat Level', 'File Hash', 'File Size', 'Scanned At' ] );
        foreach ( $rows as $r ) {
            fputcsv( $fp, [
                $r['file_path'],
                $r['status'],
                $r['threat_type']  ?? '',
                $r['threat_level'] ?? '',
                $r['file_hash']    ?? '',
                size_format( (int) $r['file_size'] ),
                $r['created_at'],
            ] );
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the CSV export handle opened above.
        fclose( $fp );

        $export_url = trailingslashit( $upload_dir['baseurl'] ) . 'ultraguard-security-suite/exports/' . $filename;
        AjaxResponseBuilder::make()
            ->success( __( 'Report exported successfully.', 'ultraguard-security-suite' ) )
            ->addData( 'download_url', $export_url )
            ->addData( 'records', count( $rows ) )
            ->addNotification( __( 'Report ready for download.', 'ultraguard-security-suite' ), 'success' )
            ->download( $export_url )
            ->send();
    }

    // ── Section A: Malware Cleaner ─────────────────────────────────────────────

    /**
     * Clean & Restore a quarantined file using MalwareCleaner (Pro only).
     *
     * POST params:
     *   quarantine_id  int   Required. Row ID in ultraguard_quarantine.
     */
    public function handle_clean_file(): void {
        $this->verify();

        // Pro gate — soft push instead of hard wall
        if ( ! \UltraguardPro\support\LicenseInfo::instance()->isPro() ) {
            \UltraguardPro\support\UpgradeManager::soft_gate_response(
                'file_quarantine',
                __( 'clean a quarantined file', 'ultraguard-security-suite' )
            );
            return;
        }

        $quarantine_id = absint( $_POST['quarantine_id'] ?? 0 );

        if ( ! $quarantine_id ) {
            AjaxResponseBuilder::make()
                ->error( __( 'Invalid quarantine ID.', 'ultraguard-security-suite' ) )
                ->send();
            return;
        }

        $cleaner = new \UltraguardPro\modules\antivirus\MalwareCleaner();
        $result  = $cleaner->clean( $quarantine_id );

        if ( ! $result['success'] ) {
            AjaxResponseBuilder::make()
                ->error( $result['message'] )
                ->addNotification( $result['message'], 'error' )
                ->send();
            return;
        }

        $method_labels = [
            'core'            => __( 'WordPress Core', 'ultraguard-security-suite' ),
            'plugin'          => __( 'Plugin Repository', 'ultraguard-security-suite' ),
            'theme'           => __( 'Theme Repository', 'ultraguard-security-suite' ),
            'injection_strip' => __( 'Injection Strip', 'ultraguard-security-suite' ),
        ];
        $method_label = $method_labels[ $result['method'] ] ?? esc_html( $result['method'] );

        $row_sel = '#ug-row-' . $quarantine_id;

        AjaxResponseBuilder::make()
            ->success( $result['message'] )
            ->addNotification(
                sprintf(
                    /* translators: %s: clean method label */
                    __( 'File cleaned successfully via %s.', 'ultraguard-security-suite' ),
                    $method_label
                ),
                'success'
            )
            ->addUpdate(
                $row_sel . ' .ultraguard-cell-status',
                '<span class="ultraguard-cell-inner success">' . esc_html__( 'Cleaned', 'ultraguard-security-suite' ) . '</span>'
            )
            ->addUpdate(
                $row_sel . ' .ultraguard-cell-method',
                '<span class="ultraguard-cell-inner info">' . esc_html( $method_label ) . '</span>'
            )
            ->emptyElement( $row_sel . ' .ultraguard-cell-actions' )
            ->send();
    }

    // ── Private helpers ───────────────────────────────────────────────────────

    /**
     * If a scan is flagged as running but was started more than 10 minutes ago
     * with no meaningful progress, mark it cancelled automatically.
     * This cleans up orphaned scan records caused by cron failures or server
     * restarts without requiring the user to manually stop the scan.
     *
     * @param  array<string,mixed> $scan  Active scan option data.
     * @return array<string,mixed>        Possibly-updated scan data.
     */
    private function maybe_cancel_stale_scan( array $scan ): array {
        if ( ( $scan['status'] ?? '' ) !== 'running' ) {
            return $scan;
        }
        if ( empty( $scan['started_at'] ) ) {
            return $scan;
        }

        $started    = strtotime( $scan['started_at'] );
        $elapsed    = time() - $started;
        $progress   = (int) ( $scan['progress'] ?? 0 );

        // Stale = running for >10 min AND still at initial 5% (cron never ran)
        // OR running for >20 min regardless of progress (cron died mid-scan)
        $is_stale = ( $elapsed > 600 && $progress <= 5 ) || ( $elapsed > 1200 );

        if ( $is_stale ) {
            $scan['status'] = 'cancelled';
            $scan['error']  = 'Scan timed out — WP-Cron may not be running. Try again.';
            update_option( 'ultraguard_active_scan', $scan, false );
        }

        return $scan;
    }

    /**
     * Build the settings array for AntivirusEngine, reading from the plugin settings store.
     * Mirrors AntivirusModule::build_engine_settings() but accessible from the AJAX class.
     *
     * @return array<string,mixed>
     */
    private function get_engine_settings(): array {
        $s = $this->plugin->settings;
        return [
            'antivirus_scan_php'         => (bool)   $s->get_option( 'antivirus_scan_php',         true    ),
            'antivirus_scan_js'          => (bool)   $s->get_option( 'antivirus_scan_js',          true    ),
            'antivirus_scan_html'        => (bool)   $s->get_option( 'antivirus_scan_html',        true    ),
            'antivirus_scan_uploads'     => (bool)   $s->get_option( 'antivirus_scan_uploads',     false   ),
            'antivirus_auto_quarantine'  => (bool)   $s->get_option( 'antivirus_auto_quarantine',  false   ),
            'antivirus_scan_depth'       => (string) $s->get_option( 'antivirus_scan_depth',       'normal' ),
            'antivirus_max_file_size_mb' => (int)    $s->get_option( 'antivirus_max_file_size_mb', 10      ),
            'antivirus_exclude_paths'    => (string) $s->get_option( 'antivirus_exclude_paths',    ''      ),
            'antivirus_wpvulndb_api_key' => (string) $s->get_option( 'antivirus_wpvulndb_api_key', ''      ),
        ];
    }

    /**
     * Verify nonce and capability — die on failure.
     */
    private function verify(): void {
        if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
            AjaxResponseBuilder::make()
                ->nonceError( __( 'Security check failed.', 'ultraguard-security-suite' ) )
                ->send( 403 );
        }

        if ( ! \UltraguardPro\ultraguard_current_user_can_manage() ) {
            AjaxResponseBuilder::make()->permissionError()->send( 403 );
        }
    }

    /**
     * Retrieve a DB table by its module-local key.
     *
     * @param  string $key  Table key as registered in AntivirusSchema::get_table_map().
     * @return mixed        Table instance or null.
     */
    private function get_table( string $key ) {
        return $this->plugin->modules->get_db_tables()->get_table( $key );
    }

    /**
     * Current antivirus counters used by the infected-files and malware pages.
     *
     * @return array<string,int>
     */
    private function get_antivirus_ui_stats(): array {
        $scan_table = $this->get_table( 'scan_results' );
        $quar_table = $this->get_table( 'quarantine' );
        $sig_table  = $this->get_table( 'malware_signatures' );

        return [
            'total_scanned'      => $scan_table ? (int) $scan_table->count() : 0,
            'infected'           => $scan_table ? (int) $scan_table->count( [ 'status' => 'infected' ] ) : 0,
            'clean'              => $scan_table ? (int) $scan_table->count( [ 'status' => 'clean' ] ) : 0,
            'quarantined'        => $scan_table ? (int) $scan_table->count( [ 'status' => 'quarantined' ] ) : 0,
            'false_positives'    => $scan_table ? (int) $scan_table->count( [ 'is_false_positive' => 1 ] ) : 0,
            'active_signatures'  => $sig_table ? (int) $sig_table->count( [ 'is_active' => 1 ] ) : 0,
            'quarantine_records' => $quar_table ? (int) $quar_table->count( [ 'status' => 'active' ] ) : 0,
        ];
    }

    /**
     * Push fresh antivirus counters to any visible admin screen that uses them.
     *
     * @param  AjaxResponseBuilder $response
     * @return AjaxResponseBuilder
     */
    private function apply_antivirus_dom_updates( AjaxResponseBuilder $response ): AjaxResponseBuilder {
        $stats = $this->get_antivirus_ui_stats();

        return $response
            ->updateText( '#ug-av-malware-detection-count .ultraguard-stat-value', (string) $stats['infected'], 'text' )
            ->updateText( '#ug-av-active-signatures-count .ultraguard-stat-value', (string) $stats['active_signatures'], 'text' )
            ->updateText( '#ug-av-false-positive-count .ultraguard-stat-value', (string) $stats['false_positives'], 'text' )
            ->updateText( '#ug-av-file-scanner-total .ultraguard-stat-value', (string) $stats['total_scanned'], 'text' )
            ->updateText( '#ug-av-file-scanner-threats .ultraguard-stat-value', (string) $stats['infected'], 'text' )
            ->updateText( '#ug-av-file-scanner-clean .ultraguard-stat-value', (string) $stats['clean'], 'text' )
            ->updateText( '#ug-av-file-scanner-quarantined .ultraguard-stat-value', (string) $stats['quarantined'], 'text' )
            ->updateText( '#ultraguard-av-sig-total', (string) $stats['active_signatures'], 'text' )
            ->addAction( [
                'type'  => 'trigger',
                'event' => 'ultraguard:antivirus:dom-updated',
                'data'  => $stats,
            ] );
    }

    /**
     * Derive a unique quarantine file path.
     *
     * @param  string $original_path  Source file path.
     * @return string                 Destination quarantine path.
     */
    private function make_quarantine_path( string $original_path ): string {
        $upload_dir     = wp_upload_dir();
        $quarantine_dir = trailingslashit( $upload_dir['basedir'] ) . 'ultraguard-quarantine/';
        wp_mkdir_p( $quarantine_dir );

        // Write .htaccess on first use
        $htaccess = $quarantine_dir . '.htaccess';
        if ( ! file_exists( $htaccess ) ) {
            file_put_contents( $htaccess, "Deny from all\nOptions -Indexes\n" );
        }

        return $quarantine_dir . basename( $original_path ) . '.' . time() . '.quarantine';
    }

    // ── Realtime fallback handlers ────────────────────────────────────────────

    /**
     * RT fallback: antivirus_status channel.
     * Called by ultraguard-realtime.js when SSE is unavailable.
     * Reuses the active-scan option already written by the scan engine.
     */
    public function handle_rt_antivirus_status(): void {
        if ( ! check_ajax_referer( 'ultraguard_admin_nonce', 'nonce', false ) ) {
            AjaxResponseBuilder::make()
                ->nonceError( __( 'Security check failed.', 'ultraguard-security-suite' ) )
                ->send( 403 );
        }
        if ( ! \UltraguardPro\ultraguard_current_user_can_manage() ) {
            AjaxResponseBuilder::make()->permissionError()->send( 403 );
            return;
        }

        $scan        = get_option( 'ultraguard_active_scan', [] );
        $is_scanning = ( ( $scan['status'] ?? '' ) === 'running' );

        $av_status = $is_scanning ? 'scanning' : 'idle';
        AjaxResponseBuilder::make()
            ->success()
            ->addData( 'status',              $av_status )
            ->addData( 'status_label',        $is_scanning ? __( 'Scanning', 'ultraguard-security-suite' ) : __( 'Idle', 'ultraguard-security-suite' ) )
            ->addData( 'is_scanning',         $is_scanning )
            ->addData( 'progress',            (int) ( $scan['progress']         ?? 0 ) )
            ->addData( 'progress_label',      ( $scan['progress'] ?? 0 ) . '%' )
            ->addData( 'phase_label',         $scan['phase']          ?? '' )
            ->addData( 'current_file',        $scan['current_file']   ?? '' )
            ->addData( 'files_scanned',       (int) ( $scan['files_scanned']    ?? 0 ) )
            ->addData( 'total_files',         (int) ( $scan['total_files']      ?? 0 ) )
            ->addData( 'threats_found',       (int) ( $scan['threats_found']    ?? 0 ) )
            ->addData( 'infected_files',      (int) ( $scan['infected_files']   ?? 0 ) )
            ->addData( 'suspicious_files',    (int) ( $scan['suspicious_files'] ?? 0 ) )
            ->addData( 'clean_files',         (int) ( $scan['clean_files']      ?? 0 ) )
            ->addData( 'quarantined',         (int) ( $scan['quarantined']      ?? 0 ) )
            ->addData( 'last_scan_human',     $scan['last_scan_human'] ?? '' )
            ->addData( 'elapsed_human',       $scan['elapsed_human']   ?? '' )
            ->addData( 'estimated_human',     $scan['estimated_human'] ?? '' )
            ->addData( 'scan_type',           $scan['scan_type']        ?? '' )
            ->addData( 'current_batch',       (int) ( $scan['current_batch']   ?? 0 ) )
            ->addData( 'total_batches',       (int) ( $scan['total_batches']   ?? 0 ) )
            ->addData( 'scan_just_completed', (bool) ( $scan['scan_just_completed'] ?? false ) )
            ->updateText( '#ultraguard-av-rt-status',       $is_scanning ? __( 'Scanning', 'ultraguard-security-suite' ) : __( 'Idle', 'ultraguard-security-suite' ) )
            ->updateText( '#ultraguard-av-rt-progress',     (string) (int) ( $scan['progress'] ?? 0 ) . '%' )
            ->updateText( '#ultraguard-av-rt-threats',      (string) (int) ( $scan['threats_found'] ?? 0 ) )
            ->send();
    }

    /**
     * RT fallback: file_scanner_status channel.
     * Mirrors the SSE SseStream::fetch_file_scanner_status() data shape.
     */
    public function handle_rt_file_scanner_status(): void {
        if ( ! check_ajax_referer( 'ultraguard_admin_nonce', 'nonce', false ) ) {
            AjaxResponseBuilder::make()
                ->nonceError( __( 'Security check failed.', 'ultraguard-security-suite' ) )
                ->send( 403 );
        }
        if ( ! \UltraguardPro\ultraguard_current_user_can_manage() ) {
            AjaxResponseBuilder::make()->permissionError()->send( 403 );
            return;
        }

        $progress    = get_transient( 'ultraguard_fc_scan_in_progress' );
        $is_scanning = ! empty( $progress );

        $tbl     = $this->plugin->modules->get_db_tables()->get_table( 'file_integrity' );
        $changes = $tbl ? (int) $tbl->count() : 0;

        $last_check       = get_option( 'ultraguard_fc_last_scan', '' );
        $last_check_human = $last_check
            ? human_time_diff( strtotime( $last_check ), time() ) . ' ' . __( 'ago', 'ultraguard-security-suite' )
            : __( 'Never', 'ultraguard-security-suite' );

        $fc_status = $is_scanning ? 'scanning' : ( $changes > 0 ? 'warning' : 'ok' );
        $fc_label  = $is_scanning ? __( 'Scanning', 'ultraguard-security-suite' ) : ( $changes > 0 ? __( 'Changes Detected', 'ultraguard-security-suite' ) : __( 'Protected', 'ultraguard-security-suite' ) );
        AjaxResponseBuilder::make()
            ->success()
            ->addData( 'status',           $fc_status )
            ->addData( 'status_label',     $fc_label )
            ->addData( 'files_checked',    (int) ( $progress['files_processed'] ?? get_option( 'ultraguard_fc_last_files_checked', 0 ) ) )
            ->addData( 'changes_detected', $changes )
            ->addData( 'last_check_human', $last_check_human )
            ->addData( 'quarantined',      0 )
            ->updateText( '#ultraguard-fc-rt-status',  $fc_label )
            ->updateText( '#ultraguard-fc-rt-changes', (string) $changes )
            ->send();
    }
}

// phpcs:enable
