<?php
/**
 * UltraGuard Pro — Server-Sent Events (SSE) Stream
 *
 * Replaces constant AJAX polling with a single long-lived HTTP connection.
 * The browser connects once; the server pushes only changed data.
 *
 * Flow:
 *  1. Client connects: GET wp-admin/admin-ajax.php?action=ultraguard_sse_stream&channels=a,b&nonce=X
 *  2. Server sets SSE headers, enters a loop (~30 s max for shared hosting)
 *  3. Each tick: check subscribed channels for changed data, push deltas only
 *  4. Client EventSource reconnects automatically with Last-Event-ID
 *  5. If SSE is blocked/unsupported, JS falls back to minimal smart polling
 *
 * @package UltraguardPro\view
 */

namespace UltraguardPro\view;

use UltraguardPro\core\CacheManager;

defined( 'ABSPATH' ) || exit;

class SseStream {

    /** Max seconds to hold the connection open (shared-host safe: 25s) */
    const MAX_DURATION = 25;

    /** Seconds between server-side ticks */
    const TICK_INTERVAL = 2;

    /** Idle channels (no active process) are throttled to this interval */
    const IDLE_TICK_INTERVAL = 10;

    /** Cache key for "scan just started" flag so SSE accelerates immediately */
    const SCAN_ACTIVE_FLAG = 'sse_scan_active';

    /** Heartbeat every N ticks to keep connection alive */
    const HEARTBEAT_EVERY = 5;

    /** Cache key prefix for last-sent fingerprints (per channel per client) */
    const FP_PREFIX = 'sse_fp_';

    /**
     * How often (in ticks) to push a refreshed nonce to the client.
     * At TICK_INTERVAL=2s this equals 300s (5 min), well within the 12-hour
     * WP nonce window so the client always reconnects with a valid nonce.
     */
    const NONCE_REFRESH_EVERY = 150; // ticks (~5 min)

    /** @var \UltraguardPro\ultraguard_pro Plugin root */
    private $plugin;

    /** @var CacheManager */
    private $cache;

    public function __construct( $plugin ) {
        $this->plugin   = $plugin;
        $this->cache    = CacheManager::get_instance();
    }

    /** Register the SSE AJAX endpoint */
    public function register(): void {
        add_action( 'wp_ajax_ultraguard_sse_stream', [ $this, 'handle_stream' ] );
    }

    // =========================================================================
    // STREAM HANDLER
    // =========================================================================

