<?php
/**
 * SSE Connection Handler.
 *
 * Handles Server-Sent Events connections for real-time updates.
 *
 * @package   Agenistrator
 * @since     1.0.0
 * @author    Alex Ruban <alex@maximum.guru>
 * @author    Maximum Software Inc <contact@maximum.software>
 * @license   GPL-3.0-only
 * @link      https://agenistrator.com/
 *
 * SPDX-FileCopyrightText: 2025-2026 Alex Ruban <alex@maximum.guru>
 * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc <contact@maximum.software>
 * SPDX-License-Identifier: GPL-3.0-only
 */

namespace Agenistrator;

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

/**
 * SSE_Connection class.
 */
class SSE_Connection {
	use Singleton;

	/**
	 * Polling interval in milliseconds
	 *
	 * @var int
	 */
	private $polling_interval = 200;

	/**
	 * Keepalive interval in milliseconds.
	 *
	 * @var int
	 */
	private $keepalive_interval = 15000;

	/**
	 * Handle SSE connection request.
	 * @since     1.0.0
	 */
	public function handle_request() {
		// Get nonce from HTTP headers
		$nonce = $this->get_nonce_from_headers();

		if ( ! wp_verify_nonce( $nonce, 'agenistrator_nonce' ) ) {
			status_header( 403 );
			die( 'Invalid nonce' );
		}

		// Check if user is logged in
		if ( ! is_user_logged_in() ) {
			status_header( 401 );
			die( 'Unauthorized' );
		}

		// Check if user has permission to use the plugin
		if ( ! current_user_can( 'manage_options' ) ) {
			status_header( 403 );
			die( 'Forbidden' );
		}

		// Get last event ID from Last-Event-ID header (SSE specification)
		// EventSource automatically sends this header on reconnection
		$last_id = 0;
		if ( isset( $_SERVER['HTTP_LAST_EVENT_ID'] ) ) {
			$last_id = intval( wp_unslash( $_SERVER['HTTP_LAST_EVENT_ID'] ) );
		}

		// Fallback to query parameters for polyfill compatibility
		if ( isset( $_GET['lastEventId'] ) ) {
			$last_id = intval( wp_unslash( $_GET['lastEventId'] ) );
		}

		// Get user ID and thread
		$user_id = get_current_user_id();
		$thread  = Thread_Manager::get_instance()->get_or_create_thread( $user_id );

		// Set headers for SSE
		header( 'Content-Type: text/event-stream' );
		header( 'Cache-Control: no-cache' );
		header( 'X-Accel-Buffering: no' ); // Disable buffering in Nginx

		// Disable output buffering
		$levels = ob_get_level();
		for ( $i = 0; $i < $levels; $i++ ) {
			ob_end_flush();
		}
		flush();

		try {
			// Send initial connection established event
			$this->send_event(
				'connection',
				array(
					'status'    => 'connected',
					'user_id'   => $user_id,
					'timestamp' => time(),
				)
			);

			// If this is a new connection (last_id <= 0), send chat log
			$after_timestamp    = 0;
			$exclude_message_id = null;
			if ( $last_id <= 0 ) {
				// Get the latest event ID BEFORE sending log to avoid race conditions
				// This ensures we don't miss events that are added while log is being sent
				$latest_event_id = Event_Queue::get_instance()->get_latest_event_id( $user_id );

				$chat_log_data      = $this->send_chat_log( $thread );
				$exclude_message_id = $chat_log_data['last_message_id'];
				$after_timestamp    = (int) $chat_log_data['last_timestamp'];

				// Send last_id event to establish the synchronization point for EventSource's Last-Event-ID
				// The event ID itself communicates the last event ID to the frontend
				$this->send_event( 'last_id', array(), $latest_event_id );

				// Send current thread state for proper UI initialization
				$event_queue   = Event_Queue::get_instance();
				$is_processing = $event_queue->is_thread_processing( $thread->get_id() );
				$this->send_event(
					'control',
					array(
						'action'    => $is_processing ? Events\Control_Event::ACTION_PROCESSING : Events\Control_Event::ACTION_READY,
						'thread_id' => $thread->get_id(),
					)
				);

				// Send rate limit state if active
				$rate_limit_remaining = $event_queue->get_rate_limit_remaining_time( $thread->get_id() );
				if ( $rate_limit_remaining > 0 ) {
					$this->send_event(
						'control',
						array(
							'action'    => Events\Control_Event::ACTION_RATE_LIMIT_WAIT,
							'thread_id' => $thread->get_id(),
							'params'    => array( 'wait_time' => $rate_limit_remaining ),
						)
					);
				}

				// Update last_id to the latest event ID to continue from there
				$last_id = $latest_event_id;
			}

			// Start time and execution limit
			$start_time         = time();
			$last_output_time   = $start_time;
			$max_execution_time = PHP_Utils::get_available_execution_time( 0.8, 15, 300 );

			// Keep connection open and poll for new events
			while ( true ) {
				$current_time = time();
				// Check if we've exceeded the maximum execution time
				if ( $current_time - $start_time > $max_execution_time ) {
					$this->send_event(
						'timeout',
						array(
							'message' => 'Connection timeout',
						)
					);
					break;
				}

				// Get new events (system-wide events + all user's thread events)
				$events = Event_Queue::get_instance()->get_events( $user_id, $last_id, $after_timestamp );

				// Filter and send events to client
				if ( ! empty( $events ) ) {
					$filtered_events = $this->filter_events( $events, $exclude_message_id );

					if ( ! empty( $filtered_events ) ) {
						foreach ( $filtered_events as $event ) {
							$this->send_event( $event->get_type(), $event->to_sse_payload(), $event->get_id() );
						}

						$last_event = end( $filtered_events );
						$last_id    = $last_event->get_id();
						// Reset filters after first poll
						$after_timestamp    = 0;
						$exclude_message_id = null;
						$last_output_time   = $current_time;
					}
				}

				// Send keep-alive comment only if we've been idle longer than keepalive_interval
				if ( ( $current_time - $last_output_time ) * 1000 >= $this->keepalive_interval ) {
					echo ": keepalive\n\n";
					flush();
					$last_output_time = $current_time;
				}

				// Sleep for polling interval
				usleep( $this->polling_interval * 1000 );
			}
		} catch ( \Throwable $e ) {
			$this->send_event(
				'error',
				array(
					'message' => $e->getMessage(),
					'code'    => $e->getCode(),
				)
			);
		}

		// Flush remaining output (if any) before closing the connection
		flush();

		// Add a brief delay before PHP terminates to allow the client time to process the
		// final events (e.g., timeout event) before the stream closes (helps reduce errors)
		usleep( 500000 );
	}