    public function handle_stream(): void {
        // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- SSE streams intentionally lift the execution limit for a bounded long-lived connection.
        @set_time_limit( 0 );
        @ignore_user_abort( true );

        // Verify nonce
        $nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? $_POST['nonce'] ?? ''  ) );
        if ( ! wp_verify_nonce( $nonce, 'ultraguard_admin_nonce' ) ) {
            http_response_code( 403 );
            wp_send_json_error( 'Invalid nonce', 403 );
            return;
        }

        if ( ! function_exists( '\\UltraguardPro\\ultraguard_current_user_can_manage' ) || ! \UltraguardPro\ultraguard_current_user_can_manage() ) {
            http_response_code( 403 );
            wp_send_json_error( 'Insufficient permissions', 403 );
            return;
        }

        // Parse subscribed channels from query string: ?channels=antivirus_status,firewall_status
        $raw_channels   = sanitize_text_field( wp_unslash( $_GET['channels'] ?? ''  ) );
        $channels       = array_slice( array_filter( array_map( 'sanitize_key', explode( ',', $raw_channels ) ) ), 0, 10 ); // max 10 channels
        $client_id      = substr( sanitize_text_field( wp_unslash( $_GET['client_id'] ?? 'unknown'  ) ), 0, 64 ); // cap length
        $last_event_id  = sanitize_text_field( wp_unslash( $_SERVER['HTTP_LAST_EVENT_ID'] ?? '' ) );

        if ( empty( $channels ) ) {
            http_response_code( 400 );
            echo 'data: {"error":"no channels"}'."\n\n";
            return;
        }

        // Set SSE headers — disable all output buffering
        $this->set_sse_headers();

        // Flush any existing output buffers
        while ( ob_get_level() > 0 ) {
            ob_end_flush();
        }

        $start     = time();
        $tick      = 0;
        $fp_key    = self::FP_PREFIX . md5( $client_id );

        // Load stored fingerprints for this client (what we last sent).
        // We use get_db() with a short TTL — the realtime group was removed
        // from CacheManager (it was a page-cache concern, not a DB concern).
        // A 60-second TTL is more than enough to survive a 25-second stream
        // window plus the immediate reconnect that follows.
        $fingerprints = $this->cache->get_db( $fp_key ) ?: [];

        // Send initial connection event
        $this->send_event( 'connected', [
            'channels'  => $channels,
            'client_id' => $client_id,
            'server_ts' => time(),
        ] );

        // ── Main push loop ────────────────────────────────────────────────────
        while ( ( time() - $start ) < self::MAX_DURATION ) {

            if ( connection_aborted() ) {
                break;
            }

            $tick++;

            // Heartbeat keeps nginx/Apache from killing idle connection
            if ( $tick % self::HEARTBEAT_EVERY === 0 ) {
                $this->send_heartbeat( $tick );
            }

            // Per-channel smart throttle: idle channels tick less frequently
            $av          = $this->plugin->antivirus ?? null;
            $scan_active = $av && method_exists( $av, 'is_scan_running' ) && $av->is_scan_running();

            // DB scanner active flag — check transient status
            $db_status       = get_transient( 'ultraguard_db_scan_status' );
            $db_scan_active  = in_array( $db_status['status'] ?? '', [ 'running', 'queued' ], true );

            // Vuln scanner active flag
            $vuln_status      = get_transient( 'ultraguard_vuln_scan_status' );
            $vuln_scan_active = in_array( $vuln_status['status'] ?? '', [ 'running', 'queued' ], true );

            foreach ( $channels as $channel ) {
                // Throttle idle antivirus channel to every 5 ticks (~10s) to avoid hammering
                if ( $channel === 'antivirus_status' && ! $scan_active ) {
                    if ( $tick % 5 !== 1 ) { continue; }
                }
                // security_score and htaccess_status are slow-changing — tick every 6 (~12s)
                if ( in_array( $channel, [ 'security_score', 'htaccess_status' ], true ) ) {
                    if ( $tick % 6 !== 1 ) { continue; }
                }
                // filechecker_progress: push every tick while scanning, every 5 ticks when idle
                if ( $channel === 'filechecker_progress' ) {
                    $fc_active = (bool) get_transient( 'ultraguard_fc_scan_in_progress' );
                    if ( ! $fc_active && $tick % 5 !== 1 ) { continue; }
                }
                // db_scan_status: push every tick while scanning, every 5 ticks when idle
                if ( $channel === 'db_scan_status' && ! $db_scan_active ) {
                    if ( $tick % 5 !== 1 ) { continue; }
                }
                // vuln_scan_status: push every tick while scanning, every 5 ticks when idle
                if ( $channel === 'vuln_scan_status' && ! $vuln_scan_active ) {
                    if ( $tick % 5 !== 1 ) { continue; }
                }

                $data = $this->fetch_channel( $channel );
                if ( $data === null ) {
                    continue;
                }

                $current_fp = md5( serialize( $data ) );
                $last_fp    = $fingerprints[ $channel ] ?? '';

                if ( $current_fp !== $last_fp ) {
                    $this->send_event( $channel, $data, $tick );
                    $fingerprints[ $channel ] = $current_fp;
                }
            }

            // Persist fingerprints — 60 s TTL covers the stream window + reconnect.
            $this->cache->set_db( $fp_key, $fingerprints, 60 );

            // Periodically push a fresh nonce so the client never reconnects with
            // an expired one.  WP nonces are valid for 12-24 h but rotate on the
            // hour boundary; refreshing every 5 min provides a comfortable margin.
            if ( $tick % self::NONCE_REFRESH_EVERY === 0 ) {
                $this->send_event( 'nonce_refresh', [
                    'nonce' => wp_create_nonce( 'ultraguard_admin_nonce' ),
                ] );
            }

            // Sleep between ticks — release CPU
            if ( ( time() - $start ) < ( self::MAX_DURATION - self::TICK_INTERVAL ) ) {
                sleep( self::TICK_INTERVAL );
            } else {
                break;
            }
        }

        // Send reconnect hint — client will reconnect immediately
        $this->send_event( 'stream_end', [ 'reconnect' => true, 'ts' => time() ] );
        exit;
    }

    // =========================================================================
    // CHANNEL DATA FETCHERS
    // =========================================================================

    /**
     * Fetch data for a named channel.
     * Returns null if the channel is unknown or has nothing to report.
     */
    private function fetch_channel( string $channel ): ?array {
        switch ( $channel ) {

            case 'antivirus_status':
                return $this->fetch_antivirus_status();

            case 'file_scanner_status':
                return $this->fetch_file_scanner_status();

            case 'firewall_status':
                return $this->fetch_firewall_status();

            case 'login_status':
                return $this->fetch_login_status();

            case 'htaccess_status':
                return $this->fetch_htaccess_status();

            case 'security_score':
                return $this->fetch_security_score();

            case 'threat_feed':
                return $this->fetch_threat_feed();

            case 'vuln_scan_status':
                return $this->fetch_vuln_scan_status();

            case 'db_scan_status':
                return $this->fetch_db_scan_status();

            case 'filechecker_progress':
                return $this->fetch_filechecker_progress();

            default:
                return null;
        }
    }

    /**
     * Fetch live filechecker scan progress from the WP transient.
     * Pushed on every SSE tick while a scan is active; throttled to every
     * 3 ticks (~6 s) when idle to avoid unnecessary overhead.
     *
     * @return array  Data payload sent to the JS renderer.
     */
    private function fetch_filechecker_progress(): array {
        $progress = get_transient( 'ultraguard_fc_scan_in_progress' );

        if ( $progress && ! empty( $progress ) ) {
            return [
                'is_scanning'     => true,
                'session_id'      => $progress['session_id']      ?? '',
                'type'            => $progress['type']            ?? 'full',
                'percent'         => (int) ( $progress['percent']         ?? 0 ),
                'status'          => $progress['status']          ?? 'running',
                'started_at'      => $progress['started_at']      ?? time(),
                'current_file'    => $progress['current_file']    ?? '',
                'changes_found'   => (int) ( $progress['changes_found']   ?? 0 ),
                'files_processed' => (int) ( $progress['files_processed'] ?? 0 ),
            ];
        }

        return [
            'is_scanning'     => false,
            'percent'         => 0,
            'current_file'    => '',
            'changes_found'   => 0,
            'files_processed' => 0,
        ];
    }

    // =========================================================================
    // CHANNEL DATA FETCHERS — one private method per channel
    // Each method reads from the same transients / DB tables used by the module's
    // own AJAX handlers so data is always consistent.  All methods are defensive:
    // missing tables or transients return a safe idle-state payload.
    // =========================================================================

    private function fetch_antivirus_status(): array {
        $scan = get_option( 'ultraguard_active_scan', [] );
        $is_scanning = ( ( $scan['status'] ?? '' ) === 'running' );

        return [
            'status'           => $is_scanning ? 'scanning' : 'idle',
            'status_label'     => $is_scanning ? __( 'Scanning', 'ultraguard-security-suite' ) : __( 'Idle', 'ultraguard-security-suite' ),
            'is_scanning'      => $is_scanning,
            'progress'         => (int) ( $scan['progress'] ?? 0 ),
            'progress_label'   => ( $scan['progress'] ?? 0 ) . '%',
            'phase_label'      => $scan['phase'] ?? '',
            'current_file'     => $scan['current_file'] ?? '',
            'files_scanned'    => (int) ( $scan['files_scanned'] ?? 0 ),
            'total_files'      => (int) ( $scan['total_files'] ?? 0 ),
            'threats_found'    => (int) ( $scan['threats_found'] ?? 0 ),
            'infected_files'   => (int) ( $scan['infected_files'] ?? 0 ),
            'suspicious_files' => (int) ( $scan['suspicious_files'] ?? 0 ),
            'clean_files'      => (int) ( $scan['clean_files'] ?? 0 ),
            'quarantined'      => (int) ( $scan['quarantined'] ?? 0 ),
            'last_scan_human'  => $scan['last_scan_human'] ?? '',
            'elapsed_human'    => $scan['elapsed_human'] ?? '',
            'estimated_human'  => $scan['estimated_human'] ?? '',
            'scan_type'        => $scan['scan_type'] ?? '',
            'current_batch'    => (int) ( $scan['current_batch'] ?? 0 ),
            'total_batches'    => (int) ( $scan['total_batches'] ?? 0 ),
            'scan_just_completed' => (bool) ( $scan['scan_just_completed'] ?? false ),
        ];
    }

    private function fetch_file_scanner_status(): array {
        $progress = get_transient( 'ultraguard_fc_scan_in_progress' );
        $is_scanning = ! empty( $progress );

        $tbl = $this->tbl( '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' );

        return [
            'status'            => $is_scanning ? 'scanning' : ( $changes > 0 ? 'warning' : 'ok' ),
            'status_label'      => $is_scanning ? __( 'Scanning', 'ultraguard-security-suite' ) : ( $changes > 0 ? __( 'Changes Detected', 'ultraguard-security-suite' ) : __( 'Protected', 'ultraguard-security-suite' ) ),
            'files_checked'     => (int) ( $progress['files_processed'] ?? get_option( 'ultraguard_fc_last_files_checked', 0 ) ),
            'changes_detected'  => $changes,
            'last_check_human'  => $last_check_human,
            'quarantined'       => $this->tbl( 'fc_quarantine' ) ? (int) $this->tbl( 'fc_quarantine' )->count() : 0,
        ];
    }

    private function fetch_firewall_status(): array {
        $today = gmdate( 'Y-m-d 00:00:00' );

        $logs_tbl    = $this->tbl( 'firewall_logs' );
        $rules_tbl   = $this->tbl( 'firewall_rules' );
        $blocked_tbl = $this->tbl( 'ip_blacklist' );

        $blocked_today = $logs_tbl   ? (int) $logs_tbl->count( [ 'action' => 'block', 'created_at >=' => $today ] ) : 0;
        $blocked_total = $blocked_tbl ? (int) $blocked_tbl->count( [ 'is_active' => 1 ] ) : 0;
        $active_rules  = $rules_tbl  ? (int) $rules_tbl->count( [ 'is_active' => 1 ] ) : 0;

        $top_ip = '';
        if ( $logs_tbl && method_exists( $logs_tbl, 'get_top_ips' ) ) {
            $top = $logs_tbl->get_top_ips( 1 );
            $top_ip = $top[0]['ip'] ?? '';
        }

        $recent_blocks = [];
        if ( $logs_tbl && method_exists( $logs_tbl, 'get_recent' ) ) {
            $recent_blocks = array_slice( (array) $logs_tbl->get_recent( 5 ), 0, 5 );
        }

        return [
            'status'        => $active_rules > 0 ? 'active' : 'inactive',
            'status_label'  => $active_rules > 0 ? __( 'Active', 'ultraguard-security-suite' ) : __( 'Inactive', 'ultraguard-security-suite' ),
            'blocked_today' => $blocked_today,
            'blocked_total' => $blocked_total,
            'active_rules'  => $active_rules,
            'top_threat_ip' => $top_ip,
            'recent_blocks' => $recent_blocks,
        ];
    }

    private function fetch_login_status(): array {
        $today = gmdate( 'Y-m-d 00:00:00' );

        $att_tbl = $this->tbl( 'login_attempts' );
        $lo_tbl  = $this->tbl( 'login_limiter_lockouts' );
        $bl_tbl  = $this->tbl( 'll_blocked_ips' );

        $attempts_today = $att_tbl ? (int) $att_tbl->count( [ 'status' => 'failed', 'created_at >=' => $today ] ) : 0;
        $locked_now     = $lo_tbl  ? (int) $lo_tbl->count( [ 'is_active' => 1 ] ) : 0;
        $blocked_ips    = $bl_tbl  ? (int) $bl_tbl->count( [ 'is_active' => 1 ] ) : 0;

        $last_attempt_human = '';
        if ( $att_tbl && method_exists( $att_tbl, 'get_last_row' ) ) {
            $row = $att_tbl->get_last_row( [ 'status' => 'failed' ] );
            if ( ! empty( $row['created_at'] ) ) {
                $last_attempt_human = human_time_diff( strtotime( $row['created_at'] ), time() ) . ' ' . __( 'ago', 'ultraguard-security-suite' );
            }
        }

        $recent_attempts = [];
        if ( $att_tbl && method_exists( $att_tbl, 'get_recent' ) ) {
            $recent_attempts = array_slice( (array) $att_tbl->get_recent( 5 ), 0, 5 );
        }

        return [
            'attempts_today'     => $attempts_today,
            'locked_now'         => $locked_now,
            'blocked_ips'        => $blocked_ips,
            'last_attempt_human' => $last_attempt_human,
            'recent_attempts'    => $recent_attempts,
        ];
    }

    private function fetch_htaccess_status(): array {
        $htaccess_path = ABSPATH . '.htaccess';
        $exists        = file_exists( $htaccess_path );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- direct writability check for SSE temp directory diagnostics.
        $writable      = $exists && is_writable( $htaccess_path );

        $change_tbl  = $this->tbl( 'htaccess_change_log' );
        $today       = gmdate( 'Y-m-d 00:00:00' );
        $blocks_today = $change_tbl ? (int) $change_tbl->count( [ 'created_at >=' => $today ] ) : 0;

        $last_modified_human = '';
        if ( $exists ) {
            $mtime = filemtime( $htaccess_path );
            if ( $mtime ) {
                $last_modified_human = human_time_diff( $mtime, time() ) . ' ' . __( 'ago', 'ultraguard-security-suite' );
            }
        }

        // Count active security rules from the rules table if available
        $rules_active = 0;
        if ( $change_tbl && method_exists( $change_tbl, 'count' ) ) {
            $rules_active = (int) $change_tbl->count( [ 'type' => 'security_rule' ] );
        }

        return [
            'status'             => $exists ? ( $writable ? 'ok' : 'warning' ) : 'error',
            'status_label'       => $exists ? ( $writable ? __( 'Protected', 'ultraguard-security-suite' ) : __( 'Read-only', 'ultraguard-security-suite' ) ) : __( 'Missing', 'ultraguard-security-suite' ),
            'rules_active'       => $rules_active,
            'blocks_today'       => $blocks_today,
            'last_modified_human'=> $last_modified_human,
            'integrity_ok'       => $exists && $writable,
        ];
    }

    private function fetch_security_score(): array {
        $plugin = $this->get_plugin_instance();
        if ( $plugin && isset( $plugin->dashboard ) ) {
            try {
                $handler = \UltraguardPro\modules\dashboard\DashboardDataHandler::get_instance( $plugin );
                $score   = $handler->get_security_score();
                return [
                    'score'       => $score['score'] ?? 0,
                    'level'       => $score['level'] ?? 'warning',
                    'label'       => ucfirst( $score['level'] ?? 'warning' ),
                    'status_text' => ucfirst( $score['level'] ?? 'warning' ),
                ];
            } catch ( \Throwable $e ) {
                // fall through to inline calculation
            }
        }

        // Inline fallback — mirrors DashboardDataHandler::get_security_score()
        $settings_key = function ( $key, $default ) {
            return (bool) get_option( 'ultraguard_' . $key, $default );
        };
        $score = 30;
        $score += $settings_key( 'firewall_enabled',      true  ) ? 20 : 0;
        $score += $settings_key( 'login_limiter_enabled', true  ) ? 15 : 0;
        $score += $settings_key( 'antivirus_enabled',     true  ) ? 20 : 0;
        $score += $settings_key( 'file_checker_enabled',  true  ) ? 10 : 0;
        $score += is_ssl()                                         ?  5 : 0;
        $score  = max( 0, min( 100, $score ) );

        $level = match ( true ) {
            $score >= 80 => 'excellent',
            $score >= 60 => 'good',
            $score >= 40 => 'warning',
            default      => 'critical',
        };

        return [
            'score'       => $score,
            'level'       => $level,
            'label'       => ucfirst( $level ),
            'status_text' => ucfirst( $level ),
        ];
    }

    private function fetch_threat_feed(): array {
        $fw_tbl = $this->tbl( 'firewall_logs' );
        $today  = gmdate( 'Y-m-d 00:00:00' );

        $threats_blocked  = $fw_tbl ? (int) $fw_tbl->count( [ 'action' => 'block', 'created_at >=' => $today ] ) : 0;
        $threats_detected = $fw_tbl ? (int) $fw_tbl->count( [ 'created_at >=' => $today ] ) : 0;

        $feed = [];
        if ( $fw_tbl && method_exists( $fw_tbl, 'get_recent' ) ) {
            $rows = (array) $fw_tbl->get_recent( 10 );
            foreach ( $rows as $row ) {
                $row = (array) $row;
                $created = $row['created_at'] ?? '';
                $feed[] = [
                    'time_human' => $created ? human_time_diff( strtotime( $created ), time() ) . ' ' . __( 'ago', 'ultraguard-security-suite' ) : '',
                    'ip'         => $row['ip_address'] ?? $row['ip'] ?? '',
                    'rule'       => $row['rule_name']  ?? $row['rule'] ?? '',
                    'action'     => $row['action']     ?? 'block',
                    'severity'   => $row['severity']   ?? 'medium',
                ];
            }
        }

        return [
            'threats_blocked'  => $threats_blocked,
            'threats_detected' => $threats_detected,
            'feed'             => $feed,
        ];
    }

    private function fetch_vuln_scan_status(): array {
        $status = get_transient( 'ultraguard_vuln_scan_status' ) ?: [
            'scan_id'  => '',
            'status'   => 'idle',
            'progress' => 0,
            'phase'    => '',
        ];

        return [
            'scan_id'        => $status['scan_id']        ?? '',
            'status'         => $status['status']         ?? 'idle',
            'progress'       => (int) ( $status['progress'] ?? 0 ),
            'phase'          => $status['phase']          ?? '',
            'threats_found'  => (int) ( $status['threats_found'] ?? 0 ),
            'completed_at'   => $status['completed_at']   ?? '',
            'error'          => $status['error']          ?? '',
        ];
    }

    private function fetch_db_scan_status(): array {
        $status = get_transient( 'ultraguard_db_scan_status' ) ?: [
            'scan_id'  => '',
            'status'   => 'idle',
            'progress' => 0,
            'phase'    => '',
        ];

        return [
            'scan_id'       => $status['scan_id']       ?? '',
            'status'        => $status['status']        ?? 'idle',
            'progress'      => (int) ( $status['progress'] ?? 0 ),
            'phase'         => $status['phase']         ?? '',
            'threats_found' => (int) ( $status['threats_found'] ?? 0 ),
            'total_checked' => (int) ( $status['total_checked'] ?? 0 ),
            'completed_at'  => $status['completed_at']  ?? '',
            'error'         => $status['error']         ?? '',
        ];
    }

    /**
     * Safely retrieve the plugin root instance.
     * Used to access module services (e.g. DashboardDataHandler) without
     * creating a hard static dependency from within this stream class.
     */
    private function get_plugin_instance() {
        return $this->plugin ?? null;
    }



    private function set_sse_headers(): void {
        // Disable WP's normal response handling
        if ( ! headers_sent() ) {
            header( 'Content-Type: text/event-stream; charset=UTF-8' );
            header( 'Cache-Control: no-cache, no-store, must-revalidate' );
            header( 'Pragma: no-cache' );
            header( 'Expires: 0' );
            header( 'X-Accel-Buffering: no' );   // nginx: disable proxy buffering
            header( 'Connection: keep-alive' );
        }
        // Ensure PHP doesn't buffer output
        if ( function_exists( 'apache_setenv' ) ) {
            apache_setenv( 'no-gzip', '1' );
        }
        // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- SSE responses require buffering controls for proper streaming behavior.
        @ini_set( 'zlib.output_compression', '0' );
        // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- SSE responses require buffering controls for proper streaming behavior.
        @ini_set( 'implicit_flush', '1' );
    }

    private function send_event( string $event, array $data, int $id = 0 ): void {
        if ( $id > 0 ) {
            echo "id: {$id}\n";  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol stream, values are internal integers/strings  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol stream
        }
        echo "event: {$event}\n";  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol stream, values are internal integers/strings  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol stream
        echo 'data: ' . wp_json_encode( $data ) . "\n\n";
        $this->flush_output();
    }

    private function send_heartbeat( int $tick ): void {
        echo ': heartbeat tick=' . absint( $tick ) . ' ts=' . time() . "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE heartbeat protocol stream, not HTML output.
        $this->flush_output();
    }

    private function flush_output(): void {
        if ( ob_get_level() > 0 ) {
            ob_flush();
        }
        flush();
    }

    /** Helper: get a DB table instance (mirrors RealTimeAjaxHandlers::tbl) */
    private function tbl( string $key ) {
        try {
            $plugin = function_exists( '\UltraguardPro\ultraguard_pro' )
                ? \UltraguardPro\ultraguard_pro()
                : null;
            $db = $plugin ? $plugin->modules->get_db_tables() : null;
            return $db ? $db->get_table( $key ) : null;
        } catch ( \Throwable $e ) {
            return null;
        }
    }
}