	/**
	 * Get nonce from HTTP headers
	 *
	 * @return string The nonce value or empty string if not found
	 */
	private function get_nonce_from_headers() {
		// Check for X-WP-Nonce header
		if ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) {
			$nonce = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) );
			if ( ! empty( $nonce ) ) {
				return $nonce;
			}
		}

		return '';
	}

	/**
	 * Send an event to the client.
	 *
	 * @param string $event_type The event type.
	 * @param array  $data       The event data.
	 * @param int    $id         The event ID.
	 * @since     1.0.0
	 */
	public function send_event( $event_type, $data, $id = null ) {
		// Add random padding to mitigate Whisper Leak side-channel attack.
		// Random padding obscures packet sizes, making it harder to infer content from TLS metadata.
		$pad_length = wp_rand( 0, 64 );
		if ( $pad_length > 0 ) {
			$data['_p'] = wp_generate_password( $pad_length, true, false );
		}

		if ( null !== $id ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol ID field, $id is an integer from database
			echo "id: {$id}\n";
		}
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE protocol event type field, $event_type is from trusted Event class constants
		echo "event: {$event_type}\n";
		echo 'data: ' . wp_json_encode( $data ) . "\n\n";
		flush();
	}

	/**
	 * Send chat log to the client.
	 *
	 * @param Thread $thread The thread instance.
	 * @return array Array with 'last_timestamp' and 'last_message_id' keys
	 * @since     1.0.0
	 */
	public function send_chat_log( Thread $thread ) {
		// Get messages from the Chat Log Manager
		$messages = Chat_Log_Manager::get_instance()->get_chat_log( $thread->get_id() );

		$last_message_timestamp = 0;
		$last_message_id        = 0;

		if ( ! empty( $messages ) ) {
			// Process and send each message directly in a single loop
			foreach ( $messages as $message ) {
				// Send message event directly to this connection
				$this->send_event(
					'message',
					array(
						'role'             => $message->get_role(),
						'content'          => $message->get_content(),
						'metadata'         => $message->get_metadata(),
						'timestamp'        => $message->get_timestamp(),
						'message_id'       => $message->get_message_id(),
						'is_from_chat_log' => true,
					)
				);
			}

			// Get last message metadata
			$last_message           = end( $messages );
			$last_message_timestamp = $last_message->get_timestamp();
			$last_message_id        = $last_message->get_message_id();
		} else {
			// If no messages found, add a welcome message
			Chat_Log_Manager::get_instance()->add_welcome_message( $thread );
		}

		// Send log loaded event
		$this->send_event(
			'chat_log_loaded',
			array(
				'count' => count( $messages ),
			)
		);

		return array(
			'last_timestamp'  => $last_message_timestamp,
			'last_message_id' => $last_message_id,
		);
	}

	/**
	 * Filter events to exclude those related to a specific message ID.
	 *
	 * @param Event[] $events The events to filter.
	 * @param int|null $exclude_message_id The chat log message ID to exclude.
	 * @return Event[] Filtered events.
	 */
	private function filter_events( $events, $exclude_message_id = null ) {
		if ( null === $exclude_message_id ) {
			return $events; // No filtering needed
		}

		$filtered_events = array();
		foreach ( $events as $event ) {
			$message_id = null;

			if ( $event instanceof Events\Message_Event ) {
				$message_id = $event->get_message()->get_message_id();
			} elseif ( $event instanceof Events\Stream_Chunk_Event ) {
				$message_id = $event->get_message_id();
			} elseif ( $event instanceof Events\Thread_Error_Event ) {
				$message_id = $event->get_message_id();
			}

			if ( $message_id !== $exclude_message_id ) {
				$filtered_events[] = $event;
			}
		}

		return $filtered_events;
	}
}
